Group Hidden File Sync initialisation progress

This commit is contained in:
vorotamoroz
2026-07-23 16:47:30 +00:00
parent c8050322e4
commit 24b941f594
7 changed files with 433 additions and 43 deletions
+1 -1
View File
@@ -136,7 +136,7 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
`test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two live versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them.
`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one mobile-safe Notice with actionable touch targets; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so legacy-profile migration remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate mobile-safe action Notice with actionable touch targets; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
`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.
@@ -7,7 +7,11 @@ const { evalObsidianJson } = vi.hoisted(() => ({
vi.mock("./cli.ts", () => ({ evalObsidianJson }));
import { assertE2eCompatibilityMarker, type CompatibilityMarkerState } from "./liveSyncWorkflow.ts";
import {
assertE2eCompatibilityMarker,
createE2eCouchDbPluginData,
type CompatibilityMarkerState,
} from "./liveSyncWorkflow.ts";
describe("compatibility marker persistence", () => {
it("waits for an accepted review to reach device-local storage", async () => {
@@ -32,3 +36,21 @@ describe("compatibility marker persistence", () => {
expect(evalObsidianJson).toHaveBeenCalledTimes(2);
});
});
describe("configured CouchDB fixture", () => {
it("starts in the current remote-profile format instead of exercising legacy migration", () => {
const pluginData = createE2eCouchDbPluginData({
uri: "https://couch.example",
username: "alice",
password: "secret",
dbName: "notes",
});
const remoteConfigurations = pluginData.remoteConfigurations as
| Record<string, { id: string; uri: string }>
| undefined;
expect(remoteConfigurations).toBeDefined();
expect(Object.keys(remoteConfigurations ?? {})).toHaveLength(1);
expect(pluginData.activeConfigurationId).toBe(Object.keys(remoteConfigurations ?? {})[0]);
});
});
+9 -2
View File
@@ -2,7 +2,8 @@ import { evalObsidianJson } from "./cli.ts";
import { SERVICE_CONTEXT_MEMBERS } from "../../contracts/serviceContext.ts";
import { DATABASE_COMPATIBILITY_VERSION_KEY } from "../../../src/common/databaseCompatibility.ts";
import { CURRENT_SETTING_VERSION } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { type ObsidianLiveSyncSettings, VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
import type { CouchDbConfig } from "./couchdb.ts";
import type { ObjectStorageConfig } from "./objectStorage.ts";
import { captureObsidianDialogue, withObsidianPage } from "./ui.ts";
@@ -236,7 +237,7 @@ export function createE2eCouchDbPluginData(
settings: Pick<CouchDbConfig, "uri" | "username" | "password"> & { dbName: string },
overrides: Record<string, unknown> = {}
): Record<string, unknown> {
return {
const pluginData = {
couchDB_URI: settings.uri,
couchDB_USER: settings.username,
couchDB_PASSWORD: settings.password,
@@ -245,6 +246,12 @@ export function createE2eCouchDbPluginData(
...E2E_PREFERRED_SETTINGS,
...overrides,
};
upsertRemoteConfigurationInPlace(pluginData as ObsidianLiveSyncSettings, "couchdb", {
id: "e2e-couchdb",
name: "E2E CouchDB",
activate: true,
});
return pluginData;
}
export function assertEqual(actual: unknown, expected: unknown, message: string): void {
@@ -31,6 +31,7 @@ import {
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
captureObsidianPage,
captureJsonResolveDialogue,
clickJsonResolveOption,
obsidianRemoteDebuggingPort,
@@ -58,6 +59,7 @@ const mergeJsonPath = ".obsidian/livesync-e2e-merge.json";
const manualMergeJsonPath = ".obsidian/livesync-e2e-manual-merge.json";
const targetPath = ".obsidian/livesync-targeted/only-a.json";
const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000);
const hiddenFileInitialisationStateKey = "__livesyncE2EHiddenFileInitialisation";
type RunnerContext = {
binary: string;
@@ -558,6 +560,230 @@ async function clearHiddenFileNoticeFixtures(port: number): Promise<void> {
});
}
async function runInitialisationNoticeGrouping(context: RunnerContext, vault: TemporaryVault): Promise<void> {
const session = await startConfiguredSession(context, vault, {
syncInternalFiles: false,
syncInternalFilesBeforeReplication: false,
});
const port = session.remoteDebuggingPort;
const timeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_NOTICE_TIMEOUT_MS ?? 10_000);
try {
await withObsidianPage(port, async (page) => {
const deadline = Date.now() + timeoutMs;
while ((await page.locator(".notice:visible").count()) > 0 && Date.now() < deadline) {
await page.locator(".notice:visible").first().click({
force: true,
position: { x: 2, y: 2 },
timeout: timeoutMs,
});
}
assertEqual(
await page.locator(".notice:visible").count(),
0,
"Transient start-up Notices remained before the Hidden File Sync initialisation check."
);
});
await withObsidianPage(port, async (page) => {
await page.evaluate((stateKey) => {
const obsidianApp = (globalThis as typeof globalThis & { app: any }).app;
const plugin = obsidianApp.plugins.plugins["obsidian-livesync"];
const core = plugin.core;
const addOn = core.getAddOn("HiddenFileSync");
const setting = core.services.setting;
const originalApplyPartial = setting.applyPartial;
const originalRebuildMerging = addOn.rebuildMerging;
const state = {
done: false,
reachedPreparation: false,
reachedInitialisation: false,
maxVisibleProgressNotices: 0,
visibleProgressNoticeTexts: [] as string[],
sawStandaloneGatheringNotice: false,
sawStandaloneRestartNotice: false,
releasePreparation: undefined as (() => void) | undefined,
releaseInitialisation: undefined as (() => void) | undefined,
error: undefined as string | undefined,
};
(globalThis as unknown as Record<string, typeof state>)[stateKey] = state;
const observer = new MutationObserver(() => {
const notices = Array.from(document.querySelectorAll<HTMLElement>(".notice"));
const progressNotices = notices.filter((notice) => notice.textContent?.includes("[⚙"));
state.sawStandaloneGatheringNotice ||= notices.some((notice) =>
notice.textContent?.includes("Gathering files for enabling Hidden File Sync")
);
state.sawStandaloneRestartNotice ||= notices.some((notice) =>
notice.textContent?.includes("Done! Restarting the app is strongly recommended!")
);
if (progressNotices.length > state.maxVisibleProgressNotices) {
state.maxVisibleProgressNotices = progressNotices.length;
state.visibleProgressNoticeTexts = progressNotices.map(
(notice) => notice.textContent?.trim() ?? ""
);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
});
setting.applyPartial = async (...args: unknown[]) => {
const update = args[0] as { syncInternalFiles?: unknown } | undefined;
if (update?.syncInternalFiles === true && !state.reachedPreparation) {
state.reachedPreparation = true;
await new Promise<void>((resolve) => {
state.releasePreparation = resolve;
});
}
return await originalApplyPartial.apply(setting, args);
};
addOn.rebuildMerging = async (...args: unknown[]) => {
state.reachedInitialisation = true;
await new Promise<void>((resolve) => {
state.releaseInitialisation = resolve;
});
return await originalRebuildMerging.apply(addOn, args);
};
void core.services.setting
.enableOptionalFeature("MERGE")
.then(
() => {
state.done = true;
},
(error: unknown) => {
state.error = error instanceof Error ? error.message : String(error);
state.done = true;
}
)
.finally(() => {
setting.applyPartial = originalApplyPartial;
addOn.rebuildMerging = originalRebuildMerging;
const notices = Array.from(document.querySelectorAll<HTMLElement>(".notice"));
const progressNotices = notices.filter((notice) => notice.textContent?.includes("[⚙"));
state.sawStandaloneGatheringNotice ||= notices.some((notice) =>
notice.textContent?.includes("Gathering files for enabling Hidden File Sync")
);
state.sawStandaloneRestartNotice ||= notices.some((notice) =>
notice.textContent?.includes("Done! Restarting the app is strongly recommended!")
);
if (progressNotices.length > state.maxVisibleProgressNotices) {
state.maxVisibleProgressNotices = progressNotices.length;
state.visibleProgressNoticeTexts = progressNotices.map(
(notice) => notice.textContent?.trim() ?? ""
);
}
observer.disconnect();
});
}, hiddenFileInitialisationStateKey);
});
const screenshotPath = await captureObsidianPage(
port,
"hidden-file-initial-scan-progress.png",
async (page) => {
await page.waitForFunction(
(stateKey) =>
(globalThis as unknown as Record<string, { reachedPreparation?: boolean } | undefined>)[
stateKey
]?.reachedPreparation === true,
hiddenFileInitialisationStateKey,
{ timeout: timeoutMs }
);
const progressNotices = page.locator(".notice").filter({ hasText: "[⚙" });
await progressNotices.first().waitFor({ state: "visible", timeout: timeoutMs });
assertEqual(
await progressNotices.count(),
1,
"Hidden File Sync showed more than one progress Notice before saving its enabled setting."
);
await progressNotices
.filter({ hasText: "Preparing Hidden File Sync..." })
.waitFor({ state: "visible", timeout: timeoutMs });
}
);
const result = await withObsidianPage(port, async (page) => {
await page.evaluate((stateKey) => {
const state = (globalThis as unknown as Record<
string,
{ releasePreparation?: () => void } | undefined
>)[stateKey];
state?.releasePreparation?.();
}, hiddenFileInitialisationStateKey);
await page.waitForFunction(
(stateKey) =>
(globalThis as unknown as Record<string, { reachedInitialisation?: boolean } | undefined>)[
stateKey
]?.reachedInitialisation === true,
hiddenFileInitialisationStateKey,
{ timeout: timeoutMs }
);
const progressNotices = page.locator(".notice").filter({ hasText: "[⚙" });
assertEqual(
await progressNotices.count(),
1,
"Hidden File Sync replaced its parent progress Notice when the first child phase started."
);
await page.evaluate((stateKey) => {
const state = (
globalThis as unknown as Record<string, { releaseInitialisation?: () => void } | undefined>
)[stateKey];
state?.releaseInitialisation?.();
}, hiddenFileInitialisationStateKey);
await page.waitForFunction(
(stateKey) =>
(globalThis as unknown as Record<string, { done?: boolean } | undefined>)[stateKey]?.done === true,
hiddenFileInitialisationStateKey,
{ timeout: hiddenFileCliTimeoutMs }
);
return await page.evaluate(
(stateKey) =>
(
globalThis as unknown as Record<
string,
{
error?: string;
maxVisibleProgressNotices: number;
visibleProgressNoticeTexts: string[];
sawStandaloneGatheringNotice: boolean;
sawStandaloneRestartNotice: boolean;
}
>
)[stateKey],
hiddenFileInitialisationStateKey
);
});
if (result.error) {
throw new Error(`Hidden File Sync initialisation failed: ${result.error}`);
}
assertEqual(
result.maxVisibleProgressNotices,
1,
`Hidden File Sync split initialisation across multiple progress Notices: ${JSON.stringify(
result.visibleProgressNoticeTexts
)}`
);
assertEqual(
result.sawStandaloneGatheringNotice,
false,
"Hidden File Sync showed the old standalone gathering Notice."
);
assertEqual(
result.sawStandaloneRestartNotice,
false,
"Hidden File Sync showed the old standalone restart recommendation."
);
console.log(
`Hidden File Sync showed one progress Notice before settings were saved and retained it throughout initialisation. Screenshot: ${screenshotPath}`
);
} finally {
await session.app.stop();
}
}
async function runConfigurationNoticeGrouping(context: RunnerContext, vault: TemporaryVault): Promise<void> {
const session = await startConfiguredSession(context, vault);
const port = session.remoteDebuggingPort;
@@ -646,6 +872,7 @@ async function main(): Promise<void> {
await runJsonConflictRoundTrip(context, vaultA, vaultB);
await runJsonManualConflictResolution(context, vaultB);
await runTargetMismatch(context, vaultA, vaultB);
await runInitialisationNoticeGrouping(context, vaultB);
await runConfigurationNoticeGrouping(context, vaultB);
} finally {
await vaultA.dispose();