diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index afd14d67..e8649667 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -163,9 +163,9 @@ Enable Obsidian's `Detect all file extensions`, then check LiveSync selectors, i ## Collect a report -For a database-wide inspection, use **Hatch** → **Inspect database integrity**. Choose **Pseudonyms (recommended)** or **Original names** before beginning. The inspection reads normal-file Metadata and current conflict branches locally and in CouchDB, then compares bytes within suspect groups and against the current Vault files. It reports unavailable sources, unreadable content, and detected changes during the run. Older revision history and other remote types are outside this inspection. A completed run means the stated inspection finished; it does not mean every entry is healthy or safe to delete. +For a database-wide inspection, use **Hatch** → **Inspect database integrity**. Choose **Pseudonyms (recommended)** or **Original names** before beginning. Both choices are visible as radio buttons, with pseudonyms selected initially. The inspection reads normal-file Metadata and current conflict branches locally and in CouchDB, then compares bytes within suspect groups and against the current Vault files. It reports unavailable sources, unreadable content, and detected changes during the run. Older revision history and other remote types are outside this inspection. A completed run means the stated inspection finished; it does not mean every entry is healthy or safe to delete. -The result dialogue provides Markdown and JSON reports with the same name policy. Pseudonyms preserve exact path equality across sources while keeping different document IDs distinct. They are temporary and are not saved as a global setting. Counts and relationships remain visible. Both modes omit file contents and credentials; review the report before sharing it. Cancelling keeps the evidence collected so far and marks the result as cancelled. +The result dialogue provides Markdown and JSON reports with the same name policy. Available formats are displayed as radio buttons. Pseudonyms preserve exact path equality across sources while keeping different document IDs distinct. They are temporary and are not saved as a global setting. Counts and relationships remain visible. Both modes omit file contents and credentials; review the report before sharing it. Cancelling keeps the evidence collected so far and marks the result as cancelled. For storage accounting, use **Hatch** → **Analyse database usage** → **Analyse**. It offers the same name choice and reads existing local revisions without changing on-demand Chunk fetching. Complete runs retain the existing TSV columns and first-reference unique/shared Chunk accounting. Sizes count stored string units, not physical database size. Incomplete runs provide JSON so their status and reasons remain attached to the rows. **Save report** requests a download; **Copy report** remains available on devices which do not support downloads. diff --git a/src/modules/services/LiveSyncUI/dialogues/DatabaseDiagnosticDialog.svelte b/src/modules/services/LiveSyncUI/dialogues/DatabaseDiagnosticDialog.svelte index 87c85856..b7b56b5f 100644 --- a/src/modules/services/LiveSyncUI/dialogues/DatabaseDiagnosticDialog.svelte +++ b/src/modules/services/LiveSyncUI/dialogues/DatabaseDiagnosticDialog.svelte @@ -90,13 +90,19 @@ ? translate("Read available local revisions and report their Chunk references. Settings are left unchanged.") : translate("Read normal-file Metadata, current conflicts, and content for suspect groups from this device and CouchDB. Other remote types and older revision history are outside this inspection. Results do not authorise deletion.")}

