From 7556058b36992a5df6afd773617c6d0462adb45f Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Tue, 29 Sep 2026 07:38:39 +0000 Subject: [PATCH] Cover Config Doctor choices and persistence in real Obsidian --- .../internal_metadata_encryption.md | 7 + package.json | 1 + test/e2e-obsidian/README.md | 2 + .../scripts/internal-metadata-migration.ts | 186 +++++++++++++++--- test/e2e-obsidian/scripts/local-suite.ts | 1 + test/e2e-obsidian/scripts/run-focused.ts | 1 + 6 files changed, 173 insertions(+), 25 deletions(-) diff --git a/docs/design_docs/internal_metadata_encryption.md b/docs/design_docs/internal_metadata_encryption.md index 5c3c9cf6..e7d16ec6 100644 --- a/docs/design_docs/internal_metadata_encryption.md +++ b/docs/design_docs/internal_metadata_encryption.md @@ -132,6 +132,13 @@ Keep focused tests for settings defaults and imports, the Doctor condition matrix, acceptance and dismissal, connection replacement, and absence of an automatic Rebuild, Fetch, or restart for this rule. +Exercise the Doctor choices in real Obsidian as well: decline the consultation, +skip the recommendation with a reminder, dismiss the current Doctor version, +and reopen it through **Run Doctor** to accept. Restart the same Vault and +profile between choices to verify persistence and whether the consultation +reappears. Preserve the local database and existing remote documents throughout +acceptance, then verify that subsequent writes encrypt internal Metadata. + Keep unit tests for known and unknown feature notifications, generic identifier presentation, retirement without a circular wait, and the unchanged snapshot behaviour after KV failure or obsolete snapshot fields. The previous batch diff --git a/package.json b/package.json index 4ef54530..fbb9b82a 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "test:e2e:obsidian:customisation-sync": "tsx test/e2e-obsidian/scripts/customisation-sync.ts", "test:e2e:obsidian:remote-feature-change": "tsx test/e2e-obsidian/scripts/remote-feature-change.ts", "test:e2e:obsidian:internal-metadata-migration": "tsx test/e2e-obsidian/scripts/internal-metadata-migration.ts", + "test:e2e:obsidian:internal-metadata-doctor": "tsx test/e2e-obsidian/scripts/internal-metadata-migration.ts --doctor", "test:e2e:obsidian:setting-markdown-export": "tsx test/e2e-obsidian/scripts/setting-markdown-export.ts", "test:e2e:obsidian:upgrade-from-stable": "tsx test/e2e-obsidian/scripts/upgrade-from-stable.ts", "test:e2e:obsidian:local-suite": "tsx test/e2e-obsidian/scripts/local-suite.ts", diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 5e82f1fc..11499a89 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -206,6 +206,8 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms `test:e2e:obsidian:internal-metadata-migration` enables internal Metadata encryption through the settings UI without Rebuild. It checks unchanged plaintext and rewritten encrypted Hidden File Sync and Customisation Sync Metadata in CouchDB, stable document IDs, mismatch rejection on a second device, and file restoration after aligning settings. It then turns the preference OFF, runs Fast Fetch, and compares content loaded from both Metadata representations and their Chunks while retaining the remote feature declaration. These focused tests use the local CouchDB fixture and are outside `test:e2e:obsidian:local-suite`. +`test:e2e:obsidian:internal-metadata-doctor` reuses the migration fixture and enables encryption through the real Config Doctor dialogues. It checks declining the consultation, skipping the recommendation with a reminder, dismissing the current Doctor version, and accepting the recommendation through **Run Doctor** after dismissal. Each choice is checked against active and persisted settings, with natural restarts of the same Vault and profile verifying reminders and retained choices. A local database sentinel, start-up flag checks, unchanged remote documents, and renderer identity checks detect an unintended automatic Rebuild, Fetch, or restart. The accepted setting then follows the two-device migration and Fast Fetch checks above. This scenario requires CouchDB and is included in `test:e2e:obsidian:local-suite`; run it separately with `npm run test:e2e:obsidian:focused -- internal-metadata-doctor` after starting the CouchDB fixture. + `test:e2e:obsidian:setting-markdown-export` enables setting Markdown export, waits for the generated Markdown file in the vault, and verifies that credentials are omitted when `writeCredentialsForSettingSync=false`. `test:e2e:obsidian:upgrade-from-stable` is the release-acceptance upgrade workflow. It installs the exact published 0.25.83 artefacts into an isolated Vault, verifies their pinned SHA-256 values, and then replaces only the plug-in artefacts with the current target while retaining the same Vault and isolated Obsidian profile. The first run downloads the old release into the ignored `_testdata/releases` cache; every later run verifies the cached bytes before use. diff --git a/test/e2e-obsidian/scripts/internal-metadata-migration.ts b/test/e2e-obsidian/scripts/internal-metadata-migration.ts index 3b45b3af..3a49cf84 100644 --- a/test/e2e-obsidian/scripts/internal-metadata-migration.ts +++ b/test/e2e-obsidian/scripts/internal-metadata-migration.ts @@ -1,5 +1,10 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; +import { DoctorRegulation } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc"; +import { + FlagFilesHumanReadable, + FlagFilesOriginal, +} from "@vrtmrz/livesync-commonlib/compat/common/models/redflag.const"; import { VERSIONING_DOCID, type LoadedEntry } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { ENCRYPTED_INTERNAL_METADATA_FEATURE } from "@vrtmrz/livesync-commonlib/replication"; @@ -28,6 +33,9 @@ import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000"; +const useDoctor = process.argv.includes("--doctor"); +const doctorTitle = "Self-hosted LiveSync Config Doctor"; +const enableWithoutRebuild = "Enable without rebuilding — update every other device first"; const hiddenPaths = [".metadata-migration/retained.json", ".metadata-migration/rewritten.json"]; const customPaths = [".obsidian/snippets/retained-metadata.css", ".obsidian/snippets/rewritten-metadata.css"]; const paths = [...hiddenPaths, ...customPaths]; @@ -71,7 +79,11 @@ async function main(): Promise { ); }; const start = async (vault: TemporaryVault, device: string) => { - const settings = { ...optionSettings, deviceAndVaultName: device }; + const settings = { + ...optionSettings, + deviceAndVaultName: device, + doctorProcessedVersion: DoctorRegulation.version, + }; session = await startObsidianLiveSyncSession({ binary, cliBinary, @@ -132,6 +144,123 @@ async function main(): Promise { "The encrypted internal Metadata declaration was not retained." ); }; + const enableWithDoctor = async () => { + const previousVersion = "1.0.0"; + const sentinelId = "_local/e2e-config-doctor"; + await evaluate(`await core.services.setting.applyPartial({doctorProcessedVersion:${JSON.stringify(previousVersion)}},true); + await core.localDatabase.localDatabase.put({_id:${JSON.stringify(sentinelId)},value:'preserved'}); + return JSON.stringify(true);`); + const restart = async () => { + await session!.app.stop(); + session = undefined; + // Retain the same data.json, profile, and local database on natural start-up. + session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: source }); + return await evaluate("return JSON.stringify(performance.timeOrigin);"); + }; + const choose = async (title: string, choice: string) => { + await withObsidianPage(session!.remoteDebuggingPort, async (page) => { + const dialog = await waitForVisibleObsidianDialogue(page, title); + await dialog.getByRole("button", { name: choice, exact: true }).click(); + await dialog.waitFor({ state: "hidden" }); + }); + }; + const assertState = async (enabled: boolean, version: string, timeOrigin: number) => { + await waitForLiveSyncCoreReady(cliBinary, session!.cliEnv); + const expected = JSON.stringify([enabled, version]); + const active = await evaluate<[boolean, string]>( + "return JSON.stringify([core.settings.encryptInternalMetadata,core.settings.doctorProcessedVersion]);" + ); + assertEqual(JSON.stringify(active), expected, "Doctor left unexpected active settings."); + const settingsPath = join(session!.install.pluginDir, "data.json"); + let saved: { encryptInternalMetadata?: boolean; doctorProcessedVersion?: string } = {}; + const saveDeadline = Date.now() + 10_000; + do { + saved = JSON.parse(await readFile(settingsPath, "utf8")); + if (JSON.stringify([saved.encryptInternalMetadata, saved.doctorProcessedVersion]) === expected) break; + await new Promise((resolve) => setTimeout(resolve, 100)); + } while (Date.now() < saveDeadline); + assertEqual( + JSON.stringify([saved.encryptInternalMetadata, saved.doctorProcessedVersion]), + expected, + "Doctor did not preserve the expected settings on disk." + ); + assertEqual( + await evaluate( + `return JSON.stringify((await core.localDatabase.localDatabase.get(${JSON.stringify(sentinelId)})).value);` + ), + "preserved", + "Doctor replaced the local database." + ); + assertEqual( + await evaluate("return JSON.stringify(performance.timeOrigin);"), + timeOrigin, + "Doctor unexpectedly restarted Obsidian." + ); + const flags: string[] = [...Object.values(FlagFilesOriginal), ...Object.values(FlagFilesHumanReadable)]; + assertEqual( + (await readdir(source.path)).some((name) => flags.includes(name)), + false, + "Doctor scheduled a Rebuild, Fetch, or suspended start-up." + ); + await withObsidianPage(session!.remoteDebuggingPort, async (page) => { + for (const candidate of page.context().pages()) { + assertEqual( + await candidate.locator(".modal-container:visible").count(), + 0, + "Doctor left an unexpected dialogue open." + ); + } + }); + }; + + let timeOrigin = await restart(); + await choose(doctorTitle, "No"); + await assertState(false, previousVersion, timeOrigin); + console.log("Declining Doctor leaves encryption OFF and permits another consultation after restart."); + + timeOrigin = await restart(); + await choose(doctorTitle, "Yes"); + await choose("Fix issue 1/1", "Leave it as is"); + await choose("Almost done!", "Yes"); + await assertState(false, previousVersion, timeOrigin); + console.log("Skipping the Metadata recommendation and requesting a reminder preserves the previous marker."); + + timeOrigin = await restart(); + await choose(doctorTitle, "No, and do not ask again until the next release"); + await assertState(false, DoctorRegulation.version, timeOrigin); + timeOrigin = await restart(); + await assertState(false, DoctorRegulation.version, timeOrigin); + console.log( + "Dismissing this Doctor version keeps encryption OFF and suppresses the next start-up consultation." + ); + + await withObsidianPage(session!.remoteDebuggingPort, async (page) => { + const navigator = await openLiveSyncSettings(page); + const hatch = await navigator.openPage("Hatch"); + await hatch.getByRole("button", { name: "Run Doctor", exact: true }).click(); + }); + await choose(doctorTitle, "Yes"); + await withObsidianPage(session!.remoteDebuggingPort, async (page) => { + const dialog = await waitForVisibleObsidianDialogue(page, "Fix issue 1/1"); + for (const text of [ + "Encrypt internal file Properties", + "manually rebuild the remote database", + "update every synchronising client before enabling it", + ]) { + await dialog.getByText(text, { exact: false }).first().waitFor({ state: "visible" }); + } + }); + assertEqual( + await evaluate("return JSON.stringify(core.settings.encryptInternalMetadata);"), + false, + "Doctor enabled encryption before acceptance." + ); + await choose("Fix issue 1/1", enableWithoutRebuild); + await assertState(true, DoctorRegulation.version, timeOrigin); + timeOrigin = await restart(); + await assertState(true, DoctorRegulation.version, timeOrigin); + console.log("Manual Doctor acceptance persists across restart without automatic Rebuild, Fetch, or restart."); + }; try { await assertCouchDbReachable(couchDb); @@ -153,31 +282,38 @@ async function main(): Promise { "The original database was not generation 12." ); - await withObsidianPage(session!.remoteDebuggingPort, async (page) => { - const navigator = await openLiveSyncSettings(page); - const remotePage = await navigator.openPage("Remote Configuration"); - await remotePage - .locator(".setting-item") - .filter({ - has: navigator.page.getByText("Configure E2EE", { exact: true }), - }) - .getByRole("button", { name: "Configure", exact: true }) - .click(); - const dialog = await waitForVisibleObsidianDialogue(navigator.page, "End-to-End Encryption"); - await dialog.getByLabel("Encrypt internal file Properties", { exact: true }).check(); - await dialog.getByRole("button", { name: "Proceed", exact: true }).click(); - const warning = await waitForVisibleObsidianDialogue(navigator.page, "Encrypt internal file Properties"); - await warning - .getByRole("button", { - name: "Enable without rebuilding — update every other device first", - exact: true, - }) - .click(); - }); + if (useDoctor) { + await enableWithDoctor(); + } else { + await withObsidianPage(session!.remoteDebuggingPort, async (page) => { + const navigator = await openLiveSyncSettings(page); + const remotePage = await navigator.openPage("Remote Configuration"); + await remotePage + .locator(".setting-item") + .filter({ + has: navigator.page.getByText("Configure E2EE", { exact: true }), + }) + .getByRole("button", { name: "Configure", exact: true }) + .click(); + const dialog = await waitForVisibleObsidianDialogue(navigator.page, "End-to-End Encryption"); + await dialog.getByLabel("Encrypt internal file Properties", { exact: true }).check(); + await dialog.getByRole("button", { name: "Proceed", exact: true }).click(); + const warning = await waitForVisibleObsidianDialogue( + navigator.page, + "Encrypt internal file Properties" + ); + await warning + .getByRole("button", { + name: enableWithoutRebuild, + exact: true, + }) + .click(); + }); + } assertEqual( await evaluate(`app.setting.close(); return JSON.stringify(core.settings.encryptInternalMetadata);`), true, - "The setting dialogue did not enable encryption." + "The dialogue did not enable encryption." ); for (let index = 0; index < entries.length; index++) { assertEqual( @@ -211,7 +347,7 @@ async function main(): Promise { } await assertDeclaration(); console.log( - "The settings UI enabled encryption without Rebuild; unchanged and encrypted Metadata coexist with stable IDs." + `${useDoctor ? "Doctor" : "The settings UI"} enabled encryption without Rebuild; unchanged and encrypted Metadata coexist with stable IDs.` ); await session!.app.stop(); session = undefined; diff --git a/test/e2e-obsidian/scripts/local-suite.ts b/test/e2e-obsidian/scripts/local-suite.ts index 87da7263..06644b5c 100644 --- a/test/e2e-obsidian/scripts/local-suite.ts +++ b/test/e2e-obsidian/scripts/local-suite.ts @@ -45,6 +45,7 @@ const testSteps: Step[] = [ { name: "two-vault synchronisation", args: ["run", "test:e2e:obsidian:two-vault-sync"] }, { name: "hidden file snippet synchronisation", args: ["run", "test:e2e:obsidian:hidden-file-snippet-sync"] }, { name: "Customisation Sync", args: ["run", "test:e2e:obsidian:customisation-sync"] }, + { name: "internal Metadata Doctor", args: ["run", "test:e2e:obsidian:internal-metadata-doctor"] }, { name: "setting Markdown export", args: ["run", "test:e2e:obsidian:setting-markdown-export"] }, ]; diff --git a/test/e2e-obsidian/scripts/run-focused.ts b/test/e2e-obsidian/scripts/run-focused.ts index acc6985c..84680c21 100644 --- a/test/e2e-obsidian/scripts/run-focused.ts +++ b/test/e2e-obsidian/scripts/run-focused.ts @@ -32,6 +32,7 @@ const focusedScenarios = new Set([ "security-seed-reconnect", "hidden-file-snippet-sync", "customisation-sync", + "internal-metadata-doctor", "setting-markdown-export", "upgrade-from-stable", ]);