mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-06 09:22:30 +00:00
Cover Config Doctor choices and persistence in real Obsidian
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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"] },
|
||||
];
|
||||
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user