test: tighten local retry recovery assertions

This commit is contained in:
vorotamoroz committed 2026-11-09 08:29:41 +00:00
1 parent 8b77127af5
commit 09c17e90dd
5 files changed
+100 -30

No files matched your search

+1 -1
View File
@@ -112,7 +112,7 @@ The same scenario checks the five actions in Reporting Tools, the separate Check
`third-party-chunks` reflects synthetic externally generated text through the installed Commonlib and the real Obsidian file handler. It supplies a split surrogate pair at the file-access read boundary, with size checking enabled, then verifies exact Vault and on-disk text, unchanged Metadata, and matching persisted provenance. Complete-pair text and a binary attachment provide controls. The underlying Metadata and Chunks are real local database entries; this does not establish that CouchDB or a particular third-party application generates this chunk layout. No remote service is required.
`local-file-retry` verifies the [local retry contract](../../docs/adr/2026_11_local_file_operation_retry.md). Until the corresponding Commonlib package is published and adopted, it requires the packed candidate; the reviewed Commonlib 0.4.0 package is its failing baseline. It needs no remote service. It injects a targeted `ENOENT` from `app.vault.adapter.read`, then uses real Vault create, rename, and modify events to check local retry after a rename, a later same-path edit, and a natural restart. Assertions inspect the current local database body, pending events, and revision ancestry. The same-path edit is observed without a manual flush; the restart reuses the same isolated Vault and profile.
`local-file-retry` verifies the [local retry contract](../../docs/adr/2026_11_local_file_operation_retry.md). Until the corresponding Commonlib package is published and adopted, it requires the packed candidate; the reviewed Commonlib 0.4.0 package is its failing baseline. It needs no remote service. It injects a targeted `ENOENT` from `app.vault.adapter.read`, then uses real Vault create, rename, and modify events to check local retry after a rename, a later same-path edit, and a natural restart. Assertions require plain Metadata and content datatypes with the exact expected database text, as well as exact Vault bytes, pending events, and revision ancestry. The same-path edit is observed without a manual flush; the restart reuses the same isolated Vault and profile and checks the combined start-up recovery result. It does not isolate snapshot replay from the normal Vault scan; Commonlib's dedicated replay tests cover that mechanism.
`stale-file-restart` needs no remote service. It advances the local database while old Vault bytes remain, persists pending storage events, and restarts the same isolated Vault and profile. It checks that an unchanged file whose recorded revision has been pruned receives the newer database content using persisted content evidence without creating a revision, that unknown-origin content is preserved on a fresh independent branch, and that losing provenance and processing the file again does not duplicate or automatically merge that branch. A third file begins with no provenance while its bytes still match the current database revision; start-up must record that revision without creating a new one, and a later incoming revision must reflect without a conflict. A fourth file has legacy revision-only provenance; start-up must verify its exact content and add a hash without creating a database revision or changing storage. The database advances and pending snapshot are controlled fixtures; start-up processing, persistence, file reflection, and conflict checking run in real Obsidian. The scenario does not simulate a mobile operating system suspending the application.
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { matchesPlainDatabaseText, type DatabaseTextObservation } from "./localFileRetryAssertions.ts";
const expected = "Rename recovery content\n";
describe("local retry database text assertions", () => {
it("accepts the exact expected plain database body", () => {
expect(matchesPlainDatabaseText({ type: "plain", datatype: "plain", body: expected }, expected)).toBe(true);
});
it("rejects base64 characters stored as plain text instead of the expected body", () => {
// This is the same wrong plaintext body accepted by the original in-app predicate.
expect(matchesPlainDatabaseText({ type: "plain", datatype: "plain", body: btoa(expected) }, expected)).toBe(
false
);
});
it.each([
{ label: "different text", observation: { type: "plain", datatype: "plain", body: "other text" } },
{ label: "binary Metadata", observation: { type: "newnote", datatype: "plain", body: expected } },
{ label: "binary datatype", observation: { type: "plain", datatype: "newnote", body: expected } },
{ label: "missing type", observation: { datatype: "plain", body: expected } },
{ label: "missing datatype", observation: { type: "plain", body: expected } },
{ label: "unavailable body", observation: { type: "plain", datatype: "plain", body: null } },
{ label: "missing revision", observation: null },
{ label: "unavailable observation", observation: undefined },
] satisfies { label: string; observation: DatabaseTextObservation | null | undefined }[])(
"rejects $label",
({ observation }) => {
expect(matchesPlainDatabaseText(observation, expected)).toBe(false);
}
);
it("preserves exact Unicode text without an encoding fallback", () => {
const text = "詩 — café\n";
expect(matchesPlainDatabaseText({ type: "plain", datatype: "plain", body: text }, text)).toBe(true);
});
it("uses the same closure-free predicate when injected into Obsidian", () => {
// The in-app boundary transports source text; evaluate that exact function independently of this module.
const injected = new Function(
`return (${matchesPlainDatabaseText.toString()});`
)() as typeof matchesPlainDatabaseText;
expect(injected({ type: "plain", datatype: "plain", body: expected }, expected)).toBe(true);
expect(injected({ type: "plain", datatype: "plain", body: btoa(expected) }, expected)).toBe(false);
expect(injected({ type: "plain", datatype: "newnote", body: expected }, expected)).toBe(false);
});
});
@@ -0,0 +1,24 @@
/** Decoded database fields inspected by the plain-text local retry scenarios. */
export interface DatabaseTextObservation {
/** Metadata type reported by the loaded revision. */
readonly type?: unknown;
/** Content datatype reported by the database reader. */
readonly datatype?: unknown;
/** Concatenated revision content, before any encoding assumption. */
readonly body?: unknown;
}
/**
* Match the exact text and representation required by the Markdown fixtures.
* @param observation - The loaded database branch, or an unavailable observation.
* @param expected - Exact text expected from the corresponding Vault file.
* @returns True only for plain Metadata and plain content with exactly the expected text.
* @remarks This closure-free function is also serialised into the isolated Obsidian evaluation.
* Base64 characters stored as plain text are different bytes, even when they encode the expected text.
*/
export function matchesPlainDatabaseText(
observation: DatabaseTextObservation | null | undefined,
expected: string
): boolean {
return observation?.type === "plain" && observation.datatype === "plain" && observation.body === expected;
}
+17 -24
View File
@@ -1,3 +1,4 @@
import { matchesPlainDatabaseText } from "../runner/localFileRetryAssertions.ts";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
@@ -57,13 +58,13 @@ const evaluationHelpers = `
const raw=await core.localDatabase.getRaw(meta._id,{rev,revs:true}).catch(()=>null);
const history=raw?._revisions?.ids?.map((id,index)=>(raw._revisions.start-index)+'-'+id)??[];
const value=Array.isArray(doc?.data)?doc.data.join(''):typeof doc?.data==='string'?doc.data:null;
branches.push({rev,body:value,deleted:!!doc?._deleted,history});
branches.push({rev,body:value,type:doc?.type,datatype:doc?.datatype,deleted:!!doc?._deleted,history});
}
const current=branches.find(branch=>branch.rev===meta._rev);
return {path:meta.path,deleted:!!meta.deleted,present:!!current&&!meta.deleted&&!current.deleted,rev:meta._rev,
currentBody:current?.body??null,branches};
currentBody:current?.body??null,current,branches};
};
const bodyMatches=(actual,expected)=>actual===expected||actual===btoa(expected);
const databaseBodyMatches=${matchesPlainDatabaseText.toString()};
const installReadFailure=paths=>{
const key='__livesyncLocalFileRetry';
globalThis[key]?.restore?.();
@@ -106,16 +107,8 @@ const evaluationHelpers = `
* @param body - The scenario expression using the shared in-app probes.
* @returns The decoded scenario result; evaluation and transport failures reject the promise.
*/
async function evaluate<T>(
cliBinary: string,
session: ObsidianLiveSyncSession,
body: string
): Promise<T> {
return await evalObsidianJson<T>(
cliBinary,
`(async()=>{${evaluationHelpers}\n${body}\n})()`,
session.cliEnv
);
async function evaluate<T>(cliBinary: string, session: ObsidianLiveSyncSession, body: string): Promise<T> {
return await evalObsidianJson<T>(cliBinary, `(async()=>{${evaluationHelpers}\n${body}\n})()`, session.cliEnv);
}
/**
@@ -225,7 +218,7 @@ async function main(): Promise<void> {
const finalState=await databaseState(${JSON.stringify(paths.renamed)});
const oldState=await databaseState(${JSON.stringify(paths.temporary)});
require(finalState&&finalState.path===${JSON.stringify(paths.renamed)}&&!finalState.deleted&&
bodyMatches(finalState.currentBody,${JSON.stringify(content.renamed)}),
databaseBodyMatches(finalState.current,${JSON.stringify(content.renamed)}),
'The final current database document does not contain the renamed file bytes at the final path.');
require(finalState.branches.length===1&&finalState.branches[0].history.length===1,
'Rename recovery created an unexpected branch or revision.');
@@ -273,10 +266,10 @@ async function main(): Promise<void> {
await waitFor(async()=>(await pendingEvents()).some(event=>eventPaths(event).includes(path)&&!initialEventKeys.has(event.key)),
'The edit which triggered recovery was not retained while the earlier read was held.');
const earlierReadBody=hook.heldReadBody;
require(bodyMatches(earlierReadBody,${JSON.stringify(content.initial)})||
bodyMatches(earlierReadBody,${JSON.stringify(content.firstEdit)}),
require(earlierReadBody===${JSON.stringify(content.initial)}||
earlierReadBody===${JSON.stringify(content.firstEdit)},
'The held retry read did not capture the pre-latest file body.');
require(!bodyMatches(earlierReadBody,${JSON.stringify(content.latestEdit)}),
require(earlierReadBody!==${JSON.stringify(content.latestEdit)},
'The held read already contained the later edit.');
require(await isPending(path),
'The failed save stopped being pending while its retry read was held.');
@@ -298,13 +291,13 @@ async function main(): Promise<void> {
await waitFor(async()=>{
const state=await databaseState(path);
return !!state&&state.present&&bodyMatches(state.currentBody,${JSON.stringify(content.latestEdit)})&&
return !!state&&state.present&&databaseBodyMatches(state.current,${JSON.stringify(content.latestEdit)})&&
!(await isPending(path));
},'The latest same-path body was not committed before its pending event resolved.');
const finalState=await databaseState(path);
require(bodyMatches(finalState.currentBody,${JSON.stringify(content.latestEdit)}),
require(databaseBodyMatches(finalState.current,${JSON.stringify(content.latestEdit)}),
'An earlier completion consumed or replaced the latest same-path body.');
require(finalState.branches.some(branch=>bodyMatches(branch.body,${JSON.stringify(content.latestEdit)})),
require(finalState.branches.some(branch=>databaseBodyMatches(branch,${JSON.stringify(content.latestEdit)})),
'The latest same-path body is absent from the current revision leaves.');
require(await app.vault.read(file)===${JSON.stringify(content.latestEdit)},
'The earlier completion replaced the latest real Vault body.');
@@ -355,15 +348,15 @@ async function main(): Promise<void> {
`
await waitFor(async()=>{
const state=await databaseState(${JSON.stringify(paths.restarted)});
return !!state&&!state.deleted&&bodyMatches(state.currentBody,${JSON.stringify(content.restart)})&&
return !!state&&!state.deleted&&databaseBodyMatches(state.current,${JSON.stringify(content.restart)})&&
!(await isPending(${JSON.stringify(paths.restarted)}));
},'Natural restart did not replay the pending file event with restored reads.');
},'Natural startup did not recover the pending file with restored reads.');
const state=await databaseState(${JSON.stringify(paths.restarted)});
const file=app.vault.getAbstractFileByPath(${JSON.stringify(paths.restarted)});
require(!!file&&await app.vault.read(file)===${JSON.stringify(content.restart)},
'The restart fixture Vault file changed during local replay.');
'The restart fixture Vault file changed during startup recovery.');
require(state.branches.length===1&&state.branches[0].history.length===1,
'Restart replay created an unexpected branch or revision.');
'Startup recovery created an unexpected branch or revision.');
return JSON.stringify({currentBody:state.currentBody,revision:state.rev,
history:state.branches[0].history,pendingResolved:!(await isPending(${JSON.stringify(paths.restarted)}))});
`