- +
+ {translate("Names in this report")} +
+ + +
+
@@ -118,14 +124,17 @@ {#if options?.kind === "usage" && output.status !== "complete"}

{translate("Partial Usage results are available as JSON so their coverage and reasons stay with the rows.")}

{/if} - {/each} - - +
+

{actionStatus}

@@ -142,9 +151,14 @@ diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index e48d9220..0a31cae4 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -87,7 +87,7 @@ 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, 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 result layout, format selection, 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, 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-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. diff --git a/test/e2e-obsidian/scripts/database-diagnostics-couchdb.ts b/test/e2e-obsidian/scripts/database-diagnostics-couchdb.ts index cf1276f7..d51279bf 100644 --- a/test/e2e-obsidian/scripts/database-diagnostics-couchdb.ts +++ b/test/e2e-obsidian/scripts/database-diagnostics-couchdb.ts @@ -171,10 +171,19 @@ async function runCase(config: CouchDbConfig, encrypted: boolean) { ); assert.equal(opened, true); const dialogue = page.locator(".database-diagnostic"); - await dialogue.locator("select").selectOption(names); + await dialogue.waitFor({ state: "visible", timeout }); + 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"; + await nameOptions.getByRole("radio", { name: nameLabel, exact: true }).check(); + assert.equal(await nameOptions.getByRole("radio", { checked: true }).count(), 1); await dialogue.getByRole("button", { name: "Begin inspection", exact: true }).click(); await page.locator('[data-diagnostic-phase="result"]').waitFor({ timeout }); - await dialogue.locator("select").selectOption("json"); + const formats = dialogue.getByRole("group", { name: "Report format", exact: true }); + assert.equal(await formats.getByRole("radio").count(), 2); + for (const name of ["MARKDOWN", "JSON"]) + assert.equal(await formats.getByRole("radio", { name, exact: true }).isVisible(), true); + await formats.getByRole("radio", { name: "JSON", exact: true }).check(); const text = await dialogue.locator("textarea").inputValue(); const report = JSON.parse(text) as IntegrityReport; assert.equal(report.status, "complete"); @@ -189,7 +198,8 @@ async function runCase(config: CouchDbConfig, encrypted: boolean) { assert.equal(suspect.content.status, "available"); assert.ok(suspect.content.chunksRead > 0); assert.ok(suspect.comparisons.some((comparison) => comparison.relation === "equal")); - await dialogue.locator("select").selectOption("markdown"); + await formats.getByRole("radio", { name: "MARKDOWN", exact: true }).check(); + assert.equal(await formats.getByRole("radio", { checked: true }).count(), 1); const markdown = await dialogue.locator("textarea").inputValue(); for (const reportText of [text, markdown]) { for (const secret of [fixtureContent.trim(), passphrase, config.password, config.uri]) { diff --git a/test/e2e-obsidian/scripts/database-diagnostics.ts b/test/e2e-obsidian/scripts/database-diagnostics.ts index 58050847..d4f44ae9 100644 --- a/test/e2e-obsidian/scripts/database-diagnostics.ts +++ b/test/e2e-obsidian/scripts/database-diagnostics.ts @@ -1,5 +1,10 @@ import assert from "node:assert/strict"; -import type { Page } from "playwright"; +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 { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts"; import { evalObsidianJson } from "../runner/cli.ts"; @@ -10,7 +15,7 @@ import { } 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 { assertMobileDialogueLayout, iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts"; import { createTemporaryVault } from "../runner/vault.ts"; const fixturePath = "Private/diagnostic-秘密.md"; @@ -33,12 +38,48 @@ async function openCommand(page: Page, kind: "usage" | "integrity") { ); assert.equal(opened, true); const dialogue = page.locator(".database-diagnostic"); - await dialogue.locator("select").selectOption("pseudonym"); + 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); assert.equal(await dialogue.getAttribute("data-diagnostic-phase"), "choice"); assert.equal(await dialogue.locator("textarea").count(), 0); return dialogue; } +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 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, @@ -130,18 +171,33 @@ async function main(): Promise { let usageFixtureRow: { path: string; id: string; revision: string } | undefined; await withObsidianPage(session.remoteDebuggingPort, async (page) => { 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.locator("select").inputValue(), "pseudonym"); + 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 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.locator("select").selectOption("json"); + 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"); @@ -173,7 +229,7 @@ async function main(): Promise { await dialogue.waitFor({ state: "hidden", timeout }); const original = await openCommand(page, "usage"); - await original.locator("select").selectOption("original"); + 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(); @@ -190,10 +246,15 @@ async function main(): Promise { .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"); + 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 integrity.locator("select").selectOption("json"); + await assertFormatChoices(integrity, ["MARKDOWN", "JSON"]); + assert.equal(await integrity.getByRole("radio", { name: "MARKDOWN", exact: true }).isChecked(), true); + await chooseFormat(integrity, "json"); const reportText = await integrity.locator("textarea").inputValue(); const integrityReport = JSON.parse(reportText); assert.equal(integrityReport.status, "partial"); @@ -262,6 +323,7 @@ async function main(): Promise { const cancelledText = await dialogue.locator("textarea").inputValue(); const cancelledReport = JSON.parse(cancelledText); assert.equal(cancelledReport.status, "cancelled"); + await assertFormatChoices(dialogue, ["JSON"]); assert.ok( cancelledReport.rows.some( (row: { path: string; id: string; revision: string }) => @@ -299,6 +361,7 @@ async function main(): Promise { 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 assertMobileRadioTargets(page, modal, "Names in this report"); await modal.getByRole("button", { name: "Cancel", exact: true }).click(); await modal.waitFor({ state: "hidden", timeout }); }); @@ -309,20 +372,21 @@ async function main(): Promise { const modal = page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") }); await assertMobileDialogueLayout(page, modal, "Database diagnostic result"); - const format = dialogue.locator("select"); - await format.selectOption("json"); + 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 format.selectOption("tsv"); + 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 format.selectOption("json"); + await chooseFormat(dialogue, "json"); }); const mobileResultScreenshot = await captureObsidianElement( session.remoteDebuggingPort, diff --git a/updates.md b/updates.md index 977d24dd..dcdc8352 100644 --- a/updates.md +++ b/updates.md @@ -22,7 +22,7 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ### Diagnostics - We can inspect normal-file Metadata and current conflicts in the local database and CouchDB together, with byte comparisons for suspect groups. Partial results retain the available evidence when a source is unavailable. The inspection leaves databases and settings unchanged. -- Database Usage analysis and integrity inspection now offer temporary pseudonyms or original names before each run. Reports omit file contents and credentials in both modes. Complete Usage runs retain the existing TSV columns; partial runs use JSON to retain their status and reasons. +- Database Usage analysis and integrity inspection now offer temporary pseudonyms or original names before each run. Both name modes and the available report formats stay visible as radio buttons. Reports omit file contents and credentials in both modes. Complete Usage runs retain the existing TSV columns; partial runs use JSON to retain their status and reasons. ## 1.0.38