mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 04:52:58 +00:00
Add verified setup workflow screenshots
This commit is contained in:
@@ -126,7 +126,7 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
|
||||
|
||||
`test:e2e:obsidian:startup-scan` configures a temporary CouchDB database, stops Obsidian, writes a note directly into the vault, restarts Obsidian, and verifies from CouchDB that the boot-time scan picked up the offline file.
|
||||
|
||||
`test:e2e:obsidian:setup-uri-workflow` runs the repository's public Commonlib-backed CouchDB provisioning and Setup URI tools against the local CouchDB fixture. It configures two data-less real Obsidian Vaults through the visible onboarding wizard, uses Rebuild for the first device and Fetch for the second, and verifies an ordinary note. After ordinary setup has completed, it independently enables Hidden File Sync on each device and verifies a snippet. Credential-bearing input is not captured; the retained screenshots cover the user decisions, initialisation confirmations, and the synchronised note.
|
||||
`test:e2e:obsidian:setup-uri-workflow` runs the repository's public Commonlib-backed CouchDB provisioning and Setup URI tools against the local CouchDB fixture. It configures two data-less real Obsidian Vaults through the visible onboarding wizard, uses Rebuild for the first device and Fetch for the second, and verifies an ordinary note. After ordinary setup has completed, it independently enables Hidden File Sync on each device and verifies a snippet. The retained Setup URI screenshot shows only the encrypted URI and a visually masked Setup URI passphrase; plaintext credentials are not captured. Files prefixed with `guide-` capture the relevant dialogue, settings panel, or workspace leaf without transient Notices. Public documentation copies selected images only after visual inspection; the E2E run does not overwrite repository documentation assets.
|
||||
|
||||
`test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, ordinary rename, a case-only file name change within the same directory, deletion, per-device target filters where one vault ignores a note that the other vault synchronises, and a separate encrypted round-trip with Path Obfuscation enabled. Directory case changes deliberately remain outside this scenario because they require directory-aware rename handling. The optional Markdown conflict automatic merge check can be enabled with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true`, but it is not part of the default local suite.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { withObsidianPage } from "@vrtmrz/obsidian-test-session";
|
||||
import type { Page } from "playwright";
|
||||
import type { Locator, Page } from "playwright";
|
||||
|
||||
export {
|
||||
obsidianRemoteDebuggingPort,
|
||||
@@ -42,6 +42,35 @@ export async function captureObsidianDialogue(
|
||||
return await captureObsidianPage(port, filename, assertReady);
|
||||
}
|
||||
|
||||
export async function captureObsidianElement(
|
||||
port: number,
|
||||
filename: string,
|
||||
resolveElement: (page: Page) => Locator | Promise<Locator>
|
||||
): Promise<string> {
|
||||
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
|
||||
const screenshotPath = join(outputDirectory, filename);
|
||||
await mkdir(dirname(screenshotPath), { recursive: true });
|
||||
|
||||
await withObsidianPage(port, async (page) => {
|
||||
try {
|
||||
const element = await resolveElement(page);
|
||||
await element.waitFor({ state: "visible", timeout: 10000 });
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
return screenshotPath;
|
||||
}
|
||||
|
||||
export async function captureJsonResolveDialogue(port: number): Promise<string> {
|
||||
return await captureObsidianDialogue(port, "hidden-file-json-resolve-dialogue.png", async (page) => {
|
||||
const optionAB = page.locator('label:has(input[name="disp"][value="AB"])');
|
||||
|
||||
@@ -22,7 +22,12 @@ import {
|
||||
type LocalDatabaseEntry,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { captureObsidianDialogue, captureObsidianPage, withObsidianPage } from "../runner/ui.ts";
|
||||
import {
|
||||
captureObsidianDialogue,
|
||||
captureObsidianElement,
|
||||
captureObsidianPage,
|
||||
withObsidianPage,
|
||||
} from "../runner/ui.ts";
|
||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
|
||||
@@ -77,6 +82,18 @@ function modalByTitle(page: Page, title: string): Locator {
|
||||
});
|
||||
}
|
||||
|
||||
function settingPanelByTitle(page: Page, title: string): Locator {
|
||||
return page
|
||||
.locator(".sls-setting")
|
||||
.locator("h4.sls-setting-panel-title:visible")
|
||||
.filter({ hasText: title })
|
||||
.locator("..");
|
||||
}
|
||||
|
||||
async function captureGuideDialogue(port: number, filename: string, title: string): Promise<string> {
|
||||
return await captureObsidianElement(port, filename, (page) => modalByTitle(page, title).locator(".modal").first());
|
||||
}
|
||||
|
||||
async function selectRadioOption(modal: Locator, title: string): Promise<void> {
|
||||
const radio = modal.locator("label").filter({ hasText: title }).locator('input[type="radio"]').first();
|
||||
await radio.check({ timeout: uiTimeoutMs });
|
||||
@@ -215,7 +232,16 @@ async function enterSetupURI(port: number, mode: "new" | "existing", artifact: S
|
||||
await setup.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await setup.locator('input[placeholder^="obsidian://setuplivesync"]').fill(artifact.setupURI);
|
||||
await setup.locator('input[name="password"]').fill(artifact.setupPassphrase);
|
||||
await setup.getByRole("button", { name: "Test Settings and Continue" }).click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
await captureGuideDialogue(
|
||||
port,
|
||||
`guide-quick-setup-${mode === "new" ? "first" : "second"}-setup-uri.png`,
|
||||
"Enter Setup URI"
|
||||
);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, "Enter Setup URI")
|
||||
.getByRole("button", { name: "Test Settings and Continue" })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -232,6 +258,11 @@ async function captureAndStartInitialisation(port: number, mode: "new" | "existi
|
||||
await modalByTitle(page, title).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
}
|
||||
);
|
||||
await captureGuideDialogue(
|
||||
port,
|
||||
`guide-quick-setup-${mode === "new" ? "first-initialise" : "second-fetch"}.png`,
|
||||
title
|
||||
);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, title).getByRole("button", { name: button }).click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
@@ -243,6 +274,7 @@ async function confirmRebuild(port: number): Promise<string> {
|
||||
const screenshot = await captureObsidianDialogue(port, "setup-uri-first-rebuild-confirmation.png", async (page) => {
|
||||
await modalByTitle(page, title).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
await captureGuideDialogue(port, "guide-quick-setup-first-rebuild-confirmation.png", title);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const modal = modalByTitle(page, title);
|
||||
await selectCheckbox(
|
||||
@@ -275,6 +307,7 @@ async function skipMissingRemoteConfiguration(port: number): Promise<string> {
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
}
|
||||
);
|
||||
await captureGuideDialogue(port, "guide-quick-setup-missing-remote-configuration.png", title);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, title)
|
||||
.getByRole("button", { name: "Skip and proceed" })
|
||||
@@ -298,6 +331,7 @@ async function acknowledgeDisabledOptionalFeatures(port: number): Promise<string
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
}
|
||||
);
|
||||
await captureGuideDialogue(port, "guide-quick-setup-optional-features-disabled.png", title);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, title).getByRole("button", { name: "OK" }).click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
@@ -313,6 +347,7 @@ async function confirmFastFetch(port: number): Promise<string[]> {
|
||||
await modalByTitle(page, firstTitle).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
}
|
||||
);
|
||||
await captureGuideDialogue(port, "guide-quick-setup-retrieval-method.png", firstTitle);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, firstTitle)
|
||||
.getByRole("button", { name: "Overwrite all with remote files" })
|
||||
@@ -327,6 +362,7 @@ async function confirmFastFetch(port: number): Promise<string[]> {
|
||||
await modalByTitle(page, secondTitle).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
}
|
||||
);
|
||||
await captureGuideDialogue(port, "guide-quick-setup-local-file-policy.png", secondTitle);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, secondTitle)
|
||||
.getByRole("button", { name: "Keep local files even if not on remote" })
|
||||
@@ -457,13 +493,90 @@ async function enableHiddenFileSync(cliBinary: string, environment: NodeJS.Proce
|
||||
].join(""),
|
||||
environment
|
||||
);
|
||||
const state = await readSetupState(cliBinary, environment);
|
||||
const state = await waitForConfiguredSetup(cliBinary, environment);
|
||||
if (!state.syncInternalFiles || !state.syncInternalFilesBeforeReplication) {
|
||||
throw new Error(`Hidden File Sync was not enabled after setup: ${JSON.stringify(state)}`);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async function captureHiddenFileGuideSettings(
|
||||
port: number,
|
||||
cliBinary: string,
|
||||
environment: NodeJS.ProcessEnv
|
||||
): Promise<string[]> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"await core.services.setting.applyPartial({",
|
||||
"useAdvancedMode:true,",
|
||||
"syncInternalFilesTargetPatterns:'^\\\\.obsidian(?:$|/snippets(?:/|$))',",
|
||||
"},true);",
|
||||
"await core.services.control.applySettings();",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
environment
|
||||
);
|
||||
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const obsidian = globalThis as typeof globalThis & {
|
||||
app?: {
|
||||
setting?: {
|
||||
open(): void;
|
||||
openTabById(tabId: string): void;
|
||||
};
|
||||
};
|
||||
};
|
||||
const setting = obsidian.app?.setting;
|
||||
if (!setting) throw new Error("Obsidian settings are unavailable");
|
||||
setting.open();
|
||||
setting.openTabById("obsidian-livesync");
|
||||
});
|
||||
const settings = page.locator(".sls-setting");
|
||||
await settings.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await settings.locator('.sls-setting-menu-btn[title="Setup"]').click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const screenshots = [
|
||||
await captureObsidianElement(port, "guide-hidden-file-advanced-features.png", (page) =>
|
||||
settingPanelByTitle(page, "Enable extra and advanced features")
|
||||
),
|
||||
];
|
||||
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page
|
||||
.locator(".sls-setting")
|
||||
.locator('.sls-setting-menu-btn[title="Selector"]')
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
screenshots.push(
|
||||
await captureObsidianElement(port, "guide-hidden-file-selector.png", (page) =>
|
||||
settingPanelByTitle(page, "Hidden Files")
|
||||
)
|
||||
);
|
||||
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page
|
||||
.locator(".sls-setting")
|
||||
.locator('.sls-setting-menu-btn[title="Sync Settings"]')
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
screenshots.push(
|
||||
await captureObsidianElement(port, "guide-hidden-file-enable.png", (page) =>
|
||||
settingPanelByTitle(page, "Hidden Files")
|
||||
)
|
||||
);
|
||||
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
return screenshots;
|
||||
}
|
||||
|
||||
async function writeNoteViaObsidian(
|
||||
cliBinary: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
@@ -555,12 +668,15 @@ async function captureSynchronisedNote(port: number): Promise<string> {
|
||||
return obsidian.app?.workspace?.openLinkText(path, "", false);
|
||||
}, notePath);
|
||||
});
|
||||
return await captureObsidianPage(port, "setup-uri-synchronised-note.png", async (page) => {
|
||||
await captureObsidianPage(port, "setup-uri-synchronised-note.png", async (page) => {
|
||||
await page.getByText("Provisioned Setup URI", { exact: false }).first().waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
});
|
||||
return await captureObsidianElement(port, "guide-quick-setup-synchronised-note.png", (page) =>
|
||||
page.locator(".workspace-leaf.mod-active").first()
|
||||
);
|
||||
}
|
||||
|
||||
async function captureFailure(session: ObsidianLiveSyncSession): Promise<void> {
|
||||
@@ -625,6 +741,13 @@ async function main(): Promise<void> {
|
||||
false,
|
||||
"Rebuild did not retain the documented optional-feature safety boundary."
|
||||
);
|
||||
screenshots.push(
|
||||
...(await captureHiddenFileGuideSettings(
|
||||
session.remoteDebuggingPort,
|
||||
context.cliBinary,
|
||||
session.cliEnv
|
||||
))
|
||||
);
|
||||
await enableHiddenFileSync(context.cliBinary, session.cliEnv);
|
||||
await uploadWorkflowFiles(context, session, vaultA);
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user