mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-21 18:17:05 +00:00
Merge origin/main into optional-file sync ownership
This commit is contained in:
@@ -287,19 +287,28 @@ export async function captureObsidianElement(
|
||||
await mkdir(dirname(screenshotPath), { recursive: true });
|
||||
|
||||
await withObsidianPage(port, async (page) => {
|
||||
try {
|
||||
const element = await resolveElement(page);
|
||||
await element.waitFor({ state: "visible", timeout: timeoutMs });
|
||||
await element.screenshot({
|
||||
path: screenshotPath,
|
||||
animations: "disabled",
|
||||
style: ".notice-container { visibility: hidden !important; }",
|
||||
});
|
||||
} catch (error) {
|
||||
const failurePath = screenshotPath.replace(/\.png$/u, ".failure.png");
|
||||
await page.screenshot({ path: failurePath, fullPage: true });
|
||||
console.error(`UI element failure screenshot: ${failurePath}`);
|
||||
throw error;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const element = await resolveElement(page);
|
||||
await element.waitFor({ state: "visible", timeout: timeoutMs });
|
||||
await element.screenshot({
|
||||
path: screenshotPath,
|
||||
animations: "disabled",
|
||||
style: ".notice-container { visibility: hidden !important; }",
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
const detachedDuringCapture =
|
||||
error instanceof Error && error.message.includes("not attached to the DOM");
|
||||
if (detachedDuringCapture && attempt < 2) {
|
||||
await page.waitForTimeout(50);
|
||||
continue;
|
||||
}
|
||||
const failurePath = screenshotPath.replace(/\.png$/u, ".failure.png");
|
||||
await page.screenshot({ path: failurePath, fullPage: true });
|
||||
console.error(`UI element failure screenshot: ${failurePath}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
waitForLocalDatabaseEntry,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { captureObsidianElement, withObsidianPage } from "../runner/ui.ts";
|
||||
import { captureObsidianElement, captureObsidianPage, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const path = "conflict-dialog-policy.md";
|
||||
@@ -14,6 +14,12 @@ const baseContent = "Conflict dialogue policy\n\nShared base.\n";
|
||||
const leftContent = "Conflict dialogue policy\n\nChanged on the left.\n";
|
||||
const rightContent = "Conflict dialogue policy\n\nChanged on the right.\n";
|
||||
const thirdContent = "Conflict dialogue policy\n\nChanged on the third branch.\n";
|
||||
const repeatedPath = "conflict-dialog-repeated.md";
|
||||
const activePath = "conflict-dialog-active.md";
|
||||
const waitingPath = "conflict-dialog-waiting.md";
|
||||
const externallyResolvedWaitingPath = "conflict-dialog-resolved-while-waiting.md";
|
||||
const unloadActivePath = "conflict-dialog-unload-active.md";
|
||||
const unloadWaitingPath = "conflict-dialog-unload-waiting.md";
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_CONFLICT_DIALOG_TIMEOUT_MS ?? 10000);
|
||||
|
||||
type ConflictFixture = {
|
||||
@@ -24,16 +30,35 @@ type ConflictFixture = {
|
||||
|
||||
type ObsidianTestApp = {
|
||||
commands?: { executeCommandById(commandId: string): boolean };
|
||||
plugins?: {
|
||||
disablePlugin(pluginId: string): Promise<void>;
|
||||
enablePlugin(pluginId: string): Promise<void>;
|
||||
plugins?: Record<
|
||||
string,
|
||||
| {
|
||||
core?: {
|
||||
services?: {
|
||||
conflict?: { ensureAllProcessed(): Promise<boolean> };
|
||||
};
|
||||
};
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
};
|
||||
};
|
||||
|
||||
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
|
||||
type ObsidianTestGlobal = typeof globalThis & {
|
||||
app?: ObsidianTestApp;
|
||||
__livesyncConflictChecksCompleted?: boolean;
|
||||
__livesyncWaitingConflictCompleted?: boolean;
|
||||
};
|
||||
|
||||
async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv, targetPath = path): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const path=${JSON.stringify(targetPath)};`,
|
||||
`const content=${JSON.stringify(baseContent)};`,
|
||||
"let file=app.vault.getAbstractFileByPath(path);",
|
||||
"if(!file) file=await app.vault.create(path,content);",
|
||||
@@ -49,13 +74,14 @@ async function createManualConflict(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
baseRev: string,
|
||||
contents: readonly string[]
|
||||
contents: readonly string[],
|
||||
targetPath = path
|
||||
): Promise<ConflictFixture> {
|
||||
return await evalObsidianJson<ConflictFixture>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const path=${JSON.stringify(targetPath)};`,
|
||||
`const baseRev=${JSON.stringify(baseRev)};`,
|
||||
`const contents=${JSON.stringify(contents)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
@@ -81,12 +107,16 @@ async function createManualConflict(
|
||||
);
|
||||
}
|
||||
|
||||
async function readConflictFixture(cliBinary: string, env: NodeJS.ProcessEnv): Promise<ConflictFixture> {
|
||||
async function readConflictFixture(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
targetPath = path
|
||||
): Promise<ConflictFixture> {
|
||||
return await evalObsidianJson<ConflictFixture>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const path=${JSON.stringify(targetPath)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const meta=await core.localDatabase.getDBEntryMeta(path,{conflicts:true,revs:true},true);",
|
||||
"if(!meta?._rev){",
|
||||
@@ -106,13 +136,14 @@ async function readConflictFixture(cliBinary: string, env: NodeJS.ProcessEnv): P
|
||||
async function waitForConflictCount(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
expectedConflictCount: number
|
||||
expectedConflictCount: number,
|
||||
targetPath = path
|
||||
): Promise<ConflictFixture> {
|
||||
const deadline = Date.now() + uiTimeoutMs;
|
||||
let fixture = await readConflictFixture(cliBinary, env);
|
||||
let fixture = await readConflictFixture(cliBinary, env, targetPath);
|
||||
while (fixture.conflicts.length !== expectedConflictCount && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
fixture = await readConflictFixture(cliBinary, env);
|
||||
fixture = await readConflictFixture(cliBinary, env, targetPath);
|
||||
}
|
||||
if (fixture.conflicts.length !== expectedConflictCount) {
|
||||
throw new Error(
|
||||
@@ -122,12 +153,17 @@ async function waitForConflictCount(
|
||||
return fixture;
|
||||
}
|
||||
|
||||
async function requestConflictCheck(cliBinary: string, env: NodeJS.ProcessEnv, waitForCompletion = false) {
|
||||
async function requestConflictCheck(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
waitForCompletion = false,
|
||||
targetPath = path
|
||||
) {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const path=${JSON.stringify(targetPath)};`,
|
||||
`const waitForCompletion=${JSON.stringify(waitForCompletion)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"await core.services.conflict.queueCheckFor(path);",
|
||||
@@ -159,13 +195,14 @@ async function applyReplicatedConflictResolution(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
revisionToDelete: string,
|
||||
expectedConflictCount = 0
|
||||
expectedConflictCount = 0,
|
||||
targetPath = path
|
||||
): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const path=${JSON.stringify(targetPath)};`,
|
||||
`const revisionToDelete=${JSON.stringify(revisionToDelete)};`,
|
||||
`const expectedConflictCount=${JSON.stringify(expectedConflictCount)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
@@ -191,10 +228,135 @@ async function applyReplicatedConflictResolution(
|
||||
);
|
||||
}
|
||||
|
||||
function conflictDialogue(page: Parameters<Parameters<typeof withObsidianPage>[1]>[0]) {
|
||||
return page.locator(".modal-container").filter({
|
||||
function conflictDialogue(page: Parameters<Parameters<typeof withObsidianPage>[1]>[0], targetPath?: string) {
|
||||
const dialogues = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Conflicting changes" }),
|
||||
});
|
||||
return targetPath === undefined ? dialogues : dialogues.filter({ hasText: targetPath });
|
||||
}
|
||||
|
||||
async function createTwoVersionConflict(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
targetPath: string
|
||||
): Promise<ConflictFixture> {
|
||||
await createAndOpenBaseFile(cliBinary, env, targetPath);
|
||||
const base = await waitForLocalDatabaseEntry(cliBinary, env, targetPath);
|
||||
const fixture = await createManualConflict(cliBinary, env, base.rev, [leftContent, rightContent], targetPath);
|
||||
if (fixture.conflicts.length !== 1) {
|
||||
throw new Error(`Expected exactly two live leaves for ${targetPath}: ${JSON.stringify(fixture)}`);
|
||||
}
|
||||
return fixture;
|
||||
}
|
||||
|
||||
async function setShowMergeDialogOnlyOnActive(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
enabled: boolean
|
||||
): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(()=>{",
|
||||
`const enabled=${JSON.stringify(enabled)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"core.settings.showMergeDialogOnlyOnActive=enabled;",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function requestRepeatedConflictChecks(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
targetPath: string,
|
||||
count: number
|
||||
): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(targetPath)};`,
|
||||
`const count=${JSON.stringify(count)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"await Promise.all(Array.from({length:count},()=>core.services.conflict.queueCheckFor(path)));",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function requestInteractiveConflictResolution(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
targetPath: string,
|
||||
fixture: ConflictFixture,
|
||||
trackCompletion = false
|
||||
): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(()=>{",
|
||||
`const path=${JSON.stringify(targetPath)};`,
|
||||
`const currentRev=${JSON.stringify(fixture.currentRev)};`,
|
||||
`const conflictRev=${JSON.stringify(fixture.conflicts[0])};`,
|
||||
`const trackCompletion=${JSON.stringify(trackCompletion)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"if(trackCompletion) globalThis.__livesyncWaitingConflictCompleted=false;",
|
||||
"const pending=core.services.conflict.resolveByUserInteraction(path,{",
|
||||
"left:{rev:currentRev,data:'Current branch',ctime:1,mtime:2},",
|
||||
"right:{rev:conflictRev,data:'Conflict branch',ctime:1,mtime:3},",
|
||||
"diff:[[0,'Current and conflict branches']],",
|
||||
"});",
|
||||
"if(trackCompletion) void pending.then(()=>{globalThis.__livesyncWaitingConflictCompleted=true;});",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function disableLiveSyncAndWaitForConflictChecks(port: number): Promise<void> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const host = globalThis as ObsidianTestGlobal;
|
||||
const conflict = host.app?.plugins?.plugins?.["obsidian-livesync"]?.core?.services?.conflict;
|
||||
if (conflict === undefined) throw new Error("LiveSync conflict service is unavailable before unload");
|
||||
host.__livesyncConflictChecksCompleted = false;
|
||||
void conflict.ensureAllProcessed().then(() => {
|
||||
host.__livesyncConflictChecksCompleted = true;
|
||||
});
|
||||
});
|
||||
await page.evaluate(async () => {
|
||||
const plugins = (globalThis as ObsidianTestGlobal).app?.plugins;
|
||||
if (plugins === undefined) throw new Error("Obsidian plug-in manager is unavailable");
|
||||
await plugins.disablePlugin("obsidian-livesync");
|
||||
});
|
||||
await conflictDialogue(page).waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const host = globalThis as ObsidianTestGlobal;
|
||||
return (
|
||||
host.__livesyncConflictChecksCompleted === true && host.__livesyncWaitingConflictCompleted === true
|
||||
);
|
||||
},
|
||||
undefined,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function enableLiveSync(port: number): Promise<void> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.evaluate(async () => {
|
||||
const plugins = (globalThis as ObsidianTestGlobal).app?.plugins;
|
||||
if (plugins === undefined) throw new Error("Obsidian plug-in manager is unavailable");
|
||||
await plugins.enablePlugin("obsidian-livesync");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -261,7 +423,17 @@ async function main(): Promise<void> {
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
const actionButtonBounds = await modal.locator(".conflict-action-button").evaluateAll((buttons) =>
|
||||
});
|
||||
const firstDialogueScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"conflict-dialog-three-versions.png",
|
||||
(page) => conflictDialogue(page).locator(".modal").first()
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const modal = conflictDialogue(page);
|
||||
const actionButtons = modal.locator(".conflict-action-button");
|
||||
await actionButtons.nth(3).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const actionButtonBounds = await actionButtons.evaluateAll((buttons) =>
|
||||
buttons.map((button) => {
|
||||
const bounds = button.getBoundingClientRect();
|
||||
return { top: bounds.top, bottom: bounds.bottom };
|
||||
@@ -273,16 +445,20 @@ async function main(): Promise<void> {
|
||||
(bounds, index) => index > 0 && bounds.top < actionButtonBounds[index - 1].bottom
|
||||
)
|
||||
) {
|
||||
const buttonDetails = await modal.locator("button").evaluateAll((buttons) =>
|
||||
buttons.map((button) => ({
|
||||
text: button.textContent,
|
||||
className: button.className,
|
||||
}))
|
||||
);
|
||||
throw new Error(
|
||||
`Conflict action buttons are not stacked vertically: ${JSON.stringify(actionButtonBounds)}`
|
||||
`Conflict action buttons are not stacked vertically: ${JSON.stringify({
|
||||
actionButtonBounds,
|
||||
buttonDetails,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
});
|
||||
const firstDialogueScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"conflict-dialog-three-versions.png",
|
||||
(page) => conflictDialogue(page).locator(".modal").first()
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const modal = conflictDialogue(page);
|
||||
await modal.getByRole("button", { name: "Concat both", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
@@ -415,11 +591,186 @@ async function main(): Promise<void> {
|
||||
.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
// Keep concurrent manual comparisons independent from the active-file
|
||||
// gate so that the dialogue serialisation policy is exercised directly.
|
||||
await setShowMergeDialogOnlyOnActive(cliBinary, session.cliEnv, false);
|
||||
|
||||
await createTwoVersionConflict(cliBinary, session.cliEnv, repeatedPath);
|
||||
await requestConflictCheck(cliBinary, session.cliEnv, false, repeatedPath);
|
||||
const repeatedSession = session;
|
||||
await withObsidianPage(repeatedSession.remoteDebuggingPort, async (page) => {
|
||||
const modal = conflictDialogue(page, repeatedPath);
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const original = await modal.elementHandle();
|
||||
if (original === null) throw new Error("Could not retain the original same-file conflict dialogue");
|
||||
|
||||
await requestRepeatedConflictChecks(cliBinary, repeatedSession.cliEnv, repeatedPath, 3);
|
||||
await page.waitForFunction((element) => !element.isConnected, original, { timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await page.waitForTimeout(250);
|
||||
const visibleCount = await conflictDialogue(page, repeatedPath).evaluateAll(
|
||||
(elements) => elements.filter((element) => element.getClientRects().length > 0).length
|
||||
);
|
||||
if (visibleCount !== 1) {
|
||||
throw new Error(`Expected one newest same-file dialogue, but found ${visibleCount}`);
|
||||
}
|
||||
});
|
||||
const repeatedDialogueScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"conflict-dialog-same-file-replacement.png",
|
||||
(page) => conflictDialogue(page, repeatedPath).locator(".modal").first()
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const modal = conflictDialogue(page, repeatedPath);
|
||||
await modal.getByRole("button", { name: "Not now", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
await waitForConflictChecks(cliBinary, session.cliEnv);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.waitForTimeout(500);
|
||||
if (await conflictDialogue(page, repeatedPath).isVisible()) {
|
||||
throw new Error("A superseded same-file conflict dialogue opened after the newest request completed");
|
||||
}
|
||||
});
|
||||
|
||||
await createTwoVersionConflict(cliBinary, session.cliEnv, activePath);
|
||||
await createTwoVersionConflict(cliBinary, session.cliEnv, waitingPath);
|
||||
const externallyResolvedWaitingFixture = await createTwoVersionConflict(
|
||||
cliBinary,
|
||||
session.cliEnv,
|
||||
externallyResolvedWaitingPath
|
||||
);
|
||||
await requestConflictCheck(cliBinary, session.cliEnv, false, activePath);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await conflictDialogue(page, activePath).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
await requestConflictCheck(cliBinary, session.cliEnv, false, waitingPath);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.waitForTimeout(500);
|
||||
if (!(await conflictDialogue(page, activePath).isVisible())) {
|
||||
throw new Error("A different-file request closed the active conflict dialogue");
|
||||
}
|
||||
if (await conflictDialogue(page, waitingPath).isVisible()) {
|
||||
throw new Error("A different-file conflict dialogue opened before the active dialogue completed");
|
||||
}
|
||||
});
|
||||
const differentFileWaitingScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"conflict-dialog-different-file-waiting.png",
|
||||
(page) => conflictDialogue(page, activePath).locator(".modal").first()
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const activeModal = conflictDialogue(page, activePath);
|
||||
await activeModal.getByRole("button", { name: "Not now", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await activeModal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
await conflictDialogue(page, waitingPath).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
await requestInteractiveConflictResolution(
|
||||
cliBinary,
|
||||
session.cliEnv,
|
||||
externallyResolvedWaitingPath,
|
||||
externallyResolvedWaitingFixture,
|
||||
true
|
||||
);
|
||||
await applyReplicatedConflictResolution(
|
||||
cliBinary,
|
||||
session.cliEnv,
|
||||
externallyResolvedWaitingFixture.conflicts[0],
|
||||
0,
|
||||
externallyResolvedWaitingPath
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
if (!(await conflictDialogue(page, waitingPath).isVisible())) {
|
||||
throw new Error("Resolving a waiting file elsewhere closed the active different-file dialogue");
|
||||
}
|
||||
if (await conflictDialogue(page, externallyResolvedWaitingPath).isVisible()) {
|
||||
throw new Error("A conflict dialogue opened for a waiting file which was already resolved elsewhere");
|
||||
}
|
||||
const waitingModal = conflictDialogue(page, waitingPath);
|
||||
await waitingModal.getByRole("button", { name: "Not now", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await waitingModal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
await page.waitForFunction(
|
||||
() => (globalThis as ObsidianTestGlobal).__livesyncWaitingConflictCompleted === true,
|
||||
undefined,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
await page.waitForTimeout(250);
|
||||
if (await conflictDialogue(page, externallyResolvedWaitingPath).isVisible()) {
|
||||
throw new Error("A resolved waiting-file dialogue opened after the active dialogue completed");
|
||||
}
|
||||
});
|
||||
await waitForConflictChecks(cliBinary, session.cliEnv);
|
||||
|
||||
await createTwoVersionConflict(cliBinary, session.cliEnv, unloadActivePath);
|
||||
const unloadWaitingFixture = await createTwoVersionConflict(cliBinary, session.cliEnv, unloadWaitingPath);
|
||||
await requestConflictCheck(cliBinary, session.cliEnv, false, unloadActivePath);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await conflictDialogue(page, unloadActivePath).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
await requestInteractiveConflictResolution(
|
||||
cliBinary,
|
||||
session.cliEnv,
|
||||
unloadWaitingPath,
|
||||
unloadWaitingFixture,
|
||||
true
|
||||
);
|
||||
const beforeUnloadScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"conflict-dialog-before-unload.png",
|
||||
(page) => conflictDialogue(page, unloadActivePath).locator(".modal").first()
|
||||
);
|
||||
await disableLiveSyncAndWaitForConflictChecks(session.remoteDebuggingPort);
|
||||
const afterUnloadScreenshot = await captureObsidianPage(
|
||||
session.remoteDebuggingPort,
|
||||
"conflict-dialog-after-unload.png",
|
||||
async (page) => {
|
||||
await page.waitForTimeout(250);
|
||||
if (await conflictDialogue(page).isVisible()) {
|
||||
throw new Error("A conflict dialogue remained visible after LiveSync was unloaded");
|
||||
}
|
||||
const pluginStillLoaded = await page.evaluate(
|
||||
() => (globalThis as ObsidianTestGlobal).app?.plugins?.plugins?.["obsidian-livesync"] !== undefined
|
||||
);
|
||||
if (pluginStillLoaded) throw new Error("LiveSync remained loaded after disablePlugin completed");
|
||||
}
|
||||
);
|
||||
|
||||
await enableLiveSync(session.remoteDebuggingPort);
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.waitForTimeout(500);
|
||||
if (await conflictDialogue(page).isVisible()) {
|
||||
throw new Error("A stale conflict dialogue reopened after LiveSync was enabled again");
|
||||
}
|
||||
});
|
||||
await createAndOpenBaseFile(cliBinary, session.cliEnv, unloadActivePath);
|
||||
await requestConflictCheck(cliBinary, session.cliEnv, false, unloadActivePath);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await conflictDialogue(page, unloadActivePath).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
const afterReloadScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"conflict-dialog-after-reload.png",
|
||||
(page) => conflictDialogue(page, unloadActivePath).locator(".modal").first()
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const modal = conflictDialogue(page, unloadActivePath);
|
||||
await modal.getByRole("button", { name: "Not now", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
await waitForConflictChecks(cliBinary, session.cliEnv);
|
||||
|
||||
console.log(
|
||||
"Real Obsidian reviewed three versions pairwise, retained the completed stage across restart, suppressed an ordinary repeat prompt after Not now, reopened the dialogue after the explicit command, and cleared both postponed and open-dialogue states after replicated resolutions."
|
||||
"Real Obsidian preserved pairwise conflict resolution and dialogue presentation; replaced only stale same-file dialogues; serialised different files; discarded externally resolved waiting requests; and drained active and waiting requests across unload and reload."
|
||||
);
|
||||
console.log(`Dialogue screenshot: ${firstDialogueScreenshot}`);
|
||||
console.log(`Postponed warning screenshot: ${warningScreenshot}`);
|
||||
console.log(`Same-file replacement screenshot: ${repeatedDialogueScreenshot}`);
|
||||
console.log(`Different-file waiting screenshot: ${differentFileWaitingScreenshot}`);
|
||||
console.log(`Before unload screenshot: ${beforeUnloadScreenshot}`);
|
||||
console.log(`After unload screenshot: ${afterUnloadScreenshot}`);
|
||||
console.log(`After reload screenshot: ${afterReloadScreenshot}`);
|
||||
} finally {
|
||||
if (session) {
|
||||
await session.app.stop();
|
||||
|
||||
@@ -688,10 +688,13 @@ async function runInitialisationNoticeGrouping(context: RunnerContext, vault: Te
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
}
|
||||
const remainingNotices = await page.locator(".notice:visible").allTextContents();
|
||||
assertEqual(
|
||||
await page.locator(".notice:visible").count(),
|
||||
remainingNotices.length,
|
||||
0,
|
||||
"Transient start-up Notices remained before the Hidden File Sync initialisation check."
|
||||
`Transient start-up Notices remained before the Hidden File Sync initialisation check: ${JSON.stringify(
|
||||
remainingNotices
|
||||
)}`
|
||||
);
|
||||
});
|
||||
await withObsidianPage(port, async (page) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ const testSteps: Step[] = [
|
||||
{ name: "smoke", args: ["run", "test:e2e:obsidian:smoke"] },
|
||||
{ name: "onboarding invitation", args: ["run", "test:e2e:obsidian:onboarding-invitation"] },
|
||||
{ name: "Svelte dialogue mounts", args: ["run", "test:e2e:obsidian:dialog-mounts"] },
|
||||
{ name: "conflict dialogue policy", args: ["run", "test:e2e:obsidian:conflict-dialog-policy"] },
|
||||
{ name: "revision repair", args: ["run", "test:e2e:obsidian:revision-repair"] },
|
||||
{ name: "settings UI", args: ["run", "test:e2e:obsidian:settings-ui"] },
|
||||
{ name: "Review Harness", args: ["run", "test:e2e:obsidian:review-harness"] },
|
||||
|
||||
@@ -8,6 +8,7 @@ const focusedScenarios = new Set([
|
||||
"smoke",
|
||||
"onboarding-invitation",
|
||||
"dialog-mounts",
|
||||
"conflict-dialog-policy",
|
||||
"revision-repair",
|
||||
"document-history-nav",
|
||||
"document-history-restore",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { assertLocatorWithinViewport, assertNoHorizontalOverflow } from "@vrtmrz/obsidian-test-session";
|
||||
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { createE2eObsidianDeviceLocalState, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
|
||||
@@ -597,26 +598,42 @@ async function verifyCompatibilityReview(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyConfigDoctorFollowsCompatibilityReview(): Promise<void> {
|
||||
async function verifyConfigDoctorFollowsCompatibilityReview(): Promise<string> {
|
||||
const screenshot = await captureObsidianDialogue(
|
||||
obsidianRemoteDebuggingPort(),
|
||||
"config-doctor-after-compatibility-review.png",
|
||||
async (page) => {
|
||||
const doctor = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Self-hosted LiveSync Config Doctor" }),
|
||||
});
|
||||
await doctor.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await doctor.getByText("Per-file-saved customization sync", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await doctor.getByText("Enhance chunk size", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
if ((await doctor.getByText("Data Compression", { exact: true }).count()) !== 0) {
|
||||
throw new Error("Config Doctor still treats supported Data Compression as a problem.");
|
||||
}
|
||||
await assertLocatorWithinViewport(page, doctor.locator(".modal").last(), {
|
||||
label: "Config Doctor dialogue",
|
||||
});
|
||||
await assertNoHorizontalOverflow(page, doctor.locator(".modal").last(), {
|
||||
label: "Config Doctor dialogue",
|
||||
});
|
||||
}
|
||||
);
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const doctor = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Self-hosted LiveSync Config Doctor" }),
|
||||
});
|
||||
await doctor.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await doctor.getByText("Per-file-saved customization sync", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await doctor.getByText("Enhance chunk size", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
if ((await doctor.getByText("Data Compression", { exact: true }).count()) !== 0) {
|
||||
throw new Error("Config Doctor still treats supported Data Compression as a problem.");
|
||||
}
|
||||
await doctor.getByRole("button", { name: /No, and do not ask again/u }).click();
|
||||
await doctor.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
async function verifyEffectiveSettings(): Promise<"declarative" | "imperative"> {
|
||||
@@ -1051,7 +1068,8 @@ async function main(): Promise<void> {
|
||||
await resumePendingCompatibilityReviewForSettings();
|
||||
} else {
|
||||
await verifyCompatibilityReview();
|
||||
await verifyConfigDoctorFollowsCompatibilityReview();
|
||||
const configDoctorScreenshot = await verifyConfigDoctorFollowsCompatibilityReview();
|
||||
console.log(`Config Doctor screenshot: ${configDoctorScreenshot}`);
|
||||
}
|
||||
settingsRenderer = await verifyEffectiveSettings();
|
||||
const initialisation = await verifyPendingSettingsInitialisationFlow();
|
||||
|
||||
@@ -4,8 +4,48 @@ import {
|
||||
inspectObsidianServiceContextContract,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const BASIC_COMMAND_IDS = [
|
||||
"livesync-replicate",
|
||||
"livesync-dump",
|
||||
"livesync-toggle",
|
||||
"livesync-suspendall",
|
||||
"livesync-scan-files",
|
||||
"livesync-runbatch",
|
||||
"livesync-abortsync",
|
||||
] as const;
|
||||
|
||||
type ObsidianCommandHost = typeof globalThis & {
|
||||
app?: { commands?: { commands?: Record<string, unknown> } };
|
||||
};
|
||||
|
||||
async function assertMenuFeaturesAreComposed(remoteDebuggingPort: number): Promise<void> {
|
||||
await withObsidianPage(remoteDebuggingPort, async (page) => {
|
||||
const registered = await page.evaluate((commandIds) => {
|
||||
const commands = (globalThis as ObsidianCommandHost).app?.commands?.commands ?? {};
|
||||
return commandIds.filter((id) => commands[`obsidian-livesync:${id}`] !== undefined);
|
||||
}, BASIC_COMMAND_IDS);
|
||||
if (registered.length !== BASIC_COMMAND_IDS.length) {
|
||||
const missing = BASIC_COMMAND_IDS.filter((id) => !registered.includes(id));
|
||||
throw new Error(`Extracted basic commands were not composed: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
const ribbonCount = await page.locator(".livesync-ribbon-replicate").count();
|
||||
if (ribbonCount !== 1) {
|
||||
throw new Error(`Expected one extracted replication ribbon action, found ${ribbonCount}.`);
|
||||
}
|
||||
|
||||
const preservedRibbonPathCount = await page
|
||||
.locator('.livesync-ribbon-replicate path[d*="c-7.66 1.98-12.2 9.61-10 17"]')
|
||||
.count();
|
||||
if (preservedRibbonPathCount !== 1) {
|
||||
throw new Error("The extracted replication ribbon does not preserve its established icon path.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
@@ -34,6 +74,8 @@ async function main(): Promise<void> {
|
||||
console.log(
|
||||
`Obsidian service Context contract passed: ${contextContract.contextType}, ${contextContract.serviceContextMismatches.length} mismatches.`
|
||||
);
|
||||
await assertMenuFeaturesAreComposed(session.remoteDebuggingPort);
|
||||
console.log("Extracted basic commands and replication ribbon were composed exactly once.");
|
||||
await new Promise((resolve) => setTimeout(resolve, Number(process.env.E2E_OBSIDIAN_SMOKE_TIMEOUT_MS ?? 1000)));
|
||||
console.log("Obsidian stayed alive after the plug-in readiness check.");
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user