Cover Config Doctor choices and persistence in real Obsidian

This commit is contained in:
vorotamoroz
2026-09-29 07:38:39 +00:00
parent c52cf98545
commit 7556058b36
6 changed files with 173 additions and 25 deletions
@@ -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
+1
View File
@@ -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",
+2
View File
@@ -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.
@@ -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<void> {
);
};
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<void> {
"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<number>("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<void> {
"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<void> {
}
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;
+1
View File
@@ -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"] },
];
+1
View File
@@ -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",
]);