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

+10 -5
View File
@@ -309,11 +309,16 @@ corrections preserve the tested package's runtime bytes and declaration syntax.
The [real Obsidian scenario](../../test/e2e-obsidian/scripts/local-file-retry.ts) The [real Obsidian scenario](../../test/e2e-obsidian/scripts/local-file-retry.ts)
uses ordinary Vault events and targeted read failures. It verifies a failed uses ordinary Vault events and targeted read failures. It verifies a failed
temporary read followed by rename recovery, a retained same-path successor temporary read followed by rename recovery, a retained same-path successor
saving the latest bytes while an earlier read is held, and replay after closing saving the latest bytes while an earlier read is held, and local recovery after
and reopening the same isolated Vault and profile without replication. Assertions closing and reopening the same isolated Vault and profile without replication.
check current content, pending ownership, and revision history. Replication Assertions check plain database content and its datatype, exact Vault content,
against a remote service and the natural atomic-writer timing are outside this pending ownership, and revision history. The restart scenario verifies the
local verification. combined start-up result; it does not isolate snapshot replay from the ordinary
Vault scan. Snapshot replay itself is covered by Commonlib's dedicated
[manager](https://github.com/vrtmrz/livesync-commonlib/blob/88641a27fb33f94a30381207c1ec28390f216a93/src/managers/StorageEventManager.unit.spec.ts)
and [database replay](https://github.com/vrtmrz/livesync-commonlib/blob/88641a27fb33f94a30381207c1ec28390f216a93/src/serviceModules/ServiceFileHandlerBase.replay.unit.spec.ts)
tests. Replication against a remote service and the natural atomic-writer timing
are outside this local verification.
## References ## References
+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. `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. `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 { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { import {
@@ -57,13 +58,13 @@ const evaluationHelpers = `
const raw=await core.localDatabase.getRaw(meta._id,{rev,revs:true}).catch(()=>null); 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 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; 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); 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, 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 installReadFailure=paths=>{
const key='__livesyncLocalFileRetry'; const key='__livesyncLocalFileRetry';
globalThis[key]?.restore?.(); globalThis[key]?.restore?.();
@@ -106,16 +107,8 @@ const evaluationHelpers = `
* @param body - The scenario expression using the shared in-app probes. * @param body - The scenario expression using the shared in-app probes.
* @returns The decoded scenario result; evaluation and transport failures reject the promise. * @returns The decoded scenario result; evaluation and transport failures reject the promise.
*/ */
async function evaluate<T>( async function evaluate<T>(cliBinary: string, session: ObsidianLiveSyncSession, body: string): Promise<T> {
cliBinary: string, return await evalObsidianJson<T>(cliBinary, `(async()=>{${evaluationHelpers}\n${body}\n})()`, session.cliEnv);
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 finalState=await databaseState(${JSON.stringify(paths.renamed)});
const oldState=await databaseState(${JSON.stringify(paths.temporary)}); const oldState=await databaseState(${JSON.stringify(paths.temporary)});
require(finalState&&finalState.path===${JSON.stringify(paths.renamed)}&&!finalState.deleted&& 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.'); '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, require(finalState.branches.length===1&&finalState.branches[0].history.length===1,
'Rename recovery created an unexpected branch or revision.'); '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)), 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.'); 'The edit which triggered recovery was not retained while the earlier read was held.');
const earlierReadBody=hook.heldReadBody; const earlierReadBody=hook.heldReadBody;
require(bodyMatches(earlierReadBody,${JSON.stringify(content.initial)})|| require(earlierReadBody===${JSON.stringify(content.initial)}||
bodyMatches(earlierReadBody,${JSON.stringify(content.firstEdit)}), earlierReadBody===${JSON.stringify(content.firstEdit)},
'The held retry read did not capture the pre-latest file body.'); '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.'); 'The held read already contained the later edit.');
require(await isPending(path), require(await isPending(path),
'The failed save stopped being pending while its retry read was held.'); 'The failed save stopped being pending while its retry read was held.');
@@ -298,13 +291,13 @@ async function main(): Promise<void> {
await waitFor(async()=>{ await waitFor(async()=>{
const state=await databaseState(path); 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)); !(await isPending(path));
},'The latest same-path body was not committed before its pending event resolved.'); },'The latest same-path body was not committed before its pending event resolved.');
const finalState=await databaseState(path); 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.'); '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.'); 'The latest same-path body is absent from the current revision leaves.');
require(await app.vault.read(file)===${JSON.stringify(content.latestEdit)}, require(await app.vault.read(file)===${JSON.stringify(content.latestEdit)},
'The earlier completion replaced the latest real Vault body.'); 'The earlier completion replaced the latest real Vault body.');
@@ -355,15 +348,15 @@ async function main(): Promise<void> {
` `
await waitFor(async()=>{ await waitFor(async()=>{
const state=await databaseState(${JSON.stringify(paths.restarted)}); 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)})); !(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 state=await databaseState(${JSON.stringify(paths.restarted)});
const file=app.vault.getAbstractFileByPath(${JSON.stringify(paths.restarted)}); const file=app.vault.getAbstractFileByPath(${JSON.stringify(paths.restarted)});
require(!!file&&await app.vault.read(file)===${JSON.stringify(content.restart)}, 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, 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, return JSON.stringify({currentBody:state.currentBody,revision:state.rev,
history:state.branches[0].history,pendingResolved:!(await isPending(${JSON.stringify(paths.restarted)}))}); history:state.branches[0].history,pendingResolved:!(await isPending(${JSON.stringify(paths.restarted)}))});
` `