mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-29 04:23:20 +00:00
Harden real-runtime workflow validation
This commit is contained in:
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);",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
Reference in new issue
Block a user