Polish diagnostic reporting and local pseudonym follow-up

This commit is contained in:
vorotamoroz committed 2026-10-20 08:23:56 +00:00
1 parent c1ed71f686
commit 32874ea97f
21 files changed
+2425 -154

No files matched your search

+6 -2
View File
@@ -87,9 +87,11 @@ 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 visible radio choices for original names and pseudonyms, their default and keyboard navigation, retained local findings when the remote is unavailable, cancellation during inspection with earlier rows retained, and unchanged settings, local revisions, and Vault files. It captures desktop results and the choice and result dialogues in mobile emulation, checking layout, radio choices for available formats, their touch targets, and export controls. Copy and download payloads are captured inside the isolated renderer, so the scenario leaves the host clipboard and download directory unchanged.
`database-diagnostics` needs no remote service. It checks the Usage command and Hatch integrity action with an isolated Vault, including visible radio choices for original names and pseudonyms, their default and keyboard navigation, the default-on integrity-only remote option, retained local findings when the remote is unavailable, cancellation during inspection with earlier rows retained, and unchanged settings, local revisions, and Vault files. It captures desktop results and the choice and result dialogues in mobile emulation, checking layout at 800×600 and 375×667, radio choices for available formats, their touch targets, and export controls. Copy and download payloads are captured inside the isolated renderer, so the scenario leaves the host clipboard and download directory unchanged.
`database-diagnostics-couchdb` needs the CouchDB fixture with CORS enabled. It checks unencrypted and encrypted databases, both browser fetch and Obsidian's native Request API, and both name policies through the real integrity dialogue. A remote-only document with a mismatched ID must produce a finding with readable Chunks and equal content. JSON and Markdown must omit file contents and credentials. The scenario observes real requests and requires GET-only access, unchanged local settings, revisions, and Vault contents, and unchanged remote documents and Security Seed. Commonlib's separate CouchDB integration tests additionally cover a missing Security Seed without creating one.
The same scenario checks the five actions in Reporting Tools, the separate Checks and Recovery panel, Metadata progress with its document total and gauge, and collapsible Markdown. It also observes Usage revision progress with a count and discovery note during a gated read. Local lookup checks cover multiple document IDs for one path, selection of an exact ID, and a follow-up report using the original salt. The compact Markdown must retain every JSON observation reference and pseudonym, and its copy and save payloads must match the full output. Results and export controls are checked at 800×600 on desktop and 375×667 in mobile emulation, including after copy and save feedback appears.
`database-diagnostics-couchdb` needs the CouchDB fixture with CORS enabled. It checks that clearing the remote option completes a local database and Vault inspection with remote coverage marked not checked and makes no HTTP requests, then confirms that each new run includes the remote database by default. It also checks unencrypted and encrypted databases, both browser fetch and Obsidian's native Request API, and both name policies through the real integrity dialogue. A remote-only document with a mismatched ID must produce a finding with readable Chunks and equal content. JSON and Markdown must omit file contents and credentials. Included inspections observe real requests and require GET-only access, unchanged local settings, revisions, and Vault contents, and unchanged remote documents and Security Seed. Commonlib's separate CouchDB integration tests additionally cover a missing Security Seed without creating one.
`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.
@@ -326,6 +328,8 @@ E2E_OBSIDIAN_ONLY_INDEPENDENT_IDS=true npm run test:e2e:obsidian:two-vault-sync
The first scenario needs no service; the other two need CouchDB. The last checks an encrypted note round trip between two real Obsidian Vaults, with Path Obfuscation and independent IDs, and rejection of incompatible keys. These checks complement Commonlib's classification, conflict, privacy, and cancellation tests.
For a real-Obsidian follow-up check, copy a path token from a pseudonym Usage or integrity report and use Hatch → Reporting Tools → **Find a file from a report pseudonym**. Confirm that **Find file** searches only the current Vault and local normal-file Metadata, returns every matching local document ID, and shows real paths and IDs only in the local dialogue. Include cases with several IDs for one path, a Vault-only candidate whose Metadata is marked not inspected, and a path which no longer matches locally. Choose a result to open an existing Vault file or prepare a shareable local report; check that it reuses the report salt and exact document ID, retains the selected pseudonym mode, and contains no file contents or credentials. Check that the lookup makes no remote requests. Its history status listing is not a scan of every historical body.
Set `E2E_OBSIDIAN_ARTIFACT_ROOT` to a directory containing the exact release `main.js`, `manifest.json`, and `styles.css` when testing downloaded artefacts. These three scenarios accept that directory. Invoke their direct commands above, because the focused wrapper rebuilds local artefacts. Record the commit, Obsidian version, artefact hashes, and results. If testing a local build, compare its hashes with the published release before relying on that evidence for BRAT acceptance.
When these checks pass for the release artefacts, BRAT acceptance can be limited to installation or update, enabling the plug-in, and a cold start. Check any other changes included in that release using their own regression scenarios. The diagnostic export checks verify report payloads; they do not exercise the operating system's clipboard or download implementation on iOS or Android.
@@ -36,11 +36,16 @@ const timeout = 20000;
interface IntegrityReport {
status: string;
scope: { remote: "included" | "excluded" };
coverage: { local: { status: string }; remote: { status: string; contentReads: number } };
observations: {
source: string;
identity: string;
content: { status: string; chunksRead: number };
path: string;
revision?: string;
metadata?: { recordedSize?: number; chunkReferences: number; uniqueChunkReferences: number };
vault: { status: string; size?: number; contentSizeDifference?: number; mtimeDifferenceMs?: number };
content: { status: string; chunksRead: number; size?: number; recordedSizeDifference?: number };
comparisons: { relation: string }[];
}[];
errors: unknown[];
@@ -165,6 +170,73 @@ async function runCase(config: CouchDbConfig, encrypted: boolean) {
let calls: { transport: string; method: string }[] = [];
try {
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.setViewportSize({ width: 800, height: 600 });
const opened = await page.evaluate(
"app.commands.executeCommandById('obsidian-livesync:inspect-database-integrity')"
);
assert.equal(opened, true);
const dialogue = page.locator(".database-diagnostic");
await dialogue.waitFor({ state: "visible", timeout });
const remoteChoice = dialogue.getByRole("checkbox", {
name: "Include remote database",
exact: true,
});
assert.equal(await remoteChoice.isChecked(), true, "Remote inspection must be selected by default");
const modal = page.locator(".modal-container").filter({ has: dialogue }).locator(".modal");
const layout = await modal.evaluate((element) => {
const modalBounds = element.getBoundingClientRect();
const choice = element.querySelector<HTMLElement>(".diagnostic-remote-choice");
if (!choice)
return { contained: false, outerScroll: element.scrollHeight - element.clientHeight };
const choiceBounds = choice.getBoundingClientRect();
return {
contained:
choiceBounds.top >= modalBounds.top - 1 &&
choiceBounds.bottom <= modalBounds.bottom + 1,
outerScroll: Math.max(0, element.scrollHeight - element.clientHeight),
};
});
assert.equal(layout.contained, true, "The remote option must remain inside the desktop modal");
assert.ok(
layout.outerScroll <= 1,
`The choice modal must not need outer scrolling; got ${layout.outerScroll}px`
);
await remoteChoice.uncheck();
assert.equal(await remoteChoice.isChecked(), false);
await dialogue.getByRole("button", { name: "Begin inspection", exact: true }).click();
await page.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
await dialogue.getByRole("radio", { name: "JSON", exact: true }).check();
const report = JSON.parse(await dialogue.locator("textarea").inputValue()) as IntegrityReport;
assert.equal(report.status, "complete");
assert.equal(report.scope.remote, "excluded");
assert.equal(report.coverage.local.status, "complete");
assert.equal(report.coverage.remote.status, "not-checked");
assert.deepEqual(report.errors, []);
assert.equal(
report.observations.some((row) => row.source === "remote"),
false
);
const localObservations = report.observations.filter((row) => row.source === "local");
assert.ok(localObservations.length > 0);
assert.ok(localObservations.every((row) => row.identity === "consistent"));
assert.ok(
localObservations.every(
(row) => row.content.status === "not-checked" && row.vault.status === "not-checked"
),
"Only the remote has a discrepancy in this fixture; excluding it leaves no suspect content group"
);
await dialogue.getByRole("button", { name: "Close", exact: true }).click();
await dialogue.waitFor({ state: "hidden", timeout });
});
} finally {
calls = await finishRequests(cli, session);
}
assert.equal(calls.length, 0, "Excluding the remote database must make no HTTP requests");
await observeRequests(cli, session);
try {
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.setViewportSize({ width: 800, height: 600 });
for (const names of ["pseudonym", "original"]) {
const opened = await page.evaluate(
"app.commands.executeCommandById('obsidian-livesync:inspect-database-integrity')"
@@ -172,6 +244,15 @@ async function runCase(config: CouchDbConfig, encrypted: boolean) {
assert.equal(opened, true);
const dialogue = page.locator(".database-diagnostic");
await dialogue.waitFor({ state: "visible", timeout });
const remoteChoice = dialogue.getByRole("checkbox", {
name: "Include remote database",
exact: true,
});
assert.equal(
await remoteChoice.isChecked(),
true,
"Each run must include the remote database by default"
);
const nameOptions = dialogue.getByRole("group", { name: "Names in this report", exact: true });
assert.equal(await nameOptions.getByRole("radio").count(), 2);
const nameLabel = names === "pseudonym" ? "Pseudonyms (recommended)" : "Original names";
@@ -187,6 +268,7 @@ async function runCase(config: CouchDbConfig, encrypted: boolean) {
const text = await dialogue.locator("textarea").inputValue();
const report = JSON.parse(text) as IntegrityReport;
assert.equal(report.status, "complete");
assert.equal(report.scope.remote, "included");
assert.equal(report.coverage.local.status, "complete");
assert.equal(report.coverage.remote.status, "complete");
assert.ok(report.coverage.remote.contentReads > 0);
@@ -197,6 +279,22 @@ async function runCase(config: CouchDbConfig, encrypted: boolean) {
assert.ok(suspect);
assert.equal(suspect.content.status, "available");
assert.ok(suspect.content.chunksRead > 0);
const byteLength = Buffer.byteLength(fixtureContent);
assert.equal(
suspect.metadata?.recordedSize,
byteLength,
"Metadata properties must be decoded before reporting"
);
assert.equal(suspect.metadata?.chunkReferences, entry.children.length);
assert.equal(suspect.content.size, byteLength);
assert.equal(suspect.content.recordedSizeDifference, 0);
assert.equal(suspect.vault.size, byteLength);
assert.equal(suspect.vault.contentSizeDifference, 0);
assert.ok(Number.isFinite(suspect.vault.mtimeDifferenceMs));
if (names === "pseudonym") {
assert.match(suspect.path, /^[A-Za-z0-9_-]{8}\$[0-9a-f]{32}$/);
assert.match(suspect.revision ?? "", /^1-psu[0-9a-f]{32}$/);
}
assert.ok(suspect.comparisons.some((comparison) => comparison.relation === "equal"));
await formats.getByRole("radio", { name: "MARKDOWN", exact: true }).check();
assert.equal(await formats.getByRole("radio", { checked: true }).count(), 1);
+397 -16
View File
@@ -6,6 +6,7 @@ import {
assertNoHorizontalOverflow,
} from "@vrtmrz/obsidian-test-session";
import { DoctorRegulation } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
import { createDiagnosticNameMapper, parseDiagnosticPathToken } from "@vrtmrz/livesync-commonlib/diagnostics";
import { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts";
import { evalObsidianJson } from "../runner/cli.ts";
import {
@@ -49,11 +50,35 @@ async function openCommand(page: Page, kind: "usage" | "integrity") {
assert.equal(await names.getByRole("radio", { name, exact: true }).isVisible(), true);
assert.equal(await names.getByRole("radio", { name: "Pseudonyms (recommended)", exact: true }).isChecked(), true);
assert.equal(await names.getByRole("radio", { checked: true }).count(), 1);
const remoteChoice = dialogue.getByRole("checkbox", { name: "Include remote database", exact: true });
if (kind === "integrity") {
assert.equal(await remoteChoice.count(), 1);
assert.equal(await remoteChoice.isChecked(), true, "The remote database must be included by default");
} else assert.equal(await remoteChoice.count(), 0, "Usage analysis has no remote database option");
assert.equal(await dialogue.getAttribute("data-diagnostic-phase"), "choice");
assert.equal(await dialogue.locator("textarea").count(), 0);
return dialogue;
}
async function assertDesktopDiagnosticChoiceLayout(page: Page, dialogue: Locator): Promise<void> {
const modal = page.locator(".modal-container").filter({ has: dialogue }).locator(".modal");
const layout = await modal.evaluate((element) => {
const modalBounds = element.getBoundingClientRect();
const choice = element.querySelector<HTMLElement>(".diagnostic-remote-choice");
if (!choice) return { contained: false, outerScroll: Math.max(0, element.scrollHeight - element.clientHeight) };
const choiceBounds = choice.getBoundingClientRect();
return {
contained: choiceBounds.top >= modalBounds.top - 1 && choiceBounds.bottom <= modalBounds.bottom + 1,
outerScroll: Math.max(0, element.scrollHeight - element.clientHeight),
};
});
assert.equal(layout.contained, true, "The remote database option must remain inside the desktop modal");
assert.ok(
layout.outerScroll <= 1,
`The diagnostic choice must not need outer scrolling; got ${layout.outerScroll}px`
);
}
async function assertFormatChoices(dialogue: Locator, formats: string[]): Promise<void> {
const group = dialogue.getByRole("group", { name: "Report format", exact: true });
assert.equal(await group.getByRole("radio").count(), formats.length);
@@ -69,6 +94,31 @@ async function chooseFormat(dialogue: Locator, format: string): Promise<void> {
assert.equal(await group.getByRole("radio", { checked: true }).count(), 1);
}
async function assertDiagnosticExportActionsWithinModal(page: Page, dialogue: Locator, action: string): Promise<void> {
for (const name of ["Copy report", "Save report"]) {
const button = dialogue.getByRole("button", { name, exact: true });
assert.equal(await button.isVisible(), true, `${name} must remain visible after ${action}`);
const contained = await button.evaluate((element) => {
const modalElement = element.closest<HTMLElement>(".modal");
if (!modalElement || !modalElement.contains(element)) return false;
const buttonBounds = element.getBoundingClientRect();
const modalBounds = modalElement.getBoundingClientRect();
return buttonBounds.top >= modalBounds.top - 1 && buttonBounds.bottom <= modalBounds.bottom + 1;
});
assert.equal(contained, true, `${name} must remain inside the modal after ${action}`);
}
}
async function assertDesktopDiagnosticResultLayout(page: Page, dialogue: Locator, action: string): Promise<void> {
const modal = page.locator(".modal-container").filter({ has: dialogue }).locator(".modal");
const outerScroll = await modal.evaluate((element) => Math.max(0, element.scrollHeight - element.clientHeight));
assert.ok(
outerScroll <= 1,
`The diagnostic modal must not need outer scrolling after ${action}; got ${outerScroll}px`
);
await assertDiagnosticExportActionsWithinModal(page, dialogue, action);
}
async function assertMobileRadioTargets(page: Page, dialogue: Locator, name: string): Promise<void> {
const radios = dialogue.getByRole("group", { name, exact: true }).getByRole("radio");
for (let index = 0; index < (await radios.count()); index++) {
@@ -113,11 +163,174 @@ async function installDiagnosticExportInterception(page: Page): Promise<void> {
async function restoreDiagnosticReadGate(cli: string, session: ObsidianLiveSyncSession): Promise<void> {
await evalObsidianJson(
cli,
`(async()=>{const gate=globalThis.databaseDiagnosticReadGate;if(gate){gate.release();const db=app.plugins.plugins['obsidian-livesync'].core.localDatabase.localDatabase;db.get=gate.originalGet;delete globalThis.databaseDiagnosticReadGate;}return JSON.stringify(true);})()`,
`(async()=>{const gate=globalThis.databaseDiagnosticReadGate;if(gate){gate.release();if(gate.reached&&gate.drainPromise)await gate.drainPromise;const db=app.plugins.plugins['obsidian-livesync'].core.localDatabase.localDatabase;db.get=gate.originalGet;delete globalThis.databaseDiagnosticReadGate;}return JSON.stringify(true);})()`,
session.cliEnv
);
}
async function installDiagnosticReadGate(
cli: string,
session: ObsidianLiveSyncSession,
documentId: string,
readKind: "metadata" | "revision"
): Promise<void> {
const condition = readKind === "metadata" ? "options?.conflicts" : "options?.rev";
await evalObsidianJson(
cli,
`(async()=>{const db=app.plugins.plugins['obsidian-livesync'].core.localDatabase.localDatabase;const gate={reached:false,release:null,releasePromise:null,drain:null,drainPromise:null,originalGet:db.get};gate.releasePromise=new Promise(resolve=>{gate.release=resolve;});gate.drainPromise=new Promise(resolve=>{gate.drain=resolve;});globalThis.databaseDiagnosticReadGate=gate;db.get=async function(id,options){if(id===${JSON.stringify(documentId)}&&${condition}&&!gate.reached){gate.reached=true;try{await gate.releasePromise;return await gate.originalGet.call(this,id,options);}finally{gate.drain();}}return await gate.originalGet.call(this,id,options);};return JSON.stringify(true);})()`,
session.cliEnv
);
}
async function assertIntegrityProgress(dialogue: Locator): Promise<void> {
const progress = dialogue.locator(".diagnostic-progress");
await progress.getByText("Metadata · local", { exact: true }).waitFor({ state: "visible", timeout });
const progressBar = progress.getByRole("progressbar");
assert.equal(await progressBar.count(), 1, "Metadata inspection must expose a progressbar");
assert.equal(await progressBar.isVisible(), true);
const numericText = progress.getByText(/^[1-9]\d* \/ \d+ documents?$/);
await numericText.waitFor({ state: "visible", timeout });
const numeric = await numericText.innerText();
const match = /^(\d+) \/ (\d+) documents?$/.exec(numeric);
assert.ok(match, `Metadata progress must report its document count; got ${numeric}`);
const [, processedText, totalText] = match;
const processed = Number(processedText);
const total = Number(totalText);
assert.ok(processed > 0, `Metadata progress must report a positive processed count; got ${numeric}`);
assert.ok(total >= processed, `Metadata progress must report its total; got ${numeric}`);
}
async function assertHatchReportingGroups(hatch: Locator): Promise<void> {
const reportingHeading = hatch.getByRole("heading", { name: "Reporting Tools", exact: true });
const checksHeading = hatch.getByRole("heading", { name: "Checks and Recovery", exact: true });
assert.equal(await reportingHeading.count(), 1);
assert.equal(await checksHeading.count(), 1);
const reportingPanel = reportingHeading.locator("..");
const checksPanel = checksHeading.locator("..");
const reportingNames = [
"Prepare the 'report' to create an issue",
"Copy database information for a file",
"Find a file from a report pseudonym",
"Inspect database integrity",
"Analyse database usage",
];
assert.equal(await reportingPanel.locator(".setting-item").count(), reportingNames.length);
for (const name of reportingNames) {
assert.equal(
await reportingPanel.getByText(name, { exact: true }).count(),
1,
`${name} belongs in Reporting Tools`
);
assert.equal(
await checksPanel.getByText(name, { exact: true }).count(),
0,
`${name} must remain outside Checks and Recovery`
);
}
assert.equal(await checksPanel.locator(".setting-item").count(), 2);
for (const name of ["Run Doctor", "Scan for Broken files"]) {
const button = checksPanel.getByRole("button", { name, exact: true });
assert.equal(await button.count(), 1, `${name} belongs in Checks and Recovery`);
assert.equal(await reportingPanel.getByRole("button", { name, exact: true }).count(), 0);
}
}
async function assertDiagnosticFileLookup(
page: Page,
hatch: Locator,
token: string,
shadowRevision: string,
session: ObsidianLiveSyncSession,
mobile = false
): Promise<void> {
const setting = hatch.locator(".setting-item").filter({
has: page.getByText("Find a file from a report pseudonym", { exact: true }),
});
await setting.getByRole("button", { name: "Find file", exact: true }).click();
const dialogue = page.locator(".diagnostic-file-lookup");
await dialogue.waitFor({ state: "visible", timeout });
const input = dialogue.getByLabel("Report path pseudonym", { exact: true });
await input.fill("file-001");
await dialogue.getByRole("button", { name: "Find file", exact: true }).click();
await dialogue.getByText("Enter a valid report path pseudonym.", { exact: true }).waitFor({ timeout });
await input.fill(token);
await dialogue.getByRole("button", { name: "Find file", exact: true }).click();
const candidates = dialogue.locator("[data-diagnostic-candidate]");
await candidates.first().waitFor({ timeout });
assert.equal(await candidates.count(), 2, "Distinct Metadata IDs claiming the same path must remain selectable");
if (mobile) {
await assertNoHorizontalOverflow(page, dialogue, { label: "Pseudonym file lookup matches" });
await assertNoHorizontalOverflow(page, dialogue.locator(".lookup-candidates"), {
label: "Pseudonym file lookup candidate list",
});
assert.ok(
(await dialogue.locator(".lookup-candidates").evaluate((element) => element.clientHeight)) >= 160,
"The mobile lookup must leave enough room to read the matching file details"
);
await captureObsidianElement(
session.remoteDebuggingPort,
"database-diagnostics-file-matches-mobile.png",
(page) => page.locator(".modal-container").filter({ has: page.locator(".diagnostic-file-lookup") })
);
}
const shadow = candidates.filter({ hasText: shadowId });
assert.equal(await shadow.count(), 1);
assert.ok((await shadow.textContent())?.includes(fixturePath));
await shadow.getByRole("button", { name: "Open file", exact: true }).click();
await page.waitForFunction(`app.workspace.getActiveFile()?.path === ${JSON.stringify(fixturePath)}`, undefined, {
timeout,
});
await shadow.getByRole("button", { name: "Prepare follow-up report", exact: true }).click();
await dialogue.locator("textarea").waitFor({ timeout });
const json = await dialogue.locator("textarea").inputValue();
const report = JSON.parse(json);
const parsed = parseDiagnosticPathToken(token);
assert.ok(parsed);
const mapper = createDiagnosticNameMapper("pseudonym", parsed.salt);
assert.equal(report.file.path, token);
assert.equal(report.file.database.documentId, mapper.id(shadowId));
assert.equal(report.file.database.currentRevision, mapper.revision(shadowRevision));
assert.equal(report.file.database.remoteQueried, false);
for (const privateValue of [fixturePath, shadowId, fixtureContent.trim()]) {
assert.ok(!json.includes(privateValue), "A follow-up report must preserve the original report's pseudonyms");
}
await installDiagnosticExportInterception(page);
await dialogue.getByRole("button", { name: "Copy report", exact: true }).click();
assert.equal(await page.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticClipboard), json);
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: "diagnostic-file-report.json",
content: json,
});
await assertDesktopDiagnosticResultLayout(page, dialogue, "saving a follow-up report");
if (mobile) {
const modal = page.locator(".modal-container").filter({ has: dialogue });
await assertMobileDialogueLayout(page, modal, "Pseudonym file follow-up report");
await assertNoHorizontalOverflow(page, dialogue, { label: "Pseudonym file follow-up content" });
await assertNoHorizontalOverflow(page, dialogue.locator(".report-token"), {
label: "Pseudonym file follow-up token",
});
}
await captureObsidianElement(
session.remoteDebuggingPort,
mobile ? "database-diagnostics-file-follow-up-mobile.png" : "database-diagnostics-file-follow-up.png",
(page) => page.locator(".modal-container").filter({ has: page.locator(".diagnostic-file-lookup") })
);
await dialogue.getByRole("radio", { name: "MARKDOWN", exact: true }).check();
const markdown = await dialogue.locator("textarea").inputValue();
assert.ok(markdown.startsWith("<details>\n"));
assert.ok(markdown.includes(json));
await dialogue.getByRole("button", { name: "Back to matches", exact: true }).click();
await candidates.first().waitFor({ timeout });
await input.fill(createDiagnosticNameMapper("pseudonym", parsed.salt).path("not-present.md"));
await dialogue.getByRole("button", { name: "Find file", exact: true }).click();
await dialogue.getByText(/No matches were found in current Vault paths/).waitFor({ timeout });
assert.equal(await candidates.count(), 0);
await dialogue.getByRole("button", { name: "Close", exact: true }).click();
await dialogue.waitFor({ state: "hidden", timeout });
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = requireObsidianCli();
@@ -161,15 +374,17 @@ async function main(): Promise<void> {
session.cliEnv
);
await waitForLocalDatabaseEntry(cli, session.cliEnv, fixturePath);
await evalObsidianJson(
const rawFixture = await evalObsidianJson<{ id: string; revision: string; shadowRevision: string }>(
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);})()`,
`(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;const stored=await db.put(shadow);return JSON.stringify({id,revision:original._rev,shadowRevision:stored.rev});})()`,
session.cliEnv
);
const before = await readDatabaseState(cli, session);
const activeSession = session;
let usageJSON = "";
let usageFixtureRow: { path: string; id: string; revision: string } | undefined;
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.setViewportSize({ width: 800, height: 600 });
const choice = await openCommand(page, "usage");
await choice.getByRole("radio", { name: "Pseudonyms (recommended)", exact: true }).focus();
await page.keyboard.press("ArrowRight");
@@ -188,6 +403,7 @@ async function main(): Promise<void> {
);
await dialogue.getByRole("button", { name: "Begin inspection", exact: true }).click();
await page.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
await assertDesktopDiagnosticResultLayout(page, dialogue, "the result appearing");
await assertFormatChoices(dialogue, ["TSV", "JSON"]);
assert.equal(await dialogue.getByRole("radio", { name: "TSV", exact: true }).isChecked(), true);
const tsv = await dialogue.locator("textarea").inputValue();
@@ -202,17 +418,24 @@ async function main(): Promise<void> {
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-")));
const fixtureRow = report.rows.find((row: { path: string; id: string; revision: string }) =>
row.path.startsWith("file-")
const token = report.rows.find((row: { path: string }) => parseDiagnosticPathToken(row.path))?.path;
const parsedToken = parseDiagnosticPathToken(token ?? "");
assert.ok(parsedToken, "The report must contain searchable salted path pseudonyms");
const mapper = createDiagnosticNameMapper("pseudonym", parsedToken.salt);
const fixtureRow = report.rows.find(
(row: { path: string; id: string; revision: string }) => row.id === mapper.id(rawFixture.id)
);
assert.ok(fixtureRow);
assert.equal(fixtureRow.path, mapper.path(fixturePath));
assert.equal(fixtureRow.revision, mapper.revision(rawFixture.revision));
assert.equal(fixtureRow.revision, `${fixtureRow.rev}-${fixtureRow.revHash}`);
usageFixtureRow = { path: fixtureRow.path, id: fixtureRow.id, revision: fixtureRow.revision };
// Capture exports inside the isolated renderer without changing the
// host clipboard or leaving downloads in a user's download directory.
await installDiagnosticExportInterception(page);
await dialogue.getByRole("button", { name: "Copy report", exact: true }).click();
await assertDesktopDiagnosticResultLayout(page, dialogue, "copying the report");
assert.equal(
await page.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticClipboard),
usageJSON
@@ -221,6 +444,7 @@ async function main(): Promise<void> {
await page.waitForFunction(() => !!(globalThis as DiagnosticPageGlobal).diagnosticDownload, undefined, {
timeout,
});
await assertDesktopDiagnosticResultLayout(page, dialogue, "saving the report");
assert.deepEqual(await page.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticDownload), {
name: "database-usage-complete.json",
content: usageJSON,
@@ -240,10 +464,69 @@ async function main(): Promise<void> {
await original.waitFor({ state: "hidden", timeout });
const settingsNavigator = await openLiveSyncSettings(page, timeout);
const hatch = await settingsNavigator.openPage("Hatch");
let hatch = await settingsNavigator.openPage("Hatch");
await assertHatchReportingGroups(hatch);
assert.ok(usageFixtureRow);
await assertDiagnosticFileLookup(
page,
hatch,
usageFixtureRow.path,
rawFixture.shadowRevision,
activeSession
);
hatch = await (await openLiveSyncSettings(page, timeout)).openPage("Hatch");
await captureObsidianElement(activeSession.remoteDebuggingPort, "database-diagnostics-hatch.png", (page) =>
page.locator(".modal.mod-settings")
);
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 progressDialogue = page.locator(".database-diagnostic");
assert.equal(
await progressDialogue
.getByRole("radio", { name: "Pseudonyms (recommended)", exact: true })
.isChecked(),
true
);
assert.equal(
await progressDialogue
.getByRole("checkbox", { name: "Include remote database", exact: true })
.isChecked(),
true
);
await assertDesktopDiagnosticChoiceLayout(page, progressDialogue);
await installDiagnosticReadGate(cli, activeSession, shadowId, "metadata");
try {
await progressDialogue.getByRole("button", { name: "Begin inspection", exact: true }).click();
await page.waitForFunction(
() => (globalThis as DiagnosticPageGlobal).databaseDiagnosticReadGate?.reached === true,
undefined,
{ timeout }
);
assert.equal(await progressDialogue.getAttribute("data-diagnostic-phase"), "running");
await assertIntegrityProgress(progressDialogue);
await captureObsidianElement(
activeSession.remoteDebuggingPort,
"database-diagnostics-progress.png",
(page) => page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") })
);
await progressDialogue.getByRole("button", { name: "Cancel inspection", exact: true }).click();
await evalObsidianJson(
cli,
`(async()=>{globalThis.databaseDiagnosticReadGate?.release();return JSON.stringify(true);})()`,
activeSession.cliEnv
);
await page.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
await assertFormatChoices(progressDialogue, ["MARKDOWN", "JSON"]);
await chooseFormat(progressDialogue, "json");
assert.equal(JSON.parse(await progressDialogue.locator("textarea").inputValue()).status, "cancelled");
await progressDialogue.getByRole("button", { name: "Close", exact: true }).click();
await progressDialogue.waitFor({ state: "hidden", timeout });
} finally {
await restoreDiagnosticReadGate(cli, activeSession);
}
await setting.getByRole("button", { name: "Inspect", exact: true }).click();
const integrity = page.locator(".database-diagnostic");
assert.equal(
@@ -254,10 +537,40 @@ async function main(): Promise<void> {
await page.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
await assertFormatChoices(integrity, ["MARKDOWN", "JSON"]);
assert.equal(await integrity.getByRole("radio", { name: "MARKDOWN", exact: true }).isChecked(), true);
const markdownReport = await integrity.locator("textarea").inputValue();
const markdownDetailsWithCodeBlock =
/<details>\s*<summary>Database integrity report \(partial\)<\/summary>\s*(`{3,})text\r?\n[\s\S]*?\1\s*<\/details>/;
assert.match(markdownReport, markdownDetailsWithCodeBlock);
await installDiagnosticExportInterception(page);
await integrity.getByRole("button", { name: "Copy report", exact: true }).click();
assert.equal(
await page.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticClipboard),
markdownReport
);
await integrity.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-integrity-partial.md",
content: markdownReport,
});
await chooseFormat(integrity, "json");
const reportText = await integrity.locator("textarea").inputValue();
const integrityReport = JSON.parse(reportText);
assert.equal(integrityReport.status, "partial");
assert.ok(markdownReport.includes("## Reading the findings"));
assert.ok(markdownReport.includes("## State counts"));
assert.ok(markdownReport.includes("## Review index"));
assert.ok(markdownReport.includes("chunks=total/unique/embedded"));
assert.equal(markdownReport.match(/^### /gm)?.length, integrityReport.observations.length);
for (const observation of integrityReport.observations) {
assert.ok(markdownReport.includes(`### \`${observation.reference}\` source=${observation.source}`));
assert.ok(markdownReport.includes(`path: \`${observation.path}\``));
assert.ok(markdownReport.includes(`doc: \`${observation.id}\``));
if (observation.revision !== undefined)
assert.ok(markdownReport.includes(`rev=\`${observation.revision}\``));
}
assert.ok(
integrityReport.errors.some((error: { reason: string }) => error.reason === "source-unavailable")
);
@@ -282,6 +595,27 @@ async function main(): Promise<void> {
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.locator(".database-diagnostic").getByRole("button", { name: "Close", exact: true }).click();
await page.keyboard.press("Escape");
const localOnly = await openCommand(page, "integrity");
await localOnly.getByRole("checkbox", { name: "Include remote database", exact: true }).uncheck();
await localOnly.getByRole("button", { name: "Begin inspection", exact: true }).click();
await page.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
await chooseFormat(localOnly, "json");
const report = JSON.parse(await localOnly.locator("textarea").inputValue());
assert.equal(report.status, "complete");
assert.equal(report.scope.remote, "excluded");
assert.equal(report.coverage.remote.status, "not-checked");
assert.deepEqual(report.errors, []);
assert.ok(
report.observations.some(
(row: { identity: string; vault: { status: string }; comparisons: { relation: string }[] }) =>
row.identity === "document-id-mismatch" &&
row.vault.status === "available" &&
row.comparisons.some((comparison) => comparison.relation === "equal")
),
"Local discrepancies must retain Vault comparisons when the remote is excluded"
);
await localOnly.getByRole("button", { name: "Close", exact: true }).click();
await localOnly.waitFor({ state: "hidden", timeout });
});
const cancellationOrder = await evalObsidianJson<{ fixtureId: string; orderedIds: string[] }>(
cli,
@@ -294,15 +628,10 @@ async function main(): Promise<void> {
assert.ok(gatePosition >= 0, "The cancellation read gate must exist in the local database");
assert.ok(fixturePosition < gatePosition, "The cancellation read gate must follow the report fixture");
assert.ok(usageFixtureRow, "The complete report must contain the cancellation fixture");
const expectedFixtureRow = usageFixtureRow;
const cancellationEnv = session.cliEnv;
try {
await evalObsidianJson(
cli,
`(async()=>{const db=app.plugins.plugins['obsidian-livesync'].core.localDatabase.localDatabase;const gate={reached:false,release:null,releasePromise:null,originalGet:db.get};gate.releasePromise=new Promise(resolve=>{gate.release=resolve;});globalThis.databaseDiagnosticReadGate=gate;db.get=async function(id,options){if(id===${JSON.stringify(shadowId)}&&options?.rev&&!gate.reached){gate.reached=true;await gate.releasePromise;}return await gate.originalGet.call(this,id,options);};return JSON.stringify(true);})()`,
session.cliEnv
);
await installDiagnosticReadGate(cli, session, shadowId, "revision");
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const dialogue = await openCommand(page, "usage");
await dialogue.getByRole("button", { name: "Begin inspection", exact: true }).click();
@@ -312,6 +641,10 @@ async function main(): Promise<void> {
{ timeout }
);
assert.equal(await dialogue.getAttribute("data-diagnostic-phase"), "running");
const usageProgress = dialogue.locator(".diagnostic-progress");
await usageProgress.getByText("Reading revisions", { exact: true }).waitFor({ timeout });
await usageProgress.getByText(/^\d+ \/ \d+ revisions$/).waitFor({ timeout });
await usageProgress.getByText(/Discovering revisions/).waitFor({ timeout });
await dialogue.getByRole("button", { name: "Cancel inspection", exact: true }).click();
await dialogue.getByText("Cancelling inspection…", { exact: true }).waitFor({ timeout });
await evalObsidianJson(
@@ -324,12 +657,18 @@ async function main(): Promise<void> {
const cancelledReport = JSON.parse(cancelledText);
assert.equal(cancelledReport.status, "cancelled");
await assertFormatChoices(dialogue, ["JSON"]);
const cancelledToken = cancelledReport.rows.find((row: { path: string }) =>
parseDiagnosticPathToken(row.path)
)?.path;
const parsedToken = parseDiagnosticPathToken(cancelledToken ?? "");
assert.ok(parsedToken);
const mapper = createDiagnosticNameMapper("pseudonym", parsedToken.salt);
assert.ok(
cancelledReport.rows.some(
(row: { path: string; id: string; revision: string }) =>
row.path === expectedFixtureRow.path &&
row.id === expectedFixtureRow.id &&
row.revision === expectedFixtureRow.revision
row.path === mapper.path(fixturePath) &&
row.id === mapper.id(rawFixture.id) &&
row.revision === mapper.revision(rawFixture.revision)
),
"Cancellation must retain report rows collected before the gated database read"
);
@@ -351,6 +690,7 @@ async function main(): Promise<void> {
await setObsidianMobileTestMode(session.remoteDebuggingPort, true, timeout);
await waitForLiveSyncCoreReady(cli, session.cliEnv);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.setViewportSize({ width: 375, height: 667 });
await openCommand(page, "integrity");
});
const mobileScreenshot = await captureObsidianElement(
@@ -359,18 +699,31 @@ async function main(): Promise<void> {
(page) => page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") })
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.setViewportSize({ width: 375, height: 667 });
const modal = page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") });
await assertMobileDialogueLayout(page, modal, "Database diagnostic choice");
await assertMobileRadioTargets(page, modal, "Names in this report");
const remoteChoice = modal.getByRole("checkbox", { name: "Include remote database", exact: true });
assert.equal(await remoteChoice.isChecked(), true);
await assertLocatorHasMinimumTouchTarget(page, remoteChoice.locator(".."), {
label: "Include remote database",
});
await assertLocatorWithinSafeArea(page, remoteChoice.locator(".."), {
label: "Include remote database",
safeAreaInsets: iPhoneSafeArea,
});
await assertNoHorizontalOverflow(page, remoteChoice.locator(".."), { label: "Include remote database" });
await modal.getByRole("button", { name: "Cancel", exact: true }).click();
await modal.waitFor({ state: "hidden", timeout });
});
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.setViewportSize({ width: 375, height: 667 });
const dialogue = await openCommand(page, "usage");
await dialogue.getByRole("button", { name: "Begin inspection", exact: true }).click();
await page.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
const modal = page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") });
await assertMobileDialogueLayout(page, modal, "Database diagnostic result");
await assertDiagnosticExportActionsWithinModal(page, dialogue, "the mobile result appearing");
await assertFormatChoices(dialogue, ["TSV", "JSON"]);
await assertMobileRadioTargets(page, dialogue, "Report format");
@@ -394,6 +747,7 @@ async function main(): Promise<void> {
(page) => page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") })
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.setViewportSize({ width: 375, height: 667 });
const dialogue = page.locator(".database-diagnostic");
await installDiagnosticExportInterception(page);
const reportText = await dialogue.locator("textarea").inputValue();
@@ -401,6 +755,7 @@ async function main(): Promise<void> {
await dialogue.getByText("Report copied.", { exact: true }).waitFor({ timeout });
const modal = page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") });
await assertMobileDialogueLayout(page, modal, "Database diagnostic copied result");
await assertDiagnosticExportActionsWithinModal(page, dialogue, "copying the mobile report");
assert.equal(
await page.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticClipboard),
reportText
@@ -414,9 +769,35 @@ async function main(): Promise<void> {
content: reportText,
});
await assertMobileDialogueLayout(page, modal, "Database diagnostic saved result");
await assertDiagnosticExportActionsWithinModal(page, dialogue, "saving the mobile report");
await captureObsidianElement(
activeSession.remoteDebuggingPort,
"database-diagnostics-mobile-saved.png",
(page) => page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") })
);
await dialogue.getByRole("button", { name: "Close", exact: true }).click();
await dialogue.waitFor({ state: "hidden", timeout });
});
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.setViewportSize({ width: 375, height: 667 });
const settings = await openLiveSyncSettings(page, timeout);
const hatch = await settings.openPage("Hatch");
assert.ok(usageFixtureRow);
await assertDiagnosticFileLookup(
page,
hatch,
usageFixtureRow.path,
rawFixture.shadowRevision,
activeSession,
true
);
await settings.close();
});
assert.deepEqual(
await readDatabaseState(cli, session),
before,
"File lookup and follow-up reports remain read-only"
);
console.log(
JSON.stringify({
status: "passed",