mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 04:52:58 +00:00
Complete Commonlib rc.6 integration and setup workflows
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { ServiceContextContract } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { CommonlibMessageKey, ServiceContextContract } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { ServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/ServiceHub";
|
||||
|
||||
export const SERVICE_CONTEXT_MEMBERS = [
|
||||
@@ -43,7 +43,10 @@ export type ServiceCompositionResult = {
|
||||
* The caller chooses the translation key because translated text is
|
||||
* host-configured. Event delivery itself is shared behaviour.
|
||||
*/
|
||||
export function observeServiceContext(context: ServiceContextContract, translationKey: string): ServiceContextResult {
|
||||
export function observeServiceContext(
|
||||
context: ServiceContextContract,
|
||||
translationKey: CommonlibMessageKey
|
||||
): ServiceContextResult {
|
||||
const receivedEvents: string[] = [];
|
||||
const unsubscribe = context.events.onEvent("hello", (value) => receivedEvents.push(value));
|
||||
try {
|
||||
|
||||
@@ -57,7 +57,7 @@ npm run test:e2e:obsidian:focused -- settings-ui
|
||||
npm run test:e2e:obsidian:focused -- two-vault-sync
|
||||
```
|
||||
|
||||
The wrapper accepts only maintained real-Obsidian scenario names; run it with `--help` for the current list. It deliberately does not manage CouchDB or Object Storage. Start the required fixtures first, or use the complete service-managed suite.
|
||||
The wrapper accepts only maintained real-Obsidian scenario names; run it with `--help` for the current list. It deliberately does not manage CouchDB, Object Storage, or the P2P signalling relay. Start the required fixture first, or use the complete service-managed suite.
|
||||
|
||||
The principal entry points are:
|
||||
|
||||
@@ -88,11 +88,11 @@ The underlying `test:e2e:obsidian:<scenario>` scripts remain available for an im
|
||||
|
||||
The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixel viewport, and explicit iPhone-style safe-area insets of 47 pixels at the top and 34 pixels at the bottom. The public `@vrtmrz/obsidian-test-session` layout assertions require each modal to remain within the viewport and safe area without horizontal overflow. They also require the Obsidian Close control to remain within the safe area and provide at least a 44 by 44 CSS-pixel touch target. The runner clicks that control to verify actionability, then completes the explicit cancellation path. These simulated checks cover deterministic layout and interaction boundaries; they do not claim to reproduce a native operating-system overlay.
|
||||
|
||||
`test:e2e:obsidian:review-harness` exercises only the boundaries owned by the opt-in maintainer Harness. It retains a real compatibility pause, uses the fixed Harness restart action to persist a device-local continuation and reload Obsidian, and requires the Harness to delete that state before reopening. It also runs the bounded local observations, confirms the dedicated Vault fixture root is removed, captures the copied privacy-bounded Markdown report, and checks the Harness layout and touch targets in mobile test mode. Compatibility explanation and persistence details remain owned by `settings-ui`, real P2P transfer remains owned by the Compose suite, and general Vault reflection remains owned by `vault-reflection`; the Harness test does not duplicate those workflows.
|
||||
`test:e2e:obsidian:review-harness` exercises only the boundaries owned by the opt-in maintainer Harness. It retains a real compatibility pause, uses the fixed Harness restart action to persist a device-local continuation and reload Obsidian, and requires the Harness to delete that state before reopening. It also runs the bounded local observations, confirms the dedicated Vault fixture root is removed, captures the copied privacy-bounded Markdown report, and checks the Harness layout and touch targets in mobile test mode. Compatibility explanation and persistence details remain owned by `settings-ui`, real P2P transfer remains owned by the dedicated P2P suites, and general Vault reflection remains owned by `vault-reflection`; the Harness test does not duplicate those workflows.
|
||||
|
||||
`test:e2e:obsidian:p2p-pane` opens the P2P status view in a temporary real Obsidian session, verifies its principal connection control and horizontal layout, and captures a screenshot. It deliberately uses no relay or peer: current-replicator replacement is covered by focused unit tests, while the Deno and Compose CLI P2P lifecycle suite owns real peer discovery, replacement, relay disconnection, and transfer behaviour.
|
||||
`test:e2e:obsidian:p2p-pane` opens the P2P status view in a temporary real Obsidian session, verifies its principal connection control and horizontal layout, and captures a screenshot. It deliberately uses no relay or peer: current-replicator replacement is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible two-device real-Obsidian path.
|
||||
|
||||
`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, settings UI, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload, CLI-to-Obsidian synchronisation, Object Storage upload, startup scan, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB and MinIO fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run.
|
||||
`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, settings UI, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI round-trip, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB, MinIO, and P2P relay fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run.
|
||||
|
||||
`test:e2e:obsidian:couchdb-upload` reuses the CouchDB variables from `.test.env` or the process environment. It expects a reachable CouchDB service, creates a unique database, starts from configured plug-in data without the device-local compatibility marker, and verifies the copied-or-restored Vault explanation in the actual compatibility dialogue. It captures the summary and details, resumes explicitly, confirms that the marker was recorded, creates a note in real Obsidian, commits the note into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the metadata document and its chunk documents.
|
||||
|
||||
@@ -121,9 +121,13 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
|
||||
|
||||
`test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service, configures Self-hosted LiveSync for Object Storage through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix. Adapter tests separately observe an in-progress SDK command, while this real-runtime workflow verifies the resulting request counters advance and rebalance.
|
||||
|
||||
`test:e2e:obsidian:object-storage-setup-uri-workflow` generates a public Commonlib-backed bootstrap URI for a unique MinIO prefix, completes visible first-device initialisation, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies A-to-B and B-to-A notes, captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped.
|
||||
|
||||
`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Nostr relay fixture. The first device imports a generated bootstrap URI, creates the additional-device URI through the registered command, and remains online while the second device imports it. The workflow selects the expected peer, accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, reconnects both finite P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes.
|
||||
|
||||
`test:e2e:obsidian:startup-scan` configures a temporary CouchDB database, stops Obsidian, writes a note directly into the vault, restarts Obsidian, and verifies from CouchDB that the boot-time scan picked up the offline file.
|
||||
|
||||
`test:e2e:obsidian:setup-uri-workflow` runs the repository's public Commonlib-backed CouchDB provisioning and Setup URI tools against the local CouchDB fixture. It configures two data-less real Obsidian Vaults through the visible onboarding wizard, uses Rebuild for the first device and Fetch for the second, and verifies an ordinary note. After ordinary setup has completed, it independently enables Hidden File Sync on each device and verifies a snippet. The retained Setup URI screenshot shows only the encrypted URI and a visually masked Setup URI passphrase; plaintext credentials are not captured. Files prefixed with `guide-` capture the relevant dialogue, settings panel, or workspace leaf without transient Notices. Public documentation copies selected images only after visual inspection; the E2E run does not overwrite repository documentation assets.
|
||||
`test:e2e:obsidian:setup-uri-workflow` runs the repository's public Commonlib-backed CouchDB provisioning and Setup URI tools against the local CouchDB fixture. It configures the first data-less real Obsidian Vault through the visible onboarding wizard and uses Rebuild. After that device is working, it generates a new Setup URI through the registered command; the second real Obsidian Vault uses that URI for Fetch instead of reusing the provisioning-time bootstrap URI. The workflow verifies ordinary notes from the first device to the second and back again, independently enables Hidden File Sync on each device, and verifies a snippet. The retained Setup URI screenshots show only encrypted URIs and visually masked Setup URI passphrases; plaintext credentials are not captured. Files prefixed with `guide-` capture the relevant dialogue, settings panel, or workspace leaf without transient Notices. Public documentation copies selected images only after visual inspection; the E2E run does not overwrite repository documentation assets.
|
||||
|
||||
`test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, ordinary rename, a case-only file name change within the same directory, deletion, per-device target filters where one vault ignores a note that the other vault synchronises, and a separate encrypted round-trip with Path Obfuscation enabled. Directory case changes deliberately remain outside this scenario because they require directory-aware rename handling. The optional Markdown conflict automatic merge check can be enabled with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true`, but it is not part of the default local suite.
|
||||
|
||||
@@ -138,6 +142,7 @@ Start the local fixtures first when they are not already running:
|
||||
```bash
|
||||
npm run test:docker-couchdb:start
|
||||
npm run test:docker-s3:start
|
||||
npm run test:docker-p2p:start
|
||||
npm run test:e2e:obsidian:local-suite
|
||||
```
|
||||
|
||||
@@ -161,6 +166,10 @@ Useful environment variables:
|
||||
- `E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS`: timeout for the settings pane and its deletion controls to become visible; default is 10 seconds.
|
||||
- `E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS`: timeout for Review Harness view and action boundaries; default is 15 seconds.
|
||||
- `E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS`: timeout for the P2P status pane and its principal connection control; default is 10 seconds.
|
||||
- `E2E_OBSIDIAN_P2P_WORKFLOW_TIMEOUT_MS`: timeout for each visible P2P Setup URI, peer-discovery, approval, and replication control; default is 60 seconds.
|
||||
- `E2E_P2P_RELAY_URL`: signalling relay used by the real-Obsidian P2P workflow; default is the local relay at `ws://127.0.0.1:4010/`.
|
||||
- `E2E_P2P_RELAY_PORT`: host port for the local P2P relay fixture; default is `4010`.
|
||||
- `E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT`: CDP port for the second concurrent real Obsidian session; default is one greater than the primary port.
|
||||
- `E2E_OBSIDIAN_READY_TIMEOUT_MS`: plug-in readiness timeout in milliseconds.
|
||||
- `E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS`: timeout for waiting until the vault-side Obsidian CLI exposes the plug-in catalogue.
|
||||
- `E2E_OBSIDIAN_CLI_TIMEOUT_MS`: timeout for each `obsidian-cli` invocation.
|
||||
|
||||
@@ -10,6 +10,7 @@ export type StartObsidianLiveSyncSessionOptions = {
|
||||
startupGraceMs?: number;
|
||||
pluginData?: Record<string, unknown>;
|
||||
localStorageEntries?: Readonly<Record<string, string>>;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
export async function startObsidianLiveSyncSession(
|
||||
@@ -24,5 +25,6 @@ export async function startObsidianLiveSyncSession(
|
||||
startupGraceMs: options.startupGraceMs,
|
||||
pluginData: options.pluginData,
|
||||
localStorageEntries: options.localStorageEntries,
|
||||
env: options.env,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import type { Locator, Page } from "playwright";
|
||||
import { evalObsidianJson } from "./cli.ts";
|
||||
import { captureObsidianDialogue, captureObsidianElement, withObsidianPage } from "./ui.ts";
|
||||
|
||||
export type SetupArtifact = {
|
||||
setupURI: string;
|
||||
setupPassphrase: string;
|
||||
};
|
||||
|
||||
export type SetupState = {
|
||||
configured: boolean;
|
||||
databaseReady: boolean;
|
||||
appReady: boolean;
|
||||
suspended: boolean;
|
||||
remoteType: string;
|
||||
activeConfigurationId: string;
|
||||
remoteConfigurationCount: number;
|
||||
endpoint: string;
|
||||
bucket: string;
|
||||
bucketPrefix: string;
|
||||
p2pEnabled: boolean;
|
||||
p2pRelays: string;
|
||||
p2pRoomId: string;
|
||||
};
|
||||
|
||||
export type SetupCaptureNames = {
|
||||
scenario: string;
|
||||
guide: string;
|
||||
};
|
||||
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000);
|
||||
const initialisationTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_INITIALISATION_TIMEOUT_MS ?? 120000);
|
||||
|
||||
export function modalByTitle(page: Page, title: string): Locator {
|
||||
return page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: title }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function captureGuideDialogue(port: number, filename: string, title: string): Promise<string> {
|
||||
return await captureObsidianElement(port, filename, (page) => modalByTitle(page, title).locator(".modal").first());
|
||||
}
|
||||
|
||||
export async function selectRadioOption(modal: Locator, title: string): Promise<void> {
|
||||
const radio = modal.locator("label").filter({ hasText: title }).locator('input[type="radio"]').first();
|
||||
await radio.check({ timeout: uiTimeoutMs });
|
||||
}
|
||||
|
||||
export async function selectCheckbox(modal: Locator, title: string): Promise<void> {
|
||||
const checkbox = modal.locator("label").filter({ hasText: title }).locator('input[type="checkbox"]').first();
|
||||
await checkbox.check({ timeout: uiTimeoutMs });
|
||||
}
|
||||
|
||||
export async function enterSetupURI(
|
||||
port: number,
|
||||
mode: "new" | "existing",
|
||||
artifact: SetupArtifact,
|
||||
captures: SetupCaptureNames
|
||||
): Promise<string> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const invitation = page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" });
|
||||
await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await invitation.locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs });
|
||||
|
||||
const intro = modalByTitle(page, "Welcome to Self-hosted LiveSync");
|
||||
await intro.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if (mode === "new") {
|
||||
await selectRadioOption(intro, "I am setting this up for the first time");
|
||||
await intro
|
||||
.getByRole("button", { name: "Yes, I want to set up a new synchronisation" })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
} else {
|
||||
await selectRadioOption(intro, "I am adding a device to an existing synchronisation setup");
|
||||
await intro
|
||||
.getByRole("button", { name: "Yes, I want to add this device to my existing synchronisation" })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
}
|
||||
|
||||
const method = modalByTitle(page, mode === "new" ? "Connection Method" : "Device Setup Method");
|
||||
await method.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await selectRadioOption(method, "Use a Setup URI (Recommended)");
|
||||
await method.getByRole("button", { name: "Proceed with Setup URI" }).click({ timeout: uiTimeoutMs });
|
||||
|
||||
const setup = modalByTitle(page, "Enter Setup URI");
|
||||
await setup.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await setup.locator('input[placeholder^="obsidian://setuplivesync"]').fill(artifact.setupURI);
|
||||
await setup.locator('input[name="password"]').fill(artifact.setupPassphrase);
|
||||
});
|
||||
const screenshot = await captureGuideDialogue(
|
||||
port,
|
||||
`guide-${captures.guide}-${mode === "new" ? "first" : "second"}-setup-uri.png`,
|
||||
"Enter Setup URI"
|
||||
);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, "Enter Setup URI")
|
||||
.getByRole("button", { name: "Test Settings and Continue" })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
export async function generateSetupURIFromDevice(
|
||||
port: number,
|
||||
setupPassphrase: string,
|
||||
captures: SetupCaptureNames
|
||||
): Promise<{ artifact: SetupArtifact; screenshots: string[] }> {
|
||||
const opened = await withObsidianPage(port, async (page) => {
|
||||
return await page.evaluate(
|
||||
(commandId) =>
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
app?: { commands?: { executeCommandById(id: string): boolean } };
|
||||
}
|
||||
).app?.commands?.executeCommandById(commandId) === true,
|
||||
"obsidian-livesync:livesync-copysetupuri"
|
||||
);
|
||||
});
|
||||
if (!opened) throw new Error("The command for generating a Setup URI was not registered.");
|
||||
|
||||
const promptTitle = "Encrypt your settings";
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const prompt = modalByTitle(page, promptTitle);
|
||||
await prompt.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await prompt.locator('input[type="password"]').fill(setupPassphrase);
|
||||
});
|
||||
const promptScreenshot = await captureGuideDialogue(
|
||||
port,
|
||||
`guide-${captures.guide}-copy-setup-uri-passphrase.png`,
|
||||
promptTitle
|
||||
);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const prompt = modalByTitle(page, promptTitle);
|
||||
await prompt.getByRole("button", { name: "OK", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await prompt.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const resultTitle = "Your Setup URI is ready to be copied";
|
||||
const setupURI = await withObsidianPage(port, async (page) => {
|
||||
const result = modalByTitle(page, resultTitle);
|
||||
await result.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
return await result.locator("textarea[readonly]").inputValue();
|
||||
});
|
||||
if (!setupURI.startsWith("obsidian://setuplivesync?settings=")) {
|
||||
throw new Error("The first device did not generate a valid Setup URI.");
|
||||
}
|
||||
const resultScreenshot = await captureGuideDialogue(
|
||||
port,
|
||||
`guide-${captures.guide}-copy-setup-uri-result.png`,
|
||||
resultTitle
|
||||
);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const result = modalByTitle(page, resultTitle);
|
||||
await result.getByRole("button", { name: "OK", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await result.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
return {
|
||||
artifact: { setupURI, setupPassphrase },
|
||||
screenshots: [promptScreenshot, resultScreenshot],
|
||||
};
|
||||
}
|
||||
|
||||
export async function captureAndStartInitialisation(
|
||||
port: number,
|
||||
mode: "new" | "existing",
|
||||
captures: SetupCaptureNames
|
||||
): Promise<string> {
|
||||
const p2pFirstDevice = mode === "new" && captures.guide === "p2p-setup";
|
||||
const title = p2pFirstDevice
|
||||
? "Setup Complete: Preparing This P2P Device"
|
||||
: mode === "new"
|
||||
? "Setup Complete: Preparing to Initialise Server"
|
||||
: "Setup Complete: Preparing to Fetch Synchronisation Data";
|
||||
const button = p2pFirstDevice
|
||||
? "Restart and Prepare This Device"
|
||||
: mode === "new"
|
||||
? "Restart and Initialise Server"
|
||||
: "Restart and Fetch Data";
|
||||
const screenshot = await captureGuideDialogue(
|
||||
port,
|
||||
`guide-${captures.guide}-${mode === "new" ? "first-initialise" : "second-fetch"}.png`,
|
||||
title
|
||||
);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, title).getByRole("button", { name: button }).click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
export async function confirmRebuild(port: number, captures: SetupCaptureNames): Promise<string> {
|
||||
const isP2P = captures.guide === "p2p-setup";
|
||||
const title = isP2P
|
||||
? "Final Confirmation: Prepare This Device for P2P"
|
||||
: "Final Confirmation: Overwrite Server Data with This Device's Files";
|
||||
const screenshot = await captureGuideDialogue(
|
||||
port,
|
||||
`guide-${captures.guide}-first-rebuild-confirmation.png`,
|
||||
title
|
||||
);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const modal = modalByTitle(page, title);
|
||||
if (isP2P) {
|
||||
await selectCheckbox(
|
||||
modal,
|
||||
"I understand that this resets only this device's local synchronisation database."
|
||||
);
|
||||
await selectRadioOption(modal, "I understand the risks and will proceed without a backup.");
|
||||
await modal
|
||||
.getByRole("button", { name: "I Understand, Prepare This Device" })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
return;
|
||||
}
|
||||
await selectCheckbox(
|
||||
modal,
|
||||
"I understand that all changes made on other smartphones or computers possibly could be lost."
|
||||
);
|
||||
await selectCheckbox(
|
||||
modal,
|
||||
"I understand that other devices will no longer be able to synchronise, and will need to be reset the synchronisation information."
|
||||
);
|
||||
await selectCheckbox(modal, "I understand that this action is irreversible once performed.");
|
||||
await selectRadioOption(modal, "I understand the risks and will proceed without a backup.");
|
||||
await modal.getByRole("button", { name: "I Understand, Overwrite Server" }).click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
export async function skipMissingRemoteConfiguration(port: number, captures: SetupCaptureNames): Promise<string> {
|
||||
const title = "Fetch Remote Configuration Failed";
|
||||
const screenshot = await captureGuideDialogue(
|
||||
port,
|
||||
`guide-${captures.guide}-missing-remote-configuration.png`,
|
||||
title
|
||||
);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, title)
|
||||
.getByRole("button", { name: "Skip and proceed" })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
export async function acknowledgeDisabledOptionalFeatures(port: number, captures: SetupCaptureNames): Promise<string> {
|
||||
const title = "All optional features are disabled";
|
||||
const screenshot = await captureGuideDialogue(
|
||||
port,
|
||||
`guide-${captures.guide}-optional-features-disabled.png`,
|
||||
title
|
||||
);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const modal = modalByTitle(page, title);
|
||||
await modal.getByRole("button", { name: "OK" }).click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
export async function confirmFastFetch(port: number, captures: SetupCaptureNames): Promise<string[]> {
|
||||
const firstTitle = "Data retrieval scheduled";
|
||||
const firstScreenshot = await captureGuideDialogue(
|
||||
port,
|
||||
`guide-${captures.guide}-retrieval-method.png`,
|
||||
firstTitle
|
||||
);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, firstTitle)
|
||||
.getByRole("button", { name: "Overwrite all with remote files" })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const secondTitle = "How to handle extra existing local files?";
|
||||
const secondScreenshot = await captureGuideDialogue(
|
||||
port,
|
||||
`guide-${captures.guide}-local-file-policy.png`,
|
||||
secondTitle
|
||||
);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, secondTitle)
|
||||
.getByRole("button", { name: "Keep local files even if not on remote" })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
return [firstScreenshot, secondScreenshot];
|
||||
}
|
||||
|
||||
function isConfiguredSetupReady(state: SetupState): boolean {
|
||||
return (
|
||||
state.configured &&
|
||||
state.databaseReady &&
|
||||
state.appReady &&
|
||||
!state.suspended &&
|
||||
state.activeConfigurationId !== "" &&
|
||||
state.remoteConfigurationCount === 1
|
||||
);
|
||||
}
|
||||
|
||||
export async function readSetupState(cliBinary: string, environment: NodeJS.ProcessEnv): Promise<SetupState> {
|
||||
return await evalObsidianJson<SetupState>(
|
||||
cliBinary,
|
||||
[
|
||||
"(()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const settings=core.services.setting.currentSettings();",
|
||||
"return JSON.stringify({",
|
||||
"configured:settings.isConfigured===true,",
|
||||
"databaseReady:core.services.database.isDatabaseReady(),",
|
||||
"appReady:core.services.appLifecycle.isReady(),",
|
||||
"suspended:core.services.appLifecycle.isSuspended(),",
|
||||
"remoteType:settings.remoteType||'',",
|
||||
"activeConfigurationId:settings.activeConfigurationId||'',",
|
||||
"remoteConfigurationCount:Object.keys(settings.remoteConfigurations||{}).length,",
|
||||
"endpoint:settings.endpoint||'',",
|
||||
"bucket:settings.bucket||'',",
|
||||
"bucketPrefix:settings.bucketPrefix||'',",
|
||||
"p2pEnabled:settings.P2P_Enabled===true,",
|
||||
"p2pRelays:settings.P2P_relays||'',",
|
||||
"p2pRoomId:settings.P2P_roomID||'',",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
environment
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForConfiguredSetup(
|
||||
cliBinary: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
timeoutMs = initialisationTimeoutMs
|
||||
): Promise<SetupState> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastState: SetupState | undefined;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
lastState = await readSetupState(cliBinary, environment);
|
||||
if (isConfiguredSetupReady(lastState)) return lastState;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for configured Setup URI state: ${JSON.stringify(lastState)}${
|
||||
lastError instanceof Error ? `; last error: ${lastError.message}` : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function finishInitialisation(
|
||||
port: number,
|
||||
cliBinary: string,
|
||||
environment: NodeJS.ProcessEnv
|
||||
): Promise<SetupState> {
|
||||
const message = "Do you want to resume file and database processing, and restart obsidian now?";
|
||||
const deadline = Date.now() + initialisationTimeoutMs;
|
||||
let readySince: number | undefined;
|
||||
while (Date.now() < deadline) {
|
||||
const resumeVisible = await withObsidianPage(port, async (page) => {
|
||||
return await modalByTitle(page, "Confirmation").filter({ hasText: message }).isVisible();
|
||||
}).catch(() => false);
|
||||
if (resumeVisible) {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const modal = modalByTitle(page, "Confirmation").filter({ hasText: message });
|
||||
await modal.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
return await waitForConfiguredSetup(cliBinary, environment);
|
||||
}
|
||||
try {
|
||||
const state = await readSetupState(cliBinary, environment);
|
||||
if (isConfiguredSetupReady(state)) {
|
||||
readySince ??= Date.now();
|
||||
if (Date.now() - readySince >= 1000) return state;
|
||||
} else {
|
||||
readySince = undefined;
|
||||
}
|
||||
} catch {
|
||||
// Obsidian may be reloading while the scheduled operation runs.
|
||||
readySince = undefined;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error("Timed out waiting for Setup URI initialisation to finish.");
|
||||
}
|
||||
|
||||
export async function resumeCompatibilityReviewIfShown(port: number): Promise<boolean> {
|
||||
const title = "Synchronisation paused for compatibility review";
|
||||
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000);
|
||||
let available = false;
|
||||
while (Date.now() < deadline && !available) {
|
||||
available = await withObsidianPage(port, async (page) => {
|
||||
const modal = modalByTitle(page, title);
|
||||
if (await modal.isVisible()) return true;
|
||||
const reminder = page.locator(".notice.livesync-compatibility-review-notice");
|
||||
if (!(await reminder.isVisible())) return false;
|
||||
await reminder.getByRole("link", { name: "Review why" }).click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
return true;
|
||||
}).catch(() => false);
|
||||
if (!available) await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
if (!available) return false;
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const modal = modalByTitle(page, title);
|
||||
await modal.getByRole("button", { name: "Resume synchronisation" }).click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -25,6 +25,11 @@ const testSteps: Step[] = [
|
||||
args: ["run", "test:e2e:obsidian:cli-to-obsidian-sync"],
|
||||
},
|
||||
{ name: "Object Storage upload", args: ["run", "test:e2e:obsidian:minio-upload"] },
|
||||
{
|
||||
name: "Object Storage Setup URI workflow",
|
||||
args: ["run", "test:e2e:obsidian:object-storage-setup-uri-workflow"],
|
||||
},
|
||||
{ name: "P2P Setup URI workflow", args: ["run", "test:e2e:obsidian:p2p-setup-uri-workflow"] },
|
||||
{ name: "startup scan", args: ["run", "test:e2e:obsidian:startup-scan"] },
|
||||
{ name: "provisioned Setup URI workflow", args: ["run", "test:e2e:obsidian:setup-uri-workflow"] },
|
||||
{ name: "two-vault synchronisation", args: ["run", "test:e2e:obsidian:two-vault-sync"] },
|
||||
@@ -35,9 +40,11 @@ const testSteps: Step[] = [
|
||||
|
||||
const manageCouchDb = process.argv.includes("--manage-couchdb") || process.argv.includes("--manage-services");
|
||||
const manageMinio = process.argv.includes("--manage-minio") || process.argv.includes("--manage-services");
|
||||
const manageP2P = process.argv.includes("--manage-p2p") || process.argv.includes("--manage-services");
|
||||
const keepServices = process.argv.includes("--keep-services");
|
||||
const keepCouchDb = keepServices || process.argv.includes("--keep-couchdb");
|
||||
const keepMinio = keepServices || process.argv.includes("--keep-minio");
|
||||
const keepP2P = keepServices || process.argv.includes("--keep-p2p");
|
||||
|
||||
function npmBinary(): string {
|
||||
return process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
@@ -84,9 +91,18 @@ async function stopManagedMinio(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function stopManagedP2P(): Promise<void> {
|
||||
await runStep({
|
||||
name: "stop P2P relay fixture",
|
||||
args: ["run", "test:docker-p2p:stop"],
|
||||
optional: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
let shouldStopCouchDb = false;
|
||||
let shouldStopMinio = false;
|
||||
let shouldStopP2P = false;
|
||||
try {
|
||||
if (manageCouchDb) {
|
||||
await stopManagedCouchDb();
|
||||
@@ -98,11 +114,19 @@ async function main(): Promise<void> {
|
||||
await runStep({ name: "start MinIO fixture", args: ["run", "test:docker-s3:start"] });
|
||||
shouldStopMinio = !keepMinio;
|
||||
}
|
||||
if (manageP2P) {
|
||||
await stopManagedP2P();
|
||||
await runStep({ name: "start P2P relay fixture", args: ["run", "test:docker-p2p:start"] });
|
||||
shouldStopP2P = !keepP2P;
|
||||
}
|
||||
|
||||
for (const step of testSteps) {
|
||||
await runStep(step);
|
||||
}
|
||||
} finally {
|
||||
if (shouldStopP2P) {
|
||||
await stopManagedP2P();
|
||||
}
|
||||
if (shouldStopMinio) {
|
||||
await stopManagedMinio();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import {
|
||||
assertEqual,
|
||||
pushLocalChanges,
|
||||
waitForLiveSyncCoreReady,
|
||||
waitForLocalDatabaseEntry,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import {
|
||||
deleteObjectStoragePrefix,
|
||||
ensureObjectStorageBucket,
|
||||
listObjectStorageObjects,
|
||||
loadObjectStorageConfig,
|
||||
makeUniqueBucketPrefix,
|
||||
type ObjectStorageConfig,
|
||||
} from "../runner/objectStorage.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import {
|
||||
acknowledgeDisabledOptionalFeatures,
|
||||
captureAndStartInitialisation,
|
||||
confirmFastFetch,
|
||||
confirmRebuild,
|
||||
enterSetupURI,
|
||||
finishInitialisation,
|
||||
generateSetupURIFromDevice,
|
||||
resumeCompatibilityReviewIfShown,
|
||||
skipMissingRemoteConfiguration,
|
||||
type SetupArtifact,
|
||||
type SetupCaptureNames,
|
||||
} from "../runner/setupUri.ts";
|
||||
import {
|
||||
captureObsidianElement,
|
||||
captureObsidianPage,
|
||||
obsidianRemoteDebuggingPort,
|
||||
withObsidianPage,
|
||||
} from "../runner/ui.ts";
|
||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const captures: SetupCaptureNames = { scenario: "object-storage-setup-uri", guide: "object-storage-setup" };
|
||||
const noteFromFirst = "E2E/object-storage/from-first.md";
|
||||
const noteFromSecond = "E2E/object-storage/from-second.md";
|
||||
const firstContent =
|
||||
"# Object Storage from the first device\n\nThis note travelled through the first device's Setup URI.\n";
|
||||
const secondContent = "# Object Storage from the second device\n\nThis note completed the return journey.\n";
|
||||
|
||||
type RunnerContext = {
|
||||
binary: string;
|
||||
cliBinary: string;
|
||||
activeSessions: Set<ObsidianLiveSyncSession>;
|
||||
};
|
||||
|
||||
function sessionEnvironment(port: number): NodeJS.ProcessEnv {
|
||||
return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) };
|
||||
}
|
||||
|
||||
function sessionPorts(): readonly [number, number] {
|
||||
const first = obsidianRemoteDebuggingPort(process.env);
|
||||
const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1);
|
||||
if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) {
|
||||
throw new Error(`Invalid secondary Obsidian remote debugging port: ${second}`);
|
||||
}
|
||||
return [first, second];
|
||||
}
|
||||
|
||||
async function runDeno(script: string, environment: NodeJS.ProcessEnv): Promise<string> {
|
||||
const { stdout } = await execFileAsync(
|
||||
"deno",
|
||||
[
|
||||
"run",
|
||||
"--minimum-dependency-age=0",
|
||||
"--config=utils/flyio/deno.jsonc",
|
||||
"--frozen",
|
||||
"--lock=utils/flyio/deno.lock",
|
||||
"--allow-env",
|
||||
script,
|
||||
],
|
||||
{ cwd: process.cwd(), env: environment, maxBuffer: 4 * 1024 * 1024 }
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function generateBootstrapSetupURI(
|
||||
objectStorage: ObjectStorageConfig,
|
||||
bucketPrefix: string
|
||||
): Promise<SetupArtifact> {
|
||||
const setupPassphrase = randomBytes(24).toString("base64url");
|
||||
const output = await runDeno("utils/setup/generate_setup_uri.ts", {
|
||||
...process.env,
|
||||
remote_type: "s3",
|
||||
endpoint: objectStorage.endpoint,
|
||||
access_key: objectStorage.accessKey,
|
||||
secret_key: objectStorage.secretKey,
|
||||
bucket: objectStorage.bucket,
|
||||
region: objectStorage.region,
|
||||
force_path_style: String(objectStorage.forcePathStyle),
|
||||
bucket_prefix: bucketPrefix,
|
||||
passphrase: randomBytes(24).toString("base64url"),
|
||||
uri_passphrase: setupPassphrase,
|
||||
});
|
||||
const setupURI = output.split(/\r?\n/u).find((line) => line.startsWith("obsidian://setuplivesync?settings="));
|
||||
if (!setupURI) throw new Error("The public Setup URI generator did not emit an Object Storage Setup URI.");
|
||||
return { setupURI, setupPassphrase };
|
||||
}
|
||||
|
||||
async function startSession(
|
||||
context: RunnerContext,
|
||||
vault: TemporaryVault,
|
||||
port: number
|
||||
): Promise<ObsidianLiveSyncSession> {
|
||||
const session = await startObsidianLiveSyncSession({
|
||||
binary: context.binary,
|
||||
cliBinary: context.cliBinary,
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
env: sessionEnvironment(port),
|
||||
});
|
||||
context.activeSessions.add(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
async function stopSessions(context: RunnerContext): Promise<void> {
|
||||
for (const session of [...context.activeSessions]) {
|
||||
await stopSession(context, session);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
|
||||
if (!context.activeSessions.has(session)) return;
|
||||
await session.app.stop();
|
||||
context.activeSessions.delete(session);
|
||||
}
|
||||
|
||||
async function writeNote(
|
||||
cliBinary: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
path: string,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const content=${JSON.stringify(content)};`,
|
||||
"const folder=path.split('/').slice(0,-1).join('/');",
|
||||
"if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);",
|
||||
"const existing=app.vault.getAbstractFileByPath(path);",
|
||||
"if(existing) await app.vault.modify(existing,content);",
|
||||
"else await app.vault.create(path,content);",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
environment
|
||||
);
|
||||
await waitForLocalDatabaseEntry(cliBinary, environment, path);
|
||||
}
|
||||
|
||||
async function waitForPathContent(vault: TemporaryVault, path: string, expected: string): Promise<void> {
|
||||
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000);
|
||||
let lastContent = "";
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
lastContent = await readFile(join(vault.path, path), "utf8");
|
||||
if (lastContent === expected) return;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`);
|
||||
}
|
||||
|
||||
async function waitForObjectStorageData(config: ObjectStorageConfig, prefix: string): Promise<void> {
|
||||
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_OBJECT_STORAGE_TIMEOUT_MS ?? 30000);
|
||||
while (Date.now() < deadline) {
|
||||
if ((await listObjectStorageObjects(config, prefix)).length > 0) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`Timed out waiting for Object Storage data under ${prefix}.`);
|
||||
}
|
||||
|
||||
async function captureNote(port: number, path: string, text: string, filename: string): Promise<string> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.evaluate((notePath) => {
|
||||
const obsidian = globalThis as typeof globalThis & {
|
||||
app?: {
|
||||
workspace?: { openLinkText(path: string, sourcePath: string, newLeaf: boolean): Promise<void> };
|
||||
};
|
||||
};
|
||||
return obsidian.app?.workspace?.openLinkText(notePath, "", false);
|
||||
}, path);
|
||||
});
|
||||
await captureObsidianPage(port, `${filename}.full.png`, async (page) => {
|
||||
await page.getByText(text, { exact: false }).first().waitFor({ state: "visible", timeout: 30000 });
|
||||
});
|
||||
return await captureObsidianElement(port, filename, (page) => page.locator(".workspace-leaf.mod-active").first());
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
||||
|
||||
const objectStorage = await loadObjectStorageConfig();
|
||||
const bucketPrefix = makeUniqueBucketPrefix("setup-uri-workflow");
|
||||
const bootstrapArtifact = await generateBootstrapSetupURI(objectStorage, bucketPrefix);
|
||||
const vaultA = await createTemporaryVault();
|
||||
const vaultB = await createTemporaryVault();
|
||||
const [portA, portB] = sessionPorts();
|
||||
const context: RunnerContext = { binary, cliBinary: cli.binary, activeSessions: new Set() };
|
||||
const screenshots: string[] = [];
|
||||
|
||||
try {
|
||||
await ensureObjectStorageBucket(objectStorage);
|
||||
console.log(`Temporary Object Storage target: ${objectStorage.bucket}/${bucketPrefix}`);
|
||||
|
||||
const sessionA = await startSession(context, vaultA, portA);
|
||||
screenshots.push(await enterSetupURI(portA, "new", bootstrapArtifact, captures));
|
||||
screenshots.push(await captureAndStartInitialisation(portA, "new", captures));
|
||||
screenshots.push(await confirmRebuild(portA, captures));
|
||||
screenshots.push(await skipMissingRemoteConfiguration(portA, captures));
|
||||
screenshots.push(await acknowledgeDisabledOptionalFeatures(portA, captures));
|
||||
const firstState = await finishInitialisation(portA, context.cliBinary, sessionA.cliEnv);
|
||||
await resumeCompatibilityReviewIfShown(portA);
|
||||
assertEqual(
|
||||
firstState.endpoint,
|
||||
objectStorage.endpoint,
|
||||
"The first device did not activate the Object Storage endpoint."
|
||||
);
|
||||
assertEqual(
|
||||
firstState.bucket,
|
||||
objectStorage.bucket,
|
||||
"The first device did not activate the Object Storage bucket."
|
||||
);
|
||||
assertEqual(
|
||||
firstState.bucketPrefix,
|
||||
bucketPrefix,
|
||||
"The first device did not activate the unique bucket prefix."
|
||||
);
|
||||
|
||||
await writeNote(context.cliBinary, sessionA.cliEnv, noteFromFirst, firstContent);
|
||||
await pushLocalChanges(context.cliBinary, sessionA.cliEnv);
|
||||
await waitForObjectStorageData(objectStorage, bucketPrefix);
|
||||
const generated = await generateSetupURIFromDevice(portA, randomBytes(24).toString("base64url"), captures);
|
||||
if (generated.artifact.setupURI === bootstrapArtifact.setupURI) {
|
||||
throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one.");
|
||||
}
|
||||
screenshots.push(...generated.screenshots);
|
||||
await stopSession(context, sessionA);
|
||||
|
||||
const sessionB = await startSession(context, vaultB, portB);
|
||||
screenshots.push(await enterSetupURI(portB, "existing", generated.artifact, captures));
|
||||
screenshots.push(await captureAndStartInitialisation(portB, "existing", captures));
|
||||
screenshots.push(...(await confirmFastFetch(portB, captures)));
|
||||
const secondState = await finishInitialisation(portB, context.cliBinary, sessionB.cliEnv);
|
||||
await resumeCompatibilityReviewIfShown(portB);
|
||||
assertEqual(
|
||||
secondState.endpoint,
|
||||
objectStorage.endpoint,
|
||||
"The second device did not import the Object Storage endpoint."
|
||||
);
|
||||
assertEqual(
|
||||
secondState.bucketPrefix,
|
||||
bucketPrefix,
|
||||
"The second device did not import the unique bucket prefix."
|
||||
);
|
||||
await pushLocalChanges(context.cliBinary, sessionB.cliEnv);
|
||||
await waitForPathContent(vaultB, noteFromFirst, firstContent);
|
||||
screenshots.push(
|
||||
await captureNote(
|
||||
portB,
|
||||
noteFromFirst,
|
||||
"Object Storage from the first device",
|
||||
"guide-object-storage-setup-first-to-second.png"
|
||||
)
|
||||
);
|
||||
|
||||
await writeNote(context.cliBinary, sessionB.cliEnv, noteFromSecond, secondContent);
|
||||
await pushLocalChanges(context.cliBinary, sessionB.cliEnv);
|
||||
await stopSession(context, sessionB);
|
||||
|
||||
const returningSessionA = await startSession(context, vaultA, portA);
|
||||
await waitForLiveSyncCoreReady(context.cliBinary, returningSessionA.cliEnv);
|
||||
await resumeCompatibilityReviewIfShown(portA);
|
||||
await pushLocalChanges(context.cliBinary, returningSessionA.cliEnv);
|
||||
await waitForPathContent(vaultA, noteFromSecond, secondContent);
|
||||
screenshots.push(
|
||||
await captureNote(
|
||||
portA,
|
||||
noteFromSecond,
|
||||
"Object Storage from the second device",
|
||||
"guide-object-storage-setup-second-to-first.png"
|
||||
)
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Object Storage Setup URI and two-device roundtrip succeeded. Screenshots: ${screenshots.join(", ")}`
|
||||
);
|
||||
} finally {
|
||||
await stopSessions(context).catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : error);
|
||||
});
|
||||
await vaultA.dispose();
|
||||
await vaultB.dispose();
|
||||
if (process.env.E2E_OBSIDIAN_KEEP_OBJECT_STORAGE !== "true") {
|
||||
await deleteObjectStoragePrefix(objectStorage, bucketPrefix).catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,540 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { connect } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { assertEqual, waitForLocalDatabaseEntry } from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import {
|
||||
acknowledgeDisabledOptionalFeatures,
|
||||
captureAndStartInitialisation,
|
||||
captureGuideDialogue,
|
||||
confirmFastFetch,
|
||||
confirmRebuild,
|
||||
enterSetupURI,
|
||||
finishInitialisation,
|
||||
generateSetupURIFromDevice,
|
||||
modalByTitle,
|
||||
resumeCompatibilityReviewIfShown,
|
||||
type SetupArtifact,
|
||||
type SetupCaptureNames,
|
||||
} from "../runner/setupUri.ts";
|
||||
import {
|
||||
captureObsidianElement,
|
||||
captureObsidianPage,
|
||||
obsidianRemoteDebuggingPort,
|
||||
withObsidianPage,
|
||||
} from "../runner/ui.ts";
|
||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const captures: SetupCaptureNames = { scenario: "p2p-setup-uri", guide: "p2p-setup" };
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_P2P_WORKFLOW_TIMEOUT_MS ?? 60000);
|
||||
const noteFromFirst = "E2E/p2p/from-first.md";
|
||||
const noteFromSecond = "E2E/p2p/from-second.md";
|
||||
const firstContent = "# P2P from the first device\n\nThis note was fetched directly from the first device.\n";
|
||||
const secondContent = "# P2P from the second device\n\nThis note completed the return journey.\n";
|
||||
|
||||
type RunnerContext = {
|
||||
binary: string;
|
||||
cliBinary: string;
|
||||
activeSessions: Set<ObsidianLiveSyncSession>;
|
||||
};
|
||||
|
||||
function sessionEnvironment(port: number): NodeJS.ProcessEnv {
|
||||
return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) };
|
||||
}
|
||||
|
||||
function sessionPorts(): readonly [number, number] {
|
||||
const first = obsidianRemoteDebuggingPort(process.env);
|
||||
const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1);
|
||||
if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) {
|
||||
throw new Error(`Invalid secondary Obsidian remote debugging port: ${second}`);
|
||||
}
|
||||
return [first, second];
|
||||
}
|
||||
|
||||
async function runDeno(script: string, environment: NodeJS.ProcessEnv): Promise<string> {
|
||||
const { stdout } = await execFileAsync(
|
||||
"deno",
|
||||
[
|
||||
"run",
|
||||
"--minimum-dependency-age=0",
|
||||
"--config=utils/flyio/deno.jsonc",
|
||||
"--frozen",
|
||||
"--lock=utils/flyio/deno.lock",
|
||||
"--allow-env",
|
||||
script,
|
||||
],
|
||||
{ cwd: process.cwd(), env: environment, maxBuffer: 4 * 1024 * 1024 }
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function generateBootstrapSetupURI(relay: string): Promise<SetupArtifact> {
|
||||
const setupPassphrase = randomBytes(24).toString("base64url");
|
||||
const output = await runDeno("utils/setup/generate_setup_uri.ts", {
|
||||
...process.env,
|
||||
remote_type: "p2p",
|
||||
p2p_relays: relay,
|
||||
p2p_room_id: `real-obsidian-${randomBytes(12).toString("hex")}`,
|
||||
p2p_passphrase: randomBytes(24).toString("base64url"),
|
||||
p2p_app_id: "self-hosted-livesync-real-obsidian-e2e",
|
||||
p2p_auto_start: "false",
|
||||
p2p_auto_broadcast: "false",
|
||||
passphrase: randomBytes(24).toString("base64url"),
|
||||
uri_passphrase: setupPassphrase,
|
||||
});
|
||||
const setupURI = output.split(/\r?\n/u).find((line) => line.startsWith("obsidian://setuplivesync?settings="));
|
||||
if (!setupURI) throw new Error("The public Setup URI generator did not emit a P2P Setup URI.");
|
||||
return { setupURI, setupPassphrase };
|
||||
}
|
||||
|
||||
async function waitForRelay(relay: string): Promise<void> {
|
||||
const endpoint = new URL(relay);
|
||||
const port = Number(endpoint.port || (endpoint.protocol === "wss:" ? 443 : 80));
|
||||
const host = endpoint.hostname === "localhost" ? "127.0.0.1" : endpoint.hostname;
|
||||
const deadline = Date.now() + Number(process.env.E2E_P2P_RELAY_READY_TIMEOUT_MS ?? 30000);
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = connect({ host, port });
|
||||
socket.setTimeout(1000);
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.once("timeout", () => {
|
||||
socket.destroy();
|
||||
reject(new Error("connection timed out"));
|
||||
});
|
||||
socket.once("error", reject);
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`P2P relay is not ready at ${relay}: ${lastError instanceof Error ? lastError.message : lastError}`
|
||||
);
|
||||
}
|
||||
|
||||
async function startSession(
|
||||
context: RunnerContext,
|
||||
vault: TemporaryVault,
|
||||
port: number
|
||||
): Promise<ObsidianLiveSyncSession> {
|
||||
const session = await startObsidianLiveSyncSession({
|
||||
binary: context.binary,
|
||||
cliBinary: context.cliBinary,
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
env: sessionEnvironment(port),
|
||||
});
|
||||
context.activeSessions.add(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
async function stopSessions(context: RunnerContext): Promise<void> {
|
||||
for (const session of [...context.activeSessions]) {
|
||||
await session.app.stop();
|
||||
context.activeSessions.delete(session);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeNote(
|
||||
cliBinary: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
path: string,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const content=${JSON.stringify(content)};`,
|
||||
"const folder=path.split('/').slice(0,-1).join('/');",
|
||||
"if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);",
|
||||
"const existing=app.vault.getAbstractFileByPath(path);",
|
||||
"if(existing) await app.vault.modify(existing,content);",
|
||||
"else await app.vault.create(path,content);",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
environment
|
||||
);
|
||||
await waitForLocalDatabaseEntry(cliBinary, environment, path);
|
||||
}
|
||||
|
||||
async function waitForPathContent(vault: TemporaryVault, path: string, expected: string): Promise<void> {
|
||||
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 60000);
|
||||
let lastContent = "";
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
lastContent = await readFile(join(vault.path, path), "utf8");
|
||||
if (lastContent === expected) return;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`);
|
||||
}
|
||||
|
||||
async function readReflectionDiagnostics(
|
||||
cliBinary: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
path: string
|
||||
): Promise<unknown> {
|
||||
return await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const settings=core.services.setting.currentSettings();",
|
||||
"const entry=await core.localDatabase.getDBEntry(path,undefined,false,true).catch(()=>false);",
|
||||
"const chunks=entry&&Array.isArray(entry.children)?await Promise.all(entry.children.map(async(id)=>{",
|
||||
"const chunk=await core.localDatabase.getDBEntry(id,undefined,false,true).catch(()=>false);",
|
||||
"return {id,found:!!chunk};",
|
||||
"})):[];",
|
||||
"return JSON.stringify({",
|
||||
"suspendFileWatching:settings.suspendFileWatching,",
|
||||
"suspendParseReplicationResult:settings.suspendParseReplicationResult,",
|
||||
"configured:settings.isConfigured,",
|
||||
"entry:entry?{id:entry._id,path:entry.path,children:entry.children||[]}:false,",
|
||||
"chunks,",
|
||||
"databaseQueueCount:core.services.replication.databaseQueueCount?.value,",
|
||||
"storageApplyingCount:core.services.replication.storageApplyingCount?.value,",
|
||||
"replicationResultCount:core.services.replication.replicationResultCount?.value,",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
environment
|
||||
);
|
||||
}
|
||||
|
||||
async function executeCommand(port: number, commandId: string): Promise<void> {
|
||||
const opened = await withObsidianPage(port, async (page) => {
|
||||
return await page.evaluate(
|
||||
(id) =>
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
app?: { commands?: { executeCommandById(commandId: string): boolean } };
|
||||
}
|
||||
).app?.commands?.executeCommandById(id) === true,
|
||||
commandId
|
||||
);
|
||||
});
|
||||
if (!opened) throw new Error(`Obsidian command was not available: ${commandId}`);
|
||||
}
|
||||
|
||||
async function openP2PStatus(port: number, filename: string): Promise<string> {
|
||||
await executeCommand(port, "obsidian-livesync:open-p2p-server-status");
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const heading = page.getByRole("heading", { name: "Signalling Status" }).last();
|
||||
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const pane = heading.locator(
|
||||
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
|
||||
);
|
||||
const open = pane.getByRole("button", { name: "Open connection" });
|
||||
if (await open.isVisible()) {
|
||||
const blockingDialogues = await page.locator(".modal-container:visible").evaluateAll((elements) =>
|
||||
elements.map((element) => ({
|
||||
title: element.querySelector(".modal-title")?.textContent?.trim() ?? "",
|
||||
text: element.textContent?.trim().replace(/\s+/gu, " ").slice(0, 240) ?? "",
|
||||
}))
|
||||
);
|
||||
if (blockingDialogues.length > 0) {
|
||||
throw new Error(
|
||||
`P2P connection control is blocked by a dialogue: ${JSON.stringify(blockingDialogues)}`
|
||||
);
|
||||
}
|
||||
await open.click({ timeout: uiTimeoutMs });
|
||||
}
|
||||
await pane.locator(".status-value.connected").waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
return await captureObsidianElement(port, filename, (page) => {
|
||||
const heading = page.getByRole("heading", { name: "Signalling Status" }).last();
|
||||
return heading.locator(
|
||||
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function reconnectP2PStatus(port: number): Promise<void> {
|
||||
await executeCommand(port, "obsidian-livesync:open-p2p-server-status");
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const heading = page.getByRole("heading", { name: "Signalling Status" }).last();
|
||||
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const pane = heading.locator(
|
||||
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
|
||||
);
|
||||
const disconnect = pane.getByRole("button", { name: "Disconnect", exact: true });
|
||||
if (await disconnect.isVisible()) {
|
||||
await disconnect.click({ timeout: uiTimeoutMs });
|
||||
}
|
||||
const open = pane.getByRole("button", { name: "Open connection", exact: true });
|
||||
await open.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await open.click({ timeout: uiTimeoutMs });
|
||||
await pane.locator(".status-value.connected").waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
}
|
||||
|
||||
async function acceptConnectionRequests(
|
||||
ports: readonly number[],
|
||||
stop: () => boolean,
|
||||
screenshots: string[]
|
||||
): Promise<void> {
|
||||
const captured = new Set<number>();
|
||||
while (!stop()) {
|
||||
for (const port of ports) {
|
||||
const visible = await withObsidianPage(port, async (page) => {
|
||||
return await modalByTitle(page, "P2P Connection Request").isVisible();
|
||||
}).catch(() => false);
|
||||
if (!visible) continue;
|
||||
if (!captured.has(port)) {
|
||||
const requestNumber =
|
||||
screenshots.filter((filename) => filename.includes("guide-p2p-setup-connection-request-")).length +
|
||||
1;
|
||||
screenshots.push(
|
||||
await captureGuideDialogue(
|
||||
port,
|
||||
`guide-p2p-setup-connection-request-${requestNumber}.png`,
|
||||
"P2P Connection Request"
|
||||
)
|
||||
);
|
||||
captured.add(port);
|
||||
}
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, "P2P Connection Request")
|
||||
.getByRole("button", { name: "Accept", exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFromFirstPeer(
|
||||
sessionA: ObsidianLiveSyncSession,
|
||||
portA: number,
|
||||
portB: number,
|
||||
screenshots: string[]
|
||||
): Promise<void> {
|
||||
try {
|
||||
await withObsidianPage(portB, async (page) => {
|
||||
const modal = modalByTitle(page, "P2P Rebuild");
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await modal.locator(".peer-item").first().waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
} catch (error) {
|
||||
const firstDeviceAlive = sessionA.app.process.exitCode === null && sessionA.app.process.signalCode === null;
|
||||
const firstDeviceUi = await withObsidianPage(portA, async (page) => {
|
||||
return await page.locator("body").innerText();
|
||||
}).catch(() => undefined);
|
||||
const secondDeviceDialogue = await withObsidianPage(portB, async (page) => {
|
||||
return await modalByTitle(page, "P2P Rebuild").innerText();
|
||||
}).catch(() => undefined);
|
||||
throw new Error(
|
||||
[
|
||||
error instanceof Error ? error.message : String(error),
|
||||
`First Obsidian process alive: ${firstDeviceAlive}`,
|
||||
`First Obsidian CDP reachable: ${firstDeviceUi !== undefined}`,
|
||||
firstDeviceUi === undefined ? undefined : `First device UI: ${firstDeviceUi.slice(0, 1_500)}`,
|
||||
secondDeviceDialogue === undefined
|
||||
? undefined
|
||||
: `Second-device P2P Rebuild dialogue: ${secondDeviceDialogue.slice(0, 1_500)}`,
|
||||
sessionA.app.output().stderr
|
||||
? `First Obsidian stderr: ${sessionA.app.output().stderr.slice(-2_000)}`
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
screenshots.push(await captureGuideDialogue(portB, "guide-p2p-setup-select-first-device.png", "P2P Rebuild"));
|
||||
|
||||
let finished = false;
|
||||
const acceptor = acceptConnectionRequests([portA, portB], () => finished, screenshots);
|
||||
try {
|
||||
await withObsidianPage(portB, async (page) => {
|
||||
const modal = modalByTitle(page, "P2P Rebuild");
|
||||
await modal
|
||||
.locator(".peer-item")
|
||||
.first()
|
||||
.getByRole("button", { name: "Sync", exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
} finally {
|
||||
finished = true;
|
||||
await acceptor;
|
||||
}
|
||||
|
||||
await withObsidianPage(portB, async (page) => {
|
||||
const modal = modalByTitle(page, "P2P Rebuild");
|
||||
if (await modal.isVisible()) {
|
||||
await modal.getByRole("button", { name: "Skip and close" }).click({ timeout: uiTimeoutMs });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function replicateFromStatusPane(port: number): Promise<void> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const heading = page.getByRole("heading", { name: "Detected Peers" }).last();
|
||||
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const pane = heading.locator(
|
||||
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
|
||||
);
|
||||
await pane.getByRole("button", { name: "Refresh", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
const replicate = pane.getByRole("button", { name: "Replicate now" }).first();
|
||||
await replicate.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await replicate.click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForDetectedPeer(port: number): Promise<void> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const heading = page.getByRole("heading", { name: "Detected Peers" }).last();
|
||||
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const pane = heading.locator(
|
||||
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
|
||||
);
|
||||
await pane.getByRole("button", { name: "Refresh", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await pane.getByRole("button", { name: "Replicate now" }).first().waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function captureNote(port: number, path: string, text: string, filename: string): Promise<string> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.evaluate((notePath) => {
|
||||
const obsidian = globalThis as typeof globalThis & {
|
||||
app?: {
|
||||
workspace?: { openLinkText(path: string, sourcePath: string, newLeaf: boolean): Promise<void> };
|
||||
};
|
||||
};
|
||||
return obsidian.app?.workspace?.openLinkText(notePath, "", false);
|
||||
}, path);
|
||||
});
|
||||
await captureObsidianPage(port, `${filename}.full.png`, async (page) => {
|
||||
await page.getByText(text, { exact: false }).first().waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
return await captureObsidianElement(port, filename, (page) => page.locator(".workspace-leaf.mod-active").first());
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
||||
|
||||
const relay = process.env.E2E_P2P_RELAY_URL ?? `ws://127.0.0.1:${process.env.E2E_P2P_RELAY_PORT ?? "4010"}/`;
|
||||
await waitForRelay(relay);
|
||||
const bootstrapArtifact = await generateBootstrapSetupURI(relay);
|
||||
const vaultA = await createTemporaryVault();
|
||||
const vaultB = await createTemporaryVault();
|
||||
const [portA, portB] = sessionPorts();
|
||||
const context: RunnerContext = { binary, cliBinary: cli.binary, activeSessions: new Set() };
|
||||
const screenshots: string[] = [];
|
||||
|
||||
try {
|
||||
console.log(`Temporary P2P relay: ${relay}`);
|
||||
console.log(`Temporary P2P devices: ${vaultA.name}, ${vaultB.name}`);
|
||||
|
||||
const sessionA = await startSession(context, vaultA, portA);
|
||||
screenshots.push(await enterSetupURI(portA, "new", bootstrapArtifact, captures));
|
||||
screenshots.push(await captureAndStartInitialisation(portA, "new", captures));
|
||||
screenshots.push(await confirmRebuild(portA, captures));
|
||||
screenshots.push(await acknowledgeDisabledOptionalFeatures(portA, captures));
|
||||
const firstState = await finishInitialisation(portA, context.cliBinary, sessionA.cliEnv);
|
||||
await resumeCompatibilityReviewIfShown(portA);
|
||||
assertEqual(firstState.p2pEnabled, true, "The first device did not enable P2P.");
|
||||
assertEqual(firstState.p2pRelays, relay, "The first device did not activate the P2P relay.");
|
||||
await writeNote(context.cliBinary, sessionA.cliEnv, noteFromFirst, firstContent);
|
||||
|
||||
const generated = await generateSetupURIFromDevice(portA, randomBytes(24).toString("base64url"), captures);
|
||||
if (generated.artifact.setupURI === bootstrapArtifact.setupURI) {
|
||||
throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one.");
|
||||
}
|
||||
screenshots.push(...generated.screenshots);
|
||||
screenshots.push(await openP2PStatus(portA, "guide-p2p-setup-first-device-connected.png"));
|
||||
|
||||
const sessionB = await startSession(context, vaultB, portB);
|
||||
screenshots.push(await enterSetupURI(portB, "existing", generated.artifact, captures));
|
||||
screenshots.push(await captureAndStartInitialisation(portB, "existing", captures));
|
||||
screenshots.push(...(await confirmFastFetch(portB, captures)));
|
||||
await fetchFromFirstPeer(sessionA, portA, portB, screenshots);
|
||||
await waitForLocalDatabaseEntry(context.cliBinary, sessionB.cliEnv, noteFromFirst, {
|
||||
timeoutMs: uiTimeoutMs,
|
||||
});
|
||||
const secondState = await finishInitialisation(portB, context.cliBinary, sessionB.cliEnv);
|
||||
await resumeCompatibilityReviewIfShown(portB);
|
||||
assertEqual(secondState.p2pEnabled, true, "The second device did not enable P2P.");
|
||||
assertEqual(secondState.p2pRelays, relay, "The second device did not import the P2P relay.");
|
||||
assertEqual(secondState.p2pRoomId, firstState.p2pRoomId, "The two devices did not join the same P2P room.");
|
||||
try {
|
||||
await waitForPathContent(vaultB, noteFromFirst, firstContent);
|
||||
} catch (error) {
|
||||
const diagnostics = await readReflectionDiagnostics(context.cliBinary, sessionB.cliEnv, noteFromFirst);
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}\nReflection diagnostics: ${JSON.stringify(diagnostics)}`
|
||||
);
|
||||
}
|
||||
screenshots.push(
|
||||
await captureNote(portB, noteFromFirst, "P2P from the first device", "guide-p2p-setup-first-to-second.png")
|
||||
);
|
||||
|
||||
await writeNote(context.cliBinary, sessionB.cliEnv, noteFromSecond, secondContent);
|
||||
await reconnectP2PStatus(portA);
|
||||
await reconnectP2PStatus(portB);
|
||||
await waitForDetectedPeer(portA);
|
||||
screenshots.push(await openP2PStatus(portA, "guide-p2p-setup-devices-connected.png"));
|
||||
let returnJourneyFinished = false;
|
||||
const returnJourneyAcceptor = acceptConnectionRequests(
|
||||
[portA, portB],
|
||||
() => returnJourneyFinished,
|
||||
screenshots
|
||||
);
|
||||
try {
|
||||
await replicateFromStatusPane(portA);
|
||||
await waitForPathContent(vaultA, noteFromSecond, secondContent);
|
||||
} finally {
|
||||
returnJourneyFinished = true;
|
||||
await returnJourneyAcceptor;
|
||||
}
|
||||
screenshots.push(
|
||||
await captureNote(
|
||||
portA,
|
||||
noteFromSecond,
|
||||
"P2P from the second device",
|
||||
"guide-p2p-setup-second-to-first.png"
|
||||
)
|
||||
);
|
||||
|
||||
console.log(`P2P Setup URI and two-device roundtrip succeeded. Screenshots: ${screenshots.join(", ")}`);
|
||||
} finally {
|
||||
await stopSessions(context).catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : error);
|
||||
});
|
||||
await vaultA.dispose();
|
||||
await vaultB.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -15,6 +15,8 @@ const focusedScenarios = new Set([
|
||||
"couchdb-upload",
|
||||
"cli-to-obsidian-sync",
|
||||
"minio-upload",
|
||||
"object-storage-setup-uri-workflow",
|
||||
"p2p-setup-uri-workflow",
|
||||
"startup-scan",
|
||||
"setup-uri-workflow",
|
||||
"two-vault-sync",
|
||||
@@ -31,8 +33,9 @@ real-Obsidian scenario. Supported scenarios:
|
||||
|
||||
${[...focusedScenarios].map((scenario) => ` ${scenario}`).join("\n")}
|
||||
|
||||
This wrapper does not start CouchDB or Object Storage. Use the documented
|
||||
service commands or the complete local-suite:services wrapper when required.`;
|
||||
This wrapper does not start CouchDB, Object Storage, or the P2P signalling
|
||||
relay. Use the documented service commands or the complete
|
||||
local-suite:services wrapper when required.`;
|
||||
}
|
||||
|
||||
// npm receives each argument directly. In particular, environment values and
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type LocalDatabaseEntry,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { generateSetupURIFromDevice, resumeCompatibilityReviewIfShown } from "../runner/setupUri.ts";
|
||||
import {
|
||||
captureObsidianDialogue,
|
||||
captureObsidianElement,
|
||||
@@ -39,6 +40,9 @@ const initialisationTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_INITIALISA
|
||||
const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000);
|
||||
const notePath = "E2E/setup-uri/provisioned-workflow.md";
|
||||
const noteContent = "# Provisioned Setup URI\n\nThis note travelled through the generated CouchDB Setup URI.\n";
|
||||
const returnNotePath = "E2E/setup-uri/from-second-device.md";
|
||||
const returnNoteContent =
|
||||
"# CouchDB from the second device\n\nThis note completed the return journey through CouchDB.\n";
|
||||
const snippetPath = ".obsidian/snippets/setup-uri-workflow.css";
|
||||
const snippetContent = [
|
||||
"body {",
|
||||
@@ -704,6 +708,7 @@ async function main(): Promise<void> {
|
||||
activeSessions: new Set(),
|
||||
};
|
||||
const screenshots: string[] = [];
|
||||
let secondDeviceArtifact: SetupArtifact | undefined;
|
||||
|
||||
try {
|
||||
await assertCouchDbReachable(couchDb);
|
||||
@@ -735,6 +740,7 @@ async function main(): Promise<void> {
|
||||
);
|
||||
if (firstCompletion.screenshot) screenshots.push(firstCompletion.screenshot);
|
||||
const firstState = firstCompletion.state;
|
||||
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
|
||||
assertEqual(firstState.remoteType, "", "The first device did not activate the CouchDB remote profile.");
|
||||
assertEqual(
|
||||
firstState.syncInternalFiles,
|
||||
@@ -750,6 +756,16 @@ async function main(): Promise<void> {
|
||||
);
|
||||
await enableHiddenFileSync(context.cliBinary, session.cliEnv);
|
||||
await uploadWorkflowFiles(context, session, vaultA);
|
||||
const generated = await generateSetupURIFromDevice(
|
||||
session.remoteDebuggingPort,
|
||||
randomBytes(24).toString("base64url"),
|
||||
{ scenario: "setup-uri-workflow", guide: "quick-setup" }
|
||||
);
|
||||
if (generated.artifact.setupURI === artifact.setupURI) {
|
||||
throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one.");
|
||||
}
|
||||
secondDeviceArtifact = generated.artifact;
|
||||
screenshots.push(...generated.screenshots);
|
||||
} catch (error) {
|
||||
await captureFailure(session);
|
||||
throw error;
|
||||
@@ -759,7 +775,9 @@ async function main(): Promise<void> {
|
||||
|
||||
session = await startUnconfiguredSession(context, vaultB);
|
||||
try {
|
||||
await enterSetupURI(session.remoteDebuggingPort, "existing", artifact);
|
||||
if (!secondDeviceArtifact)
|
||||
throw new Error("The first device did not generate the second-device Setup URI.");
|
||||
await enterSetupURI(session.remoteDebuggingPort, "existing", secondDeviceArtifact);
|
||||
screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "existing"));
|
||||
screenshots.push(...(await confirmFastFetch(session.remoteDebuggingPort)));
|
||||
const secondCompletion = await finishInitialisation(
|
||||
@@ -770,6 +788,7 @@ async function main(): Promise<void> {
|
||||
);
|
||||
if (secondCompletion.screenshot) screenshots.push(secondCompletion.screenshot);
|
||||
const secondState = secondCompletion.state;
|
||||
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
|
||||
assertEqual(secondState.remoteType, "", "The second device did not activate the CouchDB remote profile.");
|
||||
await enableHiddenFileSync(context.cliBinary, session.cliEnv);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
@@ -783,6 +802,27 @@ async function main(): Promise<void> {
|
||||
"The hidden snippet did not reach the second Setup URI device."
|
||||
);
|
||||
screenshots.push(await captureSynchronisedNote(session.remoteDebuggingPort));
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, returnNotePath, returnNoteContent);
|
||||
const returnEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, returnNotePath);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await waitForRemoteEntry(context, returnEntry);
|
||||
} catch (error) {
|
||||
await captureFailure(session);
|
||||
throw error;
|
||||
} finally {
|
||||
await stopTrackedSession(context, session);
|
||||
}
|
||||
|
||||
session = await startUnconfiguredSession(context, vaultA);
|
||||
try {
|
||||
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
const receivedReturnNote = await waitForPathContent(vaultA.path, returnNotePath, returnNoteContent);
|
||||
assertEqual(
|
||||
receivedReturnNote,
|
||||
returnNoteContent,
|
||||
"The second device's ordinary note did not return to the first Setup URI device."
|
||||
);
|
||||
} catch (error) {
|
||||
await captureFailure(session);
|
||||
throw error;
|
||||
@@ -791,7 +831,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
console.log(
|
||||
`The public provisioning and Setup URI workflow configured two fresh devices, synchronised a note, and synchronised a hidden snippet. Screenshots: ${screenshots.join(", ")}`
|
||||
`The public provisioning and first-device-generated Setup URI workflow configured two fresh devices, completed an ordinary-note round-trip, and synchronised a hidden snippet. Screenshots: ${screenshots.join(", ")}`
|
||||
);
|
||||
} finally {
|
||||
await stopTrackedSessions(context).catch((error: unknown) => {
|
||||
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
services:
|
||||
p2p-relay:
|
||||
image: ghcr.io/hoytech/strfry:latest
|
||||
container_name: livesync-e2e-p2p-relay
|
||||
entrypoint: ["/app/strfry"]
|
||||
command: ["--config", "/etc/strfry/strfry.conf", "relay"]
|
||||
ports:
|
||||
- "${E2E_P2P_RELAY_PORT:-4010}:7777"
|
||||
volumes:
|
||||
- ./strfry.conf:/etc/strfry/strfry.conf:ro
|
||||
tmpfs:
|
||||
- /app/strfry-db:rw,size=256m,mode=1777
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
db = "./strfry-db/"
|
||||
|
||||
relay {
|
||||
bind = "0.0.0.0"
|
||||
port = 7777
|
||||
nofiles = 100000
|
||||
|
||||
info {
|
||||
name = "Self-hosted LiveSync E2E relay"
|
||||
description = "Local Nostr signalling fixture for real-Obsidian P2P tests"
|
||||
}
|
||||
|
||||
maxWebsocketPayloadSize = 131072
|
||||
autoPingSeconds = 55
|
||||
|
||||
writePolicy {
|
||||
plugin = ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user