import assert from "node:assert/strict"; import type { Locator, Page } from "playwright"; import { assertLocatorHasMinimumTouchTarget, assertLocatorWithinSafeArea, 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 { 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, iPhoneSafeArea, 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 = "zz-diagnostic-shadow-document"; const timeout = 15000; type DiagnosticPageGlobal = typeof globalThis & { app: { commands: { executeCommandById(id: string): boolean } }; databaseDiagnosticReadGate?: { reached: 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.waitFor({ state: "visible", timeout }); assert.equal( await dialogue.getByRole("group", { name: "Names in this report", exact: true }).getByRole("radio").count(), 2, "Both name choices must be visible as radio buttons before inspection" ); const names = dialogue.getByRole("group", { name: "Names in this report", exact: true }); for (const name of ["Pseudonyms (recommended)", "Original names"]) 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 { 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(".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 { const group = dialogue.getByRole("group", { name: "Report format", exact: true }); assert.equal(await group.getByRole("radio").count(), formats.length); for (const name of formats) assert.equal(await group.getByRole("radio", { name, exact: true }).isVisible(), true); assert.equal(await group.getByRole("radio", { checked: true }).count(), 1); } async function chooseFormat(dialogue: Locator, format: string): Promise { const group = dialogue.getByRole("group", { name: "Report format", exact: true }); const radio = group.getByRole("radio", { name: format.toUpperCase(), exact: true }); await radio.check(); assert.equal(await radio.isChecked(), true); assert.equal(await group.getByRole("radio", { checked: true }).count(), 1); } async function assertDiagnosticExportActionsWithinModal(page: Page, dialogue: Locator, action: string): Promise { 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(".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 { 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 { const radios = dialogue.getByRole("group", { name, exact: true }).getByRole("radio"); for (let index = 0; index < (await radios.count()); index++) { const target = radios.nth(index).locator(".."); const label = `${name}: ${(await target.innerText()).trim()}`; await assertLocatorHasMinimumTouchTarget(page, target, { label }); await assertLocatorWithinSafeArea(page, target, { label, safeAreaInsets: iPhoneSafeArea }); await assertNoHorizontalOverflow(page, target, { label }); } } 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 installDiagnosticExportInterception(page: Page): Promise { await page.evaluate(`(() => { globalThis.diagnosticClipboard = undefined; globalThis.diagnosticDownload = undefined; Object.defineProperty(navigator.clipboard, "writeText", { configurable: true, value: async (text) => { globalThis.diagnosticClipboard = text; }, }); if (!globalThis.databaseDiagnosticExportInterceptionInstalled) { 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); globalThis.databaseDiagnosticExportInterceptionInstalled = true; } })()`); } async function restoreDiagnosticReadGate(cli: string, session: ObsidianLiveSyncSession): Promise { await evalObsidianJson( cli, `(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 { 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 { 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 { 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 { 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("
\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 { 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, artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT?.trim() || undefined, 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); 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;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"); assert.equal(await choice.getByRole("radio", { name: "Original names", exact: true }).isChecked(), true); await page.keyboard.press("ArrowLeft"); assert.equal( await choice.getByRole("radio", { name: "Pseudonyms (recommended)", exact: true }).isChecked(), true ); 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.getByRole("radio", { name: "Pseudonyms (recommended)", exact: true }).isChecked(), true ); 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(); 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.getByRole("radio", { name: "TSV", exact: true }).focus(); await page.keyboard.press("ArrowRight"); assert.equal(await dialogue.getByRole("radio", { name: "JSON", exact: true }).isChecked(), true); usageJSON = await dialogue.locator("textarea").inputValue(); const report = JSON.parse(usageJSON); assert.equal(report.status, "complete"); assert.ok(report.versions.commonlib); 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 ); await dialogue.getByRole("button", { name: "Save report", exact: true }).click(); 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, }); await dialogue.getByRole("button", { name: "Close", exact: true }).click(); await dialogue.waitFor({ state: "hidden", timeout }); const original = await openCommand(page, "usage"); await original.getByRole("radio", { name: "Original names", exact: true }).check(); 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); 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( await integrity.getByRole("radio", { name: "Pseudonyms (recommended)", exact: true }).isChecked(), true ); await integrity.getByRole("button", { name: "Begin inspection", exact: true }).click(); 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 = /
\s*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") ); 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 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, `(async()=>{const core=app.plugins.plugins['obsidian-livesync'].core;const db=core.localDatabase.localDatabase;return JSON.stringify({fixtureId:await core.services.path.path2id(${JSON.stringify(fixturePath)}),orderedIds:(await db.allDocs({include_docs:false})).rows.map(row=>row.id)});})()`, session.cliEnv ); const fixturePosition = cancellationOrder.orderedIds.indexOf(cancellationOrder.fixtureId); const gatePosition = cancellationOrder.orderedIds.indexOf(shadowId); assert.ok(fixturePosition >= 0, "The cancellation fixture must exist in the local database"); 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 cancellationEnv = session.cliEnv; try { 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(); await page.waitForFunction( () => (globalThis as DiagnosticPageGlobal).databaseDiagnosticReadGate?.reached === true, undefined, { 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( cli, `(async()=>{globalThis.databaseDiagnosticReadGate?.release();return JSON.stringify(true);})()`, cancellationEnv ); await page.locator('[data-diagnostic-phase="result"]').waitFor({ timeout }); const cancelledText = await dialogue.locator("textarea").inputValue(); 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 === 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" ); for (const privateValue of [fixturePath, shadowId, fixtureContent.trim()]) assert.ok(!cancelledText.includes(privateValue)); await dialogue.getByRole("button", { name: "Close", exact: true }).click(); await dialogue.waitFor({ state: "hidden", timeout }); }); } finally { await restoreDiagnosticReadGate(cli, session); } const after = await readDatabaseState(cli, session); assert.deepEqual( after, before, "Diagnostics, including cancellation, 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 page.setViewportSize({ width: 375, height: 667 }); 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) => { 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"); await chooseFormat(dialogue, "json"); const reportText = await dialogue.locator("textarea").inputValue(); const report = JSON.parse(reportText); assert.equal(report.status, "complete"); for (const privateValue of [fixturePath, shadowId, fixtureContent.trim()]) assert.ok(!reportText.includes(privateValue)); await chooseFormat(dialogue, "tsv"); 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 chooseFormat(dialogue, "json"); }); const mobileResultScreenshot = await captureObsidianElement( session.remoteDebuggingPort, "database-diagnostics-mobile-result.png", (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(); await dialogue.getByRole("button", { name: "Copy report", exact: true }).click(); 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 ); 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: 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", desktopScreenshot, mobileScreenshot, mobileResultScreenshot, 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; });