Add verified setup workflow screenshots
@@ -28,9 +28,21 @@ Use this path only when the remote database is new, or when this device is inten
|
||||
3. Select `I am setting this up for the first time`, then confirm that you want to set up a new synchronisation.
|
||||
4. On `Connection Method`, select `Use a Setup URI (Recommended)`.
|
||||
5. Paste the Setup URI, enter its Setup URI passphrase, and select `Test Settings and Continue`.
|
||||
|
||||

|
||||
|
||||
6. Review `Setup Complete: Preparing to Initialise Server`, then select `Restart and Initialise Server`.
|
||||
|
||||

|
||||
|
||||
7. Read the final overwrite warning carefully. Select `I Understand, Overwrite Server` only after checking that backups exist and that replacing the remote data is intended.
|
||||
|
||||

|
||||
|
||||
8. A newly provisioned database may show `Fetch Remote Configuration Failed` because it does not contain a saved preferred configuration yet. If this is a genuinely new setup, select `Skip and proceed`. Otherwise, stop and investigate before continuing.
|
||||
|
||||

|
||||
|
||||
9. Acknowledge `All optional features are disabled`. Optional features remain off until the ordinary synchronisation path has been verified.
|
||||
10. Allow initialisation and any requested restart to finish. Keep Obsidian open until the LiveSync progress indicators have cleared.
|
||||
|
||||
@@ -46,12 +58,23 @@ Start with a new or separately backed-up Vault. Do not use a production Vault co
|
||||
4. On `Device Setup Method`, select `Use a Setup URI (Recommended)`.
|
||||
5. Paste the same Setup URI, enter its Setup URI passphrase, and select `Test Settings and Continue`.
|
||||
6. Review `Setup Complete: Preparing to Fetch Synchronisation Data`, then select `Restart and Fetch Data`.
|
||||
|
||||

|
||||
|
||||
7. For a new or empty Vault, select `Overwrite all with remote files`. For a Vault with local work, stop and choose the appropriate strategy from the [Fast Setup guide](./tips/fast-setup.md).
|
||||
|
||||

|
||||
|
||||
8. When asked how to handle extra local files, the conservative choice is `Keep local files even if not on remote`. Select the delete option only when the local Vault is disposable and an exact remote copy is intended.
|
||||
|
||||

|
||||
|
||||
9. Allow retrieval, file reflection, and any requested restart to finish. Keep Obsidian open until the LiveSync progress indicators have cleared.
|
||||
|
||||
Confirm that the ordinary test note from the first device appears unchanged. Then edit or create a second ordinary note on the new device, and confirm that it reaches the first device.
|
||||
|
||||

|
||||
|
||||
## After ordinary synchronisation works
|
||||
|
||||
Add optional features separately so that their ownership and initialisation direction are explicit:
|
||||
|
||||
@@ -20,16 +20,32 @@ Enabling Hidden File Sync requires an initialisation direction:
|
||||
## Review the file selection
|
||||
|
||||
1. Open Self-hosted LiveSync settings.
|
||||
2. Open `Setup`, find `Extra features`, and enable `Advanced features`.
|
||||
2. Open `Setup`, find `Enable extra and advanced features`, and enable `Advanced features`.
|
||||
|
||||

|
||||
|
||||
3. Open `Selector`, then review the `Hidden Files` section.
|
||||
4. Use `Target patterns` to limit the feature to the hidden files you intend to synchronise. An empty target list includes every otherwise eligible hidden file.
|
||||
5. Review `Ignore patterns`. The default excludes `node_modules`, `.git`, and Self-hosted LiveSync's own plug-in data. `Add default patterns` offers a `Cross-platform` set which also excludes Obsidian workspace files.
|
||||
|
||||

|
||||
|
||||
Prefer a narrow target list. Device-specific workspace state and another plug-in's credentials are usually poor candidates for cross-device synchronisation.
|
||||
|
||||
Target patterns also control directory traversal. A pattern must therefore match each parent directory as well as the intended files. For example, the following pattern admits the `.obsidian` parent and only its `snippets` subtree:
|
||||
|
||||
```text
|
||||
^\.obsidian(?:$|/snippets(?:/|$))
|
||||
```
|
||||
|
||||
A pattern containing only `snippets` does not admit the `.obsidian` parent, so the scan cannot reach that directory.
|
||||
|
||||
## Enable the first device
|
||||
|
||||
1. Open `Sync Settings`, then find the advanced `Hidden Files` section.
|
||||
|
||||

|
||||
|
||||
2. Under `Enable Hidden File Sync`, select the initialisation direction chosen above.
|
||||
3. Keep Obsidian open while the initial scan and synchronisation finish.
|
||||
4. Restart Obsidian when the completion Notice recommends it.
|
||||
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -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) {
|
||||
|
||||