test: cover Commonlib 0.2 consumer workflows

This commit is contained in:
vorotamoroz committed 2026-10-23 17:37:42 +00:00
1 parent b40784e66f
commit 1a54d21e71
8 files changed
+276 -81

No files matched your search

+1
View File
@@ -58,6 +58,7 @@
"test:e2e:obsidian:onboarding-invitation": "tsx test/e2e-obsidian/scripts/onboarding-invitation.ts", "test:e2e:obsidian:onboarding-invitation": "tsx test/e2e-obsidian/scripts/onboarding-invitation.ts",
"test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts", "test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts",
"test:e2e:obsidian:joined-devices": "tsx test/e2e-obsidian/scripts/joined-devices.ts", "test:e2e:obsidian:joined-devices": "tsx test/e2e-obsidian/scripts/joined-devices.ts",
"test:e2e:obsidian:joined-devices-object-storage": "tsx test/e2e-obsidian/scripts/joined-devices.ts --object-storage",
"test:e2e:obsidian:database-diagnostics": "tsx test/e2e-obsidian/scripts/database-diagnostics.ts", "test:e2e:obsidian:database-diagnostics": "tsx test/e2e-obsidian/scripts/database-diagnostics.ts",
"test:e2e:obsidian:database-diagnostics-couchdb": "tsx test/e2e-obsidian/scripts/database-diagnostics-couchdb.ts", "test:e2e:obsidian:database-diagnostics-couchdb": "tsx test/e2e-obsidian/scripts/database-diagnostics-couchdb.ts",
"test:e2e:obsidian:conflict-dialog-policy": "tsx test/e2e-obsidian/scripts/conflict-dialog-policy.ts", "test:e2e:obsidian:conflict-dialog-policy": "tsx test/e2e-obsidian/scripts/conflict-dialog-policy.ts",
@@ -137,6 +137,62 @@ describe("joined devices service feature", () => {
data.close(); data.close();
await pending; await pending;
}); });
it.each(["dialogue cancellation", "remote switch", "unload"] as const)(
"does not send a pending forget operation after %s",
async (invalidation) => {
const data = fixture();
const resource = { read: data.read, forget: data.forget, dispose: data.dispose };
let resolve!: (value: typeof resource) => void;
data.createResource.mockReturnValueOnce(
new Promise((done) => {
resolve = done;
})
);
const { options, pending } = await open(data);
const forgetting = options.forget("other");
const rejected = expect(forgetting).rejects.toThrow();
await vi.waitFor(() => expect(data.createResource).toHaveBeenCalledOnce());
if (invalidation === "dialogue cancellation") options.cancel();
if (invalidation === "remote switch") data.settings.couchDB_DBNAME = "new-vault";
if (invalidation === "unload") for (const unload of data.unload) await unload();
resolve(resource);
await rejected;
expect(data.forget).not.toHaveBeenCalled();
expect(data.dispose).toHaveBeenCalledOnce();
data.close();
await pending;
}
);
it("discards a completed forget result after the dialogue closes and releases its resource", async () => {
const data = fixture();
let resolve!: (value: "forgotten") => void;
data.forget.mockReturnValueOnce(
new Promise((done) => {
resolve = done;
})
);
const { options, pending } = await open(data);
const forgetting = options.forget("other");
const rejected = expect(forgetting).rejects.toThrow();
await vi.waitFor(() => expect(data.forget).toHaveBeenCalledWith("other"));
data.close();
await pending;
resolve("forgotten");
await rejected;
expect(data.dispose).toHaveBeenCalledOnce();
});
it("propagates a failed forget operation after releasing its resource", async () => {
const data = fixture();
const failure = new Error("conditional write failed");
data.forget.mockRejectedValueOnce(failure);
const { options, pending } = await open(data);
await expect(options.forget("other")).rejects.toBe(failure);
expect(data.dispose).toHaveBeenCalledOnce();
data.close();
await pending;
});
it("deduplicates advisory Notices and fences their actions after configuration changes", async () => { it("deduplicates advisory Notices and fences their actions after configuration changes", async () => {
const data = fixture(); const data = fixture();
const { options, pending } = await open(data); const { options, pending } = await open(data);
+4 -4
View File
@@ -123,9 +123,9 @@ The underlying `test:e2e:obsidian:<scenario>` scripts remain available for an im
`configuration-encryption` needs no remote service. It applies a custom configuration key through the settings interface, restarts the same isolated Vault, and applies a change to only the custom string. It then copies the encrypted settings into a fresh profile without that key and checks masked input, wrong-input retry, cancellation without a settings write or database initialisation, re-enabling for another attempt, access to the settings tab, and restoration after a further restart. Connection, E2EE, profile, and independent ID values must remain unchanged. These checks establish ordinary Apply and restart behaviour, without simulating process termination between the device-key and settings writes. It also checks changes from custom and ASK modes to Default through the settings interface, and recovery from a wrong saved device key, including cancellation, the validated replacement key, and another restart. `configuration-encryption` needs no remote service. It applies a custom configuration key through the settings interface, restarts the same isolated Vault, and applies a change to only the custom string. It then copies the encrypted settings into a fresh profile without that key and checks masked input, wrong-input retry, cancellation without a settings write or database initialisation, re-enabling for another attempt, access to the settings tab, and restoration after a further restart. Connection, E2EE, profile, and independent ID values must remain unchanged. These checks establish ordinary Apply and restart behaviour, without simulating process termination between the device-key and settings writes. It also checks changes from custom and ASK modes to Default through the settings interface, and recovery from a wrong saved device key, including cancellation, the validated replacement key, and another restart.
`test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, the Setup URI controls, automatic adjustment when differences are limited to compatible chunk settings, and both manual configuration-mismatch routes. The same session opens the live log and generated full report, reaches the `Hatch` recovery controls, writes and removes its own persistent log, and runs the missing-chunk recreation and file-verification actions against the empty disposable Vault. It captures representative desktop and mobile dialogues, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that each mounted operation settles without an error. It does not apply a remote configuration, contact a remote service, or claim to repair a deliberately damaged database. `test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, the Setup URI controls, automatic adjustment when differences are limited to compatible chunk settings, and both manual configuration-mismatch routes. The P2P setup check delays the saved device-name read, requires the name field and both continuation controls to remain disabled, then verifies the saved name and selected connection settings reach the setup workflow. The same session opens the live log and generated full report, reaches the `Hatch` recovery controls, writes and removes its own persistent log, and runs the missing-chunk recreation and file-verification actions against the empty disposable Vault. It captures representative desktop and mobile dialogues, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that each mounted operation settles without an error. It does not apply a remote configuration, contact a remote service, or claim to repair a deliberately damaged database.
`test:e2e:obsidian:joined-devices` uses a temporary Vault and isolated CouchDB database to open Joined devices from Remote Configuration, a version Notice, and its registered command. It checks that the current device appears first with current metadata, reviews locked-out and other-device records, confirms a manual refresh reads an updated remote record, and forgets one device through the inline confirmation. It verifies the raw milestone retains the other device, remote state, and preferred-ID tweaks, then checks mobile scrolling, safe-area bounds, and touch targets. It captures the version Notice, desktop list, forget confirmation, completed removal, and mobile dialogue; unrelated Notices are dismissed before the list captures. `test:e2e:obsidian:joined-devices` uses a temporary Vault and isolated CouchDB database to open Joined devices from Remote Configuration, a version Notice, and its registered command. `test:e2e:obsidian:joined-devices-object-storage` runs the same assertions against a unique Object Storage prefix, including the conditional removal supported by the test S3 service. It checks that the current device appears first with current metadata, reviews locked-out and other-device records, confirms a manual refresh reads an updated remote record, and verifies that cancelling the inline confirmation leaves the remote unchanged before forgetting one device. It verifies the raw milestone retains the other device, remote state, and preferred-ID tweaks, then checks mobile scrolling, safe-area bounds, and touch targets. It captures the version Notice, desktop list, forget confirmation, completed removal, and mobile dialogue; unrelated Notices are dismissed before the list captures.
`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then enables Advanced mode and persists one numeric Advanced setting. On Obsidian before 1.13, this covers the imperative settings fallback used by the `SettingSpec` proof. On Obsidian 1.13 or later, it verifies the task-oriented landing order, the separate Synchronisation group containing Remote Configuration and Sync Settings, and the General Settings group containing Appearance, Logging, and Extra menus. It reopens and cancels onboarding from Quick Setup, opens all 14 nested settings pages, searches globally for the Advanced control, captures the landing page, search result, Advanced page, and mobile landing page, and verifies that the value remains after the settings dialogue is closed and reopened. The mobile landing check confirms that Remote Configuration is reachable without initial scrolling. Finally, it selects the Synchronisation Settings page and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour. `test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then enables Advanced mode and persists one numeric Advanced setting. On Obsidian before 1.13, this covers the imperative settings fallback used by the `SettingSpec` proof. On Obsidian 1.13 or later, it verifies the task-oriented landing order, the separate Synchronisation group containing Remote Configuration and Sync Settings, and the General Settings group containing Appearance, Logging, and Extra menus. It reopens and cancels onboarding from Quick Setup, opens all 14 nested settings pages, searches globally for the Advanced control, captures the landing page, search result, Advanced page, and mobile landing page, and verifies that the value remains after the settings dialogue is closed and reopened. The mobile landing check confirms that Remote Configuration is reachable without initial scrolling. Finally, it selects the Synchronisation Settings page and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
@@ -248,9 +248,9 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms
`test:e2e:obsidian:document-history-restore` creates a normal note, records a logical deletion while retaining readable chunks, and restores the deleted content through the visible Document History dialogue. It requires the action itself to create and reflect a new non-deleted successor revision, reopens the history at that successor, and captures the file picker, readable deleted revision, restored Vault file, and new successor revision. This scenario owns the ordinary-history restoration boundary; conflict resolution remains with **Inspect conflicts and file/database differences**. `test:e2e:obsidian:document-history-restore` creates a normal note, records a logical deletion while retaining readable chunks, and restores the deleted content through the visible Document History dialogue. It requires the action itself to create and reflect a new non-deleted successor revision, reopens the history at that successor, and captures the file picker, readable deleted revision, restored Vault file, and new successor revision. This scenario owns the ordinary-history restoration boundary; conflict resolution remains with **Inspect conflicts and file/database differences**.
`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so migration from legacy remote settings remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate action Notice whose controls remain usable in mobile layouts; a manually dismissed group must not repeat its acknowledged rows when a later change arrives. `test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. That target-pattern case keeps a normal-file allow-list for Markdown and PDF files enabled on both devices, then requires exact hidden JSON content after the receiving device includes it. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so migration from legacy remote settings remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate action Notice whose controls remain usable in mobile layouts; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
`test:e2e:obsidian:hidden-file-selectors` stores and restores selected JS, CSS, and JSON files with a normal-file allow-list set, and compares both directions with an empty allow-list. It uses one temporary Vault and a local database. Run it through the focused wrapper to build the current bundle first; no remote service is required. Set `E2E_HIDDEN_FILE_SELECTORS_OUTPUT` to choose the JSON report path. `test:e2e:obsidian:hidden-file-selectors` stores and restores selected JS, CSS, and JSON files with a normal-file allow-list set, and compares both directions with an empty allow-list. It uses one temporary Vault and a local database, and also runs in the complete local suite. Run it through the focused wrapper to build the current bundle first; no remote service is required. Set `E2E_HIDDEN_FILE_SELECTORS_OUTPUT` to choose the JSON report path.
`test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronises the entries through CouchDB, applies them on the second vault, verifies the resulting `.obsidian` files, propagates a snippet update, and verifies deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy. `test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronises the entries through CouchDB, applies them on the second vault, verifies the resulting `.obsidian` files, propagates a snippet update, and verifies deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy.
+65 -20
View File
@@ -15,6 +15,7 @@ import {
captureObsidianPage, captureObsidianPage,
obsidianRemoteDebuggingPort, obsidianRemoteDebuggingPort,
openLiveSyncSettings, openLiveSyncSettings,
waitForVisibleObsidianDialogue,
withObsidianPage as withObsidianSessionPage, withObsidianPage as withObsidianSessionPage,
} from "../runner/ui.ts"; } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts"; import { createTemporaryVault } from "../runner/vault.ts";
@@ -43,6 +44,8 @@ type DialogueMode = "desktop" | "mobile";
type DialogueRunState = { type DialogueRunState = {
done: boolean; done: boolean;
deviceNameRead?: boolean; deviceNameRead?: boolean;
deviceNameReadPending?: boolean;
releaseDeviceNameRead?: () => void;
error?: string; error?: string;
expected?: unknown; expected?: unknown;
kind: string; kind: string;
@@ -140,11 +143,20 @@ async function openP2PRemoteSelectionDialogueForInspection(): Promise<void> {
kind: "p2p-compatibility-settings", kind: "p2p-compatibility-settings",
done: false, done: false,
deviceNameRead: false, deviceNameRead: false,
deviceNameReadPending: false,
}; };
(globalThis as unknown as Record<string, DialogueRunState>)[stateKey] = state; (globalThis as unknown as Record<string, DialogueRunState>)[stateKey] = state;
const deviceNameReadGate = new Promise<void>((resolve) => {
state.releaseDeviceNameRead = resolve;
});
config.getValue = async (key, defaultValue) => { config.getValue = async (key, defaultValue) => {
const value = await originalGetValue.call(config, key, defaultValue); const value = await originalGetValue.call(config, key, defaultValue);
if (key === deviceNameKey && value === deviceName) state.deviceNameRead = true; if (key === deviceNameKey) {
state.deviceNameReadPending = true;
await deviceNameReadGate;
state.deviceNameReadPending = false;
if (value === deviceName) state.deviceNameRead = true;
}
return value; return value;
}; };
manager.onConfirmApplySettingsFromWizard = async (settings: unknown) => { manager.onConfirmApplySettingsFromWizard = async (settings: unknown) => {
@@ -156,16 +168,17 @@ async function openP2PRemoteSelectionDialogueForInspection(): Promise<void> {
.then( .then(
(result) => { (result) => {
state.result = result; state.result = result;
state.done = true;
}, },
(error: unknown) => { (error: unknown) => {
state.error = error instanceof Error ? error.message : String(error); state.error = error instanceof Error ? error.message : String(error);
state.done = true;
} }
) )
.finally(() => { .finally(() => {
state.releaseDeviceNameRead?.();
state.releaseDeviceNameRead = undefined;
manager.onConfirmApplySettingsFromWizard = originalConfirm; manager.onConfirmApplySettingsFromWizard = originalConfirm;
config.getValue = originalGetValue; config.getValue = originalGetValue;
state.done = true;
}); });
}, },
{ {
@@ -177,6 +190,15 @@ async function openP2PRemoteSelectionDialogueForInspection(): Promise<void> {
}); });
} }
async function releasePendingP2PDeviceNameRead(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.evaluate((stateKey) => {
const state = (globalThis as unknown as Record<string, DialogueRunState | undefined>)[stateKey];
state?.releaseDeviceNameRead?.();
}, dialogRunStateKey);
});
}
async function openSetupUriDialogue(): Promise<void> { async function openSetupUriDialogue(): Promise<void> {
const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
return await page.evaluate( return await page.evaluate(
@@ -523,9 +545,11 @@ async function verifyCouchDBSettingsDialogue(mode: DialogueMode): Promise<string
async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> { async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
await openP2PRemoteSelectionDialogueForInspection(); await openP2PRemoteSelectionDialogueForInspection();
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const remoteSelection = page.locator(".modal-container").filter({ const remoteSelection = await waitForVisibleObsidianDialogue(
has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }), page,
}); "Choose a synchronisation remote",
uiTimeoutMs
);
await remoteSelection await remoteSelection
.locator("label") .locator("label")
.filter({ hasText: "Peer-to-Peer (P2P)" }) .filter({ hasText: "Peer-to-Peer (P2P)" })
@@ -541,13 +565,36 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
obsidianRemoteDebuggingPort(), obsidianRemoteDebuggingPort(),
"setup-p2p-compatibility-dialogue.png", "setup-p2p-compatibility-dialogue.png",
async (page) => { async (page) => {
const modal = page.locator(".modal-container").filter({ const modal = await waitForVisibleObsidianDialogue(page, "P2P Configuration", uiTimeoutMs);
has: page.locator(".modal-title").filter({ hasText: "P2P Configuration" }), const modalPage = modal.page();
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
const peerName = modal.locator('input[name="p2p-device-peer-id"]'); const peerName = modal.getByLabel("Device Peer ID", { exact: true });
await peerName.waitFor({ state: "visible", timeout: uiTimeoutMs }); await peerName.waitFor({ state: "visible", timeout: uiTimeoutMs });
await page.waitForFunction( await withObsidianPage(obsidianRemoteDebuggingPort(), async (statePage) => {
await statePage.waitForFunction(
(stateKey) =>
(globalThis as unknown as Record<string, DialogueRunState | undefined>)[stateKey]
?.deviceNameReadPending === true,
dialogRunStateKey,
{ timeout: uiTimeoutMs }
);
});
const testAndContinue = modal.getByRole("button", { name: "Test Settings and Continue", exact: true });
const continueAnyway = modal.getByRole("button", { name: "Continue anyway", exact: true });
await testAndContinue.waitFor({ state: "visible", timeout: uiTimeoutMs });
await continueAnyway.waitFor({ state: "visible", timeout: uiTimeoutMs });
if (
!(await peerName.isDisabled()) ||
!(await testAndContinue.isDisabled()) ||
!(await continueAnyway.isDisabled())
) {
throw new Error(
"The P2P device name and setup actions must remain disabled while the saved device name is loading."
);
}
await releasePendingP2PDeviceNameRead();
await modalPage.waitForFunction(
(expectedName) => { (expectedName) => {
const input = document.querySelector('input[name="p2p-device-peer-id"]'); const input = document.querySelector('input[name="p2p-device-peer-id"]');
return input instanceof HTMLInputElement && !input.disabled && input.value === expectedName; return input instanceof HTMLInputElement && !input.disabled && input.value === expectedName;
@@ -562,7 +609,7 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
await modal.locator('input[name="p2p-password"]').fill("e2e-passphrase"); await modal.locator('input[name="p2p-password"]').fill("e2e-passphrase");
const compatibility = modal.locator("details").filter({ const compatibility = modal.locator("details").filter({
has: page.locator("summary").filter({ hasText: "Connection compatibility" }), has: modalPage.locator("summary").filter({ hasText: "Connection compatibility" }),
}); });
await compatibility.waitFor({ state: "visible", timeout: uiTimeoutMs }); await compatibility.waitFor({ state: "visible", timeout: uiTimeoutMs });
if (!(await compatibility.evaluate((element) => (element as HTMLDetailsElement).open))) { if (!(await compatibility.evaluate((element) => (element as HTMLDetailsElement).open))) {
@@ -603,7 +650,7 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
} }
const advanced = modal.locator("details").filter({ const advanced = modal.locator("details").filter({
has: page.locator("summary").filter({ hasText: "Advanced Settings" }), has: modalPage.locator("summary").filter({ hasText: "Advanced Settings" }),
}); });
if (!(await advanced.evaluate((element) => (element as HTMLDetailsElement).open))) { if (!(await advanced.evaluate((element) => (element as HTMLDetailsElement).open))) {
await advanced.locator("summary").click({ timeout: uiTimeoutMs }); await advanced.locator("summary").click({ timeout: uiTimeoutMs });
@@ -615,13 +662,13 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
if (relayOnlyElement === null || connectionPathElement === null) { if (relayOnlyElement === null || connectionPathElement === null) {
throw new Error("P2P connection-path controls were removed whilst validating TURN settings."); throw new Error("P2P connection-path controls were removed whilst validating TURN settings.");
} }
await page.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, { await modalPage.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, {
timeout: uiTimeoutMs, timeout: uiTimeoutMs,
}); });
await connectionPath.selectOption("relay"); await connectionPath.selectOption("relay");
await turnServers.fill(""); await turnServers.fill("");
await page.waitForFunction( await modalPage.waitForFunction(
(element) => (element as HTMLSelectElement).value === "automatic", (element) => (element as HTMLSelectElement).value === "automatic",
connectionPathElement, connectionPathElement,
{ timeout: uiTimeoutMs } { timeout: uiTimeoutMs }
@@ -636,7 +683,7 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
.waitFor({ state: "visible", timeout: uiTimeoutMs }); .waitFor({ state: "visible", timeout: uiTimeoutMs });
await turnServers.fill("turn:turn.example.com:3478"); await turnServers.fill("turn:turn.example.com:3478");
await page.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, { await modalPage.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, {
timeout: uiTimeoutMs, timeout: uiTimeoutMs,
}); });
await connectionPath.selectOption("relay"); await connectionPath.selectOption("relay");
@@ -644,12 +691,10 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
await advanced.locator("summary").click({ timeout: uiTimeoutMs }); await advanced.locator("summary").click({ timeout: uiTimeoutMs });
await compatibility.scrollIntoViewIfNeeded(); await compatibility.scrollIntoViewIfNeeded();
} }
); ).finally(releasePendingP2PDeviceNameRead);
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const modal = page.locator(".modal-container").filter({ const modal = await waitForVisibleObsidianDialogue(page, "P2P Configuration", uiTimeoutMs);
has: page.locator(".modal-title").filter({ hasText: "P2P Configuration" }),
});
await modal.getByRole("button", { name: "Continue anyway", exact: true }).click({ timeout: uiTimeoutMs }); await modal.getByRole("button", { name: "Continue anyway", exact: true }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
}); });
@@ -69,6 +69,7 @@ type RunnerContext = {
cliBinary: string; cliBinary: string;
couchDb: CouchDbConfig; couchDb: CouchDbConfig;
dbName: string; dbName: string;
activeSessions: Set<ObsidianLiveSyncSession>;
}; };
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> { async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
@@ -348,6 +349,7 @@ async function startConfiguredSession(
pluginData: createE2eCouchDbPluginData(couchDbSettings, hiddenFileSettings), pluginData: createE2eCouchDbPluginData(couchDbSettings, hiddenFileSettings),
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
}); });
context.activeSessions.add(session);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv); await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv);
await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, hiddenFileSettings); await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, hiddenFileSettings);
@@ -355,6 +357,11 @@ async function startConfiguredSession(
return session; return session;
} }
async function stopSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
await session.app.stop();
context.activeSessions.delete(session);
}
async function uploadHiddenFile( async function uploadHiddenFile(
context: RunnerContext, context: RunnerContext,
session: ObsidianLiveSyncSession, session: ObsidianLiveSyncSession,
@@ -430,12 +437,12 @@ async function runCreateRoundTrip(
throw new Error("Remote Rebuild did not declare and encrypt internal Metadata for its accepted writer."); throw new Error("Remote Rebuild did not declare and encrypt internal Metadata for its accepted writer.");
} }
console.log("Remote Rebuild declared the feature and preserved encrypted Hidden File Sync Metadata."); console.log("Remote Rebuild declared the feature and preserved encrypted Hidden File Sync Metadata.");
await session.app.stop(); await stopSession(context, session);
session = await startConfiguredSession(context, vaultB); session = await startConfiguredSession(context, vaultB);
await pullAndApplyHiddenFiles(context, session); await pullAndApplyHiddenFiles(context, session);
const received = await waitForPathContent(vaultB.path, snippetPath, (content) => content === snippetContent); const received = await waitForPathContent(vaultB.path, snippetPath, (content) => content === snippetContent);
await session.app.stop(); await stopSession(context, session);
assertEqual(received, snippetContent, "Hidden snippet content did not round-trip to the second vault."); assertEqual(received, snippetContent, "Hidden snippet content did not round-trip to the second vault.");
console.log(`Hidden create round-trip copied ${entry.id} to the second vault.`); console.log(`Hidden create round-trip copied ${entry.id} to the second vault.`);
@@ -450,12 +457,12 @@ async function runDeleteRoundTrip(
let session = await startConfiguredSession(context, vaultA); let session = await startConfiguredSession(context, vaultA);
await scanHiddenStorage(context.cliBinary, session.cliEnv); await scanHiddenStorage(context.cliBinary, session.cliEnv);
await pushLocalChanges(context.cliBinary, session.cliEnv); await pushLocalChanges(context.cliBinary, session.cliEnv);
await session.app.stop(); await stopSession(context, session);
session = await startConfiguredSession(context, vaultB); session = await startConfiguredSession(context, vaultB);
await pullAndApplyHiddenFiles(context, session); await pullAndApplyHiddenFiles(context, session);
await waitForPathDeleted(vaultB.path, snippetPath); await waitForPathDeleted(vaultB.path, snippetPath);
await session.app.stop(); await stopSession(context, session);
console.log("Hidden delete round-trip removed the snippet from the second vault."); console.log("Hidden delete round-trip removed the snippet from the second vault.");
} }
@@ -476,14 +483,14 @@ async function runJsonConflictRoundTrip(
const mergedOnB = await waitForPathContent(vaultB.path, mergeJsonPath, (content) => const mergedOnB = await waitForPathContent(vaultB.path, mergeJsonPath, (content) =>
hasJsonValues(content, { fromA: true, fromB: true }) hasJsonValues(content, { fromA: true, fromB: true })
); );
await session.app.stop(); await stopSession(context, session);
session = await startConfiguredSession(context, vaultA); session = await startConfiguredSession(context, vaultA);
await pullAndApplyHiddenFiles(context, session); await pullAndApplyHiddenFiles(context, session);
const mergedOnA = await waitForPathContent(vaultA.path, mergeJsonPath, (content) => const mergedOnA = await waitForPathContent(vaultA.path, mergeJsonPath, (content) =>
hasJsonValues(content, { fromA: true, fromB: true }) hasJsonValues(content, { fromA: true, fromB: true })
); );
await session.app.stop(); await stopSession(context, session);
assertEqual(mergedOnA, mergedOnB, "Merged hidden JSON content was not consistent across both vaults."); assertEqual(mergedOnA, mergedOnB, "Merged hidden JSON content was not consistent across both vaults.");
console.log("Hidden JSON conflict was automatically merged and round-tripped."); console.log("Hidden JSON conflict was automatically merged and round-tripped.");
@@ -503,7 +510,7 @@ async function runJsonManualConflictResolution(context: RunnerContext, vault: Te
const merged = await waitForPathContent(vault.path, manualMergeJsonPath, (content) => const merged = await waitForPathContent(vault.path, manualMergeJsonPath, (content) =>
hasJsonValues(content, { shared: "right", fromA: true, fromB: true }) hasJsonValues(content, { shared: "right", fromA: true, fromB: true })
); );
await session.app.stop(); await stopSession(context, session);
const parsed = JSON.parse(merged); const parsed = JSON.parse(merged);
assertEqual(parsed.shared, "right", "Manual JSON conflict resolution did not apply the selected merged result."); assertEqual(parsed.shared, "right", "Manual JSON conflict resolution did not apply the selected merged result.");
@@ -517,17 +524,19 @@ async function runTargetMismatch(
vaultA: TemporaryVault, vaultA: TemporaryVault,
vaultB: TemporaryVault vaultB: TemporaryVault
): Promise<void> { ): Promise<void> {
const selectors = { syncOnlyRegEx: String.raw`\.(md|pdf)$` };
const targetContent = JSON.stringify({ onlyA: true, targetMismatch: true }, null, 4) + "\n"; const targetContent = JSON.stringify({ onlyA: true, targetMismatch: true }, null, 4) + "\n";
await writeVaultFile(vaultA.path, targetPath, targetContent); await writeVaultFile(vaultA.path, targetPath, targetContent);
let session = await startConfiguredSession(context, vaultA); let session = await startConfiguredSession(context, vaultA, selectors);
try { try {
await uploadHiddenFile(context, session, targetPath); await uploadHiddenFile(context, session, targetPath);
} finally { } finally {
await session.app.stop(); await stopSession(context, session);
} }
session = await startConfiguredSession(context, vaultB, { session = await startConfiguredSession(context, vaultB, {
...selectors,
syncInternalFilesTargetPatterns: "snippets", syncInternalFilesTargetPatterns: "snippets",
}); });
try { try {
@@ -538,10 +547,11 @@ async function runTargetMismatch(
"Hidden file was applied on a device where it was not a target file." "Hidden file was applied on a device where it was not a target file."
); );
} finally { } finally {
await session.app.stop(); await stopSession(context, session);
} }
session = await startConfiguredSession(context, vaultB, { session = await startConfiguredSession(context, vaultB, {
...selectors,
syncInternalFilesTargetPatterns: "", syncInternalFilesTargetPatterns: "",
}); });
let received = ""; let received = "";
@@ -549,11 +559,17 @@ async function runTargetMismatch(
await pullAndApplyHiddenFiles(context, session, { resolveConflicts: false }); await pullAndApplyHiddenFiles(context, session, { resolveConflicts: false });
received = await waitForPathContent(vaultB.path, targetPath, (content) => content === targetContent); received = await waitForPathContent(vaultB.path, targetPath, (content) => content === targetContent);
} finally { } finally {
await session.app.stop(); await stopSession(context, session);
} }
assertEqual(received, targetContent, "Hidden file was not applied after it became a target file."); assertEqual(
console.log("Hidden target mismatch respected per-device target patterns, then applied after enabling the target."); received,
targetContent,
"The normal file allow-list blocked a selected hidden file from the other device."
);
console.log(
"A normal Markdown/PDF allow-list preserved hidden target exclusion and exact remote restoration after enabling the target."
);
} }
async function setHiddenFileNoticeFixtures(port: number, itemIds: string[], includeRestart: boolean): Promise<void> { async function setHiddenFileNoticeFixtures(port: number, itemIds: string[], includeRestart: boolean): Promise<void> {
@@ -828,7 +844,7 @@ async function runInitialisationNoticeGrouping(context: RunnerContext, vault: Te
`Hidden File Sync showed one progress Notice before settings were saved and retained it throughout initialisation. Screenshot: ${screenshotPath}` `Hidden File Sync showed one progress Notice before settings were saved and retained it throughout initialisation. Screenshot: ${screenshotPath}`
); );
} finally { } finally {
await session.app.stop(); await stopSession(context, session);
} }
} }
@@ -889,7 +905,7 @@ async function runConfigurationNoticeGrouping(context: RunnerContext, vault: Tem
} finally { } finally {
await clearHiddenFileNoticeFixtures(port).catch(() => undefined); await clearHiddenFileNoticeFixtures(port).catch(() => undefined);
await setObsidianMobileTestMode(port, false, timeoutMs).catch(() => undefined); await setObsidianMobileTestMode(port, false, timeoutMs).catch(() => undefined);
await session.app.stop(); await stopSession(context, session);
} }
} }
@@ -904,7 +920,7 @@ async function main(): Promise<void> {
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "hidden-roundtrip"); const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "hidden-roundtrip");
const vaultA = await createTemporaryVault(); const vaultA = await createTemporaryVault();
const vaultB = await createTemporaryVault(); const vaultB = await createTemporaryVault();
const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName }; const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName, activeSessions: new Set() };
try { try {
await assertCouchDbReachable(couchDb); await assertCouchDbReachable(couchDb);
@@ -923,6 +939,7 @@ async function main(): Promise<void> {
await runInitialisationNoticeGrouping(context, vaultB); await runInitialisationNoticeGrouping(context, vaultB);
await runConfigurationNoticeGrouping(context, vaultB); await runConfigurationNoticeGrouping(context, vaultB);
} finally { } finally {
for (const session of context.activeSessions) await stopSession(context, session);
await vaultA.dispose(); await vaultA.dispose();
await vaultB.dispose(); await vaultB.dispose();
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") { if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
+113 -41
View File
@@ -1,3 +1,4 @@
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { mkdir } from "node:fs/promises"; import { mkdir } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { DEVICE_ID_PREFERRED, MILESTONE_DOCID, NODEINFO_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { DEVICE_ID_PREFERRED, MILESTONE_DOCID, NODEINFO_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
@@ -12,12 +13,22 @@ import {
putCouchDbDocument, putCouchDbDocument,
type CouchDbDocument, type CouchDbDocument,
} from "../runner/couchdb.ts"; } from "../runner/couchdb.ts";
import {
createObjectStorageClient,
deleteObjectStoragePrefix,
ensureObjectStorageBucket,
loadObjectStorageConfig,
makeUniqueBucketPrefix,
readObjectStorageJson,
} from "../runner/objectStorage.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { import {
assertEqual, assertEqual,
assertE2eCompatibilityUnpaused, assertE2eCompatibilityUnpaused,
configureCouchDb, configureCouchDb,
configureObjectStorage,
createE2eCouchDbPluginData, createE2eCouchDbPluginData,
createE2eObjectStoragePluginData,
prepareRemote, prepareRemote,
waitForLiveSyncCoreReady, waitForLiveSyncCoreReady,
} from "../runner/liveSyncWorkflow.ts"; } from "../runner/liveSyncWorkflow.ts";
@@ -72,6 +83,73 @@ type DeviceFixture = {
preferredTweaks: Record<string, unknown>; preferredTweaks: Record<string, unknown>;
}; };
type JoinedDevicesRemote = {
label: string;
pluginData: Record<string, unknown>;
prepare(): Promise<void>;
configure(cliBinary: string, env: NodeJS.ProcessEnv): Promise<unknown>;
read(): Promise<JoinedDevicesMilestone>;
write(milestone: JoinedDevicesMilestone): Promise<void>;
cleanup(): Promise<void>;
};
async function createRemoteFixture(): Promise<JoinedDevicesRemote> {
const args = process.argv.slice(2);
if (args.some((argument) => argument !== "--object-storage")) {
throw new Error("Usage: joined-devices.ts [--object-storage]");
}
if (args.includes("--object-storage")) {
const config = await loadObjectStorageConfig();
const bucketPrefix = makeUniqueBucketPrefix("joined-devices");
const settings = { ...config, bucketPrefix };
const key = `${bucketPrefix}_00000000-milestone.json`;
return {
label: "Object Storage",
pluginData: createE2eObjectStoragePluginData(settings),
prepare: () => ensureObjectStorageBucket(config),
configure: (cliBinary, env) => configureObjectStorage(cliBinary, env, settings),
read: () => readObjectStorageJson<JoinedDevicesMilestone>(config, key),
write: async (milestone) => {
const client = createObjectStorageClient(config);
try {
await client.send(
new PutObjectCommand({
Bucket: config.bucket,
Key: key,
Body: JSON.stringify(milestone),
ContentType: "application/json",
})
);
} finally {
client.destroy();
}
},
cleanup: () => deleteObjectStoragePrefix(config, bucketPrefix),
};
}
const config = await loadCouchDbConfig();
const dbName = makeUniqueDatabaseName(config.dbPrefix, "joined-devices");
const settings = { uri: config.uri, username: config.username, password: config.password, dbName };
return {
label: "CouchDB",
pluginData: createE2eCouchDbPluginData(settings),
prepare: async () => {
await assertCouchDbReachable(config);
await createCouchDbDatabase(config, dbName);
},
configure: (cliBinary, env) => configureCouchDb(cliBinary, env, settings),
read: async () => {
const milestone = await fetchCouchDbDocument(config, dbName, MILESTONE_DOCID);
assert(Boolean(milestone._rev), "The remote milestone did not have a revision.");
return milestone as JoinedDevicesMilestone;
},
write: async (milestone) => {
await putCouchDbDocument(config, dbName, milestone);
},
cleanup: () => deleteCouchDbDatabase(config, dbName),
};
}
function assert(condition: boolean, message: string): void { function assert(condition: boolean, message: string): void {
if (!condition) throw new Error(message); if (!condition) throw new Error(message);
} }
@@ -228,13 +306,9 @@ async function assertInitialDialogue(dialogue: Locator, self: SelfDeviceInfo, pa
.waitFor({ state: "visible", timeout: uiTimeoutMs }); .waitFor({ state: "visible", timeout: uiTimeoutMs });
} }
async function refreshRemotePlatform( async function refreshRemotePlatform(page: Page, remote: JoinedDevicesRemote): Promise<void> {
page: Page, const milestone = await remote.read();
config: Awaited<ReturnType<typeof loadCouchDbConfig>>, await remote.write({
dbName: string
): Promise<void> {
const milestone = (await fetchCouchDbDocument(config, dbName, MILESTONE_DOCID)) as JoinedDevicesMilestone;
await putCouchDbDocument(config, dbName, {
...milestone, ...milestone,
node_info: { node_info: {
...milestone.node_info, ...milestone.node_info,
@@ -258,6 +332,15 @@ async function showForgetConfirmation(dialogue: Locator): Promise<void> {
}); });
} }
async function assertCancelledForget(dialogue: Locator, remote: JoinedDevicesRemote): Promise<void> {
const before = await remote.read();
await showForgetConfirmation(dialogue);
const confirmation = deviceCard(dialogue, targetId).locator(".forget-confirmation");
await confirmation.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs });
await confirmation.waitFor({ state: "detached", timeout: uiTimeoutMs });
assertEqual(JSON.stringify(await remote.read()), JSON.stringify(before), "Cancelling Forget changed the remote.");
}
async function confirmForgetTarget(dialogue: Locator): Promise<void> { async function confirmForgetTarget(dialogue: Locator): Promise<void> {
const targetCard = deviceCard(dialogue, targetId); const targetCard = deviceCard(dialogue, targetId);
await targetCard.getByRole("button", { name: "Forget device", exact: true }).click({ timeout: uiTimeoutMs }); await targetCard.getByRole("button", { name: "Forget device", exact: true }).click({ timeout: uiTimeoutMs });
@@ -268,12 +351,8 @@ async function confirmForgetTarget(dialogue: Locator): Promise<void> {
await targetCard.waitFor({ state: "detached", timeout: uiTimeoutMs }); await targetCard.waitFor({ state: "detached", timeout: uiTimeoutMs });
} }
async function assertTargetForgotten( async function assertTargetForgotten(remote: JoinedDevicesRemote, fixture: DeviceFixture): Promise<void> {
config: Awaited<ReturnType<typeof loadCouchDbConfig>>, const milestone = await remote.read();
dbName: string,
fixture: DeviceFixture
): Promise<void> {
const milestone = (await fetchCouchDbDocument(config, dbName, MILESTONE_DOCID)) as JoinedDevicesMilestone;
assertEqual( assertEqual(
JSON.stringify(milestone.accepted_nodes), JSON.stringify(milestone.accepted_nodes),
JSON.stringify([retainedId]), JSON.stringify([retainedId]),
@@ -371,42 +450,32 @@ async function main(): Promise<void> {
const cli = discoverObsidianCli(); const cli = discoverObsidianCli();
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
const couchDb = await loadCouchDbConfig(); const remote = await createRemoteFixture();
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "joined-devices");
const vault = await createTemporaryVault(); const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined; let session: ObsidianLiveSyncSession | undefined;
try { try {
await assertCouchDbReachable(couchDb); await remote.prepare();
await createCouchDbDatabase(couchDb, dbName);
session = await startObsidianLiveSyncSession({ session = await startObsidianLiveSyncSession({
binary, binary,
cliBinary: cli.binary, cliBinary: cli.binary,
vault, vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: createE2eCouchDbPluginData({ pluginData: remote.pluginData,
uri: couchDb.uri,
username: couchDb.username,
password: couchDb.password,
dbName,
}),
}); });
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await assertE2eCompatibilityUnpaused(cli.binary, session.cliEnv, session.remoteDebuggingPort); await assertE2eCompatibilityUnpaused(cli.binary, session.cliEnv, session.remoteDebuggingPort);
await configureCouchDb(cli.binary, session.cliEnv, { await remote.configure(cli.binary, session.cliEnv);
uri: couchDb.uri,
username: couchDb.username,
password: couchDb.password,
dbName,
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await prepareRemote(cli.binary, session.cliEnv); await prepareRemote(cli.binary, session.cliEnv);
const self = await readSelfDeviceInfo(cli.binary, session.cliEnv); const self = await readSelfDeviceInfo(cli.binary, session.cliEnv);
const fixture = createFixture(self); const fixture = createFixture(self);
const preparedMilestone = await fetchCouchDbDocument(couchDb, dbName, MILESTONE_DOCID); const preparedMilestone = await remote.read();
assert(Boolean(preparedMilestone._rev), "Remote preparation did not create a revisioned milestone document."); await remote.write({
await putCouchDbDocument(couchDb, dbName, { ...fixture.milestone, _rev: preparedMilestone._rev }); ...fixture.milestone,
...(preparedMilestone._rev ? { _rev: preparedMilestone._rev } : {}),
});
await captureJoinedDevices(session.remoteDebuggingPort, "joined-devices-notice.png", async (page) => { await captureJoinedDevices(session.remoteDebuggingPort, "joined-devices-notice.png", async (page) => {
const dialogue = await openFromRemoteSettings(page); const dialogue = await openFromRemoteSettings(page);
@@ -440,8 +509,9 @@ async function main(): Promise<void> {
session.remoteDebuggingPort, session.remoteDebuggingPort,
"joined-devices-forget-confirmation.png", "joined-devices-forget-confirmation.png",
async (page) => { async (page) => {
await refreshRemotePlatform(page, couchDb, dbName); await refreshRemotePlatform(page, remote);
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs); const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
await assertCancelledForget(dialogue, remote);
await showForgetConfirmation(dialogue); await showForgetConfirmation(dialogue);
await deviceCard(dialogue, targetId).locator(".forget-confirmation").scrollIntoViewIfNeeded(); await deviceCard(dialogue, targetId).locator(".forget-confirmation").scrollIntoViewIfNeeded();
return dialogue; return dialogue;
@@ -459,7 +529,7 @@ async function main(): Promise<void> {
const settings = await openLiveSyncSettings(page, uiTimeoutMs); const settings = await openLiveSyncSettings(page, uiTimeoutMs);
await settings.close(); await settings.close();
}); });
await assertTargetForgotten(couchDb, dbName, fixture); await assertTargetForgotten(remote, fixture);
await setObsidianMobileTestMode(session.remoteDebuggingPort, true, uiTimeoutMs); await setObsidianMobileTestMode(session.remoteDebuggingPort, true, uiTimeoutMs);
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
@@ -485,14 +555,16 @@ async function main(): Promise<void> {
return dialogue; return dialogue;
}); });
console.log(`Joined devices scenario passed against CouchDB database ${dbName}.`); console.log(`Joined devices scenario passed against ${remote.label}.`);
} finally { } finally {
if (session) await session.app.stop(); try {
await vault.dispose(); if (session) await session.app.stop();
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") { } finally {
await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => { try {
console.warn(error instanceof Error ? error.message : error); await vault.dispose();
}); } finally {
await remote.cleanup();
}
} }
} }
} }
+3
View File
@@ -26,6 +26,7 @@ const testSteps: Step[] = [
{ name: "Review Harness", args: ["run", "test:e2e:obsidian:review-harness"] }, { name: "Review Harness", args: ["run", "test:e2e:obsidian:review-harness"] },
{ name: "P2P status pane", args: ["run", "test:e2e:obsidian:p2p-pane"] }, { name: "P2P status pane", args: ["run", "test:e2e:obsidian:p2p-pane"] },
{ name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] }, { name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] },
{ name: "joined devices with CouchDB", args: ["run", "test:e2e:obsidian:joined-devices"] },
{ name: "CouchDB upload", args: ["run", "test:e2e:obsidian:couchdb-upload"] }, { name: "CouchDB upload", args: ["run", "test:e2e:obsidian:couchdb-upload"] },
{ {
name: "manual CouchDB setup workflow", name: "manual CouchDB setup workflow",
@@ -35,6 +36,7 @@ const testSteps: Step[] = [
name: "CLI to real Obsidian synchronisation", name: "CLI to real Obsidian synchronisation",
args: ["run", "test:e2e:obsidian:cli-to-obsidian-sync"], args: ["run", "test:e2e:obsidian:cli-to-obsidian-sync"],
}, },
{ name: "joined devices with Object Storage", args: ["run", "test:e2e:obsidian:joined-devices-object-storage"] },
{ name: "Object Storage upload", args: ["run", "test:e2e:obsidian:minio-upload"] }, { name: "Object Storage upload", args: ["run", "test:e2e:obsidian:minio-upload"] },
{ {
name: "Object Storage Setup URI workflow", name: "Object Storage Setup URI workflow",
@@ -56,6 +58,7 @@ const testSteps: Step[] = [
{ name: "startup scan", args: ["run", "test:e2e:obsidian:startup-scan"] }, { name: "startup scan", args: ["run", "test:e2e:obsidian:startup-scan"] },
{ name: "provisioned Setup URI workflow", args: ["run", "test:e2e:obsidian:setup-uri-workflow"] }, { 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"] }, { name: "two-vault synchronisation", args: ["run", "test:e2e:obsidian:two-vault-sync"] },
{ name: "hidden file selectors", args: ["run", "test:e2e:obsidian:hidden-file-selectors"] },
{ name: "hidden file snippet synchronisation", args: ["run", "test:e2e:obsidian:hidden-file-snippet-sync"] }, { name: "hidden file snippet synchronisation", args: ["run", "test:e2e:obsidian:hidden-file-snippet-sync"] },
{ name: "Customisation Sync", args: ["run", "test:e2e:obsidian:customisation-sync"] }, { name: "Customisation Sync", args: ["run", "test:e2e:obsidian:customisation-sync"] },
{ name: "internal Metadata Doctor", args: ["run", "test:e2e:obsidian:internal-metadata-doctor"] }, { name: "internal Metadata Doctor", args: ["run", "test:e2e:obsidian:internal-metadata-doctor"] },
+1
View File
@@ -9,6 +9,7 @@ const focusedScenarios = new Set([
"onboarding-invitation", "onboarding-invitation",
"dialog-mounts", "dialog-mounts",
"joined-devices", "joined-devices",
"joined-devices-object-storage",
"database-diagnostics", "database-diagnostics",
"database-diagnostics-couchdb", "database-diagnostics-couchdb",
"conflict-dialog-policy", "conflict-dialog-policy",