Integrate E2EE rebuild preservation with current main

This commit is contained in:
vorotamoroz
2026-09-02 11:30:20 +00:00
139 changed files with 8753 additions and 4714 deletions
+101 -1
View File
@@ -15,13 +15,23 @@ const runtimeMocks = vi.hoisted(() => {
const clearCache = vi.fn();
const collectFilesOnStorage = vi.fn();
const updateToDatabase = vi.fn();
const applicationReady = { value: false };
const isReady = vi.fn(() => applicationReady.value);
const markIsReady = vi.fn(() => {
applicationReady.value = true;
});
const onDatabaseInitialised = vi.fn();
const commitPendingFileEvents = vi.fn();
const p2p = {
replicator: {},
};
const serviceHub = {
API: { addLog },
appLifecycle: { isReady, markIsReady },
control: { onLoad, onReady, onUnload },
databaseEvents: { onDatabaseInitialised },
fileProcessing: { commitPendingFileEvents },
setting: { currentSettings },
vault: { scanVault },
};
@@ -40,12 +50,17 @@ const runtimeMocks = vi.hoisted(() => {
return {
addLog,
applicationReady,
options: undefined as LiveSyncBrowserServiceHubOptions<never> | undefined,
clearCache,
cleanup,
collectFilesOnStorage,
commitPendingFileEvents,
currentSettings,
getFiles,
isReady,
markIsReady,
onDatabaseInitialised,
onLoad,
onReady,
onUnload,
@@ -125,9 +140,12 @@ async function waitForMicrotasks(): Promise<void> {
describe("WebAppRuntime lifecycle", () => {
beforeEach(() => {
vi.clearAllMocks();
runtimeMocks.applicationReady.value = false;
runtimeMocks.options = undefined;
runtimeMocks.commitPendingFileEvents.mockResolvedValue(true);
runtimeMocks.currentSettings.mockReturnValue(unconfiguredSettings);
runtimeMocks.getFiles.mockResolvedValue([{ path: "one.md" }]);
runtimeMocks.onDatabaseInitialised.mockResolvedValue(true);
runtimeMocks.onLoad.mockResolvedValue(true);
runtimeMocks.onReady.mockResolvedValue(undefined);
runtimeMocks.onUnload.mockResolvedValue(undefined);
@@ -174,7 +192,7 @@ describe("WebAppRuntime lifecycle", () => {
expect(runtimeMocks.onUnload).toHaveBeenCalledOnce();
});
it("imports local files for optional P2P while the main remote remains unconfigured", async () => {
it("finalises application readiness after importing local files for optional P2P", async () => {
const runtime = new WebAppRuntime(createRootHandle());
await runtime.start();
vi.clearAllMocks();
@@ -203,11 +221,93 @@ describe("WebAppRuntime lifecycle", () => {
expect.any(Function)
);
expect(runtimeMocks.updateToDatabase).toHaveBeenCalledOnce();
expect(runtimeMocks.onDatabaseInitialised).toHaveBeenCalledOnce();
expect(runtimeMocks.commitPendingFileEvents).toHaveBeenCalledOnce();
expect(runtimeMocks.markIsReady).toHaveBeenCalledOnce();
expect(runtimeMocks.updateToDatabase.mock.invocationCallOrder[0]).toBeLessThan(
runtimeMocks.onDatabaseInitialised.mock.invocationCallOrder[0]
);
expect(runtimeMocks.onDatabaseInitialised.mock.invocationCallOrder[0]).toBeLessThan(
runtimeMocks.commitPendingFileEvents.mock.invocationCallOrder[0]
);
expect(runtimeMocks.commitPendingFileEvents.mock.invocationCallOrder[0]).toBeLessThan(
runtimeMocks.markIsReady.mock.invocationCallOrder[0]
);
expect(runtimeMocks.scanVault).not.toHaveBeenCalled();
expect(runtimeMocks.currentSettings()).toBe(unconfiguredSettings);
expect(unconfiguredSettings.isConfigured).toBe(false);
});
it("does not publish readiness after a partial local-file import", async () => {
const runtime = new WebAppRuntime(createRootHandle());
await runtime.start();
vi.clearAllMocks();
runtimeMocks.collectFilesOnStorage.mockResolvedValue({
storageFileNameMap: {
"one.md": {
path: "one.md",
stat: { ctime: 1, mtime: 1, size: 3, type: "file" },
},
"two.md": {
path: "two.md",
stat: { ctime: 2, mtime: 2, size: 3, type: "file" },
},
},
storageFileNames: ["one.md", "two.md"],
storageFileNameCI2CS: { "one.md": "one.md", "two.md": "two.md" },
});
runtimeMocks.updateToDatabase
.mockRejectedValueOnce(new Error("one.md could not be imported"))
.mockResolvedValueOnce(undefined);
await expect(runtime.scanLocalFiles()).resolves.toBe(false);
expect(runtimeMocks.updateToDatabase).toHaveBeenCalledTimes(2);
expect(runtimeMocks.onDatabaseInitialised).not.toHaveBeenCalled();
expect(runtimeMocks.commitPendingFileEvents).not.toHaveBeenCalled();
expect(runtimeMocks.markIsReady).not.toHaveBeenCalled();
});
it("does not publish readiness when database initialisation rejects manual preparation", async () => {
const runtime = new WebAppRuntime(createRootHandle());
await runtime.start();
vi.clearAllMocks();
runtimeMocks.onDatabaseInitialised.mockResolvedValue(false);
await expect(runtime.scanLocalFiles()).resolves.toBe(false);
expect(runtimeMocks.onDatabaseInitialised).toHaveBeenCalledOnce();
expect(runtimeMocks.commitPendingFileEvents).not.toHaveBeenCalled();
expect(runtimeMocks.markIsReady).not.toHaveBeenCalled();
});
it("does not publish readiness when pending file events cannot be committed", async () => {
const runtime = new WebAppRuntime(createRootHandle());
await runtime.start();
vi.clearAllMocks();
runtimeMocks.commitPendingFileEvents.mockResolvedValue(false);
await expect(runtime.scanLocalFiles()).resolves.toBe(false);
expect(runtimeMocks.onDatabaseInitialised).toHaveBeenCalledOnce();
expect(runtimeMocks.commitPendingFileEvents).toHaveBeenCalledOnce();
expect(runtimeMocks.markIsReady).not.toHaveBeenCalled();
});
it("does not repeat readiness finalisation after a successful manual scan", async () => {
const runtime = new WebAppRuntime(createRootHandle());
await runtime.start();
vi.clearAllMocks();
await expect(runtime.scanLocalFiles()).resolves.toBe(true);
await expect(runtime.scanLocalFiles()).resolves.toBe(true);
expect(runtimeMocks.updateToDatabase).toHaveBeenCalledTimes(2);
expect(runtimeMocks.onDatabaseInitialised).toHaveBeenCalledOnce();
expect(runtimeMocks.commitPendingFileEvents).toHaveBeenCalledOnce();
expect(runtimeMocks.markIsReady).toHaveBeenCalledOnce();
});
it("rejects a failed core start after cleaning up the partial runtime", async () => {
runtimeMocks.onLoad.mockResolvedValue(false);
const reportStatus = vi.fn();
@@ -0,0 +1,57 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runtimeMocks = vi.hoisted(() => {
const connect = vi.fn().mockResolvedValue(undefined);
const makeSureOpened = vi.fn().mockResolvedValue(undefined);
const start = vi.fn().mockResolvedValue(undefined);
const shutdown = vi.fn().mockResolvedValue(undefined);
const removeStatusListener = vi.fn();
const onEvent = vi.fn(() => removeStatusListener);
const WebPeerRuntime = vi.fn(function () {
return {
events: { onEvent },
p2p: {
transportLifecycle: { connect },
},
currentReplicator: { makeSureOpened },
start,
shutdown,
};
});
return {
WebPeerRuntime,
connect,
makeSureOpened,
onEvent,
removeStatusListener,
shutdown,
start,
};
});
vi.mock("@/apps/webpeer/src/WebPeerRuntime", () => ({
WebPeerRuntime: runtimeMocks.WebPeerRuntime,
}));
import { P2PCheckSession } from "@/apps/webpeer/src/P2PCheckSession";
describe("P2P connection-check session", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("opens an explicit check through the P2P transport lifecycle", async () => {
const session = new P2PCheckSession();
await session.start({} as ObsidianLiveSyncSettings, "browser-check", vi.fn());
expect(runtimeMocks.start).toHaveBeenCalledOnce();
expect(runtimeMocks.connect).toHaveBeenCalledOnce();
expect(runtimeMocks.makeSureOpened).not.toHaveBeenCalled();
await session.stop();
expect(runtimeMocks.shutdown).toHaveBeenCalledOnce();
});
});
+29 -1
View File
@@ -34,7 +34,7 @@ describe("WebPeer runtime composition", () => {
expect(runtime.context).toBe(context);
expect(runtime.services.context).toBe(context);
expect(runtime.events).toBe(context.events);
expect(runtime.currentReplicator).toBe(runtime.p2p.replicator);
expect(runtime.paneHost.p2p.transportLifecycle).toBe(runtime.p2p.transportLifecycle);
expect(runtime.paneHost.services).toBe(runtime.services);
expect(runtime.paneHost.p2p).toBe(runtime.p2p);
expect(runtime.paneHost.showPeerMenu).toBeTypeOf("function");
@@ -64,6 +64,34 @@ describe("WebPeer runtime composition", () => {
expect(layoutReady).toHaveBeenCalledOnce();
});
it("delegates automatic P2P startup to the resumed lifecycle handler", async () => {
vi.useFakeTimers();
try {
const runtime = new WebPeerRuntime({
store: createMemoryStore(),
});
vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
vi.spyOn(runtime.services.setting, "currentSettings").mockReturnValue({
...DEFAULT_SETTINGS,
P2P_AutoStart: true,
P2P_Enabled: true,
});
vi.spyOn(runtime.services.database, "openDatabase").mockResolvedValue(true);
const onResumed = vi
.spyOn(runtime.services.appLifecycle, "onResumed")
.mockResolvedValue(true);
const open = vi.spyOn(runtime.p2p.transportLifecycle, "connect").mockResolvedValue(undefined);
await runtime.start();
expect(onResumed).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(100);
expect(open).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("rejects start when the browser-local database cannot be opened", async () => {
const runtime = new WebPeerRuntime({
store: createMemoryStore(),
+2 -2
View File
@@ -121,7 +121,7 @@ The native run writes `settings-declarative-landing.png`, `settings-declarative-
The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixel viewport, and explicit iPhone-style safe-area insets of 47 pixels at the top and 34 pixels at the bottom. The public `@vrtmrz/obsidian-test-session` layout assertions require each modal to remain within the viewport and safe area without horizontal overflow. They also require the Obsidian Close control to remain within the safe area and provide at least a 44 by 44 CSS-pixel touch target. The runner clicks that control to verify actionability, then completes the explicit cancellation path. These simulated checks cover deterministic layout and interaction boundaries; they do not claim to reproduce a native operating-system overlay.
`test:e2e:obsidian:review-harness` exercises only the boundaries owned by the opt-in maintainer Harness. It retains a real compatibility pause, uses the fixed Harness restart action to persist a device-local continuation and reload Obsidian, and requires the Harness to delete that state before reopening. It also runs the bounded local observations, confirms the dedicated Vault fixture root is removed, captures the copied privacy-bounded Markdown report, and checks the Harness layout and touch targets in mobile test mode. Compatibility explanation and persistence details remain owned by `settings-ui`, real P2P transfer remains owned by the dedicated P2P suites, and general Vault reflection remains owned by `vault-reflection`; the Harness test does not duplicate those workflows.
`test:e2e:obsidian:review-harness` exercises only the boundaries owned by the opt-in maintainer Harness. It retains a real compatibility pause, uses the fixed Harness restart action to persist a device-local continuation and reload Obsidian, and requires the Harness to delete that state before reopening. It also runs the bounded settings-lifecycle observation, confirms the dedicated Vault fixture root is removed, captures the copied privacy-bounded Markdown report, and checks the Harness layout and touch targets in mobile test mode. Compatibility explanation and persistence details remain owned by `settings-ui`, real P2P transfer remains owned by the dedicated P2P suites, and general Vault reflection remains owned by `vault-reflection`; the Harness test does not duplicate those workflows.
`test:e2e:obsidian:p2p-pane` starts one configured CouchDB-only session with no P2P profile and separate configured P2P sessions for desktop and mobile. It proves that the command remains registered while the retired command, automatic pane, and ribbon entry without a P2P configuration are absent. For the configured P2P profiles, it verifies that the desktop ribbon is available, the current status command reaches the pane without it opening at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile screenshots. The mobile session uses a fresh Vault, profile, and Obsidian process, enters `app.emulateMobile(true)` through `lifecycle.beforePluginStart`, and requires the P2P view to belong to the right drawer rather than inheriting desktop workspace state. It deliberately uses no relay or peer: replacement of the active replicator is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible transfer path between two real Obsidian sessions.
@@ -158,7 +158,7 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
`test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service and starts with isolated Object Storage settings and the device-local compatibility acknowledgement already in place, keeping the scenario focused on upload rather than unconfigured start-up or setup. It confirms those settings through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix. Adapter tests separately observe an in-progress SDK command, while this real-runtime workflow verifies the resulting request counters advance and rebalance.
`test:e2e:obsidian:object-storage-setup-uri-workflow` uses the public Commonlib-backed tool to generate the initial Setup URI for a unique MinIO prefix, completes visible initialisation on the first device, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies A-to-B and B-to-A notes, captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped.
`test:e2e:obsidian:object-storage-setup-uri-workflow` uses the public Commonlib-backed tool to generate the initial Setup URI for a unique MinIO prefix, completes visible initialisation on the first device, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies the A-to-B note through explicit replication, then verifies that the B-to-A note arrives through `syncOnStart` after restarting the first device, without requesting manual replication. It captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped.
`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated initial Setup URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, checks that the menu for the three persistent per-peer actions remains within the viewport, reconnects both P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes.
@@ -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);",
+12 -7
View File
@@ -38,7 +38,12 @@ export function modalByTitle(page: Page, title: string): Locator {
}
export async function captureGuideDialogue(port: number, filename: string, title: string): Promise<string> {
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<void> {
@@ -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);
+13 -3
View File
@@ -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<Locator>
resolveElement: (page: Page) => Locator | Promise<Locator>,
timeoutMs = 10_000
): Promise<string> {
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",
@@ -32,6 +32,7 @@ import {
type SetupArtifact,
} from "../runner/setupUri.ts";
import { captureObsidianPage, openLiveSyncSettings, withObsidianPage } from "../runner/ui.ts";
import { dismissConfigDoctorIfShown } from "../runner/upgradeWorkflow.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
@@ -303,6 +304,7 @@ async function assertRemotePreferredE2EE(context: RunnerContext, expected: boole
}
async function scheduleRemoteOverwrite(port: number): Promise<void> {
await dismissConfigDoctorIfShown(port);
await withObsidianPage(port, async (page) => {
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
const maintenance = await settingsNavigator.openPage("Maintenance");
+23 -18
View File
@@ -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<ObsidianLiveSyncSession> {
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<unknown>(
context.cliBinary,
[
@@ -8,6 +8,7 @@ import { discoverObsidianCli, requireObsidianBinary } from "../runner/environmen
import {
assertEqual,
pushLocalChanges,
type ConfiguredSettings,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
@@ -57,6 +58,10 @@ type RunnerContext = {
activeSessions: Set<ObsidianLiveSyncSession>;
};
type StartupSchedulingState = ConfiguredSettings & {
periodicReplication: boolean;
};
function sessionEnvironment(port: number): NodeJS.ProcessEnv {
return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) };
}
@@ -187,6 +192,41 @@ async function waitForObjectStorageData(config: ObjectStorageConfig, prefix: str
throw new Error(`Timed out waiting for Object Storage data under ${prefix}.`);
}
async function configureMigratedStartupScheduling(
cliBinary: string,
environment: NodeJS.ProcessEnv
): Promise<StartupSchedulingState> {
return await evalObsidianJson<StartupSchedulingState>(
cliBinary,
[
"(async()=>{",
"const plugin=app.plugins.plugins['obsidian-livesync'];",
"const core=plugin.core;",
// Persist only the migration-shaped scheduling flags and leave the
// generated URI unchanged. Device A stops before B creates the
// return note, so save-triggered reconciliation cannot satisfy the
// later start-up assertion.
"await core.services.setting.applyExternalSettings({liveSync:true,syncOnStart:true,periodicReplication:false},true);",
"const current=core.services.setting.currentSettings();",
"return JSON.stringify({",
"isConfigured:current.isConfigured,",
"liveSync:current.liveSync,",
"syncOnStart:current.syncOnStart,",
"syncOnSave:current.syncOnSave,",
"periodicReplication:current.periodicReplication,",
"remoteType:current.remoteType,",
"couchDB_URI:current.couchDB_URI,",
"couchDB_DBNAME:current.couchDB_DBNAME,",
"endpoint:current.endpoint,",
"bucket:current.bucket,",
"bucketPrefix:current.bucketPrefix,",
"});",
"})()",
].join(""),
environment
);
}
async function captureNote(port: number, path: string, text: string, filename: string): Promise<string> {
await withObsidianPage(port, async (page) => {
await page.evaluate((notePath) => {
@@ -254,6 +294,24 @@ async function main(): Promise<void> {
throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one.");
}
screenshots.push(...generated.screenshots);
const startupState = await configureMigratedStartupScheduling(context.cliBinary, sessionA.cliEnv);
assertEqual(startupState.liveSync, true, "The first device did not persist its Continuous setting.");
assertEqual(startupState.syncOnStart, true, "The first device did not persist syncOnStart.");
assertEqual(
startupState.periodicReplication,
false,
"Periodic replication could mask the syncOnStart return journey."
);
assertEqual(
startupState.endpoint,
objectStorage.endpoint,
"Enabling syncOnStart changed the Object Storage endpoint."
);
assertEqual(
startupState.bucketPrefix,
bucketPrefix,
"Enabling syncOnStart changed the Object Storage bucket prefix."
);
await stopSession(context, sessionA);
const sessionB = await startSession(context, vaultB, portB);
@@ -290,7 +348,9 @@ async function main(): Promise<void> {
const returningSessionA = await startSession(context, vaultA, portA);
await waitForLiveSyncCoreReady(context.cliBinary, returningSessionA.cliEnv);
await resumeCompatibilityReviewIfShown(portA);
await pushLocalChanges(context.cliBinary, returningSessionA.cliEnv);
// Deliberately omit manual replication here. Object Storage reports
// Continuous as not applicable, so startup scheduling must honour the
// retained syncOnStart setting by running an unattended OneShot.
await waitForPathContent(vaultA, noteFromSecond, secondContent);
screenshots.push(
await captureNote(
@@ -179,14 +179,17 @@ async function openOnboardingFromSettings(): Promise<void> {
async function dismissVisibleNotices(): Promise<void> {
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 });
}
});
}
@@ -241,7 +241,11 @@ async function runScenario(): Promise<void> {
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 } });
+1 -1
View File
@@ -196,7 +196,7 @@ async function runAutomaticScenarios(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
await harness.locator('[data-testid="review-harness-run-automatic"]').click({ timeout: uiTimeoutMs });
for (const id of ["settings-lifecycle", "p2p-composition"]) {
for (const id of ["settings-lifecycle"]) {
await harness
.locator(`[data-testid="review-harness-result-${id}"]`)
.getByText("Passed:", { exact: false })
@@ -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<void> {
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"),
@@ -536,7 +536,7 @@ async function captureHiddenFileGuideSettings(
const screenshots = [
await captureObsidianElement(port, "guide-hidden-file-advanced-features.png", (page) =>
liveSyncSettingPanelByTitle(page, "Setup", "Enable extra and advanced features")
liveSyncSettingPanelByTitle(page, "General Settings", "Extra menus")
),
];