diff --git a/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts b/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts index c85d0163..85f64421 100644 --- a/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts +++ b/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts @@ -10,6 +10,7 @@ vi.mock("./cli.ts", () => ({ evalObsidianJson })); import { assertE2eCompatibilityMarker, createE2eCouchDbPluginData, + prepareRemote, waitForLiveSyncCoreReady, type CompatibilityMarkerState, } from "./liveSyncWorkflow.ts"; @@ -77,3 +78,18 @@ describe("Real Obsidian core readiness", () => { expect(evalObsidianJson).toHaveBeenCalledTimes(2); }); }); + +describe("remote fixture preparation", () => { + it("waits for the remote Security Seed after resolving a new remote", async () => { + evalObsidianJson.mockReset(); + evalObsidianJson.mockResolvedValueOnce({ status: "resolved", securitySeedReady: true }); + + await prepareRemote("obsidian-cli", {}); + + const evaluatedCode = String(evalObsidianJson.mock.calls[0]?.[1] ?? ""); + expect(evaluatedCode.indexOf("markRemoteResolved")).toBeLessThan( + evaluatedCode.indexOf("ensurePBKDF2Salt") + ); + expect(evaluatedCode).toContain("Timed out preparing the remote Security Seed"); + }); +}); diff --git a/test/e2e-obsidian/runner/liveSyncWorkflow.ts b/test/e2e-obsidian/runner/liveSyncWorkflow.ts index ca10fd62..896fb058 100644 --- a/test/e2e-obsidian/runner/liveSyncWorkflow.ts +++ b/test/e2e-obsidian/runner/liveSyncWorkflow.ts @@ -462,6 +462,7 @@ export function assertObsidianServiceContextContract(result: ObsidianServiceCont } export async function prepareRemote(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + const timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_PREPARE_TIMEOUT_MS ?? 20000); await evalObsidianJson( cliBinary, [ @@ -471,8 +472,16 @@ export async function prepareRemote(cliBinary: string, env: NodeJS.ProcessEnv): "const replicator=core.services.replicator.getActiveReplicator();", "await replicator.tryCreateRemoteDatabase(settings);", "await replicator.markRemoteResolved(settings);", + `const deadline=Date.now()+${JSON.stringify(timeoutMs)};`, + "let securitySeedReady=false;", + "do{", + "securitySeedReady=await replicator.ensurePBKDF2Salt(settings,false,false);", + "if(securitySeedReady) break;", + "await new Promise((resolve)=>setTimeout(resolve,250));", + "}while(Date.now() { cliBinary: obsidianCli.binary, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: createE2eCouchDbPluginData( + { + uri: couchDb.uri, + username: couchDb.username, + password: couchDb.password, + dbName, + }, + { + encrypt: true, + passphrase: e2eePassphrase, + usePathObfuscation: true, + E2EEAlgorithm: "v2", + } + ), + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), }); await waitForLiveSyncCoreReady(obsidianCli.binary, session.cliEnv); - await configureCouchDb( - obsidianCli.binary, - session.cliEnv, - { - uri: couchDb.uri, - username: couchDb.username, - password: couchDb.password, - dbName, - }, - { - encrypt: true, - passphrase: e2eePassphrase, - usePathObfuscation: true, - E2EEAlgorithm: "v2", - } - ); - await waitForLiveSyncCoreReady(obsidianCli.binary, session.cliEnv); await prepareRemote(obsidianCli.binary, session.cliEnv); await pushLocalChanges(obsidianCli.binary, session.cliEnv); diff --git a/test/e2e-obsidian/scripts/minio-upload.ts b/test/e2e-obsidian/scripts/minio-upload.ts index 6af8b42a..b0899f12 100644 --- a/test/e2e-obsidian/scripts/minio-upload.ts +++ b/test/e2e-obsidian/scripts/minio-upload.ts @@ -1,8 +1,27 @@ +/** + * Verifies one complete Object Storage upload from a real Obsidian Vault, + * through LiveSync's local database and Journal Sync, to an S3-compatible + * service observed independently through the AWS SDK. + * + * The isolated Vault starts with Object Storage settings and the device-local + * compatibility acknowledgement already in place. Unconfigured start-up is + * intentionally inert and belongs to the onboarding scenario; compatibility + * review and visible setup have their own dedicated workflows. Supplying those + * prerequisites here keeps this scenario focused on the upload boundary. + * + * Note creation, local-database observation, one-shot synchronisation, request + * accounting, remote-object inspection, and prefix cleanup remain in one + * scenario so that a pass proves the same payload crossed every boundary. + * Separate successes would not prove that those observations belonged to the + * same upload. + */ import { evalObsidianJson } from "../runner/cli.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { assertEqual, configureObjectStorage, + createE2eObjectStoragePluginData, + createE2eObsidianDeviceLocalState, prepareRemote, pushLocalChanges, waitForLiveSyncCoreReady, @@ -100,6 +119,11 @@ async function main(): Promise { cliBinary: cli.binary, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: createE2eObjectStoragePluginData({ + ...objectStorage, + bucketPrefix, + }), + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), }); await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); diff --git a/test/e2e-obsidian/scripts/review-harness.ts b/test/e2e-obsidian/scripts/review-harness.ts index e080a45a..3c7fb96f 100644 --- a/test/e2e-obsidian/scripts/review-harness.ts +++ b/test/e2e-obsidian/scripts/review-harness.ts @@ -1,3 +1,5 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { assertLocatorHasMinimumTouchTarget, assertLocatorWithinSafeArea, @@ -6,11 +8,17 @@ import { import { CURRENT_SETTING_VERSION } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const"; import { REVIEW_HARNESS_STATE_KEY } from "../../../src/features/ReviewHarness/reviewHarnessController.ts"; import { REVIEW_HARNESS_FIXTURE_ROOT } from "../../../src/features/ReviewHarness/reviewHarnessVaultFixture.ts"; +import { evalObsidianJson } from "../runner/cli.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; import { iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; -import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts"; +import { + captureObsidianDialogue, + captureObsidianPage, + obsidianRemoteDebuggingPort, + withObsidianPage, +} from "../runner/ui.ts"; import { createTemporaryVault } from "../runner/vault.ts"; const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS ?? 15000); @@ -26,6 +34,135 @@ type ReviewHarnessTestGlobal = typeof globalThis & { reviewHarnessCopiedReport?: string; }; +type ReviewHarnessReadinessSnapshot = { + coreAvailable: boolean; + databaseReady?: boolean; + appReady?: boolean; + configured?: boolean; + remoteType?: string; + settingVersion?: number; + suspended?: boolean; + unresolvedMessages: string[]; +}; + +const sensitiveDiagnosticLine = + /security seed|passphrase|password|credential|secret|access.?key|jwt.?key|authori[sz]ation|obsidian:\/\/setuplivesync|sls\+/iu; +const interruptedStartupMessages = [ + "No replicator has been activated or has not been initialised yet.", + "Self-hosted LiveSync cannot be initialised, exiting loading.", +]; + +function redactDiagnosticLine(line: string): string { + if (sensitiveDiagnosticLine.test(line)) return "[REDACTED SENSITIVE LOG LINE]"; + return line.replace(/\bhttps?:\/\/[^/\s:@]+:[^@\s/]+@/giu, "https://[REDACTED]@"); +} + +async function assertNoInterruptedStartupNotice(stage: string): Promise { + const notices = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.waitForTimeout(1500); + return await page.locator(".notice").allTextContents(); + }); + const interrupted = notices.filter((notice) => + interruptedStartupMessages.some((message) => notice.includes(message)) + ); + if (interrupted.length > 0) { + throw new Error(`LiveSync emitted an interrupted-startup Notice during ${stage}: ${interrupted.join(" | ")}`); + } + console.log(`No interrupted-startup Notice observed during ${stage}.`); +} + +async function captureReadinessFailure( + cliBinary: string, + session: ObsidianLiveSyncSession, + readinessError: unknown +): Promise { + const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; + await mkdir(outputDirectory, { recursive: true }); + + const captureErrors: string[] = []; + let screenshotPath: string | undefined; + try { + screenshotPath = await captureObsidianPage( + obsidianRemoteDebuggingPort(), + "review-harness-core-not-ready.png", + async () => undefined + ); + } catch (error) { + captureErrors.push(`screenshot: ${error instanceof Error ? error.message : String(error)}`); + } + + let readiness: ReviewHarnessReadinessSnapshot | undefined; + try { + readiness = await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync']?.core;", + "if(!core)return JSON.stringify({coreAvailable:false,unresolvedMessages:[]});", + "const settings=core.services.setting.currentSettings();", + "let unresolvedMessages=[];", + "try{", + "unresolvedMessages=(await core.services.appLifecycle.getUnresolvedMessages()).flat()", + ".filter((message)=>message!==undefined&&message!==null)", + ".map((message)=>String(message)).slice(-50);", + "}catch(error){unresolvedMessages=[`Could not inspect unresolved messages: ${String(error)}`];}", + "return JSON.stringify({", + "coreAvailable:true,", + "databaseReady:core.services.database.isDatabaseReady(),", + "appReady:core.services.appLifecycle.isReady(),", + "configured:settings?.isConfigured===true,", + "remoteType:settings?.remoteType??'',", + "settingVersion:settings?.settingVersion,", + "suspended:core.services.appLifecycle.isSuspended(),", + "unresolvedMessages,", + "});", + "})()", + ].join(""), + session.cliEnv + ); + readiness.unresolvedMessages = readiness.unresolvedMessages.map(redactDiagnosticLine); + } catch (error) { + captureErrors.push(`readiness snapshot: ${error instanceof Error ? error.message : String(error)}`); + } + + let recentLog: string[] = []; + try { + recentLog = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const opened = await page.evaluate( + (commandId) => + (globalThis as ReviewHarnessTestGlobal).app?.commands?.executeCommandById(commandId) === true, + "obsidian-livesync:view-log" + ); + if (!opened) throw new Error("The Show log command was not registered."); + const logPane = page.locator(".logpane"); + await logPane.waitFor({ state: "visible", timeout: 5000 }); + return (await logPane.locator(".log pre").allTextContents()).slice(-80).map(redactDiagnosticLine); + }); + } catch (error) { + captureErrors.push(`recent log: ${error instanceof Error ? error.message : String(error)}`); + } + + const resultPath = join(outputDirectory, "review-harness-core-not-ready.json"); + await writeFile( + resultPath, + `${JSON.stringify( + { + capturedAt: new Date().toISOString(), + failure: readinessError instanceof Error ? readinessError.message : String(readinessError), + screenshotPath, + readiness, + recentLog, + captureErrors, + }, + null, + 2 + )}\n`, + "utf8" + ); + if (screenshotPath) console.error(`Review Harness core readiness screenshot: ${screenshotPath}`); + console.error(`Review Harness core readiness diagnostics: ${resultPath}`); +} + async function openHarness(): Promise { const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { return await page.evaluate( @@ -203,6 +340,8 @@ async function copyAndReadReport(): Promise { async function verifyMobileHarness(): Promise { await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), true, uiTimeoutMs); await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const harness = page.locator('[data-testid="review-harness"]'); + if (await harness.isVisible()) return; await page.evaluate(async (viewType) => { const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) { @@ -252,7 +391,7 @@ async function main(): Promise { vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), pluginData: { - doctorProcessedVersion: "0.25.27", + doctorProcessedVersion: "1.0.0", settingVersion: CURRENT_SETTING_VERSION, isConfigured: true, additionalSuffixOfDatabaseName: "", @@ -269,7 +408,20 @@ async function main(): Promise { periodicReplication: true, }, }); - await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + await assertNoInterruptedStartupNotice("plug-in session start"); + try { + await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + } catch (error) { + await captureReadinessFailure(cli.binary, session, error).catch((diagnosticError: unknown) => { + console.error( + `Could not capture Review Harness readiness diagnostics: ${ + diagnosticError instanceof Error ? diagnosticError.message : String(diagnosticError) + }` + ); + }); + throw error; + } + await assertNoInterruptedStartupNotice("core readiness"); await keepCompatibilityPaused(); await openHarness(); await waitForHarness(); diff --git a/test/e2e-obsidian/scripts/security-seed-reconnect.ts b/test/e2e-obsidian/scripts/security-seed-reconnect.ts index e0027a23..d75ffc21 100644 --- a/test/e2e-obsidian/scripts/security-seed-reconnect.ts +++ b/test/e2e-obsidian/scripts/security-seed-reconnect.ts @@ -1,3 +1,26 @@ +/** + * Provides release evidence for the Security Seed refresh behaviour shared by + * supported platforms in real Obsidian. It verifies that an already-open + * device keeps its deliberately stale cached Seed until replication, refreshes + * from the managed CouchDB fixture before encrypting, and never restores the + * old Seed to the remote synchronisation-parameter document. + * + * The scenario uses isolated Vaults, profiles, and a random database because + * settings, the local database, the renderer process, and CouchDB must all + * participate in the result. Device A is restarted with the same Vault and + * profile, while device B is fresh. The devices run sequentially after the + * same-process stale-cache assertion because desktop Obsidian may enforce a + * single application instance; running them concurrently would test launcher + * behaviour rather than LiveSync's shared plug-in implementation. + * + * Seed replacement, A-to-B decryption, B-to-A return synchronisation, final + * remote-document comparison, error-log inspection, screenshots, and strict + * teardown remain one scenario. Together they prove that the same replacement + * Seed was used across the complete encrypted round trip and was not later + * rolled back. Independent passing checks would not establish that continuity. + * The result records fingerprints only and does not claim to cover an + * iPadOS-specific background or reconnect lifecycle. + */ import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { access, mkdir, readFile, writeFile } from "node:fs/promises";