Add read-only database diagnostics

This commit is contained in:
vorotamoroz committed 2026-10-19 12:49:02 +00:00
1 parent f0ea5cb3a0
commit 8aeb7c60ef
19 files changed
+1049 -236

No files matched your search

+3
View File
@@ -74,6 +74,7 @@ After changing plug-in source, use the focused wrapper rather than invoking a sc
```bash
npm run test:e2e:obsidian:focused -- settings-ui
npm run test:e2e:obsidian:focused -- database-diagnostics
npm run test:e2e:obsidian:focused -- configuration-encryption
npm run test:e2e:obsidian:focused -- configuration-encryption-sync
npm run test:e2e:obsidian:focused -- two-vault-sync
@@ -85,6 +86,8 @@ npm run test:e2e:obsidian:focused -- couchdb-http-cache
The wrapper accepts only maintained real-Obsidian scenario names; run it with `--help` for the current list. It deliberately does not manage CouchDB, Object Storage, or the P2P signalling relay. Start the required fixture first, or use the complete service-managed suite.
`database-diagnostics` needs no remote service. It checks the Usage command and Hatch integrity action with an isolated Vault, including the choice before inspection, original names and pseudonyms, retained local findings when the remote is unavailable, and unchanged settings, local revisions, and Vault files. It captures desktop results and the choice dialogue in mobile emulation. Copy and download payloads are captured inside the isolated renderer, so the scenario leaves the host clipboard and download directory unchanged. Commonlib's separate CouchDB integration test verifies encrypted reads and missing Security Seed handling against a real server.
`folder-batch` needs no remote service. It creates 24 notes and imports three notes with colons in their names into nested folders, reflects the database content into the existing files, then renames and deletes the parent through the Obsidian Vault API. It checks exact paths, descendant events, content, Chunks, deletion markers, provenance, and the absence of unexpected files. A note outside the parent must remain writable. The colon fixtures use the adapter to represent externally created files because Obsidian's Vault creation API rejects those names. The scenario also seeds a database-only colon-named note and verifies that Obsidian's refusal to create it preserves its Metadata without writing a differently named Vault file. It does not establish successful restoration of that absent note or behaviour on other operating systems.
`stale-file-restart` needs no remote service. It advances the local database while old Vault bytes remain, persists pending storage events, and restarts the same isolated Vault and profile. It checks that an unchanged file whose recorded revision has been pruned receives the newer database content using persisted content evidence without creating a revision, that unknown-origin content is preserved on a fresh independent branch, and that losing provenance and processing the file again does not duplicate or automatically merge that branch. A third file begins with no provenance while its bytes still match the current database revision; start-up must record that revision without creating a new one, and a later incoming revision must reflect without a conflict. A fourth file has legacy revision-only provenance; start-up must verify its exact content and add a hash without creating a database revision or changing storage. The database advances and pending snapshot are controlled fixtures; start-up processing, persistence, file reflection, and conflict checking run in real Obsidian. The scenario does not simulate a mobile operating system suspending the application.
@@ -0,0 +1,237 @@
import assert from "node:assert/strict";
import type { Page } from "playwright";
import { DoctorRegulation } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
import { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts";
import { evalObsidianJson } from "../runner/cli.ts";
import {
createE2eObsidianDeviceLocalState,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { captureObsidianElement, captureObsidianPage, openLiveSyncSettings, withObsidianPage } from "../runner/ui.ts";
import { assertMobileDialogueLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const fixturePath = "Private/diagnostic-秘密.md";
const fixtureContent = "Diagnostic fixture content which must never appear in a report.\n";
const shadowId = "diagnostic-shadow-document";
const timeout = 15000;
type DiagnosticPageGlobal = typeof globalThis & {
app: { commands: { executeCommandById(id: string): boolean } };
diagnosticClipboard?: string;
diagnosticDownload?: { name: string; content: string };
};
async function openCommand(page: Page, kind: "usage" | "integrity") {
const id = kind === "usage" ? "analyse-database" : "inspect-database-integrity";
const opened = await page.evaluate(
(command) => (globalThis as DiagnosticPageGlobal).app.commands.executeCommandById(command),
`obsidian-livesync:${id}`
);
assert.equal(opened, true);
const dialogue = page.locator(".database-diagnostic");
await dialogue.locator("select").selectOption("pseudonym");
assert.equal(await dialogue.getAttribute("data-diagnostic-phase"), "choice");
assert.equal(await dialogue.locator("textarea").count(), 0);
return dialogue;
}
async function readDatabaseState(cli: string, session: ObsidianLiveSyncSession) {
return await evalObsidianJson<{ sequence: unknown; settings: string; names: string[] }>(
cli,
"(async()=>{const core=app.plugins.plugins['obsidian-livesync'].core;return JSON.stringify({sequence:(await core.localDatabase.localDatabase.info()).update_seq,settings:JSON.stringify(core.services.setting.currentSettings()),names:app.vault.getFiles().map(f=>f.path).sort()});})()",
session.cliEnv
);
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = requireObsidianCli();
const vault = await createTemporaryVault("obsidian-livesync-diagnostics-");
let session: ObsidianLiveSyncSession | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli,
vault,
pluginData: {
displayLanguage: "def",
doctorProcessedVersion: DoctorRegulation.version,
isConfigured: true,
useAdvancedMode: true,
readChunksOnline: true,
liveSync: false,
remoteType: "",
couchDB_URI: "",
couchDB_DBNAME: "",
couchDB_USER: "",
couchDB_PASSWORD: "",
remoteConfigurations: {},
activeConfigurationId: "",
notifyThresholdOfRemoteStorageSize: -1,
periodicReplication: false,
syncAfterMerge: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncOnSave: false,
syncOnStart: false,
useEden: false,
},
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
await waitForLiveSyncCoreReady(cli, session.cliEnv);
await evalObsidianJson(
cli,
`(async()=>{await app.vault.createFolder('Private');await app.vault.create(${JSON.stringify(fixturePath)},${JSON.stringify(fixtureContent)});return JSON.stringify(true);})()`,
session.cliEnv
);
await waitForLocalDatabaseEntry(cli, session.cliEnv, fixturePath);
await evalObsidianJson(
cli,
`(async()=>{const core=app.plugins.plugins['obsidian-livesync'].core;await core.services.fileProcessing.commitPendingFileEvents();const db=core.localDatabase.localDatabase;const id=await core.services.path.path2id(${JSON.stringify(fixturePath)});const original=await db.get(id);const shadow={...original,_id:${JSON.stringify(shadowId)}};delete shadow._rev;await db.put(shadow);return JSON.stringify(true);})()`,
session.cliEnv
);
const before = await readDatabaseState(cli, session);
let usageJSON = "";
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const choice = await openCommand(page, "usage");
await choice.getByRole("button", { name: "Cancel", exact: true }).click();
await choice.waitFor({ state: "hidden", timeout });
const dialogue = await openCommand(page, "usage");
assert.equal(await dialogue.locator("select").inputValue(), "pseudonym");
await dialogue.getByRole("button", { name: "Begin inspection", exact: true }).click();
await page.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
const tsv = await dialogue.locator("textarea").inputValue();
assert.equal(tsv.split("\n")[0].split("\t").length, 9);
assert.ok(!tsv.includes(fixturePath));
assert.ok(!tsv.includes(shadowId));
assert.ok(!tsv.includes(fixtureContent.trim()));
await dialogue.locator("select").selectOption("json");
usageJSON = await dialogue.locator("textarea").inputValue();
const report = JSON.parse(usageJSON);
assert.equal(report.status, "complete");
assert.ok(report.versions.commonlib);
assert.ok(report.rows.some((row: { path: string }) => row.path.startsWith("file-")));
// Capture exports inside the isolated renderer without changing the
// host clipboard or leaving downloads in a user's download directory.
await page.evaluate(`(() => {
Object.defineProperty(navigator.clipboard, "writeText", {
configurable: true,
value: async (text) => { globalThis.diagnosticClipboard = text; },
});
document.addEventListener("click", (event) => {
const link = event.target;
if (!(link instanceof HTMLAnchorElement) || !link.download || !link.href.startsWith("blob:")) return;
event.preventDefault();
void fetch(link.href).then((response) => response.text()).then((content) => {
globalThis.diagnosticDownload = { name: link.download, content };
});
}, true);
})()`);
await dialogue.getByRole("button", { name: "Copy report", exact: true }).click();
assert.equal(
await page.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticClipboard),
usageJSON
);
await dialogue.getByRole("button", { name: "Save report", exact: true }).click();
await page.waitForFunction(() => !!(globalThis as DiagnosticPageGlobal).diagnosticDownload, undefined, {
timeout,
});
assert.deepEqual(await page.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticDownload), {
name: "database-usage-complete.json",
content: usageJSON,
});
await dialogue.getByRole("button", { name: "Close", exact: true }).click();
await dialogue.waitFor({ state: "hidden", timeout });
const original = await openCommand(page, "usage");
await original.locator("select").selectOption("original");
await original.getByRole("button", { name: "Begin inspection", exact: true }).click();
await page.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
const rawTSV = await original.locator("textarea").inputValue();
assert.ok(rawTSV.includes(fixturePath));
assert.ok(rawTSV.includes(shadowId));
assert.ok(!rawTSV.includes(fixtureContent.trim()));
await original.getByRole("button", { name: "Close", exact: true }).click();
await original.waitFor({ state: "hidden", timeout });
const settingsNavigator = await openLiveSyncSettings(page, timeout);
const hatch = await settingsNavigator.openPage("Hatch");
const setting = hatch
.locator(".setting-item")
.filter({ has: page.getByText("Inspect database integrity", { exact: true }) });
await setting.getByRole("button", { name: "Inspect", exact: true }).click();
const integrity = page.locator(".database-diagnostic");
assert.equal(await integrity.locator("select").inputValue(), "pseudonym");
await integrity.getByRole("button", { name: "Begin inspection", exact: true }).click();
await page.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
await integrity.locator("select").selectOption("json");
const reportText = await integrity.locator("textarea").inputValue();
const integrityReport = JSON.parse(reportText);
assert.equal(integrityReport.status, "partial");
assert.ok(
integrityReport.errors.some((error: { reason: string }) => error.reason === "source-unavailable")
);
assert.ok(
integrityReport.observations.some(
(observation: { identity: string }) => observation.identity === "document-id-mismatch"
)
);
assert.ok(
integrityReport.observations.some((observation: { comparisons: { relation: string }[] }) =>
observation.comparisons.some((comparison) => comparison.relation === "equal")
)
);
for (const privateValue of [fixturePath, shadowId, fixtureContent.trim()])
assert.ok(!reportText.includes(privateValue));
});
const desktopScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"database-diagnostics-desktop.png",
(page) => page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") })
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.locator(".database-diagnostic").getByRole("button", { name: "Close", exact: true }).click();
await page.keyboard.press("Escape");
});
const after = await readDatabaseState(cli, session);
assert.deepEqual(after, before, "Diagnostics must leave settings, revisions, and Vault files unchanged");
await setObsidianMobileTestMode(session.remoteDebuggingPort, true, timeout);
await waitForLiveSyncCoreReady(cli, session.cliEnv);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await openCommand(page, "integrity");
});
const mobileScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"database-diagnostics-mobile.png",
(page) => page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") })
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const modal = page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") });
await assertMobileDialogueLayout(page, modal, "Database diagnostic choice");
await modal.getByRole("button", { name: "Cancel", exact: true }).click();
});
console.log(JSON.stringify({ status: "passed", desktopScreenshot, mobileScreenshot, readOnly: true }));
} catch (error) {
if (session) {
await captureObsidianPage(
session.remoteDebuggingPort,
"database-diagnostics-failure.png",
async () => {}
).catch(() => {});
}
throw error;
} finally {
if (session) await session.app.stop();
await vault.dispose();
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exitCode = 1;
});
+1
View File
@@ -8,6 +8,7 @@ const focusedScenarios = new Set([
"smoke",
"onboarding-invitation",
"dialog-mounts",
"database-diagnostics",
"conflict-dialog-policy",
"revision-repair",
"document-history-nav",