Cover Customisation Sync visibility in real Obsidian

This commit is contained in:
vorotamoroz
2026-09-30 16:42:43 +00:00
parent fc71c2a9e7
commit 009bbcb37c
2 changed files with 93 additions and 4 deletions
+1 -1
View File
@@ -219,7 +219,7 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms
`test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronises the entries through CouchDB, applies them on the second vault, verifies the resulting `.obsidian` files, propagates a snippet update, and verifies deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy. `test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronises the entries through CouchDB, applies them on the second vault, verifies the resulting `.obsidian` files, propagates a snippet update, and verifies deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy.
The workflow also opens the Customisation Sync dialogue. `--case=mtime` gives the multi-file plug-in fixture modern millisecond timestamps and requires an older remote copy to remain labelled **Older** and unselected by **Select All Shiny**. With no case argument, this check runs before the existing apply, update, and deletion workflow. The workflow also opens the Customisation Sync dialogue. `--case=visibility` checks that **Hide not applicable items** hides an identical snippet, preserves a different snippet and its source selector, and restores the identical snippet when cleared. `--case=mtime` gives the multi-file plug-in fixture modern millisecond timestamps and requires an older remote copy to remain labelled **Older** and unselected by **Select All Shiny**. With no case argument, both checks run before the existing apply, update, and deletion workflow.
`test:e2e:obsidian:received-change-readiness` uses two sequential real Obsidian sessions and isolated CouchDB databases. The source creates ordinary notes and their Chunks; the target starts continuous replication, resets readiness through the public lifecycle service, and receives those documents while its Vault files remain absent. Marking the target ready twice must emit one readiness event and reflect the queued content. A second note remains absent after readiness while database reflecting is explicitly suspended, then appears when that setting is resumed. This focused scenario is outside `test:e2e:obsidian:local-suite`. `test:e2e:obsidian:received-change-readiness` uses two sequential real Obsidian sessions and isolated CouchDB databases. The source creates ordinary notes and their Chunks; the target starts continuous replication, resets readiness through the public lifecycle service, and receives those documents while its Vault files remain absent. Marking the target ready twice must emit one readiness event and reflect the queued content. A second note remains absent after readiness while database reflecting is explicitly suspended, then appears when that setting is resumed. This focused scenario is outside `test:e2e:obsidian:local-suite`.
@@ -27,6 +27,7 @@ import {
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts"; import { waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
import type { Locator } from "playwright";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000"; process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000";
@@ -58,6 +59,11 @@ const pluginDir = ".obsidian/plugins/livesync-e2e-sample";
const pluginManifestPath = `${pluginDir}/manifest.json`; const pluginManifestPath = `${pluginDir}/manifest.json`;
const pluginMainPath = `${pluginDir}/main.js`; const pluginMainPath = `${pluginDir}/main.js`;
const pluginStylesPath = `${pluginDir}/styles.css`; const pluginStylesPath = `${pluginDir}/styles.css`;
const identicalSnippetPath = ".obsidian/snippets/livesync-customisation-e2e-identical.css";
const identicalSnippetContent = ".livesync-customisation-identical { color: #3d6f54; }\n";
const differentSnippetPath = ".obsidian/snippets/livesync-customisation-e2e-different.css";
const sourceDifferentSnippetContent = ".livesync-customisation-different { color: #73548f; }\n";
const targetDifferentSnippetContent = ".livesync-customisation-different { color: #3d6f54; }\n";
const pluginManifestContent = const pluginManifestContent =
JSON.stringify( JSON.stringify(
{ {
@@ -85,15 +91,20 @@ const targetPluginStylesContent = ".livesync-e2e-sample { color: #3d6f54; }\n";
// These dates straddle a signed 32-bit wrap boundary for millisecond timestamps. // These dates straddle a signed 32-bit wrap boundary for millisecond timestamps.
const sourcePluginMtime = new Date("2026-09-01T12:00:00.000Z"); const sourcePluginMtime = new Date("2026-09-01T12:00:00.000Z");
const targetPluginMtime = new Date("2026-09-15T12:00:00.000Z"); const targetPluginMtime = new Date("2026-09-15T12:00:00.000Z");
const matchingMtime = new Date("2026-09-15T12:00:00.000Z");
const sourceDeviceName = "customisation-sync-a"; const sourceDeviceName = "customisation-sync-a";
const targetDeviceName = "customisation-sync-b"; const targetDeviceName = "customisation-sync-b";
const visibilityFixtures = [
{ path: identicalSnippetPath, source: identicalSnippetContent, target: identicalSnippetContent },
{ path: differentSnippetPath, source: sourceDifferentSnippetContent, target: targetDifferentSnippetContent },
] as const;
const pluginFixtures = [ const pluginFixtures = [
{ path: pluginManifestPath, source: pluginManifestContent, target: pluginManifestContent }, { path: pluginManifestPath, source: pluginManifestContent, target: pluginManifestContent },
{ path: pluginMainPath, source: pluginMainContent, target: pluginMainContent }, { path: pluginMainPath, source: pluginMainContent, target: pluginMainContent },
{ path: pluginStylesPath, source: pluginStylesContent, target: targetPluginStylesContent }, { path: pluginStylesPath, source: pluginStylesContent, target: targetPluginStylesContent },
] as const; ] as const;
type CustomisationSyncCase = "all" | "mtime"; type CustomisationSyncCase = "all" | "visibility" | "mtime";
type CustomisationSyncTestGlobal = typeof globalThis & { type CustomisationSyncTestGlobal = typeof globalThis & {
app?: { commands?: { executeCommandById(commandId: string): boolean } }; app?: { commands?: { executeCommandById(commandId: string): boolean } };
@@ -136,13 +147,59 @@ function selectedCase(): CustomisationSyncCase {
if (args.length === 0) return "all"; if (args.length === 0) return "all";
const arg = args[0]; const arg = args[0];
if (args.length !== 1 || !arg?.startsWith("--case=")) { if (args.length !== 1 || !arg?.startsWith("--case=")) {
throw new Error("Usage: test:e2e:obsidian:customisation-sync [--case=all|mtime]"); throw new Error("Usage: test:e2e:obsidian:customisation-sync [--case=all|visibility|mtime]");
} }
const selected = arg.slice("--case=".length); const selected = arg.slice("--case=".length);
if (selected === "all" || selected === "mtime") return selected; if (selected === "all" || selected === "visibility" || selected === "mtime") return selected;
throw new Error(`Unknown Customisation Sync E2E case: ${selected}`); throw new Error(`Unknown Customisation Sync E2E case: ${selected}`);
} }
async function assertSourceCandidate(row: Locator, term: string, name: string): Promise<void> {
const candidate = row.locator(`select option[value="${term}"]`);
try {
await candidate.waitFor({ state: "attached", timeout: 10000 });
} catch {
throw new Error(`Customisation Sync ${name} source candidate was not available: ${term}`);
}
}
async function inspectCustomisationVisibility(session: ObsidianLiveSyncSession): Promise<void> {
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const opened = await page.evaluate(() =>
(globalThis as CustomisationSyncTestGlobal).app?.commands?.executeCommandById(
"obsidian-livesync:livesync-plugin-dialog-ex"
)
);
if (opened !== true) throw new Error("Could not open the Customisation Sync dialogue command.");
const dialogue = await waitForVisibleObsidianDialogue(page, "Customization Sync (Beta3)");
const identicalName = identicalSnippetPath.split("/").pop() ?? identicalSnippetPath;
const differentName = differentSnippetPath.split("/").pop() ?? differentSnippetPath;
const identicalRow = dialogue.locator(".labelrow").filter({ hasText: identicalName }).first();
const differentRow = dialogue.locator(".labelrow").filter({ hasText: differentName }).first();
const hideCheckbox = dialogue
.locator("label")
.filter({ hasText: "Hide not applicable items" })
.locator('input[type="checkbox"]');
await identicalRow.waitFor({ state: "visible", timeout: 10000 });
await differentRow.waitFor({ state: "visible", timeout: 10000 });
await assertSourceCandidate(identicalRow, sourceDeviceName, "identical item");
await assertSourceCandidate(differentRow, sourceDeviceName, "different item");
if (await hideCheckbox.isChecked()) throw new Error("Hide not applicable items started checked.");
await hideCheckbox.check();
await identicalRow.waitFor({ state: "hidden", timeout: 10000 });
await differentRow.waitFor({ state: "visible", timeout: 10000 });
await assertSourceCandidate(differentRow, sourceDeviceName, "different item while hiding identical items");
await hideCheckbox.uncheck();
await identicalRow.waitFor({ state: "visible", timeout: 10000 });
await assertSourceCandidate(identicalRow, sourceDeviceName, "identical item after unhiding");
await page.keyboard.press("Escape");
await dialogue.waitFor({ state: "hidden", timeout: 10000 });
});
}
async function inspectPluginFreshnessAndNewestSelection(session: ObsidianLiveSyncSession): Promise<void> { async function inspectPluginFreshnessAndNewestSelection(session: ObsidianLiveSyncSession): Promise<void> {
await withObsidianPage(session.remoteDebuggingPort, async (page) => { await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const opened = await page.evaluate(() => const opened = await page.evaluate(() =>
@@ -515,6 +572,7 @@ async function applyRemoteCustomisationGroup(
async function main(): Promise<void> { async function main(): Promise<void> {
const testCase = selectedCase(); const testCase = selectedCase();
const testVisibility = testCase === "all" || testCase === "visibility";
const testMtime = testCase === "all" || testCase === "mtime"; const testMtime = testCase === "all" || testCase === "mtime";
const binary = requireObsidianBinary(); const binary = requireObsidianBinary();
const cli = discoverObsidianCli(); const cli = discoverObsidianCli();
@@ -546,11 +604,23 @@ async function main(): Promise<void> {
await writeVaultFile(vaultA.path, pluginManifestPath, pluginManifestContent); await writeVaultFile(vaultA.path, pluginManifestPath, pluginManifestContent);
await writeVaultFile(vaultA.path, pluginMainPath, pluginMainContent); await writeVaultFile(vaultA.path, pluginMainPath, pluginMainContent);
await writeVaultFile(vaultA.path, pluginStylesPath, pluginStylesContent); await writeVaultFile(vaultA.path, pluginStylesPath, pluginStylesContent);
if (testVisibility) {
for (const fixture of visibilityFixtures) {
await writeVaultFile(vaultA.path, fixture.path, fixture.source);
await setVaultFileMtime(vaultA.path, fixture.path, matchingMtime);
}
}
if (testMtime) { if (testMtime) {
for (const fixture of pluginFixtures) { for (const fixture of pluginFixtures) {
await setVaultFileMtime(vaultA.path, fixture.path, sourcePluginMtime); await setVaultFileMtime(vaultA.path, fixture.path, sourcePluginMtime);
} }
} }
if (testVisibility) {
for (const fixture of visibilityFixtures) {
await writeVaultFile(vaultB.path, fixture.path, fixture.target);
await setVaultFileMtime(vaultB.path, fixture.path, matchingMtime);
}
}
if (testMtime) { if (testMtime) {
for (const fixture of pluginFixtures) { for (const fixture of pluginFixtures) {
await writeVaultFile(vaultB.path, fixture.path, fixture.target); await writeVaultFile(vaultB.path, fixture.path, fixture.target);
@@ -566,6 +636,11 @@ async function main(): Promise<void> {
for (const fixture of pluginFixtures) { for (const fixture of pluginFixtures) {
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path); await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
} }
if (testVisibility) {
for (const fixture of visibilityFixtures) {
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
}
}
const entry = await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName); const entry = await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName);
const configEntry = await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "CONFIG", configName); const configEntry = await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "CONFIG", configName);
const pluginEntries = await waitForCustomisationEntries( const pluginEntries = await waitForCustomisationEntries(
@@ -605,6 +680,11 @@ async function main(): Promise<void> {
await session.app.stop(); await session.app.stop();
session = await startConfiguredSession(context, vaultB, targetDeviceName); session = await startConfiguredSession(context, vaultB, targetDeviceName);
if (testVisibility) {
for (const fixture of visibilityFixtures) {
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
}
}
if (testMtime) { if (testMtime) {
for (const fixture of pluginFixtures) { for (const fixture of pluginFixtures) {
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path); await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
@@ -612,6 +692,14 @@ async function main(): Promise<void> {
} }
await pushLocalChanges(context.cliBinary, session.cliEnv); await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName, sourceDeviceName); await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName, sourceDeviceName);
if (testVisibility) {
for (const fixture of visibilityFixtures) {
const name = fixture.path.split("/").pop() ?? fixture.path;
for (const term of [sourceDeviceName, targetDeviceName]) {
await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", name, term);
}
}
}
if (testMtime) { if (testMtime) {
await waitForCustomisationEntries( await waitForCustomisationEntries(
context.cliBinary, context.cliBinary,
@@ -631,6 +719,7 @@ async function main(): Promise<void> {
); );
} }
try { try {
if (testVisibility) await inspectCustomisationVisibility(session);
if (testMtime) await inspectPluginFreshnessAndNewestSelection(session); if (testMtime) await inspectPluginFreshnessAndNewestSelection(session);
} catch (error) { } catch (error) {
await session.app.stop().catch(() => undefined); await session.app.stop().catch(() => undefined);