Verify database diagnostics and keep mobile report controls visible

This commit is contained in:
vorotamoroz
2026-10-19 13:49:01 +00:00
parent 7234daeddb
commit c23b055cd6
8 changed files with 452 additions and 19 deletions
+1 -1
View File
@@ -167,7 +167,7 @@ For a database-wide inspection, use **Hatch** → **Inspect database integrity**
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.
**Analyse database usage** 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.
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.
Follow [How to report an issue](to_issue_reporting.md) for the complete reporting checklist, including Obsidian debug information and the privacy review required before sharing evidence.
+1
View File
@@ -58,6 +58,7 @@
"test:e2e:obsidian:onboarding-invitation": "tsx test/e2e-obsidian/scripts/onboarding-invitation.ts",
"test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts",
"test:e2e:obsidian:database-diagnostics": "tsx test/e2e-obsidian/scripts/database-diagnostics.ts",
"test:e2e:obsidian:database-diagnostics-couchdb": "tsx test/e2e-obsidian/scripts/database-diagnostics-couchdb.ts",
"test:e2e:obsidian:conflict-dialog-policy": "tsx test/e2e-obsidian/scripts/conflict-dialog-policy.ts",
"test:e2e:obsidian:revision-repair": "tsx test/e2e-obsidian/scripts/revision-repair.ts",
"test:e2e:obsidian:document-history-nav": "tsx test/e2e-obsidian/scripts/document-history-nav.ts",
@@ -141,8 +141,10 @@
<style>
.database-diagnostic { display: flex; flex-direction: column; gap: var(--size-4-2); }
p { margin-block: var(--size-4-2); }
label { display: flex; align-items: center; flex-wrap: wrap; gap: var(--size-4-3); }
.diagnostic-controls { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--size-4-2); }
button, select { min-height: 44px; }
textarea { width: 100%; resize: vertical; font-family: var(--font-monospace); font-size: var(--font-ui-small); }
:global(body.is-mobile) textarea { max-height: 25vh; }
</style>
+20 -1
View File
@@ -75,6 +75,7 @@ After changing plug-in source, use the focused wrapper rather than invoking a sc
```bash
npm run test:e2e:obsidian:focused -- settings-ui
npm run test:e2e:obsidian:focused -- database-diagnostics
npm run test:e2e:obsidian:focused -- database-diagnostics-couchdb
npm run test:e2e:obsidian:focused -- configuration-encryption
npm run test:e2e:obsidian:focused -- configuration-encryption-sync
npm run test:e2e:obsidian:focused -- two-vault-sync
@@ -86,7 +87,9 @@ npm run test:e2e:obsidian:focused -- couchdb-http-cache
The wrapper accepts only maintained real-Obsidian scenario names; run it with `--help` for the current list. It deliberately does not manage CouchDB, Object Storage, or the P2P signalling relay. Start the required fixture first, or use the complete service-managed suite.
`database-diagnostics` needs no remote service. It checks the Usage command and Hatch integrity action with an isolated Vault, including the choice before inspection, original names and pseudonyms, retained local findings when the remote is unavailable, and unchanged settings, local revisions, and Vault files. It captures desktop results and the choice dialogue in mobile emulation. Copy and download payloads are captured inside the isolated renderer, so the scenario leaves the host clipboard and download directory unchanged. Commonlib's separate CouchDB integration test verifies encrypted reads and missing Security Seed handling against a real server.
`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-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.
`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.
@@ -311,6 +314,22 @@ BRAT acceptance validates installation of the exact published artefacts: install
A short physical-device check is optional when a specific concern remains about localised time text, input, clipboard interaction, or responsiveness. The camera and operating-system dispatch paths are unchanged by this integration and do not require routine revalidation. Emulated mobile establishes layout and interaction, but does not establish native device performance or verify the published installation path.
### Database diagnostic release checks
For a release containing database diagnostics, run these scenarios sequentially against the same artefacts:
```bash
npm run test:e2e:obsidian:database-diagnostics
npm run test:e2e:obsidian:database-diagnostics-couchdb
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.
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.
### Environment variables
- `OBSIDIAN_BINARY`: explicit Obsidian executable path.
@@ -0,0 +1,263 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { DoctorRegulation } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
import { evalObsidianJson } from "../runner/cli.ts";
import {
assertCouchDbReachable,
createCouchDbDatabase,
deleteCouchDbDatabase,
fetchAllCouchDbDocs,
fetchCouchDbDatabaseInfo,
fetchCouchDbDocument,
fetchCouchDbLocalDocs,
loadCouchDbConfig,
makeUniqueDatabaseName,
putCouchDbDocument,
type CouchDbConfig,
} from "../runner/couchdb.ts";
import { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts";
import {
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
const fixturePath = "Diagnostic/remote-秘密.md";
const fixtureContent = "Diagnostic remote fixture contents must stay outside reports.\n";
const passphrase = "diagnostic-remote-fixture-passphrase";
const timeout = 20000;
interface IntegrityReport {
status: string;
coverage: { local: { status: string }; remote: { status: string; contentReads: number } };
observations: {
source: string;
identity: string;
content: { status: string; chunksRead: number };
comparisons: { relation: string }[];
}[];
errors: unknown[];
}
async function localState(cli: string, session: ObsidianLiveSyncSession) {
const state = await evalObsidianJson<unknown>(
cli,
"(async()=>{const core=app.plugins.plugins['obsidian-livesync'].core;const files=await Promise.all(app.vault.getFiles().map(async file=>[file.path,await app.vault.read(file)]));files.sort((a,b)=>a[0].localeCompare(b[0]));return JSON.stringify({sequence:(await core.localDatabase.localDatabase.info()).update_seq,settings:core.services.setting.currentSettings(),files});})()",
session.cliEnv
);
return createHash("sha256").update(JSON.stringify(state)).digest("hex");
}
async function remoteState(config: CouchDbConfig, name: string) {
const [info, documents, localDocuments] = await Promise.all([
fetchCouchDbDatabaseInfo(config, name),
fetchAllCouchDbDocs(config, name),
fetchCouchDbLocalDocs(config, name),
]);
// Hash the snapshot so a failed assertion cannot print Security Seed data.
return createHash("sha256")
.update(JSON.stringify([info.update_seq, documents, localDocuments]))
.digest("hex");
}
async function observeRequests(cli: string, session: ObsidianLiveSyncSession) {
await evalObsidianJson(
cli,
`(() => {
const api=app.plugins.plugins['obsidian-livesync'].core.services.API;
const state={calls:[],restore:undefined};
const originals={};
for(const name of ['webCompatFetch','nativeFetch']) {
originals[name]=api[name];
api[name]=function(request,options) {
state.calls.push({transport:name,method:options?.method??(typeof request==='string'?'GET':request.method)});
return originals[name].call(this,request,options);
};
}
state.restore=()=>{for(const name of Object.keys(originals)) api[name]=originals[name];};
globalThis.diagnosticRequests=state;
return JSON.stringify(true);
})()`,
session.cliEnv
);
}
async function finishRequests(cli: string, session: ObsidianLiveSyncSession) {
return await evalObsidianJson<{ transport: string; method: string }[]>(
cli,
"(()=>{const state=globalThis.diagnosticRequests;if(!state)return JSON.stringify([]);state.restore();delete globalThis.diagnosticRequests;return JSON.stringify(state.calls);})()",
session.cliEnv
);
}
async function runCase(config: CouchDbConfig, encrypted: boolean) {
const binary = requireObsidianBinary();
const cli = requireObsidianCli();
const name = makeUniqueDatabaseName(config.dbPrefix, "diagnostic");
const vault = await createTemporaryVault("obsidian-livesync-remote-diagnostic-");
let session: ObsidianLiveSyncSession | undefined;
let created = false;
try {
await createCouchDbDatabase(config, name);
created = true;
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli,
vault,
artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT?.trim() || undefined,
pluginData: createE2eCouchDbPluginData(
{ ...config, dbName: name },
{
doctorProcessedVersion: DoctorRegulation.version,
useAdvancedMode: true,
encrypt: encrypted,
passphrase: encrypted ? passphrase : "",
usePathObfuscation: encrypted,
encryptInternalMetadata: encrypted,
E2EEAlgorithm: "v2",
useRequestAPI: true,
periodicReplication: false,
syncAfterMerge: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
notifyThresholdOfRemoteStorageSize: -1,
}
),
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
await waitForLiveSyncCoreReady(cli, session.cliEnv);
await prepareRemote(cli, session.cliEnv);
await evalObsidianJson(
cli,
`(async()=>{await app.vault.createFolder('Diagnostic');await app.vault.create(${JSON.stringify(fixturePath)},${JSON.stringify(fixtureContent)});return JSON.stringify(true);})()`,
session.cliEnv
);
const entry = await waitForLocalDatabaseEntry(cli, session.cliEnv, fixturePath);
await pushLocalChanges(cli, session.cliEnv);
const original = await fetchCouchDbDocument(config, name, entry.id);
assert.ok(entry.children.length > 0);
if (encrypted) assert.ok(!JSON.stringify(original).includes(fixturePath));
const shadowId = await evalObsidianJson<string>(
cli,
"(async()=>JSON.stringify(await app.plugins.plugins['obsidian-livesync'].core.services.path.path2id('Diagnostic/different-identity.md')))()",
session.cliEnv
);
const shadow = { ...original, _id: shadowId };
delete shadow._rev;
await putCouchDbDocument(config, name, shadow);
for (const native of [false, true]) {
await evalObsidianJson(
cli,
`(async()=>{await app.plugins.plugins['obsidian-livesync'].core.services.setting.applyPartial({useRequestAPI:${native}});return JSON.stringify(true);})()`,
session.cliEnv
);
const localBefore = await localState(cli, session);
const remoteBefore = await remoteState(config, name);
await observeRequests(cli, session);
let calls: { transport: string; method: string }[] = [];
try {
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
for (const names of ["pseudonym", "original"]) {
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.locator("select").selectOption(names);
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 text = await dialogue.locator("textarea").inputValue();
const report = JSON.parse(text) as IntegrityReport;
assert.equal(report.status, "complete");
assert.equal(report.coverage.local.status, "complete");
assert.equal(report.coverage.remote.status, "complete");
assert.ok(report.coverage.remote.contentReads > 0);
assert.deepEqual(report.errors, []);
const suspect = report.observations.find(
(row) => row.source === "remote" && row.identity === "document-id-mismatch"
);
assert.ok(suspect);
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");
const markdown = await dialogue.locator("textarea").inputValue();
for (const reportText of [text, markdown]) {
for (const secret of [fixtureContent.trim(), passphrase, config.password, config.uri]) {
assert.ok(
!reportText.includes(secret),
"A report exposed fixture contents or credentials"
);
}
if (names === "pseudonym") {
for (const privateName of [fixturePath, entry.id, shadowId, ...entry.children]) {
assert.ok(
!reportText.includes(privateName),
"A pseudonym report exposed a raw identifier"
);
}
} else assert.ok(reportText.includes(fixturePath));
}
await dialogue.getByRole("button", { name: "Close", exact: true }).click();
await dialogue.waitFor({ state: "hidden", timeout });
}
});
} finally {
calls = await finishRequests(cli, session);
}
assert.ok(calls.some((call) => call.transport === (native ? "nativeFetch" : "webCompatFetch")));
assert.ok(
calls.every((call) => call.method === "GET"),
"Diagnostic requests must be read-only"
);
assert.equal(
await localState(cli, session),
localBefore,
"Diagnostics changed the local database, settings, or Vault"
);
assert.equal(
await remoteState(config, name),
remoteBefore,
"Diagnostics changed remote documents or Security Seed"
);
}
console.log(
JSON.stringify({
encrypted,
transports: ["webCompatFetch", "nativeFetch"],
names: ["pseudonym", "original"],
readOnly: true,
})
);
} finally {
try {
if (session) await session.app.stop();
} finally {
try {
await vault.dispose();
} finally {
if (created) await deleteCouchDbDatabase(config, name);
}
}
}
}
async function main() {
const config = await loadCouchDbConfig();
await assertCouchDbReachable(config);
for (const encrypted of [false, true]) await runCase(config, encrypted);
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exitCode = 1;
});
+163 -17
View File
@@ -15,11 +15,12 @@ import { createTemporaryVault } from "../runner/vault.ts";
const fixturePath = "Private/diagnostic-秘密.md";
const fixtureContent = "Diagnostic fixture content which must never appear in a report.\n";
const shadowId = "diagnostic-shadow-document";
const 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 };
};
@@ -46,6 +47,36 @@ async function readDatabaseState(cli: string, session: ObsidianLiveSyncSession)
);
}
async function installDiagnosticExportInterception(page: Page): Promise<void> {
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<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);})()`,
session.cliEnv
);
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = requireObsidianCli();
@@ -56,6 +87,7 @@ async function main(): Promise<void> {
binary,
cliBinary: cli,
vault,
artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT?.trim() || undefined,
pluginData: {
displayLanguage: "def",
doctorProcessedVersion: DoctorRegulation.version,
@@ -95,6 +127,7 @@ async function main(): Promise<void> {
);
const before = await readDatabaseState(cli, session);
let usageJSON = "";
let usageFixtureRow: { path: string; id: string; revision: string } | undefined;
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const choice = await openCommand(page, "usage");
await choice.getByRole("button", { name: "Cancel", exact: true }).click();
@@ -114,23 +147,15 @@ async function main(): Promise<void> {
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-")
);
assert.ok(fixtureRow);
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 page.evaluate(`(() => {
Object.defineProperty(navigator.clipboard, "writeText", {
configurable: true,
value: async (text) => { globalThis.diagnosticClipboard = text; },
});
document.addEventListener("click", (event) => {
const link = event.target;
if (!(link instanceof HTMLAnchorElement) || !link.download || !link.href.startsWith("blob:")) return;
event.preventDefault();
void fetch(link.href).then((response) => response.text()).then((content) => {
globalThis.diagnosticDownload = { name: link.download, content };
});
}, true);
})()`);
await installDiagnosticExportInterception(page);
await dialogue.getByRole("button", { name: "Copy report", exact: true }).click();
assert.equal(
await page.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticClipboard),
@@ -197,8 +222,69 @@ async function main(): Promise<void> {
await page.locator(".database-diagnostic").getByRole("button", { name: "Close", exact: true }).click();
await page.keyboard.press("Escape");
});
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 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 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");
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");
assert.ok(
cancelledReport.rows.some(
(row: { path: string; id: string; revision: string }) =>
row.path === expectedFixtureRow.path &&
row.id === expectedFixtureRow.id &&
row.revision === expectedFixtureRow.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 must leave settings, revisions, and Vault files unchanged");
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);
@@ -214,8 +300,68 @@ async function main(): Promise<void> {
const modal = page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") });
await assertMobileDialogueLayout(page, modal, "Database diagnostic choice");
await modal.getByRole("button", { name: "Cancel", exact: true }).click();
await modal.waitFor({ state: "hidden", timeout });
});
console.log(JSON.stringify({ status: "passed", desktopScreenshot, mobileScreenshot, readOnly: true }));
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
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");
const format = dialogue.locator("select");
await format.selectOption("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");
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");
});
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) => {
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");
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 dialogue.getByRole("button", { name: "Close", exact: true }).click();
await dialogue.waitFor({ state: "hidden", timeout });
});
console.log(
JSON.stringify({
status: "passed",
desktopScreenshot,
mobileScreenshot,
mobileResultScreenshot,
readOnly: true,
})
);
} catch (error) {
if (session) {
await captureObsidianPage(
+1
View File
@@ -9,6 +9,7 @@ const focusedScenarios = new Set([
"onboarding-invitation",
"dialog-mounts",
"database-diagnostics",
"database-diagnostics-couchdb",
"conflict-dialog-policy",
"revision-repair",
"document-history-nav",
@@ -1268,6 +1268,7 @@ async function runIndependentIdRoundTrip(
rejectedSession = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT,
vault: rejectedVault,
localStorageEntries: createE2eObsidianDeviceLocalState(rejectedVault.name),
pluginData: createE2eCouchDbPluginData(