From 9a28d46287180a19a125a689b51e0c9d7f8d57f6 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 31 Aug 2026 00:35:29 +0000 Subject: [PATCH] Harden real-runtime workflow validation --- src/common/databaseCompatibility.unit.spec.ts | 1 + .../runner/remoteActivityWorkflow.ts | 8 ++-- test/e2e-obsidian/runner/setupUri.ts | 19 +++++---- test/e2e-obsidian/runner/ui.ts | 16 ++++++-- .../scripts/customisation-sync.ts | 41 +++++++++++-------- .../scripts/onboarding-invitation.ts | 17 ++++---- .../scripts/p2p-connection-check.ts | 6 ++- .../scripts/setting-markdown-export.ts | 6 +-- 8 files changed, 72 insertions(+), 42 deletions(-) diff --git a/src/common/databaseCompatibility.unit.spec.ts b/src/common/databaseCompatibility.unit.spec.ts index 33f5a14e..fd6d25ff 100644 --- a/src/common/databaseCompatibility.unit.spec.ts +++ b/src/common/databaseCompatibility.unit.spec.ts @@ -177,6 +177,7 @@ describe("packaged Commonlib compatibility gate", () => { databaseService: {}, fileProcessingService: { commitPendingFileEvents: vi.fn().mockResolvedValue(true) }, replicatorService: { + acquireActiveReplicatorContext: vi.fn().mockResolvedValue(undefined), getActiveReplicator: () => ({ openReplication }), runFiniteReplicationActivity, }, diff --git a/test/e2e-obsidian/runner/remoteActivityWorkflow.ts b/test/e2e-obsidian/runner/remoteActivityWorkflow.ts index 376d338c..eff8147e 100644 --- a/test/e2e-obsidian/runner/remoteActivityWorkflow.ts +++ b/test/e2e-obsidian/runner/remoteActivityWorkflow.ts @@ -28,14 +28,16 @@ export async function startHeldOneShotReplication(cliBinary: string, env: NodeJS "const core=app.plugins.plugins['obsidian-livesync'].core;", "const replicator=core.services.replicator.getActiveReplicator();", "if(!replicator) throw new Error('No active replicator is available.');", - "const original=replicator.openReplication;", + "const methodName=typeof replicator.openOneShotReplicationWithOutcome==='function'?'openOneShotReplicationWithOutcome':'openReplication';", + "const original=replicator[methodName];", + "if(typeof original!=='function') throw new Error('The active replicator has no one-shot entry point.');", "let releaseGate;", "const gate=new Promise((resolve)=>{releaseGate=resolve;});", `const state={kind:${JSON.stringify(REMOTE_ACTIVITY_GATE_KIND.oneShot)},entered:false,done:false,released:false,error:undefined,result:undefined,promise:undefined,release:undefined,restore:undefined};`, "state.release=()=>{if(!state.released){state.released=true;releaseGate();}};", - "state.restore=()=>{replicator.openReplication=original;};", + "state.restore=()=>{replicator[methodName]=original;};", "host[stateKey]=state;", - "replicator.openReplication=async function(...args){", + "replicator[methodName]=async function(...args){", "state.entered=true;", "await gate;", "return await original.apply(this,args);", diff --git a/test/e2e-obsidian/runner/setupUri.ts b/test/e2e-obsidian/runner/setupUri.ts index 10764fbc..3a1adbaa 100644 --- a/test/e2e-obsidian/runner/setupUri.ts +++ b/test/e2e-obsidian/runner/setupUri.ts @@ -38,7 +38,12 @@ export function modalByTitle(page: Page, title: string): Locator { } export async function captureGuideDialogue(port: number, filename: string, title: string): Promise { - return await captureObsidianElement(port, filename, (page) => modalByTitle(page, title).locator(".modal").first()); + return await captureObsidianElement( + port, + filename, + (page) => modalByTitle(page, title).locator(".modal").first(), + uiTimeoutMs + ); } export async function assertVerticalActionLayout(port: number, title: string): Promise { @@ -182,16 +187,16 @@ export async function captureAndStartInitialisation( ? "Setup Complete: Preparing This P2P Device" : p2pAdditionalDevice ? "Setup Complete: Preparing to Fetch from Another Device" - : mode === "new" - ? "Setup Complete: Preparing to Initialise Server" - : "Setup Complete: Preparing to Fetch Synchronisation Data"; + : mode === "new" + ? "Setup Complete: Preparing to Initialise Server" + : "Setup Complete: Preparing to Fetch Synchronisation Data"; const button = p2pFirstDevice ? "Restart and Prepare This Device" : p2pAdditionalDevice ? "Restart and Select Source Device" - : mode === "new" - ? "Restart and Initialise Server" - : "Restart and Fetch Data"; + : mode === "new" + ? "Restart and Initialise Server" + : "Restart and Fetch Data"; if (p2pAdditionalDevice) { await withObsidianPage(port, async (page) => { const modal = modalByTitle(page, title); diff --git a/test/e2e-obsidian/runner/ui.ts b/test/e2e-obsidian/runner/ui.ts index 89a6c792..1f84ceff 100644 --- a/test/e2e-obsidian/runner/ui.ts +++ b/test/e2e-obsidian/runner/ui.ts @@ -115,7 +115,6 @@ export async function openLiveSyncSettings(page: Page, timeoutMs = 10_000): Prom const setting = host.app?.setting; if (setting === undefined) throw new Error("Obsidian settings are unavailable"); setting.open(); - setting.openTabById("obsidian-livesync"); }); const deadline = Date.now() + timeoutMs; @@ -137,6 +136,16 @@ export async function openLiveSyncSettings(page: Page, timeoutMs = 10_000): Prom } if (settingsPage === undefined) throw new Error("Obsidian did not open its settings interface"); + // Obsidian may discard a tab selection made before the settings modal has + // finished opening. Select the plug-in only after a settings renderer is + // visible so slower real-runtime sessions cannot remain on the About tab. + await hostPage.evaluate(() => { + const host = globalThis as ObsidianSettingsHost; + const setting = host.app?.setting; + if (setting === undefined) throw new Error("Obsidian settings are unavailable"); + setting.openTabById("obsidian-livesync"); + }); + const dialogue = settingsPage.locator(".modal.mod-settings:visible").last(); const imperativeRoot = dialogue.locator(".sls-setting:visible").last(); const firstDeclarativeEntry = declarativePageEntry(dialogue, "Change Log"); @@ -270,7 +279,8 @@ export async function allowPendingObsidianTestVaultOpenAction( export async function captureObsidianElement( port: number, filename: string, - resolveElement: (page: Page) => Locator | Promise + resolveElement: (page: Page) => Locator | Promise, + timeoutMs = 10_000 ): Promise { const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; const screenshotPath = join(outputDirectory, filename); @@ -279,7 +289,7 @@ export async function captureObsidianElement( await withObsidianPage(port, async (page) => { try { const element = await resolveElement(page); - await element.waitFor({ state: "visible", timeout: 10000 }); + await element.waitFor({ state: "visible", timeout: timeoutMs }); await element.screenshot({ path: screenshotPath, animations: "disabled", diff --git a/test/e2e-obsidian/scripts/customisation-sync.ts b/test/e2e-obsidian/scripts/customisation-sync.ts index 66b3799f..9455d48c 100644 --- a/test/e2e-obsidian/scripts/customisation-sync.ts +++ b/test/e2e-obsidian/scripts/customisation-sync.ts @@ -12,8 +12,11 @@ import { } from "../runner/couchdb.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { + assertE2eCompatibilityMarker, assertEqual, configureCouchDb, + createE2eCouchDbPluginData, + createE2eObsidianDeviceLocalState, prepareRemote, pushLocalChanges, waitForLiveSyncCoreReady, @@ -149,31 +152,33 @@ async function startConfiguredSession( vault: TemporaryVault, deviceName: string ): Promise { + const couchDbSettings = { + uri: context.couchDb.uri, + username: context.couchDb.username, + password: context.couchDb.password, + dbName: context.dbName, + }; + const customisationSettings = { + deviceAndVaultName: deviceName, + usePluginSync: true, + usePluginSyncV2: true, + autoSweepPlugins: false, + autoSweepPluginsPeriodic: false, + syncInternalFiles: false, + }; const session = await startObsidianLiveSyncSession({ binary: context.binary, cliBinary: context.cliBinary, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + // This scenario exercises Customisation Sync, not onboarding. Seed a + // configured Vault and its device-local compatibility acknowledgement. + pluginData: createE2eCouchDbPluginData(couchDbSettings, customisationSettings), + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), }); await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); - await configureCouchDb( - context.cliBinary, - session.cliEnv, - { - uri: context.couchDb.uri, - username: context.couchDb.username, - password: context.couchDb.password, - dbName: context.dbName, - }, - { - deviceAndVaultName: deviceName, - usePluginSync: true, - usePluginSyncV2: true, - autoSweepPlugins: false, - autoSweepPluginsPeriodic: false, - syncInternalFiles: false, - } - ); + await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv); + await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, customisationSettings); await evalObsidianJson( context.cliBinary, [ diff --git a/test/e2e-obsidian/scripts/onboarding-invitation.ts b/test/e2e-obsidian/scripts/onboarding-invitation.ts index 17f2b044..16441e2f 100644 --- a/test/e2e-obsidian/scripts/onboarding-invitation.ts +++ b/test/e2e-obsidian/scripts/onboarding-invitation.ts @@ -179,14 +179,17 @@ async function openOnboardingFromSettings(): Promise { async function dismissVisibleNotices(): Promise { await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const notices = page.locator(".notice:visible"); + let noticeIndex = 0; while ((await notices.count()) > 0) { - const noticeCount = await page.locator(".notice").count(); - await notices.first().click({ position: { x: 8, y: 8 }, timeout: uiTimeoutMs }); - await page.waitForFunction( - (previousCount) => document.querySelectorAll(".notice").length < previousCount, - noticeCount, - { timeout: uiTimeoutMs } - ); + const marker = `livesync-e2e-notice-${noticeIndex++}`; + await notices + .first() + .evaluate((element, value) => element.setAttribute("data-livesync-e2e-notice", value), marker); + const markedNotice = page.locator(`[data-livesync-e2e-notice="${marker}"]`); + await markedNotice.click({ position: { x: 8, y: 8 }, timeout: uiTimeoutMs }); + // Follow the notice which was clicked. Another concurrently added + // notice must not make a total-count wait look permanently stuck. + await markedNotice.waitFor({ state: "hidden", timeout: uiTimeoutMs }); } }); } diff --git a/test/e2e-obsidian/scripts/p2p-connection-check.ts b/test/e2e-obsidian/scripts/p2p-connection-check.ts index b935e34a..8c7ce24b 100644 --- a/test/e2e-obsidian/scripts/p2p-connection-check.ts +++ b/test/e2e-obsidian/scripts/p2p-connection-check.ts @@ -241,7 +241,11 @@ async function runScenario(): Promise { const consoleErrors: string[] = []; try { - browser = await chromium.launch({ headless: true }); + const browserExecutable = process.env.E2E_PLAYWRIGHT_CHROMIUM?.trim(); + browser = await chromium.launch({ + headless: true, + ...(browserExecutable ? { executablePath: browserExecutable } : {}), + }); const firstVault = await createTemporaryVault("obsidian-livesync-p2p-check-first-e2e-"); vaults.push(firstVault); const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } }); diff --git a/test/e2e-obsidian/scripts/setting-markdown-export.ts b/test/e2e-obsidian/scripts/setting-markdown-export.ts index 440df8d6..44a5825e 100644 --- a/test/e2e-obsidian/scripts/setting-markdown-export.ts +++ b/test/e2e-obsidian/scripts/setting-markdown-export.ts @@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { evalObsidianJson } from "../runner/cli.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; -import { assertEqual, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; +import { assertEqual } from "../runner/liveSyncWorkflow.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { createTemporaryVault } from "../runner/vault.ts"; @@ -75,8 +75,8 @@ async function main(): Promise { vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), }); - await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); - + // The export is available while an unconfigured Vault remains outside + // application readiness; the session helper has already loaded the plug-in. await configureSettingMarkdown(cli.binary, session.cliEnv); const content = await waitForFileContaining(vault.path, settingPath, [ (value) => value.includes("````yaml:livesync-setting"),