Harden real-runtime workflow validation

This commit is contained in:
vorotamoroz committed 2026-08-31 00:35:29 +00:00
1 parent 3a23b7a6b0
commit 9a28d46287
8 files changed
+72 -42

No files matched your search

@@ -177,6 +177,7 @@ describe("packaged Commonlib compatibility gate", () => {
databaseService: {}, databaseService: {},
fileProcessingService: { commitPendingFileEvents: vi.fn().mockResolvedValue(true) }, fileProcessingService: { commitPendingFileEvents: vi.fn().mockResolvedValue(true) },
replicatorService: { replicatorService: {
acquireActiveReplicatorContext: vi.fn().mockResolvedValue(undefined),
getActiveReplicator: () => ({ openReplication }), getActiveReplicator: () => ({ openReplication }),
runFiniteReplicationActivity, runFiniteReplicationActivity,
}, },
@@ -28,14 +28,16 @@ export async function startHeldOneShotReplication(cliBinary: string, env: NodeJS
"const core=app.plugins.plugins['obsidian-livesync'].core;", "const core=app.plugins.plugins['obsidian-livesync'].core;",
"const replicator=core.services.replicator.getActiveReplicator();", "const replicator=core.services.replicator.getActiveReplicator();",
"if(!replicator) throw new Error('No active replicator is available.');", "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;", "let releaseGate;",
"const gate=new Promise((resolve)=>{releaseGate=resolve;});", "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};`, `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.release=()=>{if(!state.released){state.released=true;releaseGate();}};",
"state.restore=()=>{replicator.openReplication=original;};", "state.restore=()=>{replicator[methodName]=original;};",
"host[stateKey]=state;", "host[stateKey]=state;",
"replicator.openReplication=async function(...args){", "replicator[methodName]=async function(...args){",
"state.entered=true;", "state.entered=true;",
"await gate;", "await gate;",
"return await original.apply(this,args);", "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> { 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> { export async function assertVerticalActionLayout(port: number, title: string): Promise<void> {
@@ -182,16 +187,16 @@ export async function captureAndStartInitialisation(
? "Setup Complete: Preparing This P2P Device" ? "Setup Complete: Preparing This P2P Device"
: p2pAdditionalDevice : p2pAdditionalDevice
? "Setup Complete: Preparing to Fetch from Another Device" ? "Setup Complete: Preparing to Fetch from Another Device"
: mode === "new" : mode === "new"
? "Setup Complete: Preparing to Initialise Server" ? "Setup Complete: Preparing to Initialise Server"
: "Setup Complete: Preparing to Fetch Synchronisation Data"; : "Setup Complete: Preparing to Fetch Synchronisation Data";
const button = p2pFirstDevice const button = p2pFirstDevice
? "Restart and Prepare This Device" ? "Restart and Prepare This Device"
: p2pAdditionalDevice : p2pAdditionalDevice
? "Restart and Select Source Device" ? "Restart and Select Source Device"
: mode === "new" : mode === "new"
? "Restart and Initialise Server" ? "Restart and Initialise Server"
: "Restart and Fetch Data"; : "Restart and Fetch Data";
if (p2pAdditionalDevice) { if (p2pAdditionalDevice) {
await withObsidianPage(port, async (page) => { await withObsidianPage(port, async (page) => {
const modal = modalByTitle(page, title); 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; const setting = host.app?.setting;
if (setting === undefined) throw new Error("Obsidian settings are unavailable"); if (setting === undefined) throw new Error("Obsidian settings are unavailable");
setting.open(); setting.open();
setting.openTabById("obsidian-livesync");
}); });
const deadline = Date.now() + timeoutMs; 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"); 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 dialogue = settingsPage.locator(".modal.mod-settings:visible").last();
const imperativeRoot = dialogue.locator(".sls-setting:visible").last(); const imperativeRoot = dialogue.locator(".sls-setting:visible").last();
const firstDeclarativeEntry = declarativePageEntry(dialogue, "Change Log"); const firstDeclarativeEntry = declarativePageEntry(dialogue, "Change Log");
@@ -270,7 +279,8 @@ export async function allowPendingObsidianTestVaultOpenAction(
export async function captureObsidianElement( export async function captureObsidianElement(
port: number, port: number,
filename: string, filename: string,
resolveElement: (page: Page) => Locator | Promise<Locator> resolveElement: (page: Page) => Locator | Promise<Locator>,
timeoutMs = 10_000
): Promise<string> { ): Promise<string> {
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
const screenshotPath = join(outputDirectory, filename); const screenshotPath = join(outputDirectory, filename);
@@ -279,7 +289,7 @@ export async function captureObsidianElement(
await withObsidianPage(port, async (page) => { await withObsidianPage(port, async (page) => {
try { try {
const element = await resolveElement(page); const element = await resolveElement(page);
await element.waitFor({ state: "visible", timeout: 10000 }); await element.waitFor({ state: "visible", timeout: timeoutMs });
await element.screenshot({ await element.screenshot({
path: screenshotPath, path: screenshotPath,
animations: "disabled", animations: "disabled",
+23 -18
View File
@@ -12,8 +12,11 @@ import {
} from "../runner/couchdb.ts"; } from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { import {
assertE2eCompatibilityMarker,
assertEqual, assertEqual,
configureCouchDb, configureCouchDb,
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
prepareRemote, prepareRemote,
pushLocalChanges, pushLocalChanges,
waitForLiveSyncCoreReady, waitForLiveSyncCoreReady,
@@ -149,31 +152,33 @@ async function startConfiguredSession(
vault: TemporaryVault, vault: TemporaryVault,
deviceName: string deviceName: string
): Promise<ObsidianLiveSyncSession> { ): 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({ const session = await startObsidianLiveSyncSession({
binary: context.binary, binary: context.binary,
cliBinary: context.cliBinary, cliBinary: context.cliBinary,
vault, vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), 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 waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await configureCouchDb( await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv);
context.cliBinary, await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, customisationSettings);
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 evalObsidianJson<unknown>( await evalObsidianJson<unknown>(
context.cliBinary, context.cliBinary,
[ [
@@ -179,14 +179,17 @@ async function openOnboardingFromSettings(): Promise<void> {
async function dismissVisibleNotices(): Promise<void> { async function dismissVisibleNotices(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const notices = page.locator(".notice:visible"); const notices = page.locator(".notice:visible");
let noticeIndex = 0;
while ((await notices.count()) > 0) { while ((await notices.count()) > 0) {
const noticeCount = await page.locator(".notice").count(); const marker = `livesync-e2e-notice-${noticeIndex++}`;
await notices.first().click({ position: { x: 8, y: 8 }, timeout: uiTimeoutMs }); await notices
await page.waitForFunction( .first()
(previousCount) => document.querySelectorAll(".notice").length < previousCount, .evaluate((element, value) => element.setAttribute("data-livesync-e2e-notice", value), marker);
noticeCount, const markedNotice = page.locator(`[data-livesync-e2e-notice="${marker}"]`);
{ timeout: uiTimeoutMs } 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[] = []; const consoleErrors: string[] = [];
try { 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-"); const firstVault = await createTemporaryVault("obsidian-livesync-p2p-check-first-e2e-");
vaults.push(firstVault); vaults.push(firstVault);
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } }); const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
@@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { evalObsidianJson } from "../runner/cli.ts"; import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.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 { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { createTemporaryVault } from "../runner/vault.ts"; import { createTemporaryVault } from "../runner/vault.ts";
@@ -75,8 +75,8 @@ async function main(): Promise<void> {
vault, vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), 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); await configureSettingMarkdown(cli.binary, session.cliEnv);
const content = await waitForFileContaining(vault.path, settingPath, [ const content = await waitForFileContaining(vault.path, settingPath, [
(value) => value.includes("````yaml:livesync-setting"), (value) => value.includes("````yaml:livesync-setting"),