import { spawn } from "node:child_process"; import { access, readFile, writeFile } from "node:fs/promises"; import { basename, resolve } from "node:path"; import { assertCouchDbReachable, createCouchDbDatabase, deleteCouchDbDatabase, fetchAllCouchDbDocs, fetchCouchDbDatabaseInfo, fetchCouchDbLocalDocs, loadCouchDbConfig, makeUniqueDatabaseName, type CouchDbConfig, type CouchDbDatabaseInfo, type CouchDbDocument, } from "../runner/couchdb.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { configureCouchDb, configureObjectStorage, createE2eCouchDbPluginData, createE2eObjectStoragePluginData, createE2eObsidianDeviceLocalState, prepareRemote, pushLocalChanges, waitForLiveSyncCoreReady, } from "../runner/liveSyncWorkflow.ts"; import { deleteObjectStoragePrefix, ensureObjectStorageBucket, listObjectStorageObjects, loadObjectStorageConfig, makeUniqueBucketPrefix, readObjectStorageJson, type ObjectStorageConfig, } from "../runner/objectStorage.ts"; import { ensurePinnedReleaseArtifact, UPGRADE_SOURCE_RELEASE } from "../runner/releaseArtifact.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { assertCouchDbCheckpointContinuity, assertCouchDbDocumentsUnchanged, assertJournalCheckpointAdvanced, assertJournalCheckpointLoaded, assertMilestoneContinuity, assertNoJournalReplay, assertSomeCouchDbCheckpointAdvanced, type CouchDbCheckpointSnapshot, type CouchDbDocumentRevision, type MilestoneIdentity, type RemoteObjectSnapshot, } from "../runner/upgradeContinuity.ts"; import { assertStableReleaseDefaults, assertStableRemoteSelection, assertUnconfiguredUpgradeReady, assertUnconfiguredUpgradeRestarted, assertUpgradeCompatibilityReady, assertUpgradeRemainsReady, configureStableRelease, createPostUpgradeDelta, createUpgradeScenarioPaths, createVerifierReturnDelta, dismissConfigDoctorIfShown, prepareStableRemote, readJournalCheckpoint, readLocalCouchDbCheckpoints, readRuntimeUpgradeState, readRuntimeSettingsUpgradeState, runCouchDbReplicationObserved, runJournalReplicationObserved, runStableFileHistory, STABLE_RELEASE_VERSION, verifyPostUpgradeHistory, verifyPreUpgradeHistory, verifyReturnDelta, waitForPersistentNodeIdentity, type CouchDbReplicationObservation, type RuntimeUpgradeState, type UpgradeTransportConfiguration, } from "../runner/upgradeWorkflow.ts"; import { obsidianRemoteDebuggingPort } from "../runner/ui.ts"; import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000"; type Transport = "couchdb" | "object-storage"; type RemoteMilestone = CouchDbDocument & { created?: unknown; locked?: unknown; accepted_nodes?: unknown; tweak_values?: unknown; }; type CouchDbRemoteSnapshot = { checkpoints: CouchDbCheckpointSnapshot[]; documents: CouchDbDocumentRevision[]; info: CouchDbDatabaseInfo; milestone: MilestoneIdentity; preferredTweaks: Record; }; type ObjectStorageRemoteSnapshot = { journalObjects: RemoteObjectSnapshot[]; milestone: MilestoneIdentity; preferredTweaks: Record; }; type RunnerContext = { binary: string; cliBinary: string; sourceArtifactRoot: string; targetArtifactRoot: string; targetVersion: string; activeSessions: Set; }; type ParsedArguments = { transports: Transport[]; manageServices: boolean; keepServices: boolean; }; type StartSessionOptions = { pluginData?: Record; localStorageEntries?: Readonly>; waitForCoreReady?: boolean; }; const MILESTONE_ID = "_local/obsydian_livesync_milestone"; const JOURNAL_MILESTONE_NAME = "_00000000-milestone.json"; function assert(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); } function assertEqual(actual: unknown, expected: unknown, message: string): void { if (actual !== expected) { throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`); } } function parseArguments(argv: readonly string[]): ParsedArguments { let transportValue = "all"; for (let index = 0; index < argv.length; index++) { const argument = argv[index]; if (argument === "--transport") { transportValue = argv[index + 1] ?? ""; index++; } else if (argument.startsWith("--transport=")) { transportValue = argument.slice("--transport=".length); } } const transports: Transport[] = transportValue === "all" ? ["couchdb", "object-storage"] : transportValue === "couchdb" || transportValue === "object-storage" ? [transportValue] : (() => { throw new Error(`Unsupported transport '${transportValue}'. Use couchdb, object-storage, or all.`); })(); return { transports, manageServices: argv.includes("--manage-services"), keepServices: argv.includes("--keep-services"), }; } 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]; } function npmBinary(): string { return process.platform === "win32" ? "npm.cmd" : "npm"; } function runNpmScript(name: string, optional = false): Promise { return new Promise((resolvePromise, reject) => { console.log(`\n# ${name}`); const child = spawn(npmBinary(), ["run", name], { cwd: process.cwd(), env: process.env, stdio: "inherit", }); child.on("error", reject); child.on("exit", (code, signal) => { if (code === 0 || optional) { if (code !== 0) { console.warn(`${name} did not complete cleanly (${signal ? `signal ${signal}` : `exit ${code}`}).`); } resolvePromise(); return; } reject(new Error(`${name} failed (${signal ? `signal ${signal}` : `exit ${code}`}).`)); }); }); } async function validateTargetArtifact(root: string): Promise { await Promise.all( ["main.js", "manifest.json", "styles.css"].map(async (name) => await access(resolve(root, name))) ); const manifest = JSON.parse(await readFile(resolve(root, "manifest.json"), "utf8")) as { id?: unknown; version?: unknown; }; assertEqual(manifest.id, UPGRADE_SOURCE_RELEASE.pluginId, "The target artefact has an unexpected plug-in id."); assert(typeof manifest.version === "string" && manifest.version.length > 0, "The target manifest has no version."); assert( manifest.version !== STABLE_RELEASE_VERSION, `The target artefact is still the source release ${STABLE_RELEASE_VERSION}.` ); return manifest.version; } async function startSession( context: RunnerContext, vault: TemporaryVault, port: number, artifactRoot: string, options: StartSessionOptions = {} ): Promise { const session = await startObsidianLiveSyncSession({ binary: context.binary, cliBinary: context.cliBinary, vault, artifactRoot, pluginData: options.pluginData, localStorageEntries: options.localStorageEntries, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), env: sessionEnvironment(port), }); context.activeSessions.add(session); try { if (options.waitForCoreReady !== false) { await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); } return session; } catch (error) { await stopSession(context, session).catch(() => undefined); throw error; } } async function readStoredPluginData(vault: TemporaryVault): Promise> { const path = resolve(vault.path, ".obsidian", "plugins", "obsidian-livesync", "data.json"); return JSON.parse(await readFile(path, "utf8")) as Record; } async function writeStoredPluginData(vault: TemporaryVault, data: Record): Promise { const path = resolve(vault.path, ".obsidian", "plugins", "obsidian-livesync", "data.json"); await writeFile(path, `${JSON.stringify(data, null, 2)}\n`); } async function runUnconfiguredSettingsUpgrade(context: RunnerContext, port: number): Promise { console.log(`\n# Upgrade from ${STABLE_RELEASE_VERSION}: unconfigured legacy settings`); const vault = await createTemporaryVault("obsidian-livesync-upgrade-unconfigured-"); try { let session = await startSession(context, vault, port, context.sourceArtifactRoot, { pluginData: { liveSync: false }, waitForCoreReady: false, }); const stableState = await readRuntimeSettingsUpgradeState(context.cliBinary, session.cliEnv); assertStableReleaseDefaults(stableState, false); await stopSession(context, session); const stableData = await readStoredPluginData(vault); if (stableData.isConfigured !== undefined) { assertEqual( stableData.isConfigured, false, "The stable release persisted a configured state for its default-equivalent settings." ); } // 0.25.83 infers the runtime boolean, but persistence depends on an // unrelated settings-save event. Restore the pre-flag document // explicitly so the target proves the direct legacy migration in // either case rather than depending on that timing. await writeStoredPluginData(vault, { liveSync: false }); session = await startSession(context, vault, port, context.targetArtifactRoot, { waitForCoreReady: false, }); const upgradedState = await readRuntimeSettingsUpgradeState(context.cliBinary, session.cliEnv); assertUnconfiguredUpgradeReady(stableState, upgradedState, context.targetVersion); await stopSession(context, session); const migratedData = await readStoredPluginData(vault); assertEqual(migratedData.isConfigured, false, "The inferred unconfigured state was not saved."); assertEqual( migratedData.handleFilenameCaseSensitive, false, "The inferred case-insensitive setting was not saved." ); session = await startSession(context, vault, port, context.targetArtifactRoot, { waitForCoreReady: false, }); const restartedState = await readRuntimeSettingsUpgradeState(context.cliBinary, session.cliEnv); assertUnconfiguredUpgradeRestarted(restartedState, context.targetVersion); await stopSession(context, session); console.log( `PASS unconfigured settings: ${STABLE_RELEASE_VERSION} -> ${context.targetVersion}; legacy inference, persistence, and restart idempotence verified.` ); } finally { await stopSessions(context); await vault.dispose(); } } async function stopSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise { if (!context.activeSessions.has(session)) return; await session.app.stop(); context.activeSessions.delete(session); } async function stopSessions(context: RunnerContext): Promise { for (const session of [...context.activeSessions]) await stopSession(context, session); } function milestoneIdentity(document: RemoteMilestone): MilestoneIdentity { assert(document.created !== undefined && document.created !== null, "The remote milestone has no generation."); assert(typeof document.locked === "boolean", "The remote milestone has no lock state."); assert(Array.isArray(document.accepted_nodes), "The remote milestone has no accepted-device list."); assert( document.accepted_nodes.every((value) => typeof value === "string"), "The remote milestone accepted-device list is malformed." ); return { created: document.created, locked: document.locked, acceptedNodes: document.accepted_nodes, }; } function preferredTweaks(document: RemoteMilestone): Record { const values = document.tweak_values; assert(values !== null && typeof values === "object" && !Array.isArray(values), "The remote has no tweak map."); const preferred = (values as Record).PREFERRED; assert( preferred !== null && typeof preferred === "object" && !Array.isArray(preferred), "The remote has no preferred tweak settings." ); return { ...(preferred as Record) }; } async function readCouchDbRemoteSnapshot(config: CouchDbConfig, databaseName: string): Promise { const [allDocs, localDocs, info] = await Promise.all([ fetchAllCouchDbDocs(config, databaseName), fetchCouchDbLocalDocs(config, databaseName), fetchCouchDbDatabaseInfo(config, databaseName), ]); const milestone = localDocs.rows.find(({ id }) => id === MILESTONE_ID)?.doc as RemoteMilestone | undefined; assert(milestone, "The CouchDB remote milestone is missing."); const checkpoints = localDocs.rows.flatMap(({ id, doc }) => doc && Object.prototype.hasOwnProperty.call(doc, "last_seq") ? [{ id, lastSequence: doc.last_seq }] : [] ); const documents = allDocs.rows.map(({ id, value }) => ({ id, revision: value.rev, deleted: value.deleted === true, })); return { checkpoints, documents, info, milestone: milestoneIdentity(milestone), preferredTweaks: preferredTweaks(milestone), }; } async function readObjectStorageRemoteSnapshot( config: ObjectStorageConfig, prefix: string ): Promise { const [objects, milestone] = await Promise.all([ listObjectStorageObjects(config, prefix), readObjectStorageJson(config, `${prefix}${JOURNAL_MILESTONE_NAME}`), ]); const journalObjects = objects.flatMap((object) => { if (!object.Key || basename(object.Key).startsWith("_")) return []; return [ { key: object.Key, size: object.Size ?? 0, etag: object.ETag ?? "", }, ]; }); return { journalObjects, milestone: milestoneIdentity(milestone), preferredTweaks: preferredTweaks(milestone), }; } function assertNoOpCouchDbObservation(observation: CouchDbReplicationObservation): void { assert(observation.succeeded, "The first post-upgrade CouchDB synchronisation failed."); assertEqual(observation.sentDocuments, 0, "The no-op CouchDB synchronisation resent documents."); assertEqual(observation.arrivedDocuments, 0, "The no-op CouchDB synchronisation refetched documents."); } function assertNoOpCouchDbDatabase(before: CouchDbRemoteSnapshot, after: CouchDbRemoteSnapshot): void { assertCouchDbCheckpointContinuity(before.checkpoints, after.checkpoints); assertCouchDbDocumentsUnchanged(before.documents, after.documents); assertEqual( after.info.update_seq, before.info.update_seq, "The no-op CouchDB synchronisation advanced update_seq." ); assertEqual(after.info.doc_count, before.info.doc_count, "The no-op CouchDB synchronisation changed doc_count."); assertMilestoneContinuity(before.milestone, after.milestone); } function assertRestartContinuity(before: RuntimeUpgradeState, after: RuntimeUpgradeState): void { assertEqual(after.localDatabaseName, before.localDatabaseName, "Restart opened a different local database."); assertEqual(after.nodeId, before.nodeId, "Restart changed the device node identity."); assertEqual( after.settings.activeConfigurationId, before.settings.activeConfigurationId, "Restart changed the active remote profile." ); } async function configureFreshCouchDbVerifier( context: RunnerContext, session: ObsidianLiveSyncSession, config: CouchDbConfig, databaseName: string, tweaks: Record ): Promise { await configureCouchDb( context.cliBinary, session.cliEnv, { uri: config.uri, username: config.username, password: config.password, dbName: databaseName }, tweaks ); await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); await prepareRemote(context.cliBinary, session.cliEnv); } async function configureFreshObjectStorageVerifier( context: RunnerContext, session: ObsidianLiveSyncSession, config: ObjectStorageConfig, prefix: string, tweaks: Record ): Promise { await configureObjectStorage(context.cliBinary, session.cliEnv, { ...config, bucketPrefix: prefix }, tweaks); await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); await prepareRemote(context.cliBinary, session.cliEnv); } async function runCouchDbUpgrade(context: RunnerContext, ports: readonly [number, number]): Promise { console.log(`\n# Upgrade from ${STABLE_RELEASE_VERSION}: CouchDB`); const config = await loadCouchDbConfig(); const databaseName = makeUniqueDatabaseName(config.dbPrefix, "upgrade-from-stable"); const remote: UpgradeTransportConfiguration = { kind: "couchdb", config, databaseName }; const paths = createUpgradeScenarioPaths("couchdb"); const upgradeVault = await createTemporaryVault("obsidian-livesync-upgrade-couchdb-"); const verifierVault = await createTemporaryVault("obsidian-livesync-upgrade-couchdb-verifier-"); let upgradedSession: ObsidianLiveSyncSession | undefined; try { await assertCouchDbReachable(config); await createCouchDbDatabase(config, databaseName); let session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot); assertStableReleaseDefaults(await readRuntimeUpgradeState(context.cliBinary, session.cliEnv), false); await configureStableRelease(context.cliBinary, session.cliEnv, remote); const configuredStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); assertStableReleaseDefaults(configuredStable, true); assertStableRemoteSelection(configuredStable, remote); await stopSession(context, session); session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot); const restartedStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); assertStableReleaseDefaults(restartedStable, true); assertStableRemoteSelection(restartedStable, remote); await waitForPersistentNodeIdentity(context.cliBinary, session.cliEnv); await prepareStableRemote(context.cliBinary, session.cliEnv); await runStableFileHistory(context.cliBinary, session.cliEnv, paths, async () => { const result = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv); assert(result.succeeded, "The stable CouchDB synchronisation failed."); }); await verifyPreUpgradeHistory(upgradeVault, paths); const stableState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); const stableRemote = await readCouchDbRemoteSnapshot(config, databaseName); const stableLocalCheckpoints = await readLocalCouchDbCheckpoints( context.cliBinary, session.cliEnv, stableRemote.checkpoints.map(({ id }) => id) ); assertCouchDbCheckpointContinuity(stableRemote.checkpoints, stableLocalCheckpoints); await stopSession(context, session); session = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot); upgradedSession = session; await dismissConfigDoctorIfShown(session.remoteDebuggingPort); const upgradedState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); assertUpgradeCompatibilityReady(stableState, upgradedState, context.targetVersion, remote); await verifyPreUpgradeHistory(upgradeVault, paths); const loadedLocalCheckpoints = await readLocalCouchDbCheckpoints( context.cliBinary, session.cliEnv, stableRemote.checkpoints.map(({ id }) => id) ); assertCouchDbCheckpointContinuity(stableLocalCheckpoints, loadedLocalCheckpoints); const noOpObservation = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv); assertNoOpCouchDbObservation(noOpObservation); const noOpRemote = await readCouchDbRemoteSnapshot(config, databaseName); assertNoOpCouchDbDatabase(stableRemote, noOpRemote); await createPostUpgradeDelta(context.cliBinary, session.cliEnv, paths); const deltaObservation = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv); assert(deltaObservation.succeeded, "The post-upgrade CouchDB delta failed."); assert(deltaObservation.sentDocuments > 0, "The post-upgrade CouchDB delta sent no documents."); const deltaRemote = await readCouchDbRemoteSnapshot(config, databaseName); assertSomeCouchDbCheckpointAdvanced(noOpRemote.checkpoints, deltaRemote.checkpoints); assertMilestoneContinuity(noOpRemote.milestone, deltaRemote.milestone); const verifierSettings = { uri: config.uri, username: config.username, password: config.password, dbName: databaseName, }; const verifier = await startSession(context, verifierVault, ports[1], context.targetArtifactRoot, { pluginData: createE2eCouchDbPluginData(verifierSettings, deltaRemote.preferredTweaks), localStorageEntries: createE2eObsidianDeviceLocalState(verifierVault.name), }); await configureFreshCouchDbVerifier(context, verifier, config, databaseName, deltaRemote.preferredTweaks); await pushLocalChanges(context.cliBinary, verifier.cliEnv); await verifyPostUpgradeHistory(verifierVault, paths); await createVerifierReturnDelta(context.cliBinary, verifier.cliEnv, paths); await pushLocalChanges(context.cliBinary, verifier.cliEnv); const returnObservation = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv); assert(returnObservation.succeeded, "The upgraded CouchDB device could not receive the verifier delta."); assert(returnObservation.arrivedDocuments > 0, "The verifier CouchDB delta did not arrive."); await verifyReturnDelta(upgradeVault, paths); await stopSession(context, verifier); await stopSession(context, session); upgradedSession = undefined; const restarted = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot); const restartedState = await readRuntimeUpgradeState(context.cliBinary, restarted.cliEnv); assertUpgradeRemainsReady(restartedState, context.targetVersion); assertRestartContinuity(upgradedState, restartedState); await verifyReturnDelta(upgradeVault, paths); await stopSession(context, restarted); console.log( `PASS CouchDB: ${STABLE_RELEASE_VERSION} -> ${context.targetVersion}; checkpoint lineage, no-op sync, delta sync, fresh-device round-trip, and restart continuity verified.` ); } finally { if (upgradedSession) await stopSession(context, upgradedSession).catch(() => undefined); await stopSessions(context); await Promise.all([upgradeVault.dispose(), verifierVault.dispose()]); if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") { await deleteCouchDbDatabase(config, databaseName).catch((error: unknown) => { console.warn(error instanceof Error ? error.message : error); }); } } } async function runObjectStorageUpgrade(context: RunnerContext, ports: readonly [number, number]): Promise { console.log(`\n# Upgrade from ${STABLE_RELEASE_VERSION}: Object Storage`); const config = await loadObjectStorageConfig(); const prefix = makeUniqueBucketPrefix("upgrade-from-stable"); const remote: UpgradeTransportConfiguration = { kind: "object-storage", config, bucketPrefix: prefix }; const paths = createUpgradeScenarioPaths("object-storage"); const upgradeVault = await createTemporaryVault("obsidian-livesync-upgrade-object-storage-"); const verifierVault = await createTemporaryVault("obsidian-livesync-upgrade-object-storage-verifier-"); let upgradedSession: ObsidianLiveSyncSession | undefined; try { await ensureObjectStorageBucket(config); let session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot); assertStableReleaseDefaults(await readRuntimeUpgradeState(context.cliBinary, session.cliEnv), false); await configureStableRelease(context.cliBinary, session.cliEnv, remote); const configuredStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); assertStableReleaseDefaults(configuredStable, true); assertStableRemoteSelection(configuredStable, remote); await stopSession(context, session); session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot); const restartedStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); assertStableReleaseDefaults(restartedStable, true); assertStableRemoteSelection(restartedStable, remote); await waitForPersistentNodeIdentity(context.cliBinary, session.cliEnv); await prepareStableRemote(context.cliBinary, session.cliEnv); await runStableFileHistory(context.cliBinary, session.cliEnv, paths, async () => { const result = await runJournalReplicationObserved(context.cliBinary, session.cliEnv); assert( result.succeeded, `The stable Object Storage synchronisation failed.\nObservation: ${JSON.stringify(result)}` ); }); await verifyPreUpgradeHistory(upgradeVault, paths); const stableState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); const stableCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv); const stableRemote = await readObjectStorageRemoteSnapshot(config, prefix); await stopSession(context, session); session = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot); upgradedSession = session; await dismissConfigDoctorIfShown(session.remoteDebuggingPort); const upgradedState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); assertUpgradeCompatibilityReady(stableState, upgradedState, context.targetVersion, remote); await verifyPreUpgradeHistory(upgradeVault, paths); const loadedCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv); assertJournalCheckpointLoaded(stableCheckpoint, loadedCheckpoint); const noOpObservation = await runJournalReplicationObserved(context.cliBinary, session.cliEnv); assert(noOpObservation.succeeded, "The first post-upgrade Object Storage synchronisation failed."); const noOpCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv); const noOpRemote = await readObjectStorageRemoteSnapshot(config, prefix); assertNoJournalReplay( stableCheckpoint, noOpCheckpoint, stableRemote.journalObjects, noOpRemote.journalObjects, noOpObservation ); assertMilestoneContinuity(stableRemote.milestone, noOpRemote.milestone); await createPostUpgradeDelta(context.cliBinary, session.cliEnv, paths); const deltaObservation = await runJournalReplicationObserved(context.cliBinary, session.cliEnv); assert(deltaObservation.succeeded, "The post-upgrade Object Storage delta failed."); const deltaCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv); assertJournalCheckpointAdvanced(noOpCheckpoint, deltaCheckpoint, deltaObservation); const deltaRemote = await readObjectStorageRemoteSnapshot(config, prefix); assertMilestoneContinuity(noOpRemote.milestone, deltaRemote.milestone); const verifierSettings = { ...config, bucketPrefix: prefix }; const verifier = await startSession(context, verifierVault, ports[1], context.targetArtifactRoot, { pluginData: createE2eObjectStoragePluginData(verifierSettings, deltaRemote.preferredTweaks), localStorageEntries: createE2eObsidianDeviceLocalState(verifierVault.name), }); await configureFreshObjectStorageVerifier(context, verifier, config, prefix, deltaRemote.preferredTweaks); await pushLocalChanges(context.cliBinary, verifier.cliEnv); await verifyPostUpgradeHistory(verifierVault, paths); await createVerifierReturnDelta(context.cliBinary, verifier.cliEnv, paths); await pushLocalChanges(context.cliBinary, verifier.cliEnv); const returnObservation = await runJournalReplicationObserved(context.cliBinary, session.cliEnv); assert(returnObservation.succeeded, "The upgraded Object Storage device could not receive the verifier delta."); assert(returnObservation.downloadedJournalKeys.length > 0, "The verifier Object Storage delta did not arrive."); await verifyReturnDelta(upgradeVault, paths); await stopSession(context, verifier); await stopSession(context, session); upgradedSession = undefined; const restarted = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot); const restartedState = await readRuntimeUpgradeState(context.cliBinary, restarted.cliEnv); assertUpgradeRemainsReady(restartedState, context.targetVersion); assertRestartContinuity(upgradedState, restartedState); await verifyReturnDelta(upgradeVault, paths); await stopSession(context, restarted); console.log( `PASS Object Storage: ${STABLE_RELEASE_VERSION} -> ${context.targetVersion}; checkpoint lineage, no replay, delta sync, fresh-device round-trip, and restart continuity verified.` ); } finally { if (upgradedSession) await stopSession(context, upgradedSession).catch(() => undefined); await stopSessions(context); await Promise.all([upgradeVault.dispose(), verifierVault.dispose()]); if (process.env.E2E_OBSIDIAN_KEEP_OBJECT_STORAGE !== "true") { await deleteObjectStoragePrefix(config, prefix).catch((error: unknown) => { console.warn(error instanceof Error ? error.message : error); }); } } } async function startManagedServices(transports: readonly Transport[]): Promise { if (transports.includes("couchdb")) { await runNpmScript("test:docker-couchdb:stop", true); await runNpmScript("test:docker-couchdb:start"); } if (transports.includes("object-storage")) { await runNpmScript("test:docker-s3:stop", true); await runNpmScript("test:docker-s3:start"); } } async function stopManagedServices(transports: readonly Transport[]): Promise { if (transports.includes("object-storage")) await runNpmScript("test:docker-s3:stop", true); if (transports.includes("couchdb")) await runNpmScript("test:docker-couchdb:stop", true); } async function main(): Promise { const arguments_ = parseArguments(process.argv.slice(2)); const binary = requireObsidianBinary(); const cli = discoverObsidianCli(); if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); const targetArtifactRoot = resolve(process.env.E2E_LIVESYNC_TARGET_ARTIFACT_ROOT?.trim() || process.cwd()); const targetVersion = await validateTargetArtifact(targetArtifactRoot); const sourceArtifactRoot = await ensurePinnedReleaseArtifact(); const context: RunnerContext = { binary, cliBinary: cli.binary, sourceArtifactRoot, targetArtifactRoot, targetVersion, activeSessions: new Set(), }; const ports = sessionPorts(); let managedServicesStarted = false; console.log(`Using exact source release: ${STABLE_RELEASE_VERSION}`); console.log(`Using target release candidate: ${targetVersion}`); console.log(`Source artefact cache: ${sourceArtifactRoot}`); console.log(`Target artefact root: ${targetArtifactRoot}`); try { await runUnconfiguredSettingsUpgrade(context, ports[0]); if (arguments_.manageServices) { await startManagedServices(arguments_.transports); managedServicesStarted = true; } for (const transport of arguments_.transports) { if (transport === "couchdb") await runCouchDbUpgrade(context, ports); else await runObjectStorageUpgrade(context, ports); } } finally { await stopSessions(context); if (managedServicesStarted && !arguments_.keepServices) { await stopManagedServices(arguments_.transports); } } } main().catch((error: unknown) => { console.error(error instanceof Error ? error.stack : error); process.exit(1); });