mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-24 10:03:20 +00:00
Polish diagnostic reporting and local pseudonym follow-up
This commit is contained in:
1 parent
c1ed71f686
commit
32874ea97f
21 files changed
+2425
-154
No files matched your search
@@ -1,9 +1,17 @@
|
||||
import type { DiagnosticNameMode } from "@vrtmrz/livesync-commonlib/diagnostics";
|
||||
import type { DatabaseDiagnosticKind, DatabaseDiagnosticOutput } from "./databaseDiagnostics";
|
||||
import type {
|
||||
DatabaseDiagnosticKind,
|
||||
DatabaseDiagnosticOutput,
|
||||
DatabaseDiagnosticProgress,
|
||||
} from "./databaseDiagnostics";
|
||||
|
||||
/** The dialogue can run or cancel an inspection without receiving the service container. */
|
||||
export interface DatabaseDiagnosticDialogOptions {
|
||||
kind: DatabaseDiagnosticKind;
|
||||
run(nameMode: DiagnosticNameMode, onProgress: (processed: number) => void): Promise<DatabaseDiagnosticOutput>;
|
||||
run(
|
||||
nameMode: DiagnosticNameMode,
|
||||
onProgress: (progress: DatabaseDiagnosticProgress) => void,
|
||||
includeRemote?: boolean
|
||||
): Promise<DatabaseDiagnosticOutput>;
|
||||
cancel(): void;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { REMOTE_COUCHDB, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-
|
||||
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
|
||||
import type { NecessaryServicesInterfaces } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import commonlibPackage from "@vrtmrz/livesync-commonlib/package.json";
|
||||
import { LOG_LEVEL_INFO, Logger } from "octagonal-wheels/common/logger";
|
||||
|
||||
export type DatabaseDiagnosticKind = "usage" | "integrity";
|
||||
export type DatabaseDiagnosticHost = NecessaryServicesInterfaces<
|
||||
@@ -36,6 +37,15 @@ export interface DatabaseDiagnosticOutput {
|
||||
artifacts: DatabaseDiagnosticArtifact[];
|
||||
}
|
||||
|
||||
export interface DatabaseDiagnosticProgress {
|
||||
phase: "usage" | "accounting" | "metadata" | "content";
|
||||
source?: "local" | "remote";
|
||||
processed: number;
|
||||
total?: number;
|
||||
totalIsFinal?: boolean;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export async function readDiagnosticStorage(
|
||||
storage: Pick<StorageAccess, "statHidden" | "readHiddenFileBinary">,
|
||||
path: string
|
||||
@@ -59,7 +69,7 @@ export async function readDiagnosticStorage(
|
||||
) {
|
||||
return { status: "unreadable", reason: "storage-changed" };
|
||||
}
|
||||
return { status: "available", bytes };
|
||||
return { status: "available", bytes, mtime: after.mtime };
|
||||
} catch {
|
||||
return { status: "unreadable", reason: "content-unreadable" };
|
||||
}
|
||||
@@ -70,8 +80,13 @@ export async function runDatabaseDiagnostic(
|
||||
host: DatabaseDiagnosticHost,
|
||||
kind: DatabaseDiagnosticKind,
|
||||
nameMode: DiagnosticNameMode,
|
||||
options: { signal?: AbortSignal; onProgress?(processed: number): void } = {}
|
||||
options: {
|
||||
signal?: AbortSignal;
|
||||
onProgress?(progress: DatabaseDiagnosticProgress): void;
|
||||
includeRemote?: boolean;
|
||||
} = {}
|
||||
): Promise<DatabaseDiagnosticOutput> {
|
||||
const started = performance.now();
|
||||
const configuration = JSON.stringify(host.services.setting.currentSettings());
|
||||
const settings = JSON.parse(configuration) as ObsidianLiveSyncSettings;
|
||||
const database = host.services.database.localDatabase.localDatabase;
|
||||
@@ -79,6 +94,12 @@ export async function runDatabaseDiagnostic(
|
||||
JSON.stringify(host.services.setting.currentSettings()) === configuration &&
|
||||
host.services.database.localDatabase.localDatabase === database;
|
||||
const mapper = createDiagnosticNameMapper(nameMode);
|
||||
const httpRequestCounters = { httpRequests: 0, httpFailedRequests: 0, httpRequestDurationMs: 0 };
|
||||
const getHttpRequestCounters = () => ({
|
||||
httpRequests: httpRequestCounters.httpRequests,
|
||||
httpFailedRequests: httpRequestCounters.httpFailedRequests,
|
||||
httpRequestDurationMs: Math.round(httpRequestCounters.httpRequestDurationMs),
|
||||
});
|
||||
const versions = {
|
||||
commonlib: commonlibPackage.version,
|
||||
plugin: host.services.API.getPluginVersion(),
|
||||
@@ -89,7 +110,14 @@ export async function runDatabaseDiagnostic(
|
||||
const result = await analyseDatabaseUsage({
|
||||
source: local,
|
||||
signal: options.signal,
|
||||
onProgress: (progress) => options.onProgress?.(progress.revisionsProcessed),
|
||||
onProgress: (progress) =>
|
||||
options.onProgress?.({
|
||||
phase: progress.phase === "accounting" ? "accounting" : "usage",
|
||||
processed: progress.processed,
|
||||
total: progress.total,
|
||||
totalIsFinal: progress.totalIsFinal,
|
||||
elapsedMs: Math.round(performance.now() - started),
|
||||
}),
|
||||
});
|
||||
if (!isConfigurationCurrent()) {
|
||||
result.errors.push({ code: "configuration_changed" });
|
||||
@@ -110,23 +138,41 @@ export async function runDatabaseDiagnostic(
|
||||
}
|
||||
return { status: result.status, nameMode, artifacts };
|
||||
}
|
||||
const result = await analyseDatabaseIntegrity({
|
||||
const includeRemote = options.includeRemote !== false;
|
||||
const integrityOptions: Parameters<typeof analyseDatabaseIntegrity>[0] = {
|
||||
local,
|
||||
remote:
|
||||
settings.remoteType === REMOTE_COUCHDB
|
||||
includeRemote && settings.remoteType === REMOTE_COUCHDB
|
||||
? () =>
|
||||
Promise.resolve(
|
||||
createCouchDBDiagnosticSource({
|
||||
settings,
|
||||
signal: options.signal,
|
||||
fetch: (request, requestOptions) =>
|
||||
settings.useRequestAPI
|
||||
? host.services.API.nativeFetch(request, requestOptions)
|
||||
: host.services.API.webCompatFetch(request, requestOptions),
|
||||
fetch: async (request, requestOptions) => {
|
||||
const requestStarted = performance.now();
|
||||
httpRequestCounters.httpRequests++;
|
||||
try {
|
||||
const response = settings.useRequestAPI
|
||||
? await host.services.API.nativeFetch(request, requestOptions)
|
||||
: await host.services.API.webCompatFetch(request, requestOptions);
|
||||
if (!response.ok) httpRequestCounters.httpFailedRequests++;
|
||||
return response;
|
||||
} catch (error) {
|
||||
httpRequestCounters.httpFailedRequests++;
|
||||
throw error;
|
||||
} finally {
|
||||
httpRequestCounters.httpRequestDurationMs += performance.now() - requestStarted;
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
: undefined,
|
||||
remoteUnavailableReason: settings.remoteType === REMOTE_COUCHDB ? "source-unavailable" : "unsupported-remote",
|
||||
remoteUnavailableReason: includeRemote
|
||||
? settings.remoteType === REMOTE_COUCHDB
|
||||
? "source-unavailable"
|
||||
: "unsupported-remote"
|
||||
: undefined,
|
||||
includeRemote,
|
||||
path: {
|
||||
getPath: (entry) => host.services.path.getPath(entry),
|
||||
path2id: (path, prefix) => host.services.path.path2idWithSettings(path, settings, prefix),
|
||||
@@ -141,10 +187,17 @@ export async function runDatabaseDiagnostic(
|
||||
readStorage: (path) => readDiagnosticStorage(host.serviceModules.storageAccess, path),
|
||||
isConfigurationCurrent,
|
||||
signal: options.signal,
|
||||
onProgress: (progress) => options.onProgress?.(progress.processed),
|
||||
});
|
||||
onProgress: (progress) => options.onProgress?.(progress),
|
||||
onLog: (event) =>
|
||||
Logger(
|
||||
`Database integrity inspection: ${JSON.stringify({ ...event, ...getHttpRequestCounters() })}`,
|
||||
LOG_LEVEL_INFO
|
||||
),
|
||||
};
|
||||
const result = await analyseDatabaseIntegrity(integrityOptions);
|
||||
const formattingStarted = performance.now();
|
||||
const report = projectIntegrityReport(result, mapper, versions);
|
||||
return {
|
||||
const output: DatabaseDiagnosticOutput = {
|
||||
status: result.status,
|
||||
nameMode,
|
||||
artifacts: [
|
||||
@@ -160,4 +213,13 @@ export async function runDatabaseDiagnostic(
|
||||
},
|
||||
],
|
||||
};
|
||||
const requestCounters = getHttpRequestCounters();
|
||||
Logger(
|
||||
`Database integrity report: status=${result.status}, observations=${result.observations.length}, ` +
|
||||
`httpRequests=${requestCounters.httpRequests}, httpFailedRequests=${requestCounters.httpFailedRequests}, ` +
|
||||
`httpRequestDurationMs=${requestCounters.httpRequestDurationMs}, ` +
|
||||
`formattingMs=${Math.round(performance.now() - formattingStarted)}, elapsedMs=${Math.round(performance.now() - started)}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
return output;
|
||||
}
|
||||
@@ -1,12 +1,31 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_P2P,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
const diagnosticsMocks = vi.hoisted(() => ({ analyseOptions: vi.fn() }));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/diagnostics", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@vrtmrz/livesync-commonlib/diagnostics")>();
|
||||
return {
|
||||
...actual,
|
||||
analyseDatabaseIntegrity: (options: Parameters<typeof actual.analyseDatabaseIntegrity>[0]) => {
|
||||
diagnosticsMocks.analyseOptions(options);
|
||||
return actual.analyseDatabaseIntegrity(options);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { readDiagnosticStorage, runDatabaseDiagnostic, type DatabaseDiagnosticHost } from "./databaseDiagnostics";
|
||||
|
||||
function fixture() {
|
||||
const path = "Private/日本語.md";
|
||||
const body = "private file contents";
|
||||
const bytes = new TextEncoder().encode(body);
|
||||
const settings = {
|
||||
const settings: ObsidianLiveSyncSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_P2P,
|
||||
readChunksOnline: true,
|
||||
@@ -81,6 +100,41 @@ function fixture() {
|
||||
}
|
||||
|
||||
describe("database diagnostic host composition", () => {
|
||||
it("passes growing Usage totals and the separate accounting phase to the dialogue", async () => {
|
||||
const onProgress = vi.fn();
|
||||
await runDatabaseDiagnostic(fixture().host, "usage", "pseudonym", { onProgress });
|
||||
const progress = onProgress.mock.calls.map(([value]) => value);
|
||||
expect(progress).toContainEqual(
|
||||
expect.objectContaining({
|
||||
phase: "usage",
|
||||
processed: 0,
|
||||
total: 0,
|
||||
totalIsFinal: false,
|
||||
})
|
||||
);
|
||||
expect(progress).toContainEqual(
|
||||
expect.objectContaining({
|
||||
phase: "usage",
|
||||
processed: 2,
|
||||
total: 2,
|
||||
totalIsFinal: true,
|
||||
})
|
||||
);
|
||||
const accounting = progress.filter((value) => value.phase === "accounting");
|
||||
expect(accounting.length).toBeGreaterThan(1);
|
||||
const final = accounting[accounting.length - 1];
|
||||
expect(final).toMatchObject({ processed: final.total, totalIsFinal: true });
|
||||
});
|
||||
|
||||
it("preserves phase, source, and totals for the inspection dialogue", async () => {
|
||||
const onProgress = vi.fn();
|
||||
await runDatabaseDiagnostic(fixture().host, "integrity", "pseudonym", { onProgress });
|
||||
expect(onProgress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ phase: "metadata", source: "local", processed: 0 })
|
||||
);
|
||||
expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ phase: "content", processed: 1, total: 1 }));
|
||||
});
|
||||
|
||||
it("keeps on-demand fetching enabled while analysing only existing local revisions", async () => {
|
||||
const data = fixture();
|
||||
const before = JSON.stringify(data.settings);
|
||||
@@ -113,6 +167,9 @@ describe("database diagnostic host composition", () => {
|
||||
it("keeps local findings when the remote type is unsupported and projects both formats consistently", async () => {
|
||||
const data = fixture();
|
||||
const result = await runDatabaseDiagnostic(data.host, "integrity", "pseudonym");
|
||||
expect(diagnosticsMocks.analyseOptions).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ includeRemote: true })
|
||||
);
|
||||
expect(result.status).toBe("partial");
|
||||
expect(data.fetch).not.toHaveBeenCalled();
|
||||
expect(data.put).not.toHaveBeenCalled();
|
||||
@@ -135,6 +192,82 @@ describe("database diagnostic host composition", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("excludes the remote source for a local integrity run", async () => {
|
||||
const data = fixture();
|
||||
data.settings.remoteType = REMOTE_COUCHDB;
|
||||
data.settings.couchDB_DBNAME = "private-database";
|
||||
data.settings.useRequestAPI = true;
|
||||
data.fetch
|
||||
.mockImplementationOnce(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
return { ok: false, status: 503 } as Response;
|
||||
})
|
||||
.mockRejectedValue(new Error("simulated diagnostic request failure"));
|
||||
|
||||
diagnosticsMocks.analyseOptions.mockClear();
|
||||
const result = await runDatabaseDiagnostic(data.host, "integrity", "pseudonym", {
|
||||
includeRemote: false,
|
||||
});
|
||||
|
||||
expect(data.fetch).not.toHaveBeenCalled();
|
||||
const passedOptions = diagnosticsMocks.analyseOptions.mock.calls[0][0];
|
||||
expect(passedOptions.includeRemote).toBe(false);
|
||||
expect(passedOptions.remote).toBeUndefined();
|
||||
expect(result.artifacts.map((artifact) => artifact.format)).toEqual(["markdown", "json"]);
|
||||
const report = JSON.parse(result.artifacts.find((artifact) => artifact.format === "json")!.content);
|
||||
expect(report.scope.remote).toBe("excluded");
|
||||
expect(report.coverage.remote.status).toBe("not-checked");
|
||||
expect(report.errors.some((error: { source: string }) => error.source === "remote")).toBe(false);
|
||||
});
|
||||
|
||||
it("logs cumulative HTTP dispatch, failure, and duration counters for remote inspection", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
const nonOk = fixture();
|
||||
nonOk.settings.remoteType = REMOTE_COUCHDB;
|
||||
nonOk.settings.couchDB_DBNAME = "private-database";
|
||||
nonOk.settings.useRequestAPI = true;
|
||||
nonOk.fetch.mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
return { ok: false, status: 503 } as Response;
|
||||
});
|
||||
await runDatabaseDiagnostic(nonOk.host, "integrity", "pseudonym");
|
||||
|
||||
const thrown = fixture();
|
||||
thrown.settings.remoteType = REMOTE_COUCHDB;
|
||||
thrown.settings.couchDB_DBNAME = "private-database";
|
||||
thrown.settings.useRequestAPI = true;
|
||||
thrown.fetch.mockRejectedValue(new Error("simulated diagnostic request failure"));
|
||||
await runDatabaseDiagnostic(thrown.host, "integrity", "pseudonym");
|
||||
|
||||
const messages = log.mock.calls.map(([message]) => String(message));
|
||||
const inspectionLogs = messages.filter((message) => message.includes("Database integrity inspection:"));
|
||||
expect(inspectionLogs.length).toBeGreaterThan(0);
|
||||
const inspectionEvents = inspectionLogs.map((message) => {
|
||||
const json = message.slice(message.indexOf("{"));
|
||||
return JSON.parse(json);
|
||||
});
|
||||
expect(inspectionEvents.some((event) => event.httpRequests > 0)).toBe(true);
|
||||
expect(inspectionEvents.some((event) => event.httpFailedRequests > 0)).toBe(true);
|
||||
expect(inspectionEvents.every((event) => typeof event.httpRequestDurationMs === "number")).toBe(true);
|
||||
|
||||
const summaries = messages.filter((message) => message.includes("Database integrity report:"));
|
||||
expect(summaries).toHaveLength(2);
|
||||
for (const [index, summary] of summaries.entries()) {
|
||||
const match = /httpRequests=(\d+), httpFailedRequests=(\d+), httpRequestDurationMs=(\d+)/.exec(summary);
|
||||
expect(match).not.toBeNull();
|
||||
const data = index === 0 ? nonOk : thrown;
|
||||
expect(Number(match![1])).toBe(data.fetch.mock.calls.length);
|
||||
expect(Number(match![2])).toBe(data.fetch.mock.calls.length);
|
||||
expect(Number(match![3])).toBeGreaterThanOrEqual(0);
|
||||
if (index === 0) expect(Number(match![3])).toBeGreaterThan(0);
|
||||
}
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("includes names only when requested and never includes credentials or file contents", async () => {
|
||||
const data = fixture();
|
||||
const result = await runDatabaseDiagnostic(data.host, "usage", "original");
|
||||
@@ -172,6 +305,17 @@ describe("database diagnostic host composition", () => {
|
||||
});
|
||||
|
||||
describe("diagnostic Vault reads", () => {
|
||||
it("reuses the validated snapshot timestamp without adding stat or content reads", async () => {
|
||||
const data = fixture();
|
||||
expect(await readDiagnosticStorage(data.storage, data.path)).toEqual({
|
||||
status: "available",
|
||||
bytes: data.bytes,
|
||||
mtime: 1,
|
||||
});
|
||||
expect(data.storage.statHidden).toHaveBeenCalledTimes(2);
|
||||
expect(data.storage.readHiddenFileBinary).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("distinguishes an absent file, a read error, and a file changing during its read", async () => {
|
||||
const data = fixture();
|
||||
data.storage.statHidden.mockResolvedValueOnce(null as never);
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import { $msg } from "@/common/translation";
|
||||
import { delay } from "octagonal-wheels/promises";
|
||||
import { createDiagnosticNameMapper, parseDiagnosticPathToken } from "@vrtmrz/livesync-commonlib/diagnostics";
|
||||
import { EntryTypes, type FilePath, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ICHeader, ICXHeader, PSCHeader } from "@vrtmrz/livesync-commonlib/compat/common/models/fileaccess.const";
|
||||
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import type { ComponentHasResult } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
import DiagnosticFileLookupDialog from "@/modules/services/LiveSyncUI/dialogues/DiagnosticFileLookupDialog.svelte";
|
||||
import type {
|
||||
FileDatabaseInfo,
|
||||
FileDatabaseInfoCore,
|
||||
FileDatabaseMergeBaseInfo,
|
||||
InspectFileDatabaseInfoOptions,
|
||||
RevisionDatabaseInfo,
|
||||
} from "./fileDatabaseInfo";
|
||||
import { inspectFileDatabaseInfo } from "./fileDatabaseInfo";
|
||||
import type {
|
||||
DiagnosticFileCandidate,
|
||||
DiagnosticFileLookupDialogOptions,
|
||||
DiagnosticFileLookupResult,
|
||||
FindDiagnosticFilesOptions,
|
||||
} from "./diagnosticFileLookupTypes";
|
||||
import type { DatabaseDiagnosticArtifact, DatabaseDiagnosticOutput } from "./databaseDiagnostics";
|
||||
|
||||
type CurrentFileMetadata = {
|
||||
_id: string;
|
||||
path: FilePathWithPrefix | FilePath;
|
||||
type: string;
|
||||
_deleted?: boolean;
|
||||
};
|
||||
|
||||
type FileLookupDialogManager = {
|
||||
open<TResult, TInitial = TResult>(
|
||||
component: ComponentHasResult<TResult, TInitial>,
|
||||
initialData?: TInitial
|
||||
): Promise<TResult | undefined>;
|
||||
};
|
||||
|
||||
type FileLookupUI = FileDatabaseInfoCore["services"]["UI"] & {
|
||||
dialogManager: FileLookupDialogManager;
|
||||
};
|
||||
|
||||
function isCurrentFileMetadata(value: unknown): value is CurrentFileMetadata {
|
||||
if (value === null || typeof value !== "object") return false;
|
||||
const entry = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof entry._id !== "string" ||
|
||||
typeof entry.path !== "string" ||
|
||||
(entry.type !== EntryTypes.NOTE_BINARY && entry.type !== EntryTypes.NOTE_PLAIN) ||
|
||||
entry._deleted === true
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const path = entry.path;
|
||||
return ![ICHeader, ICXHeader, PSCHeader].some((prefix) => path.startsWith(prefix));
|
||||
}
|
||||
|
||||
function cancelledResult(processed: number, candidates: DiagnosticFileCandidate[]): DiagnosticFileLookupResult {
|
||||
return { valid: true, status: "cancelled", processed, candidates };
|
||||
}
|
||||
|
||||
async function yieldToEventLoop(): Promise<void> {
|
||||
await delay(0);
|
||||
}
|
||||
|
||||
export async function findDiagnosticFiles(
|
||||
core: FileDatabaseInfoCore,
|
||||
token: string,
|
||||
options: FindDiagnosticFilesOptions = {}
|
||||
): Promise<DiagnosticFileLookupResult> {
|
||||
const parsedToken = parseDiagnosticPathToken(token);
|
||||
if (!parsedToken) {
|
||||
return { valid: false, status: "invalid", processed: 0, candidates: [] };
|
||||
}
|
||||
const mapper = createDiagnosticNameMapper("pseudonym", parsedToken.salt);
|
||||
const candidates: DiagnosticFileCandidate[] = [];
|
||||
let processed = 0;
|
||||
const reportProgress = () => options.onProgress?.(processed);
|
||||
reportProgress();
|
||||
if (options.signal?.aborted) {
|
||||
reportProgress();
|
||||
return cancelledResult(processed, candidates);
|
||||
}
|
||||
const vaultPaths = new Set(await core.storageAccess.getFileNames());
|
||||
const matchingMetadataPaths = new Set<string>();
|
||||
if (options.signal?.aborted) {
|
||||
reportProgress();
|
||||
return cancelledResult(processed, candidates);
|
||||
}
|
||||
|
||||
for await (const entry of core.localDatabase.findAllDocs()) {
|
||||
processed++;
|
||||
if (isCurrentFileMetadata(entry)) {
|
||||
const databasePath = entry.path;
|
||||
const path = stripAllPrefixes(databasePath);
|
||||
if (mapper.path(path) === token) {
|
||||
matchingMetadataPaths.add(path);
|
||||
candidates.push({
|
||||
path,
|
||||
databasePath,
|
||||
documentId: entry._id,
|
||||
storageExists: vaultPaths.has(path),
|
||||
source: "metadata",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (processed % 128 === 0) {
|
||||
reportProgress();
|
||||
await yieldToEventLoop();
|
||||
if (options.signal?.aborted) {
|
||||
reportProgress();
|
||||
return cancelledResult(processed, candidates);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options.signal?.aborted) {
|
||||
reportProgress();
|
||||
return cancelledResult(processed, candidates);
|
||||
}
|
||||
|
||||
for (const path of vaultPaths) {
|
||||
processed++;
|
||||
if (mapper.path(path) === token && !matchingMetadataPaths.has(path)) {
|
||||
candidates.push({ path, storageExists: true, source: "vault" });
|
||||
}
|
||||
if (processed % 128 === 0) {
|
||||
reportProgress();
|
||||
await yieldToEventLoop();
|
||||
if (options.signal?.aborted) {
|
||||
reportProgress();
|
||||
return cancelledResult(processed, candidates);
|
||||
}
|
||||
}
|
||||
}
|
||||
reportProgress();
|
||||
if (options.signal?.aborted) return cancelledResult(processed, candidates);
|
||||
return { valid: true, status: "complete", processed, candidates };
|
||||
}
|
||||
|
||||
function finiteMetric(value: number | undefined, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function nonnegativeMetric(value: number | undefined): number {
|
||||
return Math.max(0, finiteMetric(value));
|
||||
}
|
||||
|
||||
function revisionStatus(value: string): "available" | "missing" | "unknown" {
|
||||
if (value === "available" || value === "missing") return value;
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function storageType(value: string): "absent" | "notes" | "newnote" | "plain" | "unknown" {
|
||||
if (value === "absent" || value === "notes" || value === "newnote" || value === "plain") return value;
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function localChunkState(value: string): "available" | "deleted" | "missing" | "unknown" {
|
||||
if (value === "available" || value === "deleted" || value === "missing") return value;
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function projectRevisionInfo(info: RevisionDatabaseInfo, mapper: ReturnType<typeof createDiagnosticNameMapper>) {
|
||||
return {
|
||||
documentId: mapper.id(info.documentId),
|
||||
revision: info.revision === null ? null : mapper.revision(info.revision),
|
||||
current: info.current,
|
||||
deleted: info.deleted,
|
||||
storageType: storageType(info.storageType),
|
||||
storageLayout: info.storageLayout === "legacy-inline" ? "legacy-inline" : "chunked",
|
||||
ctime: finiteMetric(info.ctime),
|
||||
mtime: finiteMetric(info.mtime),
|
||||
recordedSize: nonnegativeMetric(info.recordedSize),
|
||||
revisionHistory: info.revisionHistory.map(({ revision, status }) => ({
|
||||
revision: mapper.revision(revision),
|
||||
status: revisionStatus(status),
|
||||
})),
|
||||
chunkReferences: nonnegativeMetric(info.chunkReferences),
|
||||
uniqueChunkReferences: nonnegativeMetric(info.uniqueChunkReferences),
|
||||
embeddedChunkReferences: nonnegativeMetric(info.embeddedChunkReferences),
|
||||
locallyStoredChunkReferences: nonnegativeMetric(info.locallyStoredChunkReferences),
|
||||
contentAvailableLocally: info.contentAvailableLocally,
|
||||
chunks: info.chunks.map((chunk) => ({
|
||||
id: mapper.chunk(chunk.id),
|
||||
referenceCount: nonnegativeMetric(chunk.referenceCount),
|
||||
embedded: chunk.embedded,
|
||||
storedInLocalDatabase: chunk.storedInLocalDatabase,
|
||||
localDatabaseState: localChunkState(chunk.localDatabaseState),
|
||||
localDatabaseRevision:
|
||||
chunk.localDatabaseRevision === null ? null : mapper.revision(chunk.localDatabaseRevision),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function projectMergeBase(info: FileDatabaseMergeBaseInfo, mapper: ReturnType<typeof createDiagnosticNameMapper>) {
|
||||
return {
|
||||
winnerRevision: mapper.revision(info.winnerRevision),
|
||||
conflictRevision: mapper.revision(info.conflictRevision),
|
||||
revision: info.revision === null ? null : mapper.revision(info.revision),
|
||||
metadataAvailableLocally: info.metadataAvailableLocally,
|
||||
contentAvailableLocally: info.contentAvailableLocally,
|
||||
missingChunkIds: info.missingChunkIds.map((id) => mapper.chunk(id)),
|
||||
unavailableSharedRevisions: info.unavailableSharedRevisions.map((revision) => mapper.revision(revision)),
|
||||
};
|
||||
}
|
||||
|
||||
function projectStorage(info: FileDatabaseInfo["storage"]) {
|
||||
return {
|
||||
exists: info.exists,
|
||||
...(info.ctime === undefined ? {} : { ctime: finiteMetric(info.ctime) }),
|
||||
...(info.mtime === undefined ? {} : { mtime: finiteMetric(info.mtime) }),
|
||||
...(info.size === undefined ? {} : { size: nonnegativeMetric(info.size) }),
|
||||
};
|
||||
}
|
||||
|
||||
function projectFileInfo(
|
||||
info: FileDatabaseInfo,
|
||||
source: DiagnosticFileCandidate["source"],
|
||||
mapper: ReturnType<typeof createDiagnosticNameMapper>
|
||||
) {
|
||||
return {
|
||||
path: mapper.path(info.path),
|
||||
databasePath: mapper.path(info.databasePath),
|
||||
source,
|
||||
storage: projectStorage(info.storage),
|
||||
database: {
|
||||
source: "local database on this device" as const,
|
||||
remoteQueried: false as const,
|
||||
inspected: true,
|
||||
exists: info.database.exists,
|
||||
...(info.database.documentId === undefined ? {} : { documentId: mapper.id(info.database.documentId) }),
|
||||
currentRevision:
|
||||
info.database.currentRevision === null ? null : mapper.revision(info.database.currentRevision),
|
||||
conflictCount: nonnegativeMetric(info.database.conflictCount),
|
||||
conflictRevisions: info.database.conflictRevisions.map((revision) => mapper.revision(revision)),
|
||||
unavailableConflictRevisions: info.database.unavailableConflictRevisions.map((revision) =>
|
||||
mapper.revision(revision)
|
||||
),
|
||||
revisions: info.database.revisions.map((revision) => projectRevisionInfo(revision, mapper)),
|
||||
mergeBases: info.database.mergeBases.map((mergeBase) => projectMergeBase(mergeBase, mapper)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectVaultOnlyCandidate(
|
||||
core: FileDatabaseInfoCore,
|
||||
candidate: DiagnosticFileCandidate,
|
||||
mapper: ReturnType<typeof createDiagnosticNameMapper>,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const exists = await core.storageAccess.isExistsIncludeHidden(candidate.path);
|
||||
if (signal?.aborted) throw new DOMException("File inspection cancelled", "AbortError");
|
||||
const stat = exists ? await core.storageAccess.statHidden(candidate.path) : null;
|
||||
return {
|
||||
path: mapper.path(candidate.path),
|
||||
source: "vault" as const,
|
||||
storage: projectStorage(
|
||||
stat ? { exists: true, ctime: stat.ctime, mtime: stat.mtime, size: stat.size } : { exists: false }
|
||||
),
|
||||
database: {
|
||||
source: "local database on this device" as const,
|
||||
remoteQueried: false as const,
|
||||
inspected: false,
|
||||
reason: "no-local-target" as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildPseudonymousFileReport(
|
||||
core: FileDatabaseInfoCore,
|
||||
candidate: DiagnosticFileCandidate,
|
||||
salt: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<DatabaseDiagnosticOutput> {
|
||||
if (signal?.aborted) throw new DOMException("File inspection cancelled", "AbortError");
|
||||
const mapper = createDiagnosticNameMapper("pseudonym", salt);
|
||||
if (!mapper.salt) throw new Error("Invalid diagnostic path token");
|
||||
if (candidate.source === "metadata" && candidate.documentId === undefined) {
|
||||
throw new Error("A Metadata candidate requires its exact document ID");
|
||||
}
|
||||
const info =
|
||||
candidate.source === "metadata" && candidate.documentId !== undefined
|
||||
? await inspectFileDatabaseInfo(core, candidate.path, {
|
||||
documentId: candidate.documentId,
|
||||
signal,
|
||||
...(candidate.databasePath === undefined ? {} : { databasePath: candidate.databasePath }),
|
||||
} satisfies InspectFileDatabaseInfoOptions)
|
||||
: undefined;
|
||||
const file = info
|
||||
? projectFileInfo(info, candidate.source, mapper)
|
||||
: await inspectVaultOnlyCandidate(core, candidate, mapper, signal);
|
||||
if (signal?.aborted) throw new DOMException("File inspection cancelled", "AbortError");
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
kind: "file",
|
||||
status: "complete",
|
||||
nameMode: "pseudonym",
|
||||
salt: mapper.salt,
|
||||
scope: {
|
||||
source: "local database on this device",
|
||||
remoteQueried: false,
|
||||
vaultContentRead: false,
|
||||
fileContents: "omitted",
|
||||
revisions: "current revision, current conflict revisions, and selected merge-base revisions",
|
||||
revisionHistory: "status metadata is listed for current and conflict revisions where available",
|
||||
historicalFileBodiesScanned: false,
|
||||
},
|
||||
file,
|
||||
};
|
||||
const json = JSON.stringify(report, null, 2);
|
||||
const markdown = `<details>\n<summary>${$msg("Local file diagnostic report")}</summary>\n\n\`\`\`json\n${json}\n\`\`\`\n</details>`;
|
||||
const artifacts: DatabaseDiagnosticArtifact[] = [
|
||||
{ format: "json", filename: "diagnostic-file-report.json", content: json },
|
||||
{ format: "markdown", filename: "diagnostic-file-report.md", content: markdown },
|
||||
];
|
||||
return { status: "complete", nameMode: "pseudonym", artifacts };
|
||||
}
|
||||
|
||||
export async function showDiagnosticFileLookup(
|
||||
core: FileDatabaseInfoCore,
|
||||
openFile?: (path: string) => Promise<void> | void
|
||||
): Promise<void> {
|
||||
let activeController: AbortController | undefined;
|
||||
const options: DiagnosticFileLookupDialogOptions = {
|
||||
find: async (token, onProgress) => {
|
||||
activeController?.abort();
|
||||
activeController = new AbortController();
|
||||
return await findDiagnosticFiles(core, token, { signal: activeController.signal, onProgress });
|
||||
},
|
||||
prepareReport: async (candidate, token) => {
|
||||
const parsed = parseDiagnosticPathToken(token);
|
||||
if (!parsed) throw new Error("Invalid diagnostic path token");
|
||||
activeController?.abort();
|
||||
activeController = new AbortController();
|
||||
return await buildPseudonymousFileReport(core, candidate, parsed.salt, activeController.signal);
|
||||
},
|
||||
openFile,
|
||||
cancel: () => activeController?.abort(),
|
||||
};
|
||||
const ui = core.services.UI as FileLookupUI;
|
||||
try {
|
||||
await ui.dialogManager.open(DiagnosticFileLookupDialog, options);
|
||||
} finally {
|
||||
activeController?.abort();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { createDiagnosticNameMapper } from "@vrtmrz/livesync-commonlib/diagnostics";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildPseudonymousFileReport, findDiagnosticFiles, showDiagnosticFileLookup } from "./diagnosticFileLookup";
|
||||
import type { DiagnosticFileLookupDialogOptions } from "./diagnosticFileLookupTypes";
|
||||
|
||||
type TestDocument = {
|
||||
_id: string;
|
||||
_rev?: string;
|
||||
_conflicts?: string[];
|
||||
_revs_info?: Array<{ rev: string; status: string }>;
|
||||
_deleted?: boolean;
|
||||
children?: string[];
|
||||
ctime?: number;
|
||||
data?: string;
|
||||
deleted?: boolean;
|
||||
eden?: Record<string, unknown>;
|
||||
mtime?: number;
|
||||
path?: string;
|
||||
size?: number;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
async function* entries(documents: TestDocument[]) {
|
||||
for (const document of documents) yield document;
|
||||
}
|
||||
|
||||
function createCore(vaultPaths: string[] = [], documents: TestDocument[] = []) {
|
||||
const existingPaths = new Set(vaultPaths);
|
||||
const chunkRevisions = new Map([["h:secret-chunk", "1-secret-chunk-revision"]]);
|
||||
const core = {
|
||||
settings: {},
|
||||
storageAccess: {
|
||||
getFileNames: vi.fn(async () => [...vaultPaths]),
|
||||
getFilesIncludeHidden: vi.fn(async () => [...vaultPaths]),
|
||||
isExistsIncludeHidden: vi.fn(async (path: string) => existingPaths.has(path)),
|
||||
statHidden: vi.fn(async (path: string) =>
|
||||
existingPaths.has(path) ? { ctime: 10, mtime: 20, size: 30, type: "file" } : undefined
|
||||
),
|
||||
},
|
||||
localDatabase: {
|
||||
allDocsRaw: vi.fn(async ({ keys }: { keys: string[] }) => ({
|
||||
rows: keys.flatMap((key) => {
|
||||
const rev = chunkRevisions.get(key);
|
||||
return rev ? [{ id: key, key, value: { rev } }] : [];
|
||||
}),
|
||||
})),
|
||||
findAllDocs: vi.fn(() => entries(documents)),
|
||||
getDBEntryFromMeta: vi.fn(),
|
||||
getDBEntry: vi.fn(),
|
||||
localDatabase: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
const document = documents.find(({ _id }) => _id === id);
|
||||
if (document) return document;
|
||||
throw Object.assign(new Error("missing"), { status: 404 });
|
||||
}),
|
||||
},
|
||||
},
|
||||
services: {
|
||||
path: { path2id: vi.fn(async () => "f:path-derived") },
|
||||
UI: {
|
||||
promptCopyToClipboard: vi.fn(async () => true),
|
||||
dialogManager: { open: vi.fn() },
|
||||
},
|
||||
},
|
||||
};
|
||||
return core;
|
||||
}
|
||||
|
||||
function diagnosticPath(path: string, salt = "salt1234"): string {
|
||||
return createDiagnosticNameMapper("pseudonym", salt).path(path);
|
||||
}
|
||||
|
||||
describe("diagnostic file lookup", () => {
|
||||
it.each(["metadata", "chunks"] as const)(
|
||||
"stops follow-up reads when the dialogue closes during a %s read",
|
||||
async (pendingRead) => {
|
||||
const path = "cancelled.md";
|
||||
const selected = {
|
||||
_id: "f:cancelled",
|
||||
_rev: "3-current",
|
||||
_conflicts: ["3-conflict"],
|
||||
path,
|
||||
type: "plain",
|
||||
children: ["h:secret-chunk"],
|
||||
};
|
||||
const core = createCore([path], [selected]);
|
||||
let markStarted!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
let releaseRead!: () => void;
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
releaseRead = resolve;
|
||||
});
|
||||
if (pendingRead === "metadata") {
|
||||
core.localDatabase.localDatabase.get.mockImplementationOnce(async () => {
|
||||
markStarted();
|
||||
await pending;
|
||||
return selected;
|
||||
});
|
||||
} else {
|
||||
core.localDatabase.allDocsRaw.mockImplementationOnce(async () => {
|
||||
markStarted();
|
||||
await pending;
|
||||
return { rows: [] };
|
||||
});
|
||||
}
|
||||
core.services.UI.dialogManager.open.mockImplementation(async (...args: unknown[]) => {
|
||||
const options = args[1] as DiagnosticFileLookupDialogOptions;
|
||||
const preparation = options.prepareReport(
|
||||
{ path, documentId: selected._id, storageExists: true, source: "metadata" },
|
||||
diagnosticPath(path)
|
||||
);
|
||||
await started;
|
||||
const cancelled = expect(preparation).rejects.toMatchObject({ name: "AbortError" });
|
||||
options.cancel();
|
||||
releaseRead();
|
||||
await cancelled;
|
||||
});
|
||||
|
||||
await showDiagnosticFileLookup(core as never);
|
||||
|
||||
expect(core.localDatabase.localDatabase.get).toHaveBeenCalledTimes(1);
|
||||
expect(core.localDatabase.allDocsRaw).toHaveBeenCalledTimes(pendingRead === "metadata" ? 0 : 1);
|
||||
}
|
||||
);
|
||||
|
||||
it("rejects an invalid path token before reading storage or the database", async () => {
|
||||
const core = createCore(["note.md"], [{ _id: "f:note", path: "note.md", type: "plain" }]);
|
||||
|
||||
await expect(findDiagnosticFiles(core as never, "not-a-path-token")).resolves.toEqual({
|
||||
valid: false,
|
||||
status: "invalid",
|
||||
processed: 0,
|
||||
candidates: [],
|
||||
});
|
||||
|
||||
expect(core.storageAccess.getFileNames).not.toHaveBeenCalled();
|
||||
expect(core.localDatabase.findAllDocs).not.toHaveBeenCalled();
|
||||
expect(core.localDatabase.localDatabase.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("matches path spelling exactly without case folding or Unicode normalisation", async () => {
|
||||
const decomposed = "Cafe\u0301.md";
|
||||
const core = createCore(
|
||||
[decomposed, "Café.md", "CAFÉ.md"],
|
||||
[
|
||||
{ _id: "f:decomposed", path: decomposed, type: "plain" },
|
||||
{ _id: "f:composed", path: "Café.md", type: "plain" },
|
||||
{ _id: "f:case", path: "CAFÉ.md", type: "plain" },
|
||||
]
|
||||
);
|
||||
const progressCounts: number[] = [];
|
||||
|
||||
const result = await findDiagnosticFiles(core as never, diagnosticPath(decomposed), {
|
||||
onProgress: (processed) => progressCounts.push(processed),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ valid: true, status: "complete" });
|
||||
expect(result.candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
path: decomposed,
|
||||
documentId: "f:decomposed",
|
||||
source: "metadata",
|
||||
storageExists: true,
|
||||
}),
|
||||
]);
|
||||
expect(core.localDatabase.localDatabase.get).not.toHaveBeenCalled();
|
||||
expect(progressCounts).toEqual([0, 6]);
|
||||
});
|
||||
|
||||
it("keeps distinct Metadata documents with the same exact path as separate candidates", async () => {
|
||||
const path = "duplicate.md";
|
||||
const core = createCore(
|
||||
[path],
|
||||
[
|
||||
{ _id: "f:duplicate-one", path, type: "plain" },
|
||||
{ _id: "f:duplicate-two", path, type: "plain" },
|
||||
]
|
||||
);
|
||||
|
||||
const result = await findDiagnosticFiles(core as never, diagnosticPath(path));
|
||||
|
||||
expect(result.candidates).toEqual([
|
||||
expect.objectContaining({ documentId: "f:duplicate-one", path, storageExists: true, source: "metadata" }),
|
||||
expect.objectContaining({ documentId: "f:duplicate-two", path, storageExists: true, source: "metadata" }),
|
||||
]);
|
||||
expect(result.candidates).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps logically deleted current file Metadata as an exact candidate", async () => {
|
||||
const path = "vault-only.md";
|
||||
const core = createCore(
|
||||
[path],
|
||||
[
|
||||
{ _id: "f:deleted", path, type: "plain", deleted: true },
|
||||
{ _id: "f:chunk", path, type: "chunk" },
|
||||
{ _id: "i:internal", path: "i:.obsidian/config.json", type: "plain" },
|
||||
]
|
||||
);
|
||||
|
||||
const result = await findDiagnosticFiles(core as never, diagnosticPath(path));
|
||||
expect(result.candidates).toEqual([
|
||||
expect.objectContaining({ path, documentId: "f:deleted", storageExists: true, source: "metadata" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns a Vault-only candidate when there is no current normal-file Metadata", async () => {
|
||||
const path = "vault-only.md";
|
||||
const core = createCore(
|
||||
[path],
|
||||
[
|
||||
{ _id: "f:chunk", path, type: "chunk" },
|
||||
{ _id: "f:legacy", path, type: "notes" },
|
||||
{ _id: "i:internal", path: "i:.obsidian/config.json", type: "plain" },
|
||||
]
|
||||
);
|
||||
|
||||
const result = await findDiagnosticFiles(core as never, diagnosticPath(path));
|
||||
expect(result.candidates).toEqual([expect.objectContaining({ path, storageExists: true, source: "vault" })]);
|
||||
expect(result.candidates[0]).not.toHaveProperty("documentId");
|
||||
|
||||
const report = await buildPseudonymousFileReport(core as never, result.candidates[0], "salt1234");
|
||||
const jsonText = report.artifacts.find(({ format }) => format === "json")!.content;
|
||||
const json = JSON.parse(jsonText);
|
||||
|
||||
expect(json.file.path).toBe(diagnosticPath(path));
|
||||
expect(jsonText).not.toContain(path);
|
||||
expect(json.file.database).toEqual({
|
||||
source: "local database on this device",
|
||||
remoteQueried: false,
|
||||
inspected: false,
|
||||
reason: "no-local-target",
|
||||
});
|
||||
expect(json.scope).toEqual({
|
||||
source: "local database on this device",
|
||||
remoteQueried: false,
|
||||
vaultContentRead: false,
|
||||
fileContents: "omitted",
|
||||
revisions: "current revision, current conflict revisions, and selected merge-base revisions",
|
||||
revisionHistory: "status metadata is listed for current and conflict revisions where available",
|
||||
historicalFileBodiesScanned: false,
|
||||
});
|
||||
expect(core.localDatabase.localDatabase.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns partial exact matches when cancellation arrives during enumeration", async () => {
|
||||
const path = "match.md";
|
||||
const documents = [
|
||||
{ _id: "f:match", path, type: "plain" },
|
||||
...Array.from({ length: 299 }, (_, index) => ({
|
||||
_id: `f:other-${index}`,
|
||||
path: `other-${index}.md`,
|
||||
type: "plain",
|
||||
})),
|
||||
];
|
||||
const core = createCore([], documents);
|
||||
const controller = new AbortController();
|
||||
const progressCounts: number[] = [];
|
||||
|
||||
const result = await findDiagnosticFiles(core as never, diagnosticPath(path), {
|
||||
signal: controller.signal,
|
||||
onProgress: (processed) => {
|
||||
progressCounts.push(processed);
|
||||
if (processed >= 128) controller.abort();
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ valid: true, status: "cancelled", processed: 128 });
|
||||
expect(result.candidates).toEqual([
|
||||
expect.objectContaining({ documentId: "f:match", path, storageExists: false, source: "metadata" }),
|
||||
]);
|
||||
expect(progressCounts).toEqual([0, 128, 128]);
|
||||
});
|
||||
|
||||
it("projects an exact target with the token salt, preserves revision generations, and omits raw values", async () => {
|
||||
const path = "private-name.md";
|
||||
const salt = "salt1234";
|
||||
const token = diagnosticPath(path, salt);
|
||||
const selected = {
|
||||
_id: "f:selected-private-id",
|
||||
_rev: "3-private-revision",
|
||||
_conflicts: [],
|
||||
_revs_info: [{ rev: "3-private-revision", status: "private-status-value" }],
|
||||
path,
|
||||
ctime: 11,
|
||||
mtime: 22,
|
||||
size: 33,
|
||||
type: "plain",
|
||||
data: "private file contents",
|
||||
children: ["h:private-chunk-id"],
|
||||
eden: {},
|
||||
unreported: "private-extra-value",
|
||||
};
|
||||
const decoy = { ...selected, _id: "f:path-derived", path: "different.md", _rev: "9-decoy-revision" };
|
||||
const core = createCore([path], [selected, decoy]);
|
||||
core.localDatabase.localDatabase.get.mockImplementation(async (id: string) => {
|
||||
if (id === selected._id) return selected;
|
||||
if (id === decoy._id) return decoy;
|
||||
throw Object.assign(new Error("missing"), { status: 404 });
|
||||
});
|
||||
const candidate = {
|
||||
path,
|
||||
databasePath: path as never,
|
||||
documentId: selected._id,
|
||||
storageExists: true,
|
||||
source: "metadata" as const,
|
||||
};
|
||||
|
||||
const output = await buildPseudonymousFileReport(core as never, candidate, salt);
|
||||
const jsonText = output.artifacts.find(({ format }) => format === "json")!.content;
|
||||
const json = JSON.parse(jsonText);
|
||||
const revision = json.file.database.revisions[0];
|
||||
|
||||
expect(output.nameMode).toBe("pseudonym");
|
||||
expect(json.kind).toBe("file");
|
||||
expect(json.nameMode).toBe("pseudonym");
|
||||
expect(json.salt).toBe(salt);
|
||||
expect(json.file.path).toBe(token);
|
||||
expect(json.scope).toMatchObject({ vaultContentRead: false, fileContents: "omitted" });
|
||||
expect(revision.documentId).toMatch(/^document-[0-9a-f]{32}$/);
|
||||
expect(revision.revision).toMatch(/^3-psu[0-9a-f]{32}$/);
|
||||
expect(revision.chunks[0].id).toMatch(/^chunk-[0-9a-f]{32}$/);
|
||||
expect(revision.revisionHistory[0].status).toBe("unknown");
|
||||
expect(json.file.database.inspected).toBe(true);
|
||||
expect(output.artifacts.map(({ format }) => format)).toEqual(["json", "markdown"]);
|
||||
expect(output.artifacts.find(({ format }) => format === "markdown")?.content).toContain("```json");
|
||||
expect(jsonText).not.toContain(path);
|
||||
expect(jsonText).not.toContain(selected._id);
|
||||
expect(jsonText).not.toContain("3-private-revision");
|
||||
expect(jsonText).not.toContain("h:private-chunk-id");
|
||||
expect(jsonText).not.toContain("private-status-value");
|
||||
expect(jsonText).not.toContain("private file contents");
|
||||
expect(jsonText).not.toContain("private-extra-value");
|
||||
expect(core.localDatabase.localDatabase.get.mock.calls.map(([id]) => id)).toEqual([selected._id]);
|
||||
expect(core.services.path.path2id).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps an exact Metadata target visibly missing without substituting a path-derived document", async () => {
|
||||
const path = "vanished.md";
|
||||
const salt = "salt1234";
|
||||
const selected = { _id: "f:vanished", path, type: "plain" };
|
||||
const decoy = { _id: "f:path-derived", path: "wrong.md", type: "plain", _rev: "1-decoy" };
|
||||
const core = createCore([path], [selected, decoy]);
|
||||
const lookup = await findDiagnosticFiles(core as never, diagnosticPath(path, salt));
|
||||
core.localDatabase.localDatabase.get.mockImplementation(async (id: string) => {
|
||||
if (id === decoy._id) return decoy;
|
||||
throw Object.assign(new Error("missing"), { status: 404 });
|
||||
});
|
||||
|
||||
const output = await buildPseudonymousFileReport(core as never, lookup.candidates[0], salt);
|
||||
const jsonText = output.artifacts.find(({ format }) => format === "json")!.content;
|
||||
const json = JSON.parse(jsonText);
|
||||
|
||||
expect(json.file.database).toMatchObject({ inspected: true, exists: false });
|
||||
expect(json.file.database.documentId).toMatch(/^document-[0-9a-f]{32}$/);
|
||||
expect(jsonText).not.toContain("f:vanished");
|
||||
expect(jsonText).not.toContain("f:path-derived");
|
||||
expect(core.localDatabase.localDatabase.get.mock.calls.map(([id]) => id)).toEqual([selected._id]);
|
||||
expect(core.services.path.path2id).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FilePath, FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { DatabaseDiagnosticOutput } from "./databaseDiagnostics";
|
||||
|
||||
export type DiagnosticFileCandidate = {
|
||||
path: string;
|
||||
databasePath?: FilePathWithPrefix | FilePath;
|
||||
documentId?: string;
|
||||
storageExists: boolean;
|
||||
source: "metadata" | "vault";
|
||||
};
|
||||
|
||||
export type DiagnosticFileLookupResult =
|
||||
| {
|
||||
valid: false;
|
||||
status: "invalid";
|
||||
processed: 0;
|
||||
candidates: [];
|
||||
}
|
||||
| {
|
||||
valid: true;
|
||||
status: "cancelled" | "complete";
|
||||
processed: number;
|
||||
candidates: DiagnosticFileCandidate[];
|
||||
};
|
||||
|
||||
export interface FindDiagnosticFilesOptions {
|
||||
signal?: AbortSignal;
|
||||
onProgress?(processed: number): void;
|
||||
}
|
||||
|
||||
export interface DiagnosticFileLookupDialogOptions {
|
||||
find(token: string, onProgress: (processed: number) => void): Promise<DiagnosticFileLookupResult>;
|
||||
prepareReport(candidate: DiagnosticFileCandidate, token: string): Promise<DatabaseDiagnosticOutput>;
|
||||
openFile?(path: string): Promise<void> | void;
|
||||
cancel(): void;
|
||||
}
|
||||
@@ -91,6 +91,7 @@ export type FileDatabaseInfo = {
|
||||
source: "local database on this device";
|
||||
remoteQueried: false;
|
||||
exists: boolean;
|
||||
documentId?: string;
|
||||
currentRevision: string | null;
|
||||
conflictCount: number;
|
||||
conflictRevisions: string[];
|
||||
@@ -100,6 +101,12 @@ export type FileDatabaseInfo = {
|
||||
};
|
||||
};
|
||||
|
||||
export type InspectFileDatabaseInfoOptions = {
|
||||
documentId?: string;
|
||||
databasePath?: FilePathWithPrefix | FilePath;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
const REPORT_WARNING =
|
||||
"All revisions and chunk availability below are a snapshot of this device's local database; the remote is not queried. Review the Vault-relative path, document identifier, content-derived chunk identifiers, and metadata before sharing this report. File contents are omitted.";
|
||||
|
||||
@@ -125,25 +132,35 @@ type RawDatabaseDocument = {
|
||||
data?: string | string[];
|
||||
eden?: Record<string, unknown>;
|
||||
mtime?: number;
|
||||
path?: FilePathWithPrefix | FilePath;
|
||||
size?: number;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
function checkInspectionCancelled(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) throw new DOMException("File inspection cancelled", "AbortError");
|
||||
}
|
||||
|
||||
async function getLocalDatabaseMeta(
|
||||
core: FileDatabaseInfoCore,
|
||||
path: FilePathWithPrefix | FilePath,
|
||||
options: PouchDB.Core.GetOptions
|
||||
options: PouchDB.Core.GetOptions,
|
||||
target?: InspectFileDatabaseInfoOptions
|
||||
): Promise<DatabaseMeta | false> {
|
||||
const documentId = await core.services.path.path2id(path);
|
||||
checkInspectionCancelled(target?.signal);
|
||||
const documentId = target?.documentId ?? (await core.services.path.path2id(path));
|
||||
checkInspectionCancelled(target?.signal);
|
||||
let raw: RawDatabaseDocument;
|
||||
try {
|
||||
raw = await core.localDatabase.localDatabase.get(documentId, options);
|
||||
} catch (error) {
|
||||
checkInspectionCancelled(target?.signal);
|
||||
if (isNotFoundError(error)) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
checkInspectionCancelled(target?.signal);
|
||||
|
||||
if (raw.type === "leaf") {
|
||||
return false;
|
||||
@@ -156,13 +173,15 @@ async function getLocalDatabaseMeta(
|
||||
const legacy = rawStorageType === null || rawStorageType === "notes";
|
||||
const type = legacy ? "notes" : rawStorageType;
|
||||
const legacyBodyPresent =
|
||||
legacy && (typeof raw.data === "string" || (Array.isArray(raw.data) && raw.data.every((item) => typeof item === "string")));
|
||||
legacy &&
|
||||
(typeof raw.data === "string" ||
|
||||
(Array.isArray(raw.data) && raw.data.every((item) => typeof item === "string")));
|
||||
return {
|
||||
_id: raw._id,
|
||||
_rev: raw._rev,
|
||||
_conflicts: raw._conflicts,
|
||||
_revs_info: raw._revs_info,
|
||||
path,
|
||||
path: target ? (raw.path ?? path) : path,
|
||||
data: legacyBodyPresent ? raw.data : "",
|
||||
ctime: raw.ctime ?? 0,
|
||||
mtime: raw.mtime ?? 0,
|
||||
@@ -180,8 +199,10 @@ async function getLocalDatabaseMeta(
|
||||
async function collectRevisionDatabaseInfo(
|
||||
core: FileDatabaseInfoCore,
|
||||
meta: DatabaseMeta,
|
||||
current: boolean
|
||||
current: boolean,
|
||||
signal?: AbortSignal
|
||||
): Promise<RevisionDatabaseInfo> {
|
||||
checkInspectionCancelled(signal);
|
||||
const legacy = meta._rawStorageType === null || meta._rawStorageType === "notes";
|
||||
const children = legacy ? [] : "children" in meta ? meta.children : [];
|
||||
const uniqueChildren = [...new Set(children)];
|
||||
@@ -201,6 +222,7 @@ async function collectRevisionDatabaseInfo(
|
||||
include_docs: false,
|
||||
})
|
||||
).rows;
|
||||
checkInspectionCancelled(signal);
|
||||
const localChunkStates = new Map(
|
||||
localRows
|
||||
.filter((row) => "value" in row)
|
||||
@@ -236,9 +258,7 @@ async function collectRevisionDatabaseInfo(
|
||||
locallyStoredChunkReferences: children.filter((id) => localChunkStates.get(id)?.state === "available").length,
|
||||
contentAvailableLocally: legacy
|
||||
? meta._legacyBodyPresent
|
||||
: uniqueChildren.every(
|
||||
(id) => embeddedChildren.has(id) || localChunkStates.get(id)?.state === "available"
|
||||
),
|
||||
: uniqueChildren.every((id) => embeddedChildren.has(id) || localChunkStates.get(id)?.state === "available"),
|
||||
chunks: uniqueChildren.map((id) => {
|
||||
const localState = localChunkStates.get(id);
|
||||
return {
|
||||
@@ -273,15 +293,27 @@ function missingChunkIds(info: RevisionDatabaseInfo): string[] {
|
||||
.map(({ id }) => id);
|
||||
}
|
||||
|
||||
export async function inspectFileDatabaseInfo(core: FileDatabaseInfoCore, path: string): Promise<FileDatabaseInfo> {
|
||||
export async function inspectFileDatabaseInfo(
|
||||
core: FileDatabaseInfoCore,
|
||||
path: string,
|
||||
options?: InspectFileDatabaseInfoOptions
|
||||
): Promise<FileDatabaseInfo> {
|
||||
checkInspectionCancelled(options?.signal);
|
||||
const storageExists = await core.storageAccess.isExistsIncludeHidden(path);
|
||||
checkInspectionCancelled(options?.signal);
|
||||
const storageStat = storageExists ? await core.storageAccess.statHidden(path) : null;
|
||||
const databasePath = toDatabasePath(path);
|
||||
const currentMeta = await getLocalDatabaseMeta(core, databasePath, {
|
||||
conflicts: true,
|
||||
revs: true,
|
||||
revs_info: true,
|
||||
});
|
||||
const requestedDatabasePath = options?.databasePath ?? toDatabasePath(path);
|
||||
const currentMeta = await getLocalDatabaseMeta(
|
||||
core,
|
||||
requestedDatabasePath,
|
||||
{
|
||||
conflicts: true,
|
||||
revs: true,
|
||||
revs_info: true,
|
||||
},
|
||||
options
|
||||
);
|
||||
const databasePath = options && currentMeta ? currentMeta.path : requestedDatabasePath;
|
||||
|
||||
const revisions: RevisionDatabaseInfo[] = [];
|
||||
const conflictRevisions = currentMeta === false ? [] : (currentMeta._conflicts ?? []);
|
||||
@@ -292,25 +324,31 @@ export async function inspectFileDatabaseInfo(core: FileDatabaseInfoCore, path:
|
||||
metadataByRevision.set(currentMeta._rev, currentMeta);
|
||||
}
|
||||
const getRevisionMeta = async (revision: string): Promise<DatabaseMeta | false> => {
|
||||
checkInspectionCancelled(options?.signal);
|
||||
const cached = metadataByRevision.get(revision);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
const meta = await getLocalDatabaseMeta(core, databasePath, {
|
||||
rev: revision,
|
||||
revs: true,
|
||||
revs_info: true,
|
||||
});
|
||||
const meta = await getLocalDatabaseMeta(
|
||||
core,
|
||||
requestedDatabasePath,
|
||||
{
|
||||
rev: revision,
|
||||
revs: true,
|
||||
revs_info: true,
|
||||
},
|
||||
options
|
||||
);
|
||||
metadataByRevision.set(revision, meta);
|
||||
return meta;
|
||||
};
|
||||
|
||||
if (currentMeta) {
|
||||
revisions.push(await collectRevisionDatabaseInfo(core, currentMeta, true));
|
||||
revisions.push(await collectRevisionDatabaseInfo(core, currentMeta, true, options?.signal));
|
||||
for (const revision of conflictRevisions) {
|
||||
const conflictMeta = await getRevisionMeta(revision);
|
||||
if (conflictMeta) {
|
||||
revisions.push(await collectRevisionDatabaseInfo(core, conflictMeta, false));
|
||||
revisions.push(await collectRevisionDatabaseInfo(core, conflictMeta, false, options?.signal));
|
||||
const winnerHistory = revisionHistory(currentMeta);
|
||||
const conflictHistory = revisionHistory(conflictMeta);
|
||||
const conflictHistoryByRevision = new Map(
|
||||
@@ -323,13 +361,12 @@ export async function inspectFileDatabaseInfo(core: FileDatabaseInfoCore, path:
|
||||
const unavailableSharedRevisions = sharedHistory
|
||||
.filter(
|
||||
({ revision: historyRevision, status }) =>
|
||||
status !== "available" ||
|
||||
conflictHistoryByRevision.get(historyRevision) !== "available"
|
||||
status !== "available" || conflictHistoryByRevision.get(historyRevision) !== "available"
|
||||
)
|
||||
.map(({ revision: historyRevision }) => historyRevision);
|
||||
const sharedMeta = sharedRevision ? await getRevisionMeta(sharedRevision) : false;
|
||||
const sharedInfo = sharedMeta
|
||||
? await collectRevisionDatabaseInfo(core, sharedMeta, false)
|
||||
? await collectRevisionDatabaseInfo(core, sharedMeta, false, options?.signal)
|
||||
: undefined;
|
||||
mergeBases.push({
|
||||
winnerRevision: currentMeta._rev ?? "",
|
||||
@@ -346,6 +383,7 @@ export async function inspectFileDatabaseInfo(core: FileDatabaseInfoCore, path:
|
||||
}
|
||||
}
|
||||
|
||||
checkInspectionCancelled(options?.signal);
|
||||
const report: FileDatabaseInfo = {
|
||||
path,
|
||||
databasePath,
|
||||
@@ -363,6 +401,7 @@ export async function inspectFileDatabaseInfo(core: FileDatabaseInfoCore, path:
|
||||
source: "local database on this device",
|
||||
remoteQueried: false,
|
||||
exists: currentMeta !== false,
|
||||
...(options?.documentId !== undefined ? { documentId: options.documentId } : {}),
|
||||
currentRevision: currentMeta ? (currentMeta._rev ?? null) : null,
|
||||
conflictCount: conflictRevisions.length,
|
||||
conflictRevisions,
|
||||
|
||||
@@ -198,8 +198,10 @@ describe("file database information", () => {
|
||||
|
||||
it("reports the exact shared ancestor and its missing chunks for each conflict", async () => {
|
||||
const { conflict, core, current } = createCore();
|
||||
const selectedCurrent = { ...current, _id: "f:selected" };
|
||||
const selectedConflict = { ...conflict, _id: "f:selected" };
|
||||
const parent = {
|
||||
...current,
|
||||
...selectedCurrent,
|
||||
_rev: "2-parent",
|
||||
_conflicts: undefined,
|
||||
_revs_info: [
|
||||
@@ -212,7 +214,7 @@ describe("file database information", () => {
|
||||
core.localDatabase.localDatabase.get.mockImplementation(async (_id: string, options?: { rev?: string }) => {
|
||||
if (options?.rev === "2-conflict") {
|
||||
return {
|
||||
...conflict,
|
||||
...selectedConflict,
|
||||
_revs_info: [
|
||||
{ rev: "2-conflict", status: "available" },
|
||||
{ rev: "2-parent", status: "available" },
|
||||
@@ -224,7 +226,7 @@ describe("file database information", () => {
|
||||
return parent;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
...selectedCurrent,
|
||||
_revs_info: [
|
||||
{ rev: "3-current", status: "available" },
|
||||
{ rev: "2-parent", status: "available" },
|
||||
@@ -233,7 +235,10 @@ describe("file database information", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const info = await inspectFileDatabaseInfo(core as never, "note.md");
|
||||
const info = await inspectFileDatabaseInfo(core as never, "note.md", {
|
||||
documentId: "f:selected",
|
||||
databasePath: "note.md" as never,
|
||||
});
|
||||
|
||||
expect(info.database.mergeBases).toEqual([
|
||||
{
|
||||
@@ -246,6 +251,64 @@ describe("file database information", () => {
|
||||
unavailableSharedRevisions: ["1-root"],
|
||||
},
|
||||
]);
|
||||
expect(core.localDatabase.localDatabase.get.mock.calls.map(([id]) => id)).toEqual([
|
||||
"f:selected",
|
||||
"f:selected",
|
||||
"f:selected",
|
||||
]);
|
||||
});
|
||||
|
||||
it("inspects the exact selected document when its ID differs from the path-derived ID", async () => {
|
||||
const { core, current } = createCore();
|
||||
const selected = {
|
||||
...current,
|
||||
_id: "f:selected",
|
||||
_rev: "4-selected",
|
||||
_conflicts: [],
|
||||
path: "stored-name.md",
|
||||
children: [],
|
||||
};
|
||||
core.localDatabase.localDatabase.get.mockImplementation(async (id: string) =>
|
||||
id === "f:selected" ? selected : current
|
||||
);
|
||||
|
||||
const info = await inspectFileDatabaseInfo(core as never, "note.md", {
|
||||
documentId: "f:selected",
|
||||
databasePath: "listed-name.md" as never,
|
||||
});
|
||||
|
||||
expect(info.database.exists).toBe(true);
|
||||
expect(info.database.revisions).toEqual([
|
||||
expect.objectContaining({
|
||||
documentId: "f:selected",
|
||||
revision: "4-selected",
|
||||
}),
|
||||
]);
|
||||
expect(info.databasePath).toBe("stored-name.md");
|
||||
expect(info.database.documentId).toBe("f:selected");
|
||||
expect(core.services.path.path2id).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a missing exact document without falling back to the path-derived ID", async () => {
|
||||
const { core, current } = createCore();
|
||||
core.localDatabase.localDatabase.get.mockImplementation(async (id: string) => {
|
||||
if (id === "f:missing") {
|
||||
throw Object.assign(new Error("missing"), { status: 404 });
|
||||
}
|
||||
return current;
|
||||
});
|
||||
|
||||
const info = await inspectFileDatabaseInfo(core as never, "note.md", {
|
||||
documentId: "f:missing",
|
||||
databasePath: "requested-name.md" as never,
|
||||
});
|
||||
|
||||
expect(info.database.exists).toBe(false);
|
||||
expect(info.database.revisions).toEqual([]);
|
||||
expect(info.databasePath).toBe("requested-name.md");
|
||||
expect(info.database.documentId).toBe("f:missing");
|
||||
expect(core.localDatabase.localDatabase.get.mock.calls.map(([id]) => id)).toEqual(["f:missing"]);
|
||||
expect(core.services.path.path2id).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not decode a revision whose chunks are not all available locally", async () => {
|
||||
@@ -293,13 +356,7 @@ describe("file database information", () => {
|
||||
|
||||
await retryReadFileDatabaseRevision(core as never, "note.md", "2-conflict");
|
||||
|
||||
expect(core.localDatabase.getDBEntry).toHaveBeenCalledWith(
|
||||
"note.md",
|
||||
{ rev: "2-conflict" },
|
||||
false,
|
||||
true,
|
||||
true
|
||||
);
|
||||
expect(core.localDatabase.getDBEntry).toHaveBeenCalledWith("note.md", { rev: "2-conflict" }, false, true, true);
|
||||
});
|
||||
|
||||
it("reports the exact revision as locally available after retry recovers its missing chunk", async () => {
|
||||
@@ -322,14 +379,10 @@ describe("file database information", () => {
|
||||
: [],
|
||||
}));
|
||||
|
||||
await expect(
|
||||
retryReadFileDatabaseRevision(core as never, "note.md", "2-conflict")
|
||||
).resolves.not.toBe(false);
|
||||
await expect(retryReadFileDatabaseRevision(core as never, "note.md", "2-conflict")).resolves.not.toBe(false);
|
||||
const information = await inspectFileDatabaseInfo(core as never, "note.md");
|
||||
|
||||
expect(
|
||||
information.database.revisions.find(({ revision }) => revision === "2-conflict")
|
||||
).toEqual(
|
||||
expect(information.database.revisions.find(({ revision }) => revision === "2-conflict")).toEqual(
|
||||
expect.objectContaining({
|
||||
contentAvailableLocally: true,
|
||||
chunks: [
|
||||
|
||||
@@ -23,11 +23,12 @@ export const useDatabaseDiagnosticsFeature = createServiceFeature((host: Databas
|
||||
try {
|
||||
await services.UI.dialogManager.open<"close", DatabaseDiagnosticDialogOptions>(DatabaseDiagnosticDialog, {
|
||||
kind,
|
||||
run: async (nameMode, onProgress) => {
|
||||
run: async (nameMode, onProgress, includeRemote = true) => {
|
||||
if (!services.database.isDatabaseReady()) throw new Error("local-database-unavailable");
|
||||
return await runDatabaseDiagnostic(host, kind, nameMode, {
|
||||
signal: controller.signal,
|
||||
onProgress,
|
||||
includeRemote,
|
||||
});
|
||||
},
|
||||
cancel: () => controller.abort(),
|
||||
|
||||
@@ -119,4 +119,29 @@ describe("database diagnostics serviceFeature", () => {
|
||||
data.close();
|
||||
await opened;
|
||||
});
|
||||
|
||||
it("forwards the per-run remote inspection choice and defaults existing callers to include the remote database", async () => {
|
||||
vi.mocked(runDatabaseDiagnostic).mockClear();
|
||||
const data = fixture();
|
||||
const opened = data.view.open("integrity");
|
||||
const options = data.openDialog.mock.calls[0][1];
|
||||
|
||||
await options.run("pseudonym", () => {}, false);
|
||||
expect(runDatabaseDiagnostic).toHaveBeenLastCalledWith(
|
||||
data.host,
|
||||
"integrity",
|
||||
"pseudonym",
|
||||
expect.objectContaining({ includeRemote: false, signal: expect.any(AbortSignal) })
|
||||
);
|
||||
|
||||
await options.run("pseudonym", () => {});
|
||||
expect(runDatabaseDiagnostic).toHaveBeenLastCalledWith(
|
||||
data.host,
|
||||
"integrity",
|
||||
"pseudonym",
|
||||
expect.objectContaining({ includeRemote: true, signal: expect.any(AbortSignal) })
|
||||
);
|
||||
data.close();
|
||||
await opened;
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user