wip
This commit is contained in:
parent
31a5bac054
commit
4c002f9acd
26 changed files with 14543 additions and 4367 deletions
57
frontend/test-support/README.md
Normal file
57
frontend/test-support/README.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Shared sync harness support
|
||||
|
||||
Test-only code shared by the deterministic and seeded clients.
|
||||
|
||||
| Suite | Coverage |
|
||||
| --- | --- |
|
||||
| `harness.test.ts`, `oracle-regressions.test.ts` | Independent content/identity oracles, exercised faults, exclusive operations, persistence ownership, cleanup and seeds |
|
||||
| `file-application.test.ts` | Swaps, file/directory transitions, visible conflict files, live edits, exclusions, and one metadata save per application |
|
||||
| `recovery.test.ts` | Interrupt every application filesystem/save boundary, reopen metadata, modify disk, and verify stable ordinary scans; manifest policy and notification identity |
|
||||
| `engine-regressions.test.ts`, `sync-audit.test.ts` | Offline identities, unknown network outcomes, rejection/retry, exclusions, interrupted in-memory pagination, namespace races and lifecycle changes |
|
||||
| `event-delivery.test.ts`, `polling.test.ts` | WebSocket hints with HTTP-only event reads, missed notifications, opt-in polling, shutdown and retry |
|
||||
| `protocol.test.ts` | Abrupt worker termination after accepted requests, exact retries, real server SIGKILL, CAS and event ordering |
|
||||
| `restart-reconciliation.test.ts` | Interrupted file application followed by user edits, two-client/server convergence and a second restart |
|
||||
| `server-restore.test.ts` | History checkpoints, server backup restores and interrupted metadata resets |
|
||||
| `content-limits.test.ts`, `protocol-audit.test.ts` | Content validation and API invariants |
|
||||
| `real-disk.test.ts` | Production Node adapter direct writes, no-replace moves, unsafe links, and rename cycles |
|
||||
| `production-adapters.test.ts` | Actual CLI processes, raw watchers, Obsidian adapter, complete metadata and restart after SIGKILL |
|
||||
|
||||
The filesystem interruption tests assert the supported contract: complete metadata
|
||||
remains usable, scans observe the current files, and a second scan is stable.
|
||||
They do not demand the pre-interruption file contents, identities or a replay of
|
||||
unfinished operations. Normal-operation tests still check exact bytes and identities.
|
||||
There is no filesystem journal or hidden recovery archive.
|
||||
|
||||
`MemoryDisk` exposes `before:`, `visible:` and `durable:` boundaries. A process
|
||||
interruption retains visible changes; a simulated power loss rolls unflushed
|
||||
changes back. This provides different partial-application states for testing; it
|
||||
is not an APFS/ext4 writeback model or a production durability requirement.
|
||||
`MemoryPersistence` atomically replaces complete objects and can reject before or
|
||||
after replacement. Abandoned filesystem sessions are fenced against later writes.
|
||||
|
||||
`crash-worker.ts` runs a complete client in a worker, which the parent terminates
|
||||
at accepted-response checkpoints without graceful shutdown. A new runtime receives
|
||||
the saved disk and metadata. Real-disk tests exercise the production Node adapter.
|
||||
The production integration suite launches built CLI processes and an Obsidian
|
||||
filesystem adapter against a real server. Obsidian unit tests exercise unsaved editor
|
||||
text, selections and mobile storage contracts; they do not launch the Obsidian app.
|
||||
Filesystem path checks are not a sandbox against hostile concurrent changes to
|
||||
ancestor directories.
|
||||
|
||||
Scripted and seeded suites run multiple clients against real servers. They check
|
||||
visible bytes, UUID mappings, canonical server state, content heads and event cursors.
|
||||
Polling is disabled except in tests specifically exercising it. Faults must fire;
|
||||
missed injections fail cleanup rather than silently passing. Workload traces,
|
||||
per-worker results and logs are saved by `scripts/e2e.sh` for reproduction.
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```sh
|
||||
E2E_WORKERS=32 E2E_SEED=12 E2E_TIMEOUT_SECONDS=9000 E2E_ITERATIONS=100 scripts/e2e.sh
|
||||
```
|
||||
|
||||
The command rebuilds peers and both production integrations, runs core and adapter
|
||||
unit tests plus harness/protocol/scripted suites, and then
|
||||
runs seeds 12 through 43. `E2E_ARTIFACTS` optionally selects the artifact directory.
|
||||
The runner tests also verify subprocess exit codes, log errors, spawn failures,
|
||||
timeouts, workspace dependency consistency and CI artifact wiring.
|
||||
157
frontend/test-support/canonical.ts
Normal file
157
frontend/test-support/canonical.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { StoredDatabase } from "sync-client";
|
||||
import { assertQuiescent, assertSameFiles } from "./oracles";
|
||||
|
||||
export interface CanonicalSnapshot {
|
||||
headEventId: number;
|
||||
fileManifest: { fileManifestId: number; entries: Record<string, string> };
|
||||
documents: { documentId: string; vaultUpdateId: number }[];
|
||||
}
|
||||
export interface InspectableClient {
|
||||
files(): Map<string, Uint8Array>;
|
||||
database(): Partial<StoredDatabase> | undefined;
|
||||
}
|
||||
|
||||
export async function getJson<T>(url: string, token: string): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Device-Id": "harness-observer"
|
||||
},
|
||||
signal: AbortSignal.timeout(5_000)
|
||||
});
|
||||
assert(response.ok, `Observer GET ${url}: HTTP ${response.status}`);
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
/** Equality alone can pass when every replica loses the same bytes. Check the
|
||||
* canonical UUIDs, content heads, receipts and event cursor as well.
|
||||
* Scenario-specific content/identity oracles still run separately.
|
||||
*/
|
||||
export async function assertCanonical(
|
||||
clients: InspectableClient[],
|
||||
vaultUrl: string,
|
||||
token: string
|
||||
): Promise<CanonicalSnapshot> {
|
||||
assert.equal(
|
||||
new Set(clients).size,
|
||||
clients.length,
|
||||
"Cannot compare a client to itself"
|
||||
);
|
||||
const files = clients.map((client) => client.files());
|
||||
const states = clients.map((client) => client.database());
|
||||
assertSameFiles(files);
|
||||
const snapshot = await getJson<CanonicalSnapshot>(
|
||||
`${vaultUrl}/vault-snapshot`,
|
||||
token
|
||||
);
|
||||
const entries = snapshot.fileManifest.entries;
|
||||
for (const id of Object.keys(entries))
|
||||
assert(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
id
|
||||
),
|
||||
`Non-UUID document identity: ${id}`
|
||||
);
|
||||
const heads = Object.fromEntries(
|
||||
snapshot.documents.map((doc) => [doc.documentId, doc])
|
||||
);
|
||||
assert.equal(
|
||||
Object.keys(heads).length,
|
||||
snapshot.documents.length,
|
||||
"Duplicate document heads"
|
||||
);
|
||||
assert.deepEqual(
|
||||
Object.keys(heads).sort(),
|
||||
Object.keys(entries).sort(),
|
||||
"Snapshot heads differ from membership"
|
||||
);
|
||||
for (const [i, state] of states.entries()) {
|
||||
assert(state?.initialized, `Client ${i} never initialized`);
|
||||
assertQuiescent(state, entries, snapshot.headEventId);
|
||||
assert.equal(
|
||||
state.fileManifest?.fileManifestId,
|
||||
snapshot.fileManifest.fileManifestId,
|
||||
"Wrong manifest CAS parent"
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...files[i].keys()].sort(),
|
||||
Object.values(entries).sort(),
|
||||
"Untracked or missing disk files"
|
||||
);
|
||||
for (const id of Object.keys(entries)) {
|
||||
assert(
|
||||
state.documents?.[id]?.materialized,
|
||||
`Unmaterialized document: ${id}`
|
||||
);
|
||||
assert.equal(
|
||||
state.documents?.[id]?.base?.vaultUpdateId,
|
||||
heads[id]?.vaultUpdateId,
|
||||
`Wrong content CAS parent: ${id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [id, path] of Object.entries(entries)) {
|
||||
const doc = await getJson<{ contentBase64: string }>(
|
||||
`${vaultUrl}/documents/${id}`,
|
||||
token
|
||||
);
|
||||
const serverBytes = new Uint8Array(
|
||||
Buffer.from(doc.contentBase64, "base64")
|
||||
);
|
||||
assert.deepEqual(
|
||||
files[0].get(path),
|
||||
serverBytes,
|
||||
`Server bytes differ for ${id} at ${path}`
|
||||
);
|
||||
const hash = createHash("sha256").update(serverBytes).digest("hex");
|
||||
for (const [i, state] of states.entries()) {
|
||||
assert.equal(
|
||||
state!.documents?.[id]?.base?.hash,
|
||||
hash,
|
||||
`Wrong base hash: client ${i}, ${id}`
|
||||
);
|
||||
assert.equal(
|
||||
state!.documents?.[id]?.observedHash,
|
||||
hash,
|
||||
`Wrong observed hash: client ${i}, ${id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
const events: { eventId: number; requestId: string }[] = [];
|
||||
let after = 0;
|
||||
do {
|
||||
const batch = await getJson<{
|
||||
headEventId: number;
|
||||
endEventId?: number;
|
||||
events: { eventId: number; requestId: string }[];
|
||||
}>(`${vaultUrl}/events-since?after=${after}`, token);
|
||||
assert.equal(
|
||||
batch.headEventId,
|
||||
snapshot.headEventId,
|
||||
"Server changed while checking convergence"
|
||||
);
|
||||
const end = batch.endEventId ?? batch.headEventId;
|
||||
assert.equal(
|
||||
end,
|
||||
batch.events.at(-1)?.eventId ?? after,
|
||||
"Event page cursor differs from its contents"
|
||||
);
|
||||
if (after < snapshot.headEventId)
|
||||
assert(end > after, "Event page made no progress");
|
||||
events.push(...batch.events);
|
||||
after = end;
|
||||
} while (after < snapshot.headEventId);
|
||||
assert.deepEqual(
|
||||
events.map((e) => e.eventId),
|
||||
Array.from({ length: snapshot.headEventId }, (_, i) => i + 1),
|
||||
"Server event log is not contiguous and totally ordered"
|
||||
);
|
||||
assert.equal(
|
||||
new Set(events.map((e) => e.requestId)).size,
|
||||
events.length,
|
||||
"An idempotent request emitted multiple events"
|
||||
);
|
||||
return snapshot;
|
||||
}
|
||||
103
frontend/test-support/content-limits.test.ts
Normal file
103
frontend/test-support/content-limits.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { fixture, bytes, randomUUID } from "./sync-server-fixture";
|
||||
test("stale manifest reconciliation must not fetch excluded remote content", async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const id = randomUUID(),
|
||||
big = randomUUID();
|
||||
await f.put("/documents/" + id, f.content("A"));
|
||||
const m = await f.put("/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: 0,
|
||||
entries: { [id]: "a.md" }
|
||||
});
|
||||
await f.client.setSetting("maxFileSizeMB", 100 / (1024 * 1024));
|
||||
await f.client.start();
|
||||
await f.client.setSetting("isSyncEnabled", false);
|
||||
await f.disk.userRename("a.md", "renamed.md");
|
||||
await f.client.syncLocallyUpdatedFile({
|
||||
oldPath: "a.md",
|
||||
relativePath: "renamed.md"
|
||||
});
|
||||
let injected = false,
|
||||
downloaded = 0;
|
||||
f.setFetchHook(async (input, init) => {
|
||||
const path = String(input);
|
||||
if (
|
||||
!injected &&
|
||||
init?.method === "PUT" &&
|
||||
path.endsWith("/file-manifest")
|
||||
) {
|
||||
injected = true;
|
||||
await f.put("/documents/" + big, f.content("B".repeat(4096)));
|
||||
await f.put("/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: m.fileManifestId,
|
||||
entries: { [id]: "a.md", [big]: "big.bin" }
|
||||
});
|
||||
}
|
||||
const response = await fetch(input, init);
|
||||
if (
|
||||
path.includes("/documents/" + big) &&
|
||||
!path.endsWith("/metadata") &&
|
||||
(!init?.method || init.method === "GET")
|
||||
)
|
||||
downloaded += (await response.clone().arrayBuffer()).byteLength;
|
||||
return response;
|
||||
});
|
||||
await f.client.setSetting("isSyncEnabled", true);
|
||||
|
||||
assert(injected);
|
||||
assert(!f.disk.userFiles().has("big.bin"));
|
||||
assert.equal(downloaded, 0);
|
||||
} finally {
|
||||
await f.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("stale content acknowledgements contain metadata without the excluded payload", async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const id = randomUUID();
|
||||
await f.put("/documents/" + id, f.content("B".repeat(4096)));
|
||||
const stale = await f.put("/documents/" + id, f.content("LOCAL"));
|
||||
assert.equal(stale.type, "StaleBase");
|
||||
assert.equal(stale.contentSize, 4096);
|
||||
assert.equal(stale.contentBase64, undefined);
|
||||
const metadata = await f.get("/documents/" + id + "/metadata");
|
||||
assert.equal(metadata.contentSize, 4096);
|
||||
assert.equal(metadata.contentBase64, undefined);
|
||||
} finally {
|
||||
await f.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("highly fragmented local edits use a snapshot within the server operation budget", async () => {
|
||||
const { fixture } = await import("./sync-fixture");
|
||||
const { toStoredSnapshot } = await import(
|
||||
"../sync-client/src/sync-operations/content"
|
||||
);
|
||||
const f = await fixture({});
|
||||
f.contentCache.resize(1024 * 1024);
|
||||
const original = Array.from({ length: 12_000 }, (_, i) => `word${i}`).join(
|
||||
" "
|
||||
);
|
||||
const edited = Array.from({ length: 12_000 }, (_, i) =>
|
||||
i % 2 ? `changed${i}` : `word${i}`
|
||||
).join(" ");
|
||||
f.contentCache.put(1, bytes(original));
|
||||
const syncer = f.syncer as unknown as {
|
||||
pushContent(
|
||||
path: string,
|
||||
version: number,
|
||||
snapshot: unknown
|
||||
): Promise<{ type: string }>;
|
||||
};
|
||||
const request = await syncer.pushContent(
|
||||
"a.md",
|
||||
1,
|
||||
await toStoredSnapshot({ content: bytes(edited) })
|
||||
);
|
||||
assert.equal(request.type, "Snapshot");
|
||||
});
|
||||
757
frontend/test-support/engine-regressions.test.ts
Normal file
757
frontend/test-support/engine-regressions.test.ts
Normal file
|
|
@ -0,0 +1,757 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { PermanentSyncError } from "../sync-client/src/errors/errors";
|
||||
import { test } from "node:test";
|
||||
import { Database, emptyState } from "../sync-client/src/persistence/database";
|
||||
import { Syncer } from "../sync-client/src/sync-operations/syncer";
|
||||
import { SyncClient, type StoredClient } from "../sync-client/src/sync-client";
|
||||
import { toStoredSnapshot } from "../sync-client/src/sync-operations/content";
|
||||
import { allocatePortablePath } from "../sync-client/src/utils/portable-path";
|
||||
import { MemoryDisk, MemoryPersistence } from "./storage";
|
||||
|
||||
import { bytes, head, fixture } from "./sync-fixture";
|
||||
|
||||
test("offline content notifications need no saves and restart reads the current bytes", async () => {
|
||||
const disk = new MemoryDisk();
|
||||
await disk.userWrite("a.md", bytes("original"));
|
||||
const initial = emptyState(JSON.stringify(["http://offline.test", "test"]));
|
||||
initial.initialized = true;
|
||||
initial.local = { a: "a.md" };
|
||||
initial.documents.a = {
|
||||
materialized: true,
|
||||
observedHash: (await toStoredSnapshot({ content: bytes("original") }))
|
||||
.hash
|
||||
};
|
||||
const store = new MemoryPersistence({
|
||||
database: initial,
|
||||
settings: { remoteUri: "http://offline.test", vaultName: "test" }
|
||||
});
|
||||
let client = await SyncClient.create({ fs: disk, persistence: store });
|
||||
try {
|
||||
const saved = store.snapshot();
|
||||
store.boundary = () => {
|
||||
throw new Error("content notifications must not save");
|
||||
};
|
||||
for (let edit = 0; edit < 20; edit++) {
|
||||
await disk.userWrite("a.md", bytes(`edit ${edit}`));
|
||||
await client.syncLocallyUpdatedFile({ relativePath: "a.md" });
|
||||
}
|
||||
await client.destroy();
|
||||
assert.deepEqual(store.snapshot(), saved);
|
||||
store.boundary = () => { };
|
||||
// A final edit has no notification at all.
|
||||
await disk.userWrite("a.md", bytes("latest offline bytes"));
|
||||
client = await SyncClient.create({ fs: disk, persistence: store });
|
||||
const internals = client as unknown as {
|
||||
database: Database;
|
||||
syncer: {
|
||||
stopped: boolean;
|
||||
scan(): Promise<void>;
|
||||
preparePush(): Promise<boolean>;
|
||||
};
|
||||
};
|
||||
await internals.syncer.scan();
|
||||
internals.syncer.stopped = false;
|
||||
assert.equal(await internals.syncer.preparePush(), true);
|
||||
const pending = internals.database.state.pending;
|
||||
assert(pending?.type === "content");
|
||||
assert.equal(pending.documentId, "a");
|
||||
assert.equal(
|
||||
Buffer.from(pending.snapshot.contentBase64, "base64").toString(),
|
||||
"latest offline bytes"
|
||||
);
|
||||
} finally {
|
||||
store.boundary = () => { };
|
||||
await client.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy content notifications preserve the acknowledged namespace prefix on restart", async () => {
|
||||
const disk = new MemoryDisk();
|
||||
await disk.userWrite("d.md", bytes("A"));
|
||||
await disk.userWrite("b.md", bytes("B"));
|
||||
const initial = emptyState(JSON.stringify(["http://offline.test", "test"]));
|
||||
initial.initialized = true;
|
||||
initial.local = { a: "c.md", b: "b.md" };
|
||||
initial.fileManifest.entries = { ...initial.local };
|
||||
for (const [id, content] of [
|
||||
["a", "A"],
|
||||
["b", "B"]
|
||||
])
|
||||
initial.documents[id] = {
|
||||
materialized: true,
|
||||
observedHash: (await toStoredSnapshot({ content: bytes(content) }))
|
||||
.hash
|
||||
};
|
||||
initial.lastAppliedLocalChangeId = "consumed-update";
|
||||
const saved: StoredClient = {
|
||||
database: initial,
|
||||
settings: { remoteUri: "http://offline.test", vaultName: "test" },
|
||||
localChangesVaultKey: initial.vaultKey,
|
||||
localChanges: [
|
||||
{
|
||||
type: "move",
|
||||
oldPath: "a.md",
|
||||
relativePath: "b.md",
|
||||
identities: { a: "a.md" },
|
||||
changeId: "consumed-move"
|
||||
},
|
||||
{ type: "update", path: "c.md", changeId: "consumed-update" },
|
||||
{ type: "update", path: "b.md", changeId: "new-update" },
|
||||
{
|
||||
type: "move",
|
||||
oldPath: "c.md",
|
||||
relativePath: "d.md",
|
||||
identities: { a: "c.md" },
|
||||
changeId: "new-move"
|
||||
}
|
||||
]
|
||||
};
|
||||
const store = new MemoryPersistence(saved);
|
||||
const client = await SyncClient.create({ fs: disk, persistence: store });
|
||||
try {
|
||||
const internals = client as unknown as {
|
||||
database: Database;
|
||||
syncer: { scan(): Promise<void> };
|
||||
};
|
||||
await internals.syncer.scan();
|
||||
assert.deepEqual(internals.database.state.local, {
|
||||
a: "d.md",
|
||||
b: "b.md"
|
||||
});
|
||||
assert.equal(
|
||||
internals.database.state.lastAppliedLocalChangeId,
|
||||
"new-move"
|
||||
);
|
||||
} finally {
|
||||
await client.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("remote binary replacement and deletion do not create backup artifacts", async () => {
|
||||
const f = await fixture({ "a.bin": "private edit" });
|
||||
await f.files.apply(structuredClone(f.database.state), {
|
||||
a: { replacement: await toStoredSnapshot({ content: bytes("remote") }) }
|
||||
});
|
||||
assert.deepEqual(await f.disk.listFilesRecursively(), ["a.bin"]);
|
||||
assert.deepEqual(f.disk.userFiles(), new Map([["a.bin", bytes("remote")]]));
|
||||
await f.internals.incorporateFileManifest({
|
||||
fileManifestId: 2,
|
||||
entries: {}
|
||||
});
|
||||
assert.deepEqual(await f.disk.listFilesRecursively(), []);
|
||||
});
|
||||
|
||||
test("rejected content recovery remains idempotent across a rename and network failure", async () => {
|
||||
let offline = true;
|
||||
const f = await fixture(undefined, {
|
||||
getDocumentVersionContent: async () => {
|
||||
if (offline) throw new Error("offline");
|
||||
return bytes("base");
|
||||
}
|
||||
});
|
||||
const snapshot = (await f.files.snapshot("a.md"))!;
|
||||
f.database.state.documents.a.base = {
|
||||
...head("a", 1, "base"),
|
||||
hash: (await toStoredSnapshot({ content: bytes("base") })).hash
|
||||
};
|
||||
f.database.state.pending = {
|
||||
type: "content",
|
||||
documentId: "a",
|
||||
snapshot,
|
||||
request: {
|
||||
requestId: "request",
|
||||
parentVersionId: 1,
|
||||
content: { type: "Snapshot", value: snapshot.contentBase64 }
|
||||
},
|
||||
response: {
|
||||
type: "StaleBase",
|
||||
...head("a", 2, "remote")
|
||||
}
|
||||
};
|
||||
await f.database.save();
|
||||
await f.disk.userRename("a.md", "renamed.md");
|
||||
await f.syncer.syncLocallyUpdatedFile({
|
||||
oldPath: "a.md",
|
||||
relativePath: "renamed.md"
|
||||
});
|
||||
await assert.rejects(f.internals.finishPending(), /offline/);
|
||||
offline = false;
|
||||
await f.internals.finishPending();
|
||||
assert.equal(f.database.state.pending, undefined);
|
||||
assert.equal(f.database.state.local.a, "renamed.md");
|
||||
});
|
||||
|
||||
test("conflict names respect component limits without imposing a total path limit", () => {
|
||||
const wanted = "a".repeat(235) + ".md";
|
||||
const allocated = allocatePortablePath(
|
||||
wanted,
|
||||
"00000000-0000-4000-8000-000000000001",
|
||||
{ other: wanted }
|
||||
);
|
||||
assert(Buffer.byteLength(allocated) <= 255);
|
||||
assert(allocated.endsWith(".md"));
|
||||
assert.notEqual(allocated, wanted);
|
||||
const longPath = "folder/".repeat(100) + "note.md";
|
||||
assert.equal(allocatePortablePath(longPath, "id", {}), longPath);
|
||||
});
|
||||
|
||||
test("an unmaterialized file with a directory at its path is deconflicted", async () => {
|
||||
const f = await fixture(
|
||||
{},
|
||||
{ getDocumentVersionContent: async () => bytes("remote") }
|
||||
);
|
||||
await f.disk.userWrite("folder/note.md", bytes("local"));
|
||||
f.database.state.local.a = "folder";
|
||||
f.database.state.documents.a = { materialized: false };
|
||||
f.database.state.fileManifest.entries.a = "folder";
|
||||
await f.database.save();
|
||||
await f.internals.incorporateContent(head("a", 2, "remote"));
|
||||
assert(
|
||||
[...f.disk.userFiles().values()].some(
|
||||
(value) => Buffer.from(value).toString() === "remote"
|
||||
)
|
||||
);
|
||||
assert(
|
||||
[...f.disk.userFiles().values()].some(
|
||||
(value) => Buffer.from(value).toString() === "local"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test("ignored materialized content survives remote deletion", async () => {
|
||||
const f = await fixture();
|
||||
await f.settings.setSettings({ ignorePatterns: ["a.md"] });
|
||||
await f.internals.incorporateFileManifest({
|
||||
fileManifestId: 2,
|
||||
entries: {}
|
||||
});
|
||||
assert.deepEqual(f.disk.userFiles(), new Map([["a.md", bytes("local")]]));
|
||||
assert.deepEqual(f.database.state.fileManifest.entries, {});
|
||||
});
|
||||
|
||||
test("unfinished bootstrap cannot be rebound to another vault", async () => {
|
||||
const database = emptyState(JSON.stringify(["http://example.test", "old"]));
|
||||
database.bootstrap = {
|
||||
headEventId: 1,
|
||||
fileManifest: { fileManifestId: 1, entries: {} },
|
||||
documents: []
|
||||
};
|
||||
const persistence = new MemoryPersistence({
|
||||
settings: { remoteUri: "http://example.test", vaultName: "old" },
|
||||
database
|
||||
});
|
||||
const client = await SyncClient.create({
|
||||
fs: new MemoryDisk(),
|
||||
persistence
|
||||
});
|
||||
try {
|
||||
await assert.rejects(
|
||||
client.setSettings({ vaultName: "new" }),
|
||||
/separate state store/
|
||||
);
|
||||
assert.equal(persistence.snapshot().settings!.vaultName, "old");
|
||||
} finally {
|
||||
await client.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("a rejected upload is retained locally and does not block another file or a corrected snapshot", async () => {
|
||||
let reject = true;
|
||||
const sent: string[] = [];
|
||||
const f = await fixture(
|
||||
{ "a.md": "too large", "b.md": "other" },
|
||||
{
|
||||
putFileContent: async (id) => {
|
||||
sent.push(id);
|
||||
if (id === "a" && reject)
|
||||
throw new PermanentSyncError("HTTP 413");
|
||||
return {
|
||||
type: "Accepted",
|
||||
...head(id, sent.length + 2, "small")
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
f.internals.stopped = false;
|
||||
assert(await f.internals.preparePush());
|
||||
await f.internals.finishPending();
|
||||
assert.equal(f.database.state.pending, undefined);
|
||||
assert.match(f.database.state.documents.a.rejected!.message, /413/);
|
||||
assert(await f.internals.preparePush());
|
||||
await f.internals.finishPending();
|
||||
assert.deepEqual(sent, ["a", "b"]);
|
||||
assert.equal(await f.internals.preparePush(), false);
|
||||
await f.disk.userWrite("a.md", bytes("small"));
|
||||
reject = false;
|
||||
assert(await f.internals.preparePush());
|
||||
await f.internals.finishPending();
|
||||
assert.deepEqual(sent, ["a", "b", "a"]);
|
||||
assert.equal(f.database.state.documents.a.rejected, undefined);
|
||||
});
|
||||
|
||||
test("a transient upload failure retries the exact request and recovers its acknowledgement", async () => {
|
||||
const requests: unknown[] = [];
|
||||
const f = await fixture(undefined, {
|
||||
putFileContent: async (_id, request) => {
|
||||
requests.push(structuredClone(request));
|
||||
if (requests.length === 1) throw new Error("lost upload reply");
|
||||
return { type: "Accepted", ...head("a", 2, "local") };
|
||||
}
|
||||
});
|
||||
f.internals.stopped = false;
|
||||
await f.internals.preparePush();
|
||||
await assert.rejects(f.internals.finishPending(), /lost upload reply/);
|
||||
assert(f.database.state.pending);
|
||||
await f.internals.finishPending();
|
||||
assert.equal(requests.length, 2);
|
||||
assert.deepEqual(requests[1], requests[0]);
|
||||
assert.equal(f.database.state.documents.a.base!.vaultUpdateId, 2);
|
||||
assert.equal(f.database.state.documents.a.rejected, undefined);
|
||||
assert.equal(f.database.state.pending, undefined);
|
||||
});
|
||||
|
||||
test("a persisted upload rejection finishes without filesystem writes", async () => {
|
||||
let uploads = 0;
|
||||
const f = await fixture(undefined, {
|
||||
putFileContent: async () => {
|
||||
uploads++;
|
||||
throw new PermanentSyncError("HTTP 413");
|
||||
}
|
||||
});
|
||||
f.internals.stopped = false;
|
||||
await f.internals.preparePush();
|
||||
f.disk.boundary = (label) => {
|
||||
if (!label.startsWith("read:"))
|
||||
throw new Error("Unexpected filesystem mutation");
|
||||
};
|
||||
await f.internals.finishPending();
|
||||
assert.equal(uploads, 1);
|
||||
assert.equal(f.database.state.pending, undefined);
|
||||
assert.equal(f.database.state.documents.a.rejected?.message, "HTTP 413");
|
||||
assert.deepEqual(f.disk.userFiles(), new Map([["a.md", bytes("local")]]));
|
||||
});
|
||||
|
||||
test("event replay incorporates an earlier accepted upload after a permanent retry rejection", async () => {
|
||||
const f = await fixture(undefined, {
|
||||
putFileContent: async () => {
|
||||
throw new PermanentSyncError(
|
||||
"HTTP 413 after proxy reconfiguration"
|
||||
);
|
||||
},
|
||||
getDocumentVersionContent: async () => bytes("local")
|
||||
});
|
||||
f.internals.stopped = false;
|
||||
await f.internals.preparePush();
|
||||
const { pending } = f.database.state;
|
||||
assert(pending);
|
||||
const { requestId } = pending.request;
|
||||
await f.internals.finishPending();
|
||||
await assert.rejects(f.syncer.waitUntilFinished(), /413/);
|
||||
f.database.state.lastSeenUpdateId = 1;
|
||||
await f.internals.incorporateEventBatch({
|
||||
headEventId: 2,
|
||||
events: [
|
||||
{
|
||||
eventId: 2,
|
||||
requestId,
|
||||
type: "content",
|
||||
document: head("a", 2, "local")
|
||||
}
|
||||
]
|
||||
});
|
||||
assert.equal(f.database.state.documents.a.base?.vaultUpdateId, 2);
|
||||
assert.equal(f.database.state.pending, undefined);
|
||||
assert.equal(await f.internals.preparePush(), false);
|
||||
await f.syncer.waitUntilFinished();
|
||||
assert.deepEqual(f.disk.userFiles(), new Map([["a.md", bytes("local")]]));
|
||||
});
|
||||
|
||||
test("rejected manifests can be replaced after editing the local namespace", async () => {
|
||||
let reject = true;
|
||||
const f = await fixture(
|
||||
{},
|
||||
{
|
||||
pushFileManifest: async () => {
|
||||
if (reject) throw new PermanentSyncError("HTTP 413");
|
||||
return { type: "Accepted", fileManifestId: 3 };
|
||||
}
|
||||
}
|
||||
);
|
||||
f.database.state.fileManifest.entries = { a: "old.md" };
|
||||
f.database.state.pending = {
|
||||
type: "fileManifest",
|
||||
request: {
|
||||
requestId: "request",
|
||||
parentFileManifestId: 1,
|
||||
entries: { a: "new.md" }
|
||||
}
|
||||
};
|
||||
f.database.state.local = { a: "new.md" };
|
||||
f.database.state.documents.a = {
|
||||
materialized: false,
|
||||
base: { ...head("a", 1, "x"), hash: "hash" }
|
||||
};
|
||||
await f.database.save();
|
||||
await f.internals.finishPending();
|
||||
f.internals.stopped = false;
|
||||
assert.equal(await f.internals.preparePush(), false);
|
||||
f.database.state.local = {};
|
||||
reject = false;
|
||||
assert(await f.internals.preparePush());
|
||||
await f.internals.finishPending();
|
||||
assert.deepEqual(f.database.state.fileManifest.entries, {});
|
||||
});
|
||||
|
||||
test("offline delete/recreate identities survive restarting before any network connection", async () => {
|
||||
const key = JSON.stringify(["http://offline.test", "test"]);
|
||||
const initial = emptyState(key);
|
||||
initial.initialized = true;
|
||||
initial.local.a = "a.md";
|
||||
initial.documents.a = { materialized: true };
|
||||
initial.fileManifest = { fileManifestId: 1, entries: { a: "a.md" } };
|
||||
const disk = new MemoryDisk();
|
||||
await disk.userWrite("a.md", bytes("same"));
|
||||
const persistence = new MemoryPersistence({
|
||||
database: initial,
|
||||
settings: { remoteUri: "http://offline.test", vaultName: "test" }
|
||||
});
|
||||
const first = await SyncClient.create({ fs: disk, persistence });
|
||||
await disk.userDelete("a.md");
|
||||
await first.syncLocallyDeletedFile("a.md");
|
||||
await disk.userWrite("a.md", bytes("same"));
|
||||
await first.syncLocallyCreatedFile("a.md");
|
||||
await first.destroy();
|
||||
const second = await SyncClient.create({ fs: disk, persistence });
|
||||
try {
|
||||
const internals = second as unknown as {
|
||||
syncer: { scan(): Promise<void> };
|
||||
database: Database;
|
||||
};
|
||||
await internals.syncer.scan();
|
||||
assert.equal(internals.database.state.local.a, undefined);
|
||||
assert.equal(
|
||||
Object.values(internals.database.state.local).filter(
|
||||
(path) => path === "a.md"
|
||||
).length,
|
||||
1
|
||||
);
|
||||
} finally {
|
||||
await second.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("offline notifications bind an otherwise empty client to its vault", async () => {
|
||||
const disk = new MemoryDisk();
|
||||
await disk.userWrite("a.md", bytes("local"));
|
||||
const persistence = new MemoryPersistence({
|
||||
settings: { remoteUri: "http://offline.test", vaultName: "test" }
|
||||
});
|
||||
const client = await SyncClient.create({ fs: disk, persistence });
|
||||
await client.syncLocallyCreatedFile("a.md");
|
||||
await assert.rejects(
|
||||
client.setSettings({ vaultName: "other" }),
|
||||
/separate state store/
|
||||
);
|
||||
await client.destroy();
|
||||
const stored = persistence.snapshot();
|
||||
stored.settings!.vaultName = "other";
|
||||
await persistence.save(stored);
|
||||
await assert.rejects(
|
||||
SyncClient.create({ fs: disk, persistence }),
|
||||
/another vault/
|
||||
);
|
||||
});
|
||||
|
||||
test("unignoring a deleted file resumes the deferred remote deletion without resurrection", async () => {
|
||||
const f = await fixture();
|
||||
await f.settings.setSettings({ ignorePatterns: ["a.md"] });
|
||||
await f.internals.incorporateFileManifest({
|
||||
fileManifestId: 2,
|
||||
entries: {}
|
||||
});
|
||||
await f.settings.setSettings({ ignorePatterns: [] });
|
||||
await f.internals.incorporateFileManifest(f.database.state.fileManifest);
|
||||
assert.equal(f.disk.userFiles().size, 0);
|
||||
assert.deepEqual(f.database.state.local, {});
|
||||
});
|
||||
|
||||
test("an ignored file does not move when a remote rename targets its path", async () => {
|
||||
const f = await fixture({ "a.md": "A", "b.md": "B" });
|
||||
await f.settings.setSettings({ ignorePatterns: ["a.md"] });
|
||||
await f.internals.incorporateFileManifest({
|
||||
fileManifestId: 2,
|
||||
entries: { a: "c.md", b: "a.md" }
|
||||
});
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles(),
|
||||
new Map([
|
||||
["a.md", bytes("A")],
|
||||
["b.md", bytes("B")]
|
||||
])
|
||||
);
|
||||
f.internals.stopped = false;
|
||||
assert.equal(await f.internals.preparePush(), false);
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles(),
|
||||
new Map([
|
||||
["a.md", bytes("A")],
|
||||
["b.md", bytes("B")]
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
for (const kind of ["path", "content"] as const) {
|
||||
test(`a remote ${kind} change that was reverted before observation preserves the local edit`, async () => {
|
||||
const f = await fixture(
|
||||
{ "a.md": "local" },
|
||||
{
|
||||
getDocumentVersionContent: async ({ vaultUpdateId }) =>
|
||||
bytes(vaultUpdateId === 2 ? "temporary" : "base")
|
||||
}
|
||||
);
|
||||
f.database.state.lastSeenUpdateId = 1;
|
||||
f.database.state.documents.a.base = {
|
||||
...head("a", 1, "base"),
|
||||
hash: (await toStoredSnapshot({ content: bytes("base") })).hash
|
||||
};
|
||||
if (kind === "path") {
|
||||
await f.disk.userRename("a.md", "c.md");
|
||||
await f.syncer.syncLocallyUpdatedFile({
|
||||
oldPath: "a.md",
|
||||
relativePath: "c.md"
|
||||
});
|
||||
}
|
||||
await f.internals.incorporateEventBatch({
|
||||
headEventId: 3,
|
||||
events: [2, 3].map((id) =>
|
||||
kind === "path"
|
||||
? {
|
||||
eventId: id,
|
||||
requestId: String(id),
|
||||
type: "fileManifest",
|
||||
fileManifest: {
|
||||
fileManifestId: id,
|
||||
entries: { a: id === 2 ? "b.md" : "a.md" }
|
||||
}
|
||||
}
|
||||
: {
|
||||
eventId: id,
|
||||
requestId: String(id),
|
||||
type: "content",
|
||||
document: head(
|
||||
"a",
|
||||
id,
|
||||
id === 2 ? "temporary" : "base"
|
||||
)
|
||||
}
|
||||
)
|
||||
});
|
||||
assert.equal(
|
||||
f.database.state.local.a,
|
||||
kind === "path" ? "c.md" : "a.md"
|
||||
);
|
||||
assert.equal(
|
||||
Buffer.from(
|
||||
(await f.disk.read(f.database.state.local.a))!.content
|
||||
).toString(),
|
||||
"local"
|
||||
);
|
||||
assert.equal(f.database.state.lastSeenUpdateId, 3);
|
||||
});
|
||||
}
|
||||
|
||||
test("concurrent settings updates cannot bind the engine to a different vault than its settings", async () => {
|
||||
const disk = new MemoryDisk();
|
||||
const persistence = new MemoryPersistence();
|
||||
const client = await SyncClient.create({ fs: disk, persistence });
|
||||
try {
|
||||
await Promise.all([
|
||||
client.setSettings({ vaultName: "changed" }),
|
||||
client.setSettings({ maxFileSizeMB: 20 })
|
||||
]);
|
||||
const stored = persistence.snapshot();
|
||||
assert.equal(
|
||||
stored.database!.vaultKey,
|
||||
JSON.stringify([
|
||||
stored.settings!.remoteUri,
|
||||
stored.settings!.vaultName
|
||||
])
|
||||
);
|
||||
assert.equal(stored.settings!.maxFileSizeMB, 20);
|
||||
} finally {
|
||||
await client.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("destroy waits for an in-progress offline notification to become durable", async () => {
|
||||
const disk = new MemoryDisk();
|
||||
const persistence = new MemoryPersistence();
|
||||
const client = await SyncClient.create({ fs: disk, persistence });
|
||||
await disk.userWrite("a.md", bytes("local"));
|
||||
const reached = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<void>();
|
||||
persistence.boundary = async (label) => {
|
||||
if (label === "before:save") {
|
||||
reached.resolve();
|
||||
await release.promise;
|
||||
}
|
||||
};
|
||||
const notification = client.syncLocallyCreatedFile("a.md");
|
||||
await reached.promise;
|
||||
let destroyed = false;
|
||||
const destroying = client.destroy().then(() => {
|
||||
destroyed = true;
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(destroyed, false);
|
||||
release.resolve();
|
||||
await Promise.all([notification, destroying]);
|
||||
});
|
||||
|
||||
test("ignoring a locally renamed file defers its move without forgetting the local rename", async () => {
|
||||
const f = await fixture();
|
||||
f.database.state.documents.a.base = {
|
||||
...head("a", 1, "local"),
|
||||
hash: f.database.state.documents.a.observedHash!
|
||||
};
|
||||
await f.disk.userRename("a.md", "ignored.md");
|
||||
await f.syncer.syncLocallyUpdatedFile({
|
||||
oldPath: "a.md",
|
||||
relativePath: "ignored.md"
|
||||
});
|
||||
await f.settings.setSettings({ ignorePatterns: ["ignored.md"] });
|
||||
await f.internals.scan();
|
||||
f.internals.stopped = false;
|
||||
assert.equal(await f.internals.preparePush(), false);
|
||||
await f.internals.incorporateFileManifest({
|
||||
fileManifestId: 2,
|
||||
entries: { a: "a.md" }
|
||||
});
|
||||
await f.settings.setSettings({ ignorePatterns: [] });
|
||||
await f.internals.incorporateFileManifest(f.database.state.fileManifest);
|
||||
assert.equal(f.database.state.local.a, "ignored.md");
|
||||
assert.equal(
|
||||
Buffer.from(
|
||||
(await f.disk.read("ignored.md"))!.content
|
||||
).toString(),
|
||||
"local"
|
||||
);
|
||||
});
|
||||
|
||||
test("a notified deletion of an ignored file does not delete its server membership", async () => {
|
||||
const f = await fixture();
|
||||
await f.settings.setSettings({ ignorePatterns: ["a.md"] });
|
||||
await f.disk.userDelete("a.md");
|
||||
await f.syncer.syncLocallyDeletedFile("a.md");
|
||||
await f.internals.scan();
|
||||
f.internals.stopped = false;
|
||||
assert.equal(await f.internals.preparePush(), false);
|
||||
assert.equal(f.database.state.local.a, undefined);
|
||||
await f.settings.setSettings({ ignorePatterns: [] });
|
||||
await f.internals.incorporateFileManifest(f.database.state.fileManifest);
|
||||
assert(await f.internals.preparePush());
|
||||
assert.equal(f.database.state.pending?.type, "fileManifest");
|
||||
});
|
||||
|
||||
test("an incoming ignored document cannot force an existing ignored file to move", async () => {
|
||||
const f = await fixture();
|
||||
await f.settings.setSettings({ ignorePatterns: ["a.md", "*conflict*"] });
|
||||
await f.internals.incorporateFileManifest({
|
||||
fileManifestId: 2,
|
||||
entries: { a: "b.md", other: "a.md" }
|
||||
});
|
||||
f.internals.stopped = false;
|
||||
await f.internals.preparePush();
|
||||
assert.deepEqual(f.disk.userFiles(), new Map([["a.md", bytes("local")]]));
|
||||
assert.equal(
|
||||
new Set(Object.values(f.database.state.local)).size,
|
||||
Object.keys(f.database.state.local).length
|
||||
);
|
||||
});
|
||||
|
||||
test("rejected content is reported to callers and an explicit retry clears the rejection", async () => {
|
||||
const f = await fixture(undefined, {
|
||||
putFileContent: async () => {
|
||||
throw new PermanentSyncError("HTTP 413");
|
||||
}
|
||||
});
|
||||
f.internals.stopped = false;
|
||||
await f.internals.preparePush();
|
||||
await f.internals.finishPending();
|
||||
await assert.rejects(f.syncer.waitUntilFinished(), /413/);
|
||||
assert(f.syncer.isBusy);
|
||||
await f.syncer.retryRejectedRequests();
|
||||
assert(await f.internals.preparePush());
|
||||
});
|
||||
|
||||
test("notification saves cannot roll back an uncertain engine commit, even if the first reload fails", async () => {
|
||||
const key = JSON.stringify(["http://offline.test", "test"]);
|
||||
const initial = emptyState(key);
|
||||
initial.initialized = true;
|
||||
const disk = new MemoryDisk();
|
||||
const store = new MemoryPersistence({
|
||||
database: initial,
|
||||
settings: { remoteUri: "http://offline.test", vaultName: "test" }
|
||||
});
|
||||
let failLoad = false;
|
||||
const persistence = {
|
||||
load: async () => {
|
||||
if (failLoad) {
|
||||
failLoad = false;
|
||||
throw new Error("reload failed");
|
||||
}
|
||||
return store.load();
|
||||
},
|
||||
save: async (state: Parameters<typeof store.save>[0]) =>
|
||||
store.save(state)
|
||||
};
|
||||
const client = await SyncClient.create({ fs: disk, persistence });
|
||||
try {
|
||||
const internals = client as unknown as {
|
||||
database: Database;
|
||||
syncer: Syncer;
|
||||
};
|
||||
const next = structuredClone(initial);
|
||||
next.lastSeenUpdateId = 2;
|
||||
store.boundary = (label) => {
|
||||
if (label === "durable:save") {
|
||||
store.boundary = () => { };
|
||||
throw new Error("durable save failed");
|
||||
}
|
||||
};
|
||||
await assert.rejects(
|
||||
internals.database.commit(next),
|
||||
/durable save failed/
|
||||
);
|
||||
await disk.userWrite("a.md", bytes("new"));
|
||||
failLoad = true;
|
||||
await assert.rejects(
|
||||
client.syncLocallyCreatedFile("a.md"),
|
||||
/reload failed/
|
||||
);
|
||||
await internals.syncer.flushLocalChanges();
|
||||
assert.equal(store.snapshot().database!.lastSeenUpdateId, 2);
|
||||
await internals.database.recoverPersistence();
|
||||
assert.equal(internals.database.state.lastSeenUpdateId, 2);
|
||||
} finally {
|
||||
await client.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
"a settings listener can await client shutdown without deadlocking the lifecycle lock",
|
||||
{ timeout: 1_000 },
|
||||
async () => {
|
||||
const client = await SyncClient.create({
|
||||
fs: new MemoryDisk(),
|
||||
persistence: new MemoryPersistence()
|
||||
});
|
||||
let notified = false;
|
||||
client.onSettingsChanged.add(async () => {
|
||||
await client.destroy();
|
||||
notified = true;
|
||||
});
|
||||
await client.setSettings({ maxFileSizeMB: 20 });
|
||||
assert(notified);
|
||||
}
|
||||
);
|
||||
175
frontend/test-support/event-delivery.test.ts
Normal file
175
frontend/test-support/event-delivery.test.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
Database,
|
||||
emptyState,
|
||||
type StoredDatabase
|
||||
} from "../sync-client/src/persistence/database";
|
||||
import { Settings } from "../sync-client/src/persistence/settings";
|
||||
import { FileOperations } from "../sync-client/src/file-operations/file-operations";
|
||||
import type { ServerConfig } from "../sync-client/src/services/server-config";
|
||||
import type { SyncService } from "../sync-client/src/services/sync-service";
|
||||
import type { WebSocketManager } from "../sync-client/src/services/websocket-manager";
|
||||
import type { EventBatch } from "../sync-client/src/services/types/EventBatch";
|
||||
import type { EventRecord } from "../sync-client/src/services/types/EventRecord";
|
||||
import { Syncer } from "../sync-client/src/sync-operations/syncer";
|
||||
import { Logger } from "../sync-client/src/tracing/logger";
|
||||
import { SyncHistory } from "../sync-client/src/tracing/sync-history";
|
||||
import { EventListeners } from "../sync-client/src/utils/data-structures/event-listeners";
|
||||
import { FixedSizeDocumentCache } from "../sync-client/src/utils/data-structures/fix-sized-cache";
|
||||
import { MemoryDisk, MemoryPersistence } from "./storage";
|
||||
|
||||
for (const [count, delivery] of [
|
||||
[1, "before the loop"],
|
||||
[9, "before the loop"],
|
||||
[3, "during the first batch"]
|
||||
] as const) {
|
||||
test(
|
||||
`${count} WS hints delivered ${delivery} catch up through HTTP without polling`,
|
||||
{ timeout: 5_000 },
|
||||
async () => {
|
||||
const logger = new Logger();
|
||||
const initial = emptyState("event-delivery");
|
||||
initial.initialized = true;
|
||||
const persistence = new MemoryPersistence({ database: initial });
|
||||
const database = new Database(
|
||||
initial,
|
||||
async (next) => persistence.save({ database: next }),
|
||||
initial.vaultKey,
|
||||
async () =>
|
||||
(await persistence.load()).database as StoredDatabase
|
||||
);
|
||||
const settings = new Settings(
|
||||
logger,
|
||||
{ syncIntervalMs: 0 },
|
||||
async () => {}
|
||||
);
|
||||
assert.equal(settings.getSettings().syncIntervalMs, 0);
|
||||
const disk = new MemoryDisk();
|
||||
const initializing = Promise.withResolvers<void>();
|
||||
const initialized = Promise.withResolvers<void>();
|
||||
const firstBatch = Promise.withResolvers<void>();
|
||||
const continueBatch = Promise.withResolvers<void>();
|
||||
let heldBatch = false;
|
||||
const applied: number[] = [];
|
||||
persistence.boundary = async (label) => {
|
||||
if (label !== "durable:save") return;
|
||||
const cursor =
|
||||
persistence.snapshot().database!.lastSeenUpdateId;
|
||||
if (cursor && applied.at(-1) !== cursor) applied.push(cursor);
|
||||
if (
|
||||
delivery === "during the first batch" &&
|
||||
cursor === 1 &&
|
||||
!heldBatch
|
||||
) {
|
||||
heldBatch = true;
|
||||
firstBatch.resolve();
|
||||
await continueBatch.promise;
|
||||
}
|
||||
};
|
||||
const config = {
|
||||
initialize: async () => {
|
||||
initializing.resolve();
|
||||
await initialized.promise;
|
||||
},
|
||||
getConfig: async () => ({ mergeableFileExtensions: ["md"] }),
|
||||
reset: () => {}
|
||||
} as unknown as ServerConfig;
|
||||
const events: EventRecord[] = Array.from(
|
||||
{ length: count },
|
||||
(_, index) => ({
|
||||
eventId: index + 1,
|
||||
requestId: `manifest-${index + 1}`,
|
||||
type: "fileManifest",
|
||||
fileManifest: { fileManifestId: index + 1, entries: {} }
|
||||
})
|
||||
);
|
||||
let available: number = delivery === "before the loop" ? count : 1;
|
||||
const fetchedAfter: number[] = [];
|
||||
const service = {
|
||||
getEvents: async (after: number): Promise<EventBatch> => {
|
||||
fetchedAfter.push(after);
|
||||
return {
|
||||
headEventId: available,
|
||||
events: events.filter(
|
||||
(event) =>
|
||||
event.eventId > after &&
|
||||
event.eventId <= available
|
||||
)
|
||||
};
|
||||
}
|
||||
} as unknown as SyncService;
|
||||
const websocket = {
|
||||
onWebSocketStatusChanged: new EventListeners<
|
||||
(connected: boolean) => unknown
|
||||
>(),
|
||||
onRemoteVaultUpdateReceived: new EventListeners<
|
||||
(batch: EventBatch) => unknown
|
||||
>()
|
||||
};
|
||||
const syncer = new Syncer(
|
||||
"device",
|
||||
logger,
|
||||
database,
|
||||
settings,
|
||||
service,
|
||||
websocket as unknown as WebSocketManager,
|
||||
new FileOperations(disk.session(), database, config),
|
||||
config,
|
||||
new SyncHistory(logger),
|
||||
new FixedSizeDocumentCache(1024)
|
||||
);
|
||||
const deliver = async (event: EventRecord) => {
|
||||
await websocket.onRemoteVaultUpdateReceived.triggerAsync({
|
||||
headEventId: event.eventId,
|
||||
// Payloads are deliberately inconsistent with HTTP. The socket
|
||||
// only wakes the loop; it cannot supply reconciliation state.
|
||||
events: [
|
||||
{
|
||||
...event,
|
||||
type: "fileManifest",
|
||||
fileManifest: { fileManifestId: 999, entries: {} }
|
||||
}
|
||||
]
|
||||
});
|
||||
};
|
||||
try {
|
||||
syncer.start();
|
||||
await initializing.promise;
|
||||
if (delivery === "before the loop") {
|
||||
for (const event of events) await deliver(event);
|
||||
initialized.resolve();
|
||||
} else {
|
||||
await deliver(events[0]);
|
||||
initialized.resolve();
|
||||
await firstBatch.promise;
|
||||
available = count;
|
||||
for (const event of events.slice(1)) await deliver(event);
|
||||
continueBatch.resolve();
|
||||
}
|
||||
await syncer.waitUntilFinished();
|
||||
assert.equal(
|
||||
database.state.lastSeenUpdateId,
|
||||
count,
|
||||
"Client became idle with already-delivered events unapplied"
|
||||
);
|
||||
assert.deepEqual(
|
||||
applied,
|
||||
delivery === "before the loop" ? [count] : [1, count],
|
||||
"Each HTTP catchup commits its final cursor"
|
||||
);
|
||||
assert.deepEqual(
|
||||
fetchedAfter,
|
||||
delivery === "before the loop" ? [0] : [0, 1]
|
||||
);
|
||||
assert.equal(database.state.fileManifest.fileManifestId, count);
|
||||
assert.equal(database.state.pending, undefined);
|
||||
assert.equal(syncer.isBusy, false);
|
||||
} finally {
|
||||
initialized.resolve();
|
||||
continueBatch.resolve();
|
||||
await syncer.stop();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
311
frontend/test-support/file-application.test.ts
Normal file
311
frontend/test-support/file-application.test.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { fixture, bytes } from "./sync-fixture";
|
||||
import { toStoredSnapshot } from "../sync-client/src/sync-operations/content";
|
||||
import { LocalChangesDuringReconciliation } from "../sync-client/src/errors/errors";
|
||||
|
||||
for (const equal of [false, true])
|
||||
test(`swaps preserve identities with ${equal ? "equal" : "distinct"} content and save once`, async () => {
|
||||
const f = await fixture({ "a.md": "A", "b.md": equal ? "A" : "B" });
|
||||
let saves = 0;
|
||||
f.persistence.boundary = (label) => {
|
||||
if (label === "before:save") saves++;
|
||||
};
|
||||
const next = structuredClone(f.database.state);
|
||||
next.local = { a: "b.md", b: "a.md" };
|
||||
await f.files.apply(next);
|
||||
assert.deepEqual(f.database.state.local, next.local);
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles(),
|
||||
new Map([
|
||||
["a.md", bytes(equal ? "A" : "B")],
|
||||
["b.md", bytes("A")]
|
||||
])
|
||||
);
|
||||
assert.equal(saves, 1);
|
||||
assert.deepEqual(await f.disk.listFilesRecursively(), ["a.md", "b.md"]);
|
||||
});
|
||||
|
||||
for (const direction of ["file-to-directory", "directory-to-file"])
|
||||
test(direction, async () => {
|
||||
const f = await fixture(
|
||||
direction === "file-to-directory"
|
||||
? { folder: "A", "b.md": "B" }
|
||||
: { "a.md": "A", "folder/b.md": "B" }
|
||||
);
|
||||
const ids = Object.keys(f.database.state.local);
|
||||
const next = structuredClone(f.database.state);
|
||||
next.local =
|
||||
direction === "file-to-directory"
|
||||
? { [ids[0]]: "folder/a.md", [ids[1]]: "b.md" }
|
||||
: { [ids[0]]: "folder", [ids[1]]: "other.md" };
|
||||
const expected = structuredClone(next.local);
|
||||
await f.files.apply(next);
|
||||
assert.deepEqual(f.database.state.local, expected);
|
||||
assert.deepEqual(
|
||||
new Set(f.disk.userFiles().keys()),
|
||||
new Set(Object.values(f.database.state.local))
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...f.disk.userFiles().values()]
|
||||
.map((value) => Buffer.from(value).toString())
|
||||
.sort(),
|
||||
["A", "B"]
|
||||
);
|
||||
});
|
||||
|
||||
test("an unexpected occupant is preserved under a visible conflict name", async () => {
|
||||
const f = await fixture();
|
||||
await f.disk.userWrite("remote.md", bytes("unexpected"));
|
||||
const next = structuredClone(f.database.state);
|
||||
next.local.a = "remote.md";
|
||||
await f.files.apply(next);
|
||||
assert.equal(f.database.state.local.a, "remote.md");
|
||||
assert.equal(f.disk.userFiles().size, 2);
|
||||
assert.deepEqual(
|
||||
[...f.disk.userFiles().values()]
|
||||
.map((value) => Buffer.from(value).toString())
|
||||
.sort(),
|
||||
["local", "unexpected"]
|
||||
);
|
||||
assert.equal(Object.keys(f.database.state.local).length, 2);
|
||||
});
|
||||
|
||||
test("content edited after planning is merged with the incoming snapshot", async () => {
|
||||
const f = await fixture({ "a.md": "base\n" });
|
||||
const expected = (await f.files.snapshot("a.md"))!;
|
||||
await f.disk.userWrite("a.md", bytes("base\nlocal\n"));
|
||||
await f.files.apply(structuredClone(f.database.state), {
|
||||
a: {
|
||||
expected,
|
||||
replacement: await toStoredSnapshot({
|
||||
content: bytes("remote\nbase\n")
|
||||
})
|
||||
}
|
||||
});
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles().get("a.md"),
|
||||
bytes("remote\nbase\nlocal\n")
|
||||
);
|
||||
});
|
||||
|
||||
test("deleting the source after planning does not resurrect it", async () => {
|
||||
const f = await fixture();
|
||||
const next = structuredClone(f.database.state);
|
||||
next.local.a = "remote.md";
|
||||
await f.disk.userDelete("a.md");
|
||||
await f.files.apply(next, {
|
||||
a: { replacement: await toStoredSnapshot({ content: bytes("remote") }) }
|
||||
});
|
||||
assert.deepEqual(f.disk.userFiles(), new Map());
|
||||
assert.deepEqual(f.database.state.local, {});
|
||||
});
|
||||
|
||||
for (const protect of ["ignore", "size"] as const)
|
||||
test(`protect ${protect} at application time`, async () => {
|
||||
const f = await fixture();
|
||||
const next = structuredClone(f.database.state);
|
||||
next.local.a = "remote.md";
|
||||
if (protect === "ignore")
|
||||
await f.settings.setSetting("ignorePatterns", ["a.md"]);
|
||||
else await f.settings.setSetting("maxFileSizeMB", 0);
|
||||
await f.files.apply(next, {
|
||||
a: {
|
||||
replacement: await toStoredSnapshot({
|
||||
content: bytes("remote")
|
||||
})
|
||||
}
|
||||
});
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles(),
|
||||
new Map([["a.md", bytes("local")]])
|
||||
);
|
||||
assert.equal(f.database.state.local.a, "a.md");
|
||||
assert.equal(f.database.state.excluded?.a, "a.md");
|
||||
});
|
||||
|
||||
for (const when of ["planning", "installed"])
|
||||
test(`notified move during ${when} is attributed to the right identity on rescan`, async () => {
|
||||
const f = await fixture({ "a.md": "A", "b.md": "B" });
|
||||
let changed = false;
|
||||
f.disk.boundary = async (label) => {
|
||||
if (
|
||||
changed ||
|
||||
label !==
|
||||
(when === "planning"
|
||||
? "read:stat:a.md"
|
||||
: "visible:rename:a.md->b.md")
|
||||
)
|
||||
return;
|
||||
changed = true;
|
||||
const oldPath = when === "planning" ? "a.md" : "b.md";
|
||||
await f.disk.userRename(oldPath, "editor.md");
|
||||
await f.syncer.syncLocallyUpdatedFile({
|
||||
oldPath,
|
||||
relativePath: "editor.md"
|
||||
});
|
||||
};
|
||||
const next = structuredClone(f.database.state);
|
||||
next.local = { a: "b.md", b: "a.md" };
|
||||
if (when === "planning")
|
||||
await assert.rejects(
|
||||
f.files.apply(next),
|
||||
LocalChangesDuringReconciliation
|
||||
);
|
||||
else await f.files.apply(next);
|
||||
assert(changed);
|
||||
await f.internals.scan();
|
||||
assert.equal(f.database.state.local.a, "editor.md");
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles().get(f.database.state.local.a),
|
||||
bytes("A")
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles().get(f.database.state.local.b),
|
||||
bytes("B")
|
||||
);
|
||||
});
|
||||
|
||||
for (const action of ["move", "delete"] as const)
|
||||
test(`notified ${action}/recreate between file operations does not repurpose the replacement file`, async () => {
|
||||
const f = await fixture({ "a.md": "A", "b.md": "B" });
|
||||
await f.settings.setSetting("ignorePatterns", ["private.md"]);
|
||||
let changed = false;
|
||||
f.disk.boundary = async (label) => {
|
||||
if (changed || label !== "visible:rename:a.md->remote-a.md") return;
|
||||
changed = true;
|
||||
if (action === "move") {
|
||||
await f.disk.userRename("b.md", "private.md");
|
||||
await f.syncer.syncLocallyUpdatedFile({
|
||||
oldPath: "b.md",
|
||||
relativePath: "private.md"
|
||||
});
|
||||
} else {
|
||||
await f.disk.userDelete("b.md");
|
||||
await f.syncer.syncLocallyDeletedFile("b.md");
|
||||
}
|
||||
await f.disk.userWrite("b.md", bytes("replacement"));
|
||||
await f.syncer.syncLocallyCreatedFile("b.md");
|
||||
};
|
||||
const next = structuredClone(f.database.state);
|
||||
next.local = { a: "remote-a.md", b: "remote-b.md" };
|
||||
await f.files.apply(next);
|
||||
assert(changed);
|
||||
await f.internals.scan();
|
||||
assert.deepEqual(f.disk.userFiles().get("b.md"), bytes("replacement"));
|
||||
assert.equal(f.disk.userFiles().has("remote-b.md"), false);
|
||||
assert.equal(
|
||||
f.database.state.local.b,
|
||||
action === "move" ? "private.md" : undefined
|
||||
);
|
||||
if (action === "move")
|
||||
assert.deepEqual(f.disk.userFiles().get("private.md"), bytes("B"));
|
||||
});
|
||||
|
||||
test("a file and nested directory exchange their exact paths", async () => {
|
||||
const f = await fixture({ a: "A", "b/deep/child": "B" });
|
||||
const [a, b] = Object.keys(f.database.state.local);
|
||||
const next = structuredClone(f.database.state);
|
||||
next.local = { [a]: "b", [b]: "a/deep/child" };
|
||||
await f.files.apply(next);
|
||||
assert.deepEqual(f.database.state.local, { [a]: "b", [b]: "a/deep/child" });
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles(),
|
||||
new Map([
|
||||
["b", bytes("A")],
|
||||
["a/deep/child", bytes("B")]
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
for (const phase of ["before", "durable"])
|
||||
test(`retrying after a ${phase}-save failure does not merge text twice`, async () => {
|
||||
const f = await fixture({ "a.md": "base\n" });
|
||||
const original = structuredClone(f.database.state);
|
||||
const write = {
|
||||
expected: (await f.files.snapshot("a.md"))!,
|
||||
replacement: await toStoredSnapshot({
|
||||
content: bytes("remote\nbase\n")
|
||||
})
|
||||
};
|
||||
await f.disk.userWrite("a.md", bytes("base\nlocal\n"));
|
||||
f.persistence.boundary = (label) => {
|
||||
if (label === `${phase}:save`) {
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles().get("a.md"),
|
||||
bytes("remote\nbase\nlocal\n")
|
||||
);
|
||||
throw new Error("save failed");
|
||||
}
|
||||
};
|
||||
await assert.rejects(
|
||||
f.files.apply(structuredClone(original), { a: write }),
|
||||
/save failed/
|
||||
);
|
||||
f.persistence.boundary = () => {};
|
||||
await f.database.recoverPersistence();
|
||||
await f.files.apply(structuredClone(original), { a: write });
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles().get("a.md"),
|
||||
bytes("remote\nbase\nlocal\n")
|
||||
);
|
||||
assert.equal(
|
||||
f.database.state.documents.a.observedHash,
|
||||
(await f.files.snapshot("a.md"))!.hash
|
||||
);
|
||||
});
|
||||
|
||||
test("atomic updater includes an edit made after the last snapshot without unlinking the file", async () => {
|
||||
const f = await fixture({ "a.md": "base\n" });
|
||||
const expected = (await f.files.snapshot("a.md"))!;
|
||||
let edited = false;
|
||||
f.disk.boundary = async (label) => {
|
||||
assert(
|
||||
!label.includes("unlink:"),
|
||||
"Updating text must not delete the file"
|
||||
);
|
||||
if (label === "before:write:a.md" && !edited) {
|
||||
edited = true;
|
||||
await f.disk.userWrite("a.md", bytes("base\nlatest edit\n"));
|
||||
}
|
||||
};
|
||||
await f.files.apply(structuredClone(f.database.state), {
|
||||
a: {
|
||||
expected,
|
||||
replacement: await toStoredSnapshot({
|
||||
content: bytes("remote\nbase\n")
|
||||
})
|
||||
}
|
||||
});
|
||||
assert(edited);
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles().get("a.md"),
|
||||
bytes("remote\nbase\nlatest edit\n")
|
||||
);
|
||||
assert.equal(
|
||||
f.database.state.documents.a.observedHash,
|
||||
(await f.files.snapshot("a.md"))!.hash
|
||||
);
|
||||
});
|
||||
|
||||
test("a failed atomic update leaves database metadata unchanged", async () => {
|
||||
const f = await fixture();
|
||||
const original = structuredClone(f.database.state);
|
||||
f.disk.boundary = (label) => {
|
||||
if (label === "before:write:a.md") throw new Error("update failed");
|
||||
};
|
||||
await assert.rejects(
|
||||
f.files.apply(structuredClone(original), {
|
||||
a: {
|
||||
expected: (await f.files.snapshot("a.md"))!,
|
||||
replacement: await toStoredSnapshot({
|
||||
content: bytes("remote")
|
||||
})
|
||||
}
|
||||
}),
|
||||
/update failed/
|
||||
);
|
||||
assert.deepEqual(f.database.state, original);
|
||||
assert.deepEqual(f.persistence.snapshot().database, original);
|
||||
assert.deepEqual(f.disk.userFiles().get("a.md"), bytes("local"));
|
||||
});
|
||||
317
frontend/test-support/harness.test.ts
Normal file
317
frontend/test-support/harness.test.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { MemoryDisk, MemoryPersistence } from "./storage";
|
||||
import { NetworkFaults, classifyRequest } from "./network";
|
||||
import {
|
||||
ContentLedger,
|
||||
assertManifest,
|
||||
assertSameFiles,
|
||||
pathDecision
|
||||
} from "./oracles";
|
||||
import { Random } from "./random";
|
||||
import { TestRunner } from "../deterministic-tests/src/test-runner";
|
||||
import type { ServerControl } from "../deterministic-tests/src/server-control";
|
||||
import { Logger } from "sync-client";
|
||||
import { ManagedWebSocketFactory } from "../deterministic-tests/src/managed-websocket";
|
||||
|
||||
const bytes = (text: string) => new TextEncoder().encode(text);
|
||||
|
||||
test("consistency oracle rejects self comparison, divergent paths and invalid UTF-8 bytes", () => {
|
||||
const first = new Map([["a.bin", new Uint8Array([0xff])]]);
|
||||
assert.throws(() => assertSameFiles([first, first]), /itself/);
|
||||
assert.throws(() =>
|
||||
assertSameFiles([first, new Map([["a.bin", new Uint8Array([0xfe])]])])
|
||||
);
|
||||
assert.throws(() => assertSameFiles([first, new Map()]));
|
||||
assertSameFiles([first, structuredClone(first)]);
|
||||
});
|
||||
|
||||
test("ledger catches shared loss, duplicated bytes, and unrelated loss in delete workloads", () => {
|
||||
const ledger = new ContentLedger();
|
||||
ledger.add("first-marker");
|
||||
ledger.add("second-marker");
|
||||
ledger.removedBy("first-marker", "explicit delete of doc A");
|
||||
assert.throws(
|
||||
() => ledger.assertPreserved(new Map()),
|
||||
/second-marker.*lost/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
ledger.assertPreserved(
|
||||
new Map([["a", bytes("second-marker second-marker")]])
|
||||
),
|
||||
/duplicated/
|
||||
);
|
||||
ledger.assertPreserved(new Map([["a", bytes("second-marker")]]));
|
||||
});
|
||||
|
||||
test("memory adapter enforces v4 operations and snapshot ownership", async () => {
|
||||
const disk = new MemoryDisk();
|
||||
await disk.userWrite("folder/a", bytes("A"));
|
||||
const snap = (await disk.read("folder/a"))!;
|
||||
snap.content[0] = 0;
|
||||
assert.equal(
|
||||
Buffer.from((await disk.read("folder/a"))!.content).toString(),
|
||||
"A"
|
||||
);
|
||||
await disk.write("folder/a", { content: bytes("updated") });
|
||||
assert.equal(
|
||||
Buffer.from((await disk.read("folder/a"))!.content).toString(),
|
||||
"updated"
|
||||
);
|
||||
await disk.userWrite("folder/b", bytes("B"));
|
||||
await assert.rejects(disk.rename("folder/a", "folder/b"), /exists/);
|
||||
await assert.rejects(disk.deleteDirectory("folder/a"), /regular file/);
|
||||
await assert.rejects(disk.deleteDirectory("folder"), /regular file/);
|
||||
await assert.rejects(disk.read("../outside"), /Unsafe/);
|
||||
await assert.rejects(disk.stat("folder/a/child"), /ancestor/);
|
||||
await assert.rejects(disk.read("folder"), /directory/);
|
||||
});
|
||||
|
||||
for (const phase of ["before", "visible", "durable"] as const) {
|
||||
for (const powerLoss of [false, true]) {
|
||||
test(`${powerLoss ? "power loss" : "process death"} at ${phase} rename boundary`, async () => {
|
||||
const disk = new MemoryDisk();
|
||||
await disk.userWrite("a", bytes("A"));
|
||||
const session = disk.session();
|
||||
let fired = false;
|
||||
disk.boundary = (label) => {
|
||||
if (label === `${phase}:rename:a->b`) {
|
||||
fired = true;
|
||||
disk.crash(powerLoss);
|
||||
throw new Error("crash");
|
||||
}
|
||||
};
|
||||
await assert.rejects(session.rename("a", "b"), /crash/);
|
||||
assert(fired);
|
||||
disk.boundary = () => { };
|
||||
const moved =
|
||||
phase === "durable" || (phase === "visible" && !powerLoss);
|
||||
assert.deepEqual([...disk.userFiles().keys()], [moved ? "b" : "a"]);
|
||||
assert.throws(
|
||||
() => session.write("stale", { content: bytes("bad") }),
|
||||
/crashed/
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("persistence has deep ownership and both old/new uncertain save outcomes", async () => {
|
||||
for (const phase of ["before", "durable"]) {
|
||||
const store = new MemoryPersistence({ settings: { vaultName: "old" } });
|
||||
const loaded = await store.load();
|
||||
loaded.settings!.vaultName = "mutated";
|
||||
assert.equal(store.snapshot().settings!.vaultName, "old");
|
||||
store.boundary = (label) => {
|
||||
if (label === `${phase}:save`) throw new Error("save failed");
|
||||
};
|
||||
await assert.rejects(
|
||||
store.save({ settings: { vaultName: "new" } }),
|
||||
/save failed/
|
||||
);
|
||||
assert.equal(
|
||||
store.snapshot().settings!.vaultName,
|
||||
phase === "before" ? "old" : "new"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("CAS hooks match v4 create, update and manifest and preserve retry payload", async () => {
|
||||
for (const kind of ["create", "content", "manifest"] as const) {
|
||||
const net = new NetworkFaults();
|
||||
const url = `http://test/vaults/test/${kind === "manifest" ? "file-manifest" : "documents/uuid"}`;
|
||||
const body = {
|
||||
requestId: "same",
|
||||
parentVersionId: kind === "create" ? null : 3
|
||||
};
|
||||
const init = { method: "PUT", body: JSON.stringify(body) };
|
||||
assert.equal(classifyRequest(url, init)?.kind, kind);
|
||||
let committed = 0;
|
||||
const wrapped = net.wrap(async () => {
|
||||
committed++;
|
||||
return new Response('{"type":"Accepted"}');
|
||||
});
|
||||
net.arm(kind);
|
||||
await assert.rejects(wrapped(url, init), /Injected after-commit/);
|
||||
await net.wait();
|
||||
assert.throws(() => net.assertConsumed(), /never retried/);
|
||||
assert.equal(committed, 1);
|
||||
await wrapped(url, init);
|
||||
net.assertConsumed();
|
||||
await assert.rejects(
|
||||
wrapped(url, {
|
||||
...init,
|
||||
body: JSON.stringify({ ...body, changed: true })
|
||||
}),
|
||||
/Retry changed/
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
classifyRequest("http://test/documents", { method: "POST" }),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test("unconsumed and before-send faults cannot silently pass", async () => {
|
||||
const net = new NetworkFaults();
|
||||
net.arm("manifest", "before");
|
||||
assert.throws(() => net.assertConsumed(), /never fired/);
|
||||
let called = false;
|
||||
await assert.rejects(
|
||||
net.wrap(async () => {
|
||||
called = true;
|
||||
return new Response();
|
||||
})("http://test/file-manifest", {
|
||||
method: "PUT",
|
||||
body: '{"requestId":"r"}'
|
||||
})
|
||||
);
|
||||
assert(!called);
|
||||
assert.throws(() => net.assertConsumed(), /never retried/);
|
||||
await net.wrap(async () => new Response())("http://test/file-manifest", {
|
||||
method: "PUT",
|
||||
body: '{"requestId":"r"}'
|
||||
});
|
||||
net.assertConsumed();
|
||||
});
|
||||
|
||||
test("HTTP observation checkpoint blocks catchup, resumes, and respects abort", async () => {
|
||||
const network = new NetworkFaults();
|
||||
const wrapped = network.wrap(async () => new Response("{}"));
|
||||
network.pauseObservation();
|
||||
let delivered = false;
|
||||
const request = wrapped("http://test/events-since?after=0").then(() => {
|
||||
delivered = true;
|
||||
});
|
||||
await network.waitForObservation();
|
||||
assert(!delivered, "Catchup bypassed the observation checkpoint");
|
||||
network.resumeObservation();
|
||||
await request;
|
||||
network.assertConsumed();
|
||||
network.pauseObservation();
|
||||
const controller = new AbortController();
|
||||
const interrupted = wrapped("http://test/vault-snapshot", {
|
||||
signal: controller.signal
|
||||
});
|
||||
const rejected = assert.rejects(interrupted, /abort/i);
|
||||
await network.waitForObservation();
|
||||
controller.abort();
|
||||
await rejected;
|
||||
network.resumeObservation();
|
||||
network.assertConsumed();
|
||||
});
|
||||
|
||||
test("independent path decision truth table covers create, delete and rename", () => {
|
||||
for (const base of [undefined, "a", "b"])
|
||||
for (const local of [undefined, "a", "b"])
|
||||
for (const remote of [undefined, "a", "b"]) {
|
||||
const result = pathDecision(base, local, remote);
|
||||
assert.equal(result, remote !== base ? remote : local);
|
||||
}
|
||||
assertManifest({ a: "folder/é.md", b: "other.bin" });
|
||||
for (const entries of [
|
||||
{ a: "A", b: "a" },
|
||||
{ a: "é", b: "e\u0301" },
|
||||
{ a: "x", b: "x/y" }
|
||||
])
|
||||
assert.throws(() => assertManifest(entries));
|
||||
});
|
||||
|
||||
test("seeded actions are repeatable, including seed zero", () => {
|
||||
for (const seed of [0, 1, 12345, 0xffffffff]) {
|
||||
const first = new Random(seed);
|
||||
const second = new Random(seed);
|
||||
assert.deepEqual(
|
||||
Array.from({ length: 100 }, () => first.int(100)),
|
||||
Array.from({ length: 100 }, () => second.int(100))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("runner cleanup errors fail an otherwise successful test", async () => {
|
||||
const server = {
|
||||
isRunning: () => true,
|
||||
resume: () => { }
|
||||
} as unknown as ServerControl;
|
||||
const runner = new TestRunner(
|
||||
server,
|
||||
new Logger(),
|
||||
"unused",
|
||||
"http://unused"
|
||||
);
|
||||
let cleaned = 0;
|
||||
Object.assign(runner, {
|
||||
initializeAgents: async () => { },
|
||||
agents: [
|
||||
{
|
||||
clientId: 0,
|
||||
database: () => ({ evidence: "before cleanup" }),
|
||||
disk: { image: () => ({ bytes: [0, 255] }) },
|
||||
network: { requests: [{ body: { requestId: "original" } }] },
|
||||
cleanup: async () => {
|
||||
cleaned++;
|
||||
throw new Error("late background error");
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
const result = await runner.runTest("cleanup-probe", {
|
||||
clients: 2,
|
||||
steps: []
|
||||
});
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error!, /late background error/);
|
||||
assert.equal(cleaned, 1, "Failed cleanup was run twice");
|
||||
assert.deepEqual(result.diagnostics, [
|
||||
{
|
||||
client: 0,
|
||||
database: { evidence: "before cleanup" },
|
||||
disk: { bytes: [0, 255] },
|
||||
requests: [{ body: { requestId: "original" } }]
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test("disposed harness rejects late websocket reconnects", async () => {
|
||||
const factory = new ManagedWebSocketFactory();
|
||||
const Socket = factory.constructorFn;
|
||||
await factory.finish();
|
||||
assert.throws(() => new Socket("ws://unused"), /after harness cleanup/);
|
||||
});
|
||||
|
||||
test("runner preserves primary failure and cleanup diagnostics", async () => {
|
||||
const server = {
|
||||
isRunning: () => true,
|
||||
resume: () => { }
|
||||
} as unknown as ServerControl;
|
||||
const runner = new TestRunner(
|
||||
server,
|
||||
new Logger(),
|
||||
"unused",
|
||||
"http://unused"
|
||||
);
|
||||
Object.assign(runner, {
|
||||
initializeAgents: async () => { },
|
||||
executeStep: async () => {
|
||||
throw new Error("primary assertion failed");
|
||||
},
|
||||
agents: [
|
||||
{
|
||||
clientId: 0,
|
||||
database: () => ({}),
|
||||
disk: { image: () => ({}) },
|
||||
network: { requests: [] },
|
||||
cleanup: async () => {
|
||||
throw new Error("cleanup failed too");
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
const result = await runner.runTest("cleanup-probe", {
|
||||
clients: 2,
|
||||
steps: [{ type: "barrier" }]
|
||||
});
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error!, /primary assertion failed/);
|
||||
assert.match(result.error!, /cleanup failed too/);
|
||||
});
|
||||
30
frontend/test-support/listener-crash-child.ts
Normal file
30
frontend/test-support/listener-crash-child.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { fixture } from "./sync-fixture";
|
||||
(async () => {
|
||||
const f = await fixture(
|
||||
{},
|
||||
{
|
||||
getEvents: async () => ({ headEventId: 0, events: [] })
|
||||
}
|
||||
);
|
||||
let delivered = 0;
|
||||
f.syncer.onRemainingOperationsCountChanged.add(() => {
|
||||
throw new Error("observer failure");
|
||||
});
|
||||
f.syncer.onRemainingOperationsCountChanged.add(async () => {
|
||||
throw new Error("async observer failure");
|
||||
});
|
||||
f.syncer.onRemainingOperationsCountChanged.add(() => {
|
||||
delivered++;
|
||||
});
|
||||
try {
|
||||
f.syncer.start();
|
||||
await f.syncer.waitUntilFinished();
|
||||
const before = delivered;
|
||||
f.syncer.wake();
|
||||
await f.syncer.waitUntilFinished();
|
||||
assert(delivered > before, "Background sync must remain usable");
|
||||
} finally {
|
||||
await f.syncer.stop();
|
||||
}
|
||||
})();
|
||||
243
frontend/test-support/network.ts
Normal file
243
frontend/test-support/network.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import assert from "node:assert/strict";
|
||||
|
||||
export type RequestKind = "create" | "content" | "manifest";
|
||||
export interface RequestRecord {
|
||||
kind: RequestKind;
|
||||
method: string;
|
||||
url: string;
|
||||
body: Record<string, unknown>;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
interface InjectedFault {
|
||||
kind: RequestKind;
|
||||
point: "before" | "after";
|
||||
fired: boolean;
|
||||
interrupted?: RequestRecord;
|
||||
retried: boolean;
|
||||
reached: Promise<void>;
|
||||
resolve: () => void;
|
||||
}
|
||||
|
||||
export function classifyRequest(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit
|
||||
): RequestRecord | undefined {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input));
|
||||
const method = (
|
||||
init?.method ?? (input instanceof Request ? input.method : "GET")
|
||||
).toUpperCase();
|
||||
if (method !== "PUT") return undefined;
|
||||
// The engine passes JSON strings. Fail loudly if that contract changes.
|
||||
if (!/\/(documents\/[^/]+|file-manifest)\/?$/.test(url.pathname))
|
||||
return undefined;
|
||||
assert.equal(
|
||||
typeof init?.body,
|
||||
"string",
|
||||
"CAS request body must be observable JSON"
|
||||
);
|
||||
const body = JSON.parse(init!.body as string) as Record<string, unknown>;
|
||||
const kind = /\/file-manifest\/?$/.test(url.pathname)
|
||||
? "manifest"
|
||||
: body.parentVersionId === null
|
||||
? "create"
|
||||
: "content";
|
||||
assert.equal(
|
||||
typeof body.requestId,
|
||||
"string",
|
||||
"CAS request missing requestId"
|
||||
);
|
||||
return { kind, method, url: url.href, body };
|
||||
}
|
||||
|
||||
export class NetworkFaults {
|
||||
public readonly requests: RequestRecord[] = [];
|
||||
private observation?: {
|
||||
released: boolean;
|
||||
fired: boolean;
|
||||
held: Promise<void>;
|
||||
release: () => void;
|
||||
reached: Promise<void>;
|
||||
reach: () => void;
|
||||
};
|
||||
public pauseObservation(): void {
|
||||
assert(
|
||||
!this.observation || this.observation.released,
|
||||
"Observation already paused"
|
||||
);
|
||||
let release!: () => void, reach!: () => void;
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const reached = new Promise<void>((resolve) => {
|
||||
reach = resolve;
|
||||
});
|
||||
this.observation = {
|
||||
released: false,
|
||||
fired: false,
|
||||
held,
|
||||
release,
|
||||
reached,
|
||||
reach
|
||||
};
|
||||
}
|
||||
public async waitForObservation(): Promise<void> {
|
||||
assert(this.observation, "No observation checkpoint armed");
|
||||
await this.observation.reached;
|
||||
}
|
||||
public resumeObservation(): void {
|
||||
if (this.observation) {
|
||||
this.observation.released = true;
|
||||
this.observation.release();
|
||||
}
|
||||
}
|
||||
private async observationCheckpoint(
|
||||
signal?: AbortSignal | null
|
||||
): Promise<void> {
|
||||
const gate = this.observation;
|
||||
if (!gate || gate.released) return;
|
||||
gate.fired = true;
|
||||
gate.reach();
|
||||
signal?.throwIfAborted();
|
||||
let abort = () => {};
|
||||
try {
|
||||
await Promise.race([
|
||||
gate.held,
|
||||
new Promise<never>((_, reject) => {
|
||||
abort = () =>
|
||||
reject(
|
||||
signal?.reason ?? new Error("Observation aborted")
|
||||
);
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
})
|
||||
]);
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
private armed?: InjectedFault;
|
||||
private readonly faults: InjectedFault[] = [];
|
||||
public isExpectedFailure(message: string): boolean {
|
||||
return this.faults.some(
|
||||
(fault) =>
|
||||
fault.fired &&
|
||||
message.includes(
|
||||
`Injected ${fault.point}-commit ${fault.kind} network failure`
|
||||
)
|
||||
);
|
||||
}
|
||||
public arm(kind: RequestKind, point: "before" | "after" = "after"): void {
|
||||
assert(
|
||||
!this.armed || (this.armed.fired && this.armed.retried),
|
||||
"Previous network fault was not exercised and retried"
|
||||
);
|
||||
let resolve!: () => void;
|
||||
const reached = new Promise<void>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
this.armed = {
|
||||
kind,
|
||||
point,
|
||||
fired: false,
|
||||
retried: false,
|
||||
reached,
|
||||
resolve
|
||||
};
|
||||
this.faults.push(this.armed);
|
||||
}
|
||||
public async wait(): Promise<void> {
|
||||
assert(this.armed, "No network fault armed");
|
||||
await this.armed.reached;
|
||||
}
|
||||
public assertConsumed(): void {
|
||||
assert(
|
||||
!this.observation ||
|
||||
(this.observation.fired && this.observation.released),
|
||||
"Observation checkpoint was not exercised and released"
|
||||
);
|
||||
for (const fault of this.faults) {
|
||||
assert(fault.fired, "Armed network fault never fired");
|
||||
assert(fault.retried, "Interrupted CAS request was never retried");
|
||||
}
|
||||
}
|
||||
public wrap(fetch: typeof globalThis.fetch): typeof globalThis.fetch {
|
||||
return async (input, init) => {
|
||||
const url = new URL(
|
||||
input instanceof Request ? input.url : String(input)
|
||||
);
|
||||
const observing =
|
||||
(
|
||||
init?.method ??
|
||||
(input instanceof Request ? input.method : "GET")
|
||||
).toUpperCase() === "GET" && !url.pathname.endsWith("/ping");
|
||||
const signal =
|
||||
init?.signal ??
|
||||
(input instanceof Request ? input.signal : undefined);
|
||||
if (observing) await this.observationCheckpoint(signal);
|
||||
const record = classifyRequest(input, init);
|
||||
if (record) {
|
||||
// A client serializes its persisted CAS requests. Its next CAS
|
||||
// after a lost response must replay the interrupted operation,
|
||||
// even if it has since observed newer remote state.
|
||||
const interrupted = this.armed?.interrupted;
|
||||
if (interrupted && !this.armed!.retried) {
|
||||
assert.equal(
|
||||
record.url,
|
||||
interrupted.url,
|
||||
"Retry changed target"
|
||||
);
|
||||
assert.deepEqual(
|
||||
record.body,
|
||||
interrupted.body,
|
||||
"Retry changed payload/request identity"
|
||||
);
|
||||
this.armed!.retried = true;
|
||||
}
|
||||
const previous = this.requests.find(
|
||||
(r) => r.body.requestId === record.body.requestId
|
||||
);
|
||||
if (previous) {
|
||||
assert.equal(
|
||||
record.url,
|
||||
previous.url,
|
||||
"Retry changed target"
|
||||
);
|
||||
assert.deepEqual(
|
||||
record.body,
|
||||
previous.body,
|
||||
"Retry changed payload/request identity"
|
||||
);
|
||||
}
|
||||
this.requests.push(record);
|
||||
}
|
||||
const fault = this.armed;
|
||||
const matches =
|
||||
record && fault && !fault.fired && fault.kind === record.kind;
|
||||
const fail = () => {
|
||||
fault!.interrupted = structuredClone(record!);
|
||||
fault!.fired = true;
|
||||
fault!.resolve();
|
||||
throw new TypeError(
|
||||
`Injected ${fault!.point}-commit ${fault!.kind} network failure`
|
||||
);
|
||||
};
|
||||
if (matches && fault.point === "before") fail();
|
||||
const response = await fetch(input, init);
|
||||
if (observing) await this.observationCheckpoint(signal);
|
||||
if (record) record.status = response.status;
|
||||
// Only drop accepted replies, never confuse a rejected CAS with commit.
|
||||
if (
|
||||
matches &&
|
||||
!fault.fired &&
|
||||
fault.point === "after" &&
|
||||
response.ok &&
|
||||
((await response.clone().json()) as { type?: string }).type ===
|
||||
"Accepted"
|
||||
) {
|
||||
await response.body?.cancel();
|
||||
fail();
|
||||
}
|
||||
return response;
|
||||
};
|
||||
}
|
||||
}
|
||||
15
frontend/test-support/observer.test.ts
Normal file
15
frontend/test-support/observer.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
test("a status observer exception must not terminate a background sync process", () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
resolve(__dirname, "../node_modules/tsx/dist/cli.mjs"),
|
||||
resolve(__dirname, "listener-crash-child.ts")
|
||||
],
|
||||
{ encoding: "utf8", timeout: 10000 }
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
351
frontend/test-support/oracle-regressions.test.ts
Normal file
351
frontend/test-support/oracle-regressions.test.ts
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { test } from "node:test";
|
||||
import type { StoredDatabase } from "sync-client";
|
||||
import { assertCanonical } from "./canonical";
|
||||
import { assertManifest } from "./oracles";
|
||||
import { NetworkFaults } from "./network";
|
||||
import { MemoryDisk } from "./storage";
|
||||
import { DeterministicAgent } from "../deterministic-tests/src/deterministic-agent";
|
||||
import { AssertableState } from "../deterministic-tests/src/utils/assertable-state";
|
||||
import type { ExpectedDocument } from "../deterministic-tests/src/test-definition";
|
||||
|
||||
const bytes = (text: string) => new TextEncoder().encode(text);
|
||||
const id = "00000000-0000-4000-8000-000000000001";
|
||||
|
||||
test("canonical checks reject repeated clients before reading snapshots", async () => {
|
||||
const client = { files: () => new Map(), database: () => ({}) };
|
||||
await assert.rejects(
|
||||
assertCanonical([client, client], "http://unused", "unused"),
|
||||
/itself/
|
||||
);
|
||||
});
|
||||
|
||||
test("canonical checks independently verify both persisted content hashes", async (context) => {
|
||||
const content = bytes("\ufeffexact bytes\r\n");
|
||||
const hash = createHash("sha256").update(content).digest("hex");
|
||||
const document = {
|
||||
documentId: id,
|
||||
vaultUpdateId: 1,
|
||||
contentSize: content.length,
|
||||
userId: "user",
|
||||
deviceId: "device",
|
||||
updatedDate: "2026-09-20T00:00:00Z"
|
||||
};
|
||||
const manifest = { fileManifestId: 2, entries: { [id]: "a.md" } };
|
||||
const state: Partial<StoredDatabase> = {
|
||||
initialized: true,
|
||||
local: manifest.entries,
|
||||
fileManifest: manifest,
|
||||
lastSeenUpdateId: 2,
|
||||
documents: {
|
||||
[id]: {
|
||||
materialized: true,
|
||||
observedHash: hash,
|
||||
base: { ...document, hash }
|
||||
}
|
||||
}
|
||||
};
|
||||
context.mock.method(
|
||||
globalThis,
|
||||
"fetch",
|
||||
async (url: string) =>
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
url.endsWith("/vault-snapshot")
|
||||
? {
|
||||
headEventId: 2,
|
||||
fileManifest: manifest,
|
||||
documents: [document]
|
||||
}
|
||||
: url.includes("/events-since")
|
||||
? {
|
||||
headEventId: 2,
|
||||
events: [
|
||||
{ eventId: 1, requestId: "create" },
|
||||
{ eventId: 2, requestId: "manifest" }
|
||||
]
|
||||
}
|
||||
: {
|
||||
contentBase64:
|
||||
Buffer.from(content).toString("base64")
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
const client = (database = state) => ({
|
||||
files: () => new Map([["a.md", content.slice()]]),
|
||||
database: () => structuredClone(database)
|
||||
});
|
||||
await assertCanonical([client(), client()], "http://test", "token");
|
||||
for (const field of ["base", "observed"] as const) {
|
||||
const corrupt = structuredClone(state);
|
||||
if (field === "base") corrupt.documents![id].base!.hash = "wrong";
|
||||
else corrupt.documents![id].observedHash = "wrong";
|
||||
await assert.rejects(
|
||||
assertCanonical(
|
||||
[client(), client(corrupt)],
|
||||
"http://test",
|
||||
"token"
|
||||
),
|
||||
new RegExp(`Wrong ${field} hash`)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("lost responses require an identical retry, including request identity and target", async () => {
|
||||
for (const point of ["before", "after"] as const) {
|
||||
const faults = new NetworkFaults();
|
||||
let sent = 0;
|
||||
const send = faults.wrap(async () => {
|
||||
sent++;
|
||||
return new Response('{"type":"Accepted"}');
|
||||
});
|
||||
const url = "http://test/documents/a";
|
||||
const body = {
|
||||
requestId: "original",
|
||||
parentVersionId: 3,
|
||||
content: { type: "Snapshot", value: "YQ==" }
|
||||
};
|
||||
const init = { method: "PUT", body: JSON.stringify(body) };
|
||||
faults.arm("content", point);
|
||||
await assert.rejects(send(url, init), /Injected/);
|
||||
assert.throws(() => faults.assertConsumed(), /never retried/);
|
||||
assert.throws(
|
||||
() => faults.arm("manifest"),
|
||||
/not exercised and retried/
|
||||
);
|
||||
const before = sent;
|
||||
await assert.rejects(
|
||||
send(url, {
|
||||
...init,
|
||||
body: JSON.stringify({ ...body, requestId: "regenerated" })
|
||||
}),
|
||||
/Retry changed/
|
||||
);
|
||||
await assert.rejects(
|
||||
send(`${url}-different`, init),
|
||||
/Retry changed target/
|
||||
);
|
||||
await assert.rejects(
|
||||
send(url, {
|
||||
...init,
|
||||
body: JSON.stringify({ ...body, parentVersionId: 4 })
|
||||
}),
|
||||
/Retry changed/
|
||||
);
|
||||
assert.equal(sent, before, "Invalid retries must not reach the server");
|
||||
assert.throws(() => faults.assertConsumed(), /never retried/);
|
||||
await send(url, init);
|
||||
faults.assertConsumed();
|
||||
}
|
||||
});
|
||||
|
||||
test("an update fault cannot be consumed by creating a new UUID", async () => {
|
||||
const faults = new NetworkFaults();
|
||||
const send = faults.wrap(async () => new Response('{"type":"Accepted"}'));
|
||||
faults.arm("content");
|
||||
await send("http://test/documents/new", {
|
||||
method: "PUT",
|
||||
body: '{"requestId":"new","parentVersionId":null}'
|
||||
});
|
||||
assert.throws(() => faults.assertConsumed(), /never fired/);
|
||||
const update = {
|
||||
method: "PUT",
|
||||
body: '{"requestId":"update","parentVersionId":1}'
|
||||
};
|
||||
await assert.rejects(
|
||||
send("http://test/documents/existing", update),
|
||||
/Injected/
|
||||
);
|
||||
await send("http://test/documents/existing", update);
|
||||
faults.assertConsumed();
|
||||
});
|
||||
|
||||
test("unlink is idempotent even after an uncertain visible deletion", async () => {
|
||||
const disk = new MemoryDisk();
|
||||
await disk.userWrite("a", bytes("A"));
|
||||
disk.boundary = (label) => {
|
||||
if (label === "visible:unlink:a") throw new Error("uncertain unlink");
|
||||
};
|
||||
await assert.rejects(disk.deleteFile("a"), /uncertain/);
|
||||
disk.boundary = () => {};
|
||||
await disk.deleteFile("a");
|
||||
disk.crash(true);
|
||||
assert.equal(await disk.exists("a"), false);
|
||||
await disk.deleteFile("a");
|
||||
await disk.createDirectory("directory");
|
||||
await assert.rejects(disk.deleteFile("directory"), /directory/);
|
||||
});
|
||||
|
||||
test("text assertions preserve BOM, CRLF, empty content and missing final newline", async () => {
|
||||
const agent = new DeterministicAgent(0, {}, () => {});
|
||||
for (const content of [
|
||||
"",
|
||||
"\ufeffhello",
|
||||
"\ufeffa\r\nb\r\n",
|
||||
"last line"
|
||||
]) {
|
||||
await agent.disk.userWrite("a.md", bytes(content));
|
||||
assert.equal(await agent.getFileContent("a.md"), content);
|
||||
}
|
||||
});
|
||||
|
||||
test("durably pruning a directory also removes its already-unlinked durable children", async () => {
|
||||
const disk = new MemoryDisk();
|
||||
await disk.userWrite("transaction/source", bytes("staged bytes"));
|
||||
disk.boundary = (label) => {
|
||||
if (label === "visible:unlink:transaction/source") {
|
||||
disk.crash(false);
|
||||
throw new Error("process died after unlink");
|
||||
}
|
||||
};
|
||||
await assert.rejects(disk.deleteFile("transaction/source"), /process died/);
|
||||
disk.boundary = () => {};
|
||||
await disk.deleteDirectory("transaction");
|
||||
disk.crash(true);
|
||||
assert.deepEqual(disk.userFiles(), new Map());
|
||||
assert.equal(await disk.exists("transaction"), false);
|
||||
});
|
||||
|
||||
test("portable components are limited by UTF-8 bytes, not characters or total path length", () => {
|
||||
for (const component of ["a".repeat(255), "é".repeat(127) + "a"])
|
||||
assertManifest({ [id]: `${component}/${component}/${component}` });
|
||||
for (const component of ["a".repeat(256), "é".repeat(128)])
|
||||
assert.throws(
|
||||
() => assertManifest({ [id]: `nested/${component}` }),
|
||||
/Oversized component/
|
||||
);
|
||||
});
|
||||
|
||||
function state(documents: { path: string; content: string; id: string }[]) {
|
||||
return new AssertableState({
|
||||
files: new Map(documents.map((doc) => [doc.path, doc.content])),
|
||||
clientFiles: [],
|
||||
manifests: [],
|
||||
canonical: Object.fromEntries(
|
||||
documents.map((doc) => [doc.id, doc.path])
|
||||
),
|
||||
bytes: new Map(documents.map((doc) => [doc.path, bytes(doc.content)]))
|
||||
});
|
||||
}
|
||||
|
||||
test("complete scenario assertions reject collapsed creates, forgotten edits, reverted moves and recycled UUIDs", () => {
|
||||
const identities = new Map<string, string>();
|
||||
const expected: ExpectedDocument[] = [
|
||||
{ key: "a", path: "a.md", content: "A\nimportant edit" },
|
||||
{ key: "b", path: "b.md", content: "B" }
|
||||
];
|
||||
const original = [
|
||||
{ id: "id-a", path: "a.md", content: "A\nimportant edit" },
|
||||
{ id: "id-b", path: "b.md", content: "B" }
|
||||
];
|
||||
state(original).assertDocuments(expected, identities);
|
||||
assert.throws(
|
||||
() =>
|
||||
state([
|
||||
{ ...original[0], content: "A\nimportant edit\nB" }
|
||||
]).assertDocuments(expected, identities),
|
||||
/Expected 2 file/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
state([
|
||||
{ ...original[0], content: "A" },
|
||||
original[1]
|
||||
]).assertDocuments(expected, identities),
|
||||
/Byte mismatch/
|
||||
);
|
||||
const moved = [
|
||||
{ ...expected[0], path: "b.md" },
|
||||
{ ...expected[1], path: "a.md" }
|
||||
];
|
||||
assert.throws(
|
||||
() => state(original).assertDocuments(moved, identities),
|
||||
/Byte mismatch/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
state([
|
||||
{ ...original[0], id: "id-b" },
|
||||
{ ...original[1], id: "id-a" }
|
||||
]).assertDocuments(expected, identities),
|
||||
/identity changed/
|
||||
);
|
||||
state([original[1]]).assertDocuments([expected[1]], identities);
|
||||
assert.throws(
|
||||
() =>
|
||||
state(original).assertDocuments(
|
||||
[{ ...expected[0], key: "recreated" }, expected[1]],
|
||||
identities
|
||||
),
|
||||
/reused.*deleted UUID/
|
||||
);
|
||||
assert.equal(identities.has("recreated"), false);
|
||||
const conflict = [
|
||||
{ key: "one", path: "same.md", content: "one" },
|
||||
{ key: "two", path: "same.md", conflict: true, content: "two" }
|
||||
];
|
||||
state([
|
||||
{ id: "one", path: "same.md", content: "one" },
|
||||
{ id: "two", path: "same (conflict two).md", content: "two" }
|
||||
]).assertDocuments(conflict, new Map());
|
||||
});
|
||||
|
||||
test("canonical checks follow pages without weakening gap or duplicate-request detection", async (context) => {
|
||||
const manifest = { fileManifestId: 3, entries: {} };
|
||||
const client = () => ({
|
||||
files: () => new Map<string, Uint8Array>(),
|
||||
database: () => ({
|
||||
initialized: true,
|
||||
local: {},
|
||||
documents: {},
|
||||
fileManifest: manifest,
|
||||
lastSeenUpdateId: 3
|
||||
})
|
||||
});
|
||||
let fault = "none";
|
||||
context.mock.method(globalThis, "fetch", async (input: string) => {
|
||||
if (input.endsWith("/vault-snapshot"))
|
||||
return Response.json({
|
||||
headEventId: 3,
|
||||
fileManifest: manifest,
|
||||
documents: []
|
||||
});
|
||||
const after = Number(new URL(input).searchParams.get("after"));
|
||||
if (after === 0)
|
||||
return Response.json({
|
||||
headEventId: 3,
|
||||
endEventId: 1,
|
||||
events: [{ eventId: 1, requestId: "first" }]
|
||||
});
|
||||
assert.equal(after, 1);
|
||||
return Response.json({
|
||||
headEventId: 3,
|
||||
endEventId: fault === "empty" ? 1 : 3,
|
||||
events:
|
||||
fault === "empty"
|
||||
? []
|
||||
: [
|
||||
...(fault === "gap"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
eventId: 2,
|
||||
requestId:
|
||||
fault === "duplicate"
|
||||
? "first"
|
||||
: "second"
|
||||
}
|
||||
]),
|
||||
{ eventId: 3, requestId: "third" }
|
||||
]
|
||||
});
|
||||
});
|
||||
await assertCanonical([client(), client()], "http://test", "token");
|
||||
for (fault of ["gap", "duplicate", "empty"])
|
||||
await assert.rejects(
|
||||
assertCanonical([client(), client()], "http://test", "token"),
|
||||
/contiguous|multiple events|progress/
|
||||
);
|
||||
});
|
||||
163
frontend/test-support/oracles.ts
Normal file
163
frontend/test-support/oracles.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import assert from "node:assert/strict";
|
||||
import type { StoredDatabase } from "sync-client";
|
||||
|
||||
export function assertSameFiles(
|
||||
clients: readonly Map<string, Uint8Array>[]
|
||||
): void {
|
||||
assert(
|
||||
clients.length >= 2,
|
||||
"Consistency requires at least two distinct clients"
|
||||
);
|
||||
assert.equal(
|
||||
new Set(clients).size,
|
||||
clients.length,
|
||||
"Cannot compare a client to itself"
|
||||
);
|
||||
const sorted = (files: Map<string, Uint8Array>) =>
|
||||
[...files].sort(([a], [b]) => a.localeCompare(b));
|
||||
for (let i = 1; i < clients.length; i++)
|
||||
assert.deepEqual(
|
||||
sorted(clients[i]),
|
||||
sorted(clients[0]),
|
||||
`Client ${i} differs from client 0 (paths or bytes)`
|
||||
);
|
||||
}
|
||||
|
||||
export function assertManifest(entries: Record<string, string>): void {
|
||||
const paths = Object.values(entries);
|
||||
const folded = paths.map((p) =>
|
||||
p.normalize("NFC").toUpperCase().normalize("NFC")
|
||||
);
|
||||
const directorySpellings = new Map<string, string>();
|
||||
for (const path of paths) {
|
||||
assert.equal(
|
||||
path,
|
||||
path.normalize("NFC"),
|
||||
`Noncanonical Unicode path: ${path}`
|
||||
);
|
||||
const parts = path.split("/");
|
||||
parts.forEach((part, i) => {
|
||||
assert(
|
||||
Buffer.byteLength(part, "utf8") <= 255,
|
||||
`Oversized component: ${part}`
|
||||
);
|
||||
assert(
|
||||
!/[<>:"\\|?*\p{Cc}]|[. ]$/u.test(part),
|
||||
`Nonportable component: ${part}`
|
||||
);
|
||||
assert(
|
||||
!/^(CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[1-9¹²³]|LPT[1-9¹²³])$/u.test(
|
||||
part.split(".")[0].trimEnd().toUpperCase()
|
||||
),
|
||||
`Reserved component: ${part}`
|
||||
);
|
||||
if (i < parts.length - 1) {
|
||||
const prefix = parts.slice(0, i + 1).join("/");
|
||||
const key = prefix.toUpperCase().normalize("NFC");
|
||||
const previous = directorySpellings.get(key);
|
||||
assert(
|
||||
previous === undefined || previous === prefix,
|
||||
`Aliased directory spellings: ${prefix} and ${previous}`
|
||||
);
|
||||
directorySpellings.set(key, prefix);
|
||||
}
|
||||
});
|
||||
}
|
||||
assert.equal(
|
||||
new Set(folded).size,
|
||||
paths.length,
|
||||
"Manifest contains aliased/duplicate paths"
|
||||
);
|
||||
for (const path of folded) {
|
||||
assert(
|
||||
path && !path.startsWith("/") && !path.includes("\\"),
|
||||
`Invalid path ${path}`
|
||||
);
|
||||
assert(
|
||||
path.split("/").every((p) => p && p !== "." && p !== ".."),
|
||||
`Invalid component ${path}`
|
||||
);
|
||||
assert(
|
||||
path.split("/")[0] !== ".VAULT-LINK-SYNC",
|
||||
"Internal path in manifest"
|
||||
);
|
||||
assert(
|
||||
!folded.some(
|
||||
(other) => other !== path && other.startsWith(`${path}/`)
|
||||
),
|
||||
`File/ancestor conflict: ${path}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Independent per-ID policy oracle, intentionally not importing merge code. */
|
||||
export function pathDecision(
|
||||
base: string | undefined,
|
||||
local: string | undefined,
|
||||
remote: string | undefined
|
||||
): string | undefined {
|
||||
if (local === base) return remote;
|
||||
if (remote === base) return local;
|
||||
return remote;
|
||||
}
|
||||
|
||||
export function assertQuiescent(
|
||||
state: Partial<StoredDatabase>,
|
||||
entries: Record<string, string>,
|
||||
head: number
|
||||
): void {
|
||||
assert.equal(
|
||||
state.pending,
|
||||
undefined,
|
||||
"Pending CAS request at convergence"
|
||||
);
|
||||
assert.deepEqual(
|
||||
state.local,
|
||||
entries,
|
||||
"Local UUID/path map differs from server"
|
||||
);
|
||||
assert.deepEqual(
|
||||
state.fileManifest?.entries,
|
||||
entries,
|
||||
"Client manifest differs from server"
|
||||
);
|
||||
assert.equal(
|
||||
state.lastSeenUpdateId,
|
||||
head,
|
||||
"Client event watermark differs from server"
|
||||
);
|
||||
assertManifest(entries);
|
||||
}
|
||||
|
||||
/** Removal exemptions name exact markers and the explicit destructive action.
|
||||
* Deletion mode never disables preservation for unrelated documents. */
|
||||
export class ContentLedger {
|
||||
private readonly markers = new Map<string, string | undefined>();
|
||||
public add(marker: string): void {
|
||||
assert(!this.markers.has(marker), "Duplicate marker");
|
||||
this.markers.set(marker, undefined);
|
||||
}
|
||||
public removedBy(content: string, action: string): void {
|
||||
assert(action.length > 0);
|
||||
for (const marker of this.markers.keys())
|
||||
if (content.includes(marker)) this.markers.set(marker, action);
|
||||
}
|
||||
public assertPreserved(files: Map<string, Uint8Array>): void {
|
||||
const texts = [...files.values()].map((bytes) =>
|
||||
Buffer.from(bytes).toString("utf8")
|
||||
);
|
||||
for (const [marker, deletion] of this.markers) {
|
||||
const count = texts.reduce(
|
||||
(n, text) => n + text.split(marker).length - 1,
|
||||
0
|
||||
);
|
||||
assert(count <= 1, `Marker ${marker} duplicated ${count} times`);
|
||||
if (!deletion)
|
||||
assert.equal(
|
||||
count,
|
||||
1,
|
||||
`Marker ${marker} lost without an explicit delete/overwrite`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
316
frontend/test-support/polling.test.ts
Normal file
316
frontend/test-support/polling.test.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
Database,
|
||||
emptyState,
|
||||
type StoredDatabase
|
||||
} from "../sync-client/src/persistence/database";
|
||||
import { Settings } from "../sync-client/src/persistence/settings";
|
||||
import { FileOperations } from "../sync-client/src/file-operations/file-operations";
|
||||
import { Syncer } from "../sync-client/src/sync-operations/syncer";
|
||||
import { toStoredSnapshot } from "../sync-client/src/sync-operations/content";
|
||||
import { Logger } from "../sync-client/src/tracing/logger";
|
||||
import { SyncHistory } from "../sync-client/src/tracing/sync-history";
|
||||
import { FixedSizeDocumentCache } from "../sync-client/src/utils/data-structures/fix-sized-cache";
|
||||
import { EventListeners } from "../sync-client/src/utils/data-structures/event-listeners";
|
||||
import type { SyncService } from "../sync-client/src/services/sync-service";
|
||||
import type { ServerConfig } from "../sync-client/src/services/server-config";
|
||||
import type { WebSocketManager } from "../sync-client/src/services/websocket-manager";
|
||||
import type { EventBatch } from "../sync-client/src/services/types/EventBatch";
|
||||
import type { EventRecord } from "../sync-client/src/services/types/EventRecord";
|
||||
import type { PutFileContent } from "../sync-client/src/services/types/PutFileContent";
|
||||
import { MemoryDisk, MemoryPersistence } from "./storage";
|
||||
|
||||
const documentId = "00000000-0000-4000-8000-000000000001";
|
||||
const interval = 1_000;
|
||||
const retryInterval = 75;
|
||||
const bytes = (text: string) => new TextEncoder().encode(text);
|
||||
|
||||
/** Real sync loop over in-memory disk/persistence and an observable version
|
||||
* store. The transport never wakes the engine itself. */
|
||||
async function fixture(syncIntervalMs: number | undefined) {
|
||||
const disk = new MemoryDisk();
|
||||
await disk.userWrite("note.bin", bytes("base"));
|
||||
let headEventId = 2; // Initial content (1), then manifest (2).
|
||||
const versions = new Map([[1, bytes("base")]]);
|
||||
const head = (version: number) => ({
|
||||
documentId,
|
||||
vaultUpdateId: version,
|
||||
contentSize: versions.get(version)!.length,
|
||||
updatedDate: "2026-09-20T00:00:00Z",
|
||||
userId: "user",
|
||||
deviceId: "device"
|
||||
});
|
||||
let latest = head(1);
|
||||
const events: EventRecord[] = [];
|
||||
const writes: PutFileContent[] = [];
|
||||
let eventReads = 0;
|
||||
let failNextRead = false;
|
||||
const publishContent = (content: Uint8Array, requestId: string) => {
|
||||
const eventId = ++headEventId;
|
||||
versions.set(eventId, new Uint8Array(content));
|
||||
latest = head(eventId);
|
||||
events.push({ eventId, requestId, type: "content", document: latest });
|
||||
return latest;
|
||||
};
|
||||
const batch = (after = 2): EventBatch =>
|
||||
structuredClone({
|
||||
headEventId,
|
||||
events: events.filter((event) => event.eventId > after)
|
||||
});
|
||||
const service: Partial<SyncService> = {
|
||||
getEvents: async (after) => {
|
||||
eventReads++;
|
||||
if (failNextRead) {
|
||||
failNextRead = false;
|
||||
throw new Error("offline");
|
||||
}
|
||||
return batch(after);
|
||||
},
|
||||
getDocumentVersionContent: async ({ vaultUpdateId }) => {
|
||||
const content = versions.get(vaultUpdateId);
|
||||
assert(content, `Missing immutable version ${vaultUpdateId}`);
|
||||
return content.slice();
|
||||
},
|
||||
putFileContent: async (id, request) => {
|
||||
assert.equal(id, documentId);
|
||||
assert.equal(request.parentVersionId, latest.vaultUpdateId);
|
||||
assert(request.content.type === "Snapshot");
|
||||
writes.push(structuredClone(request));
|
||||
return {
|
||||
type: "Accepted",
|
||||
...publishContent(
|
||||
Buffer.from(request.content.value, "base64"),
|
||||
request.requestId
|
||||
)
|
||||
};
|
||||
}
|
||||
};
|
||||
const initial = emptyState("polling-test");
|
||||
initial.initialized = true;
|
||||
initial.lastSeenUpdateId = 2;
|
||||
initial.local = { [documentId]: "note.bin" };
|
||||
initial.fileManifest = { fileManifestId: 2, entries: { ...initial.local } };
|
||||
initial.remoteHeads = { [documentId]: latest };
|
||||
const hash = (await toStoredSnapshot({ content: bytes("base") })).hash;
|
||||
initial.documents[documentId] = {
|
||||
materialized: true,
|
||||
observedHash: hash,
|
||||
base: { ...latest, hash }
|
||||
};
|
||||
const persistence = new MemoryPersistence({ database: initial });
|
||||
const logger = new Logger();
|
||||
const database = new Database(
|
||||
initial,
|
||||
async (next) => persistence.save({ database: next }),
|
||||
initial.vaultKey,
|
||||
async () => (await persistence.load()).database as StoredDatabase
|
||||
);
|
||||
const settings = new Settings(
|
||||
logger,
|
||||
{ syncIntervalMs, networkRetryIntervalMs: retryInterval },
|
||||
async () => {}
|
||||
);
|
||||
const config = {
|
||||
initialize: async () => {},
|
||||
getConfig: async () => ({ mergeableFileExtensions: [] }),
|
||||
reset: () => {}
|
||||
} as unknown as ServerConfig;
|
||||
const websocket = {
|
||||
onWebSocketStatusChanged: new EventListeners<
|
||||
(connected: boolean) => unknown
|
||||
>(),
|
||||
onRemoteVaultUpdateReceived: new EventListeners<
|
||||
(batch: EventBatch) => Promise<void>
|
||||
>()
|
||||
};
|
||||
const syncer = new Syncer(
|
||||
"device",
|
||||
logger,
|
||||
database,
|
||||
settings,
|
||||
service as SyncService,
|
||||
websocket as unknown as WebSocketManager,
|
||||
new FileOperations(disk.session(), database, config),
|
||||
config,
|
||||
new SyncHistory(logger),
|
||||
new FixedSizeDocumentCache(0)
|
||||
);
|
||||
return {
|
||||
disk,
|
||||
database,
|
||||
syncer,
|
||||
writes,
|
||||
batch,
|
||||
websocket,
|
||||
reads: () => eventReads,
|
||||
serverContent: () => versions.get(latest.vaultUpdateId),
|
||||
failNextRead: () => {
|
||||
failNextRead = true;
|
||||
},
|
||||
changeRemote: () => {
|
||||
publishContent(bytes("remote edit"), "remote-content");
|
||||
const eventId = ++headEventId;
|
||||
events.push({
|
||||
eventId,
|
||||
requestId: "remote-rename",
|
||||
type: "fileManifest",
|
||||
fileManifest: {
|
||||
fileManifestId: eventId,
|
||||
entries: { [documentId]: "renamed.bin" }
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
for (const syncIntervalMs of [undefined, 0, interval]) {
|
||||
for (const side of ["local", "remote"] as const) {
|
||||
test(`${side} changes without notifications ${syncIntervalMs ? "are recovered by explicit polling" : `remain unobserved when polling is ${syncIntervalMs === 0 ? "zero" : "unset"}`}`, async (context) => {
|
||||
context.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const f = await fixture(syncIntervalMs);
|
||||
try {
|
||||
f.syncer.start();
|
||||
await f.syncer.waitUntilFinished();
|
||||
const reads = f.reads();
|
||||
if (side === "local")
|
||||
await f.disk.userWrite("note.bin", bytes("local edit"));
|
||||
else f.changeRemote();
|
||||
const assertUnobserved = () => {
|
||||
assert.equal(
|
||||
f.reads(),
|
||||
reads,
|
||||
"A hidden wakeup fetched events"
|
||||
);
|
||||
assert.equal(f.database.state.lastSeenUpdateId, 2);
|
||||
assert.equal(f.writes.length, 0);
|
||||
assert.deepEqual(
|
||||
f.serverContent(),
|
||||
bytes(side === "local" ? "base" : "remote edit")
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles(),
|
||||
new Map([
|
||||
[
|
||||
"note.bin",
|
||||
bytes(side === "local" ? "local edit" : "base")
|
||||
]
|
||||
])
|
||||
);
|
||||
};
|
||||
context.mock.timers.tick(
|
||||
syncIntervalMs ? interval - 1 : 60_000
|
||||
);
|
||||
await f.syncer.waitUntilFinished();
|
||||
assertUnobserved();
|
||||
if (syncIntervalMs) context.mock.timers.tick(1);
|
||||
else if (side === "local")
|
||||
await f.syncer.syncLocallyUpdatedFile({
|
||||
relativePath: "note.bin"
|
||||
});
|
||||
else
|
||||
await f.websocket.onRemoteVaultUpdateReceived.triggerAsync(
|
||||
f.batch()
|
||||
);
|
||||
await f.syncer.waitUntilFinished();
|
||||
const content = bytes(
|
||||
side === "local" ? "local edit" : "remote edit"
|
||||
);
|
||||
const path = side === "local" ? "note.bin" : "renamed.bin";
|
||||
assert.deepEqual(f.serverContent(), content);
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles(),
|
||||
new Map([[path, content]])
|
||||
);
|
||||
assert.deepEqual(f.database.state.local, {
|
||||
[documentId]: path
|
||||
});
|
||||
assert.equal(
|
||||
f.database.state.lastSeenUpdateId,
|
||||
side === "local" ? 3 : 4
|
||||
);
|
||||
assert.equal(f.syncer.isBusy, false);
|
||||
assert.equal(f.writes.length, side === "local" ? 1 : 0);
|
||||
if (syncIntervalMs) {
|
||||
const beforeNextPoll = f.reads();
|
||||
context.mock.timers.tick(interval);
|
||||
await f.syncer.waitUntilFinished();
|
||||
assert.equal(
|
||||
f.reads(),
|
||||
beforeNextPoll + 1,
|
||||
"Polling stopped after its first successful run"
|
||||
);
|
||||
assert.equal(
|
||||
f.writes.length,
|
||||
side === "local" ? 1 : 0,
|
||||
"An idle poll resubmitted unchanged content"
|
||||
);
|
||||
}
|
||||
await f.syncer.stop();
|
||||
const stoppedReads = f.reads();
|
||||
context.mock.timers.tick(60_000);
|
||||
await f.syncer.waitUntilFinished();
|
||||
assert.equal(
|
||||
f.reads(),
|
||||
stoppedReads,
|
||||
"Polling continued after stop"
|
||||
);
|
||||
} finally {
|
||||
await f.syncer.stop();
|
||||
context.mock.timers.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("transient failures retry with polling disabled, then leave the client idle", async (context) => {
|
||||
context.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const f = await fixture(0);
|
||||
try {
|
||||
f.changeRemote();
|
||||
f.failNextRead();
|
||||
f.syncer.start();
|
||||
await assert.rejects(f.syncer.waitUntilFinished(), /offline/);
|
||||
const reads = f.reads();
|
||||
context.mock.timers.tick(retryInterval - 1);
|
||||
await assert.rejects(f.syncer.waitUntilFinished(), /offline/);
|
||||
assert.equal(f.reads(), reads);
|
||||
context.mock.timers.tick(1);
|
||||
await f.syncer.waitUntilFinished();
|
||||
assert.equal(f.reads(), reads + 1);
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles(),
|
||||
new Map([["renamed.bin", bytes("remote edit")]])
|
||||
);
|
||||
assert.equal(f.database.state.lastSeenUpdateId, 4);
|
||||
assert.equal(f.syncer.isBusy, false);
|
||||
context.mock.timers.tick(60_000);
|
||||
await f.syncer.waitUntilFinished();
|
||||
assert.equal(
|
||||
f.reads(),
|
||||
reads + 1,
|
||||
"Network retries became clean-state polling"
|
||||
);
|
||||
} finally {
|
||||
await f.syncer.stop();
|
||||
context.mock.timers.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed WebSocket connection catches up over HTTP even when polling is disabled", async () => {
|
||||
const f = await fixture(0);
|
||||
try {
|
||||
f.syncer.start();
|
||||
await f.syncer.waitUntilFinished();
|
||||
f.changeRemote();
|
||||
f.websocket.onWebSocketStatusChanged.trigger(false);
|
||||
await f.syncer.waitUntilFinished();
|
||||
assert.equal(f.database.state.lastSeenUpdateId, 4);
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles(),
|
||||
new Map([["renamed.bin", bytes("remote edit")]])
|
||||
);
|
||||
} finally {
|
||||
await f.syncer.stop();
|
||||
}
|
||||
});
|
||||
207
frontend/test-support/production-adapters.test.ts
Normal file
207
frontend/test-support/production-adapters.test.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { Logger, SyncClient } from "sync-client";
|
||||
import { ServerControl } from "../deterministic-tests/src/server-control";
|
||||
import { ClientPersistence } from "../local-client-cli/src/client-persistence";
|
||||
import { NodeFileSystemOperations } from "../local-client-cli/src/node-filesystem";
|
||||
import { ObsidianFileSystemOperations } from "../obsidian-plugin/src/obsidian-file-system";
|
||||
import { getJson, type CanonicalSnapshot } from "./canonical";
|
||||
|
||||
const root = path.resolve(__dirname, "../..");
|
||||
const token = "test-token-change-me";
|
||||
const pause = () => new Promise((resolve) => setTimeout(resolve, 50));
|
||||
async function until(check: () => Promise<boolean>, message: string) {
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (!(await check())) {
|
||||
assert(Date.now() < deadline, message);
|
||||
await pause();
|
||||
}
|
||||
}
|
||||
async function contents(file: string) {
|
||||
try {
|
||||
return await fs.readFile(file, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
||||
return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function stop(child: ChildProcess, signal: NodeJS.Signals) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
const exited = once(child, "exit");
|
||||
child.kill(signal);
|
||||
await exited;
|
||||
}
|
||||
|
||||
test(
|
||||
"production CLI watchers, Obsidian adapter, and full metadata survive process replacement",
|
||||
{ timeout: 90_000 },
|
||||
async (t) => {
|
||||
const directory = await fs.mkdtemp(
|
||||
path.join(tmpdir(), "vaultlink-production-")
|
||||
);
|
||||
const server = new ServerControl(
|
||||
path.join(root, "sync-server/target/release/sync_server"),
|
||||
path.join(root, "sync-server/config-e2e.yml"),
|
||||
new Logger()
|
||||
);
|
||||
const children: ChildProcess[] = [];
|
||||
let obsidian: SyncClient | undefined;
|
||||
t.after(async () => {
|
||||
await obsidian?.destroy();
|
||||
await Promise.all(children.map((child) => stop(child, "SIGKILL")));
|
||||
await server.stop();
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
await server.start();
|
||||
const a = path.join(directory, "a"),
|
||||
b = path.join(directory, "b"),
|
||||
c = path.join(directory, "obsidian");
|
||||
await Promise.all([a, b, c].map((p) => fs.mkdir(p)));
|
||||
await fs.writeFile(path.join(a, "note.md"), "initial\n");
|
||||
async function launch(vault: string) {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(root, "frontend/local-client-cli/dist/cli.js"),
|
||||
"-l",
|
||||
vault,
|
||||
"-r",
|
||||
server.remoteUri,
|
||||
"-t",
|
||||
token,
|
||||
"-v",
|
||||
"production",
|
||||
"--ignore-pattern",
|
||||
"**/*.tmp"
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] }
|
||||
);
|
||||
children.push(child);
|
||||
let log = "";
|
||||
child.stdout!.on("data", (data: Buffer) => {
|
||||
log += data.toString();
|
||||
});
|
||||
child.stderr!.on("data", (data: Buffer) => {
|
||||
log += data.toString();
|
||||
});
|
||||
await until(async () => {
|
||||
assert(
|
||||
child.exitCode === null && child.signalCode === null,
|
||||
`CLI exited: ${log}`
|
||||
);
|
||||
return log.includes("File watcher started");
|
||||
}, `CLI did not start: ${log}`);
|
||||
return child;
|
||||
}
|
||||
let first = await launch(a);
|
||||
await launch(b);
|
||||
await until(
|
||||
async () =>
|
||||
(await contents(path.join(b, "note.md"))) === "initial\n",
|
||||
"CLI initial download failed"
|
||||
);
|
||||
const persistence = (vault: string) =>
|
||||
new ClientPersistence(
|
||||
path.join(vault, ".vaultlink/sync-data.json"),
|
||||
{}
|
||||
);
|
||||
const original = (await persistence(a).load()).database!;
|
||||
const [documentId] = Object.keys(original.local!);
|
||||
assert(documentId);
|
||||
|
||||
const settings = {
|
||||
remoteUri: server.remoteUri,
|
||||
token,
|
||||
vaultName: "production",
|
||||
isSyncEnabled: true,
|
||||
enableTelemetry: false,
|
||||
ignorePatterns: [".vaultlink/**"],
|
||||
syncIntervalMs: 0
|
||||
};
|
||||
obsidian = await SyncClient.create({
|
||||
fs: new ObsidianFileSystemOperations(
|
||||
new NodeFileSystemOperations(c),
|
||||
() => undefined
|
||||
),
|
||||
persistence: new ClientPersistence(
|
||||
path.join(c, ".vaultlink/sync-data.json"),
|
||||
settings
|
||||
)
|
||||
});
|
||||
await obsidian.start();
|
||||
await obsidian.waitUntilFinished();
|
||||
assert.equal(await contents(path.join(c, "note.md")), "initial\n");
|
||||
await fs.writeFile(
|
||||
path.join(a, "replacement.tmp"),
|
||||
"atomic editor save\n"
|
||||
);
|
||||
await fs.rename(
|
||||
path.join(a, "replacement.tmp"),
|
||||
path.join(a, "note.md")
|
||||
);
|
||||
await until(
|
||||
async () =>
|
||||
(await contents(path.join(b, "note.md"))) ===
|
||||
"atomic editor save\n" &&
|
||||
(await contents(path.join(c, "note.md"))) ===
|
||||
"atomic editor save\n",
|
||||
"Editor replacement did not converge"
|
||||
);
|
||||
assert.equal(
|
||||
(await persistence(a).load()).database!.local![documentId],
|
||||
"note.md"
|
||||
);
|
||||
await fs.rename(path.join(b, "note.md"), path.join(b, "renamed.md"));
|
||||
await until(
|
||||
async () =>
|
||||
(await contents(path.join(a, "renamed.md"))) ===
|
||||
"atomic editor save\n" &&
|
||||
(await contents(path.join(a, "note.md"))) === undefined,
|
||||
"CLI rename did not converge"
|
||||
);
|
||||
await until(
|
||||
async () =>
|
||||
(await persistence(a).load()).database!.local![documentId] ===
|
||||
"renamed.md",
|
||||
"Rename changed document identity"
|
||||
);
|
||||
const saved = await persistence(a).load();
|
||||
assert(
|
||||
saved.historyCheckpoint?.checkpoint,
|
||||
"CLI dropped the history checkpoint"
|
||||
);
|
||||
await stop(first, "SIGKILL");
|
||||
await fs.writeFile(path.join(a, "renamed.md"), "edited after kill\n");
|
||||
first = await launch(a);
|
||||
await until(
|
||||
async () =>
|
||||
(await contents(path.join(b, "renamed.md"))) ===
|
||||
"edited after kill\n" &&
|
||||
(await contents(path.join(c, "renamed.md"))) ===
|
||||
"edited after kill\n",
|
||||
"Restart failed to reconcile current files"
|
||||
);
|
||||
const snapshot = await getJson<CanonicalSnapshot>(
|
||||
`${server.remoteUri}/vaults/production/vault-snapshot`,
|
||||
token
|
||||
);
|
||||
assert.deepEqual(snapshot.fileManifest.entries, {
|
||||
[documentId]: "renamed.md"
|
||||
});
|
||||
await fs.unlink(path.join(b, "renamed.md"));
|
||||
await until(
|
||||
async () =>
|
||||
(await contents(path.join(a, "renamed.md"))) === undefined &&
|
||||
(await contents(path.join(c, "renamed.md"))) === undefined,
|
||||
"Delete did not converge"
|
||||
);
|
||||
await stop(first, "SIGTERM");
|
||||
assert.equal(first.exitCode, 0, "CLI shutdown failed");
|
||||
}
|
||||
);
|
||||
390
frontend/test-support/protocol-audit.test.ts
Normal file
390
frontend/test-support/protocol-audit.test.ts
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { ServerControl } from "../deterministic-tests/src/server-control";
|
||||
import { Logger } from "sync-client";
|
||||
import { SyncClient } from "../sync-client/src/sync-client";
|
||||
import { MemoryDisk, MemoryPersistence } from "./storage";
|
||||
import { resolve, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
const root = resolve(__dirname, "../..");
|
||||
const token = "test-token-change-me";
|
||||
const bytes = (s: string) => new TextEncoder().encode(s);
|
||||
const headers = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Device-Id": "audit",
|
||||
"Content-Type": "application/json"
|
||||
};
|
||||
async function put(url: string, body: unknown) {
|
||||
const r = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
assert(r.ok, await r.clone().text());
|
||||
return r.json();
|
||||
}
|
||||
const content = (s: string) => ({
|
||||
requestId: randomUUID(),
|
||||
parentVersionId: null,
|
||||
content: { type: "Snapshot", value: Buffer.from(s).toString("base64") }
|
||||
});
|
||||
|
||||
test(
|
||||
"connection admission rejects excess clients and releases disconnected slots",
|
||||
{ timeout: 15000 },
|
||||
async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "vault-link-admission-"));
|
||||
const config = join(dir, "config.yml");
|
||||
await writeFile(
|
||||
config,
|
||||
(
|
||||
await readFile(root + "/sync-server/config-e2e.yml", "utf8")
|
||||
).replace("max_clients_per_vault: 256", "max_clients_per_vault: 1")
|
||||
);
|
||||
const server = new ServerControl(
|
||||
root + "/sync-server/target/release/sync_server",
|
||||
config,
|
||||
new Logger()
|
||||
);
|
||||
const sockets: WebSocket[] = [];
|
||||
try {
|
||||
await server.start();
|
||||
const url = server.remoteUri + "/vaults/" + randomUUID();
|
||||
await put(url + "/documents/" + randomUUID(), content("event"));
|
||||
const connect = async () => {
|
||||
const ws = new WebSocket(url.replace("http:", "ws:") + "/ws");
|
||||
sockets.push(ws);
|
||||
const accepted = await new Promise<boolean>((resolve) => {
|
||||
ws.onopen = () =>
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "handshake",
|
||||
token,
|
||||
deviceId: randomUUID()
|
||||
})
|
||||
);
|
||||
ws.onmessage = () => resolve(true);
|
||||
ws.onerror = () => resolve(false);
|
||||
ws.onclose = () => resolve(false);
|
||||
});
|
||||
return { ws, accepted };
|
||||
};
|
||||
const first = await connect();
|
||||
assert(first.accepted);
|
||||
assert.equal((await connect()).accepted, false);
|
||||
await new Promise<void>((resolve) => {
|
||||
first.ws.onclose = () => resolve();
|
||||
first.ws.close();
|
||||
});
|
||||
// A completed close releases the server's owned connection permit.
|
||||
assert.equal((await connect()).accepted, true);
|
||||
} finally {
|
||||
for (const ws of sockets) ws.close();
|
||||
await server.stop();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"an unauthenticated upgraded socket expires without a handshake",
|
||||
{ timeout: 10000 },
|
||||
async () => {
|
||||
const server = new ServerControl(
|
||||
root + "/sync-server/target/release/sync_server",
|
||||
root + "/sync-server/config-e2e.yml",
|
||||
new Logger()
|
||||
);
|
||||
let ws: WebSocket | undefined;
|
||||
try {
|
||||
await server.start();
|
||||
ws = new WebSocket(
|
||||
server.remoteUri.replace("http:", "ws:") +
|
||||
"/vaults/" +
|
||||
randomUUID() +
|
||||
"/ws"
|
||||
);
|
||||
const socket = ws;
|
||||
const closed = await new Promise<boolean>((resolve) => {
|
||||
const timer = setTimeout(() => resolve(false), 6500);
|
||||
socket.onclose = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
};
|
||||
socket.onerror = () => { };
|
||||
});
|
||||
assert(closed, "handshake deadline must release idle sockets");
|
||||
} finally {
|
||||
ws?.close();
|
||||
await server.stop();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"bootstrap notifications adopt the existing remote document identity",
|
||||
{ timeout: 15000 },
|
||||
async () => {
|
||||
const server = new ServerControl(
|
||||
root + "/sync-server/target/release/sync_server",
|
||||
root + "/sync-server/config-e2e.yml",
|
||||
new Logger()
|
||||
);
|
||||
let client: SyncClient | undefined;
|
||||
try {
|
||||
await server.start();
|
||||
const vault = randomUUID(),
|
||||
id = randomUUID();
|
||||
const url = server.remoteUri + "/vaults/" + vault;
|
||||
await put(url + "/documents/" + id, content("REMOTE"));
|
||||
await put(url + "/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: 0,
|
||||
entries: { [id]: "a.md" }
|
||||
});
|
||||
const disk = new MemoryDisk();
|
||||
const store = new MemoryPersistence({
|
||||
settings: {
|
||||
remoteUri: server.remoteUri,
|
||||
vaultName: vault,
|
||||
token,
|
||||
isSyncEnabled: true,
|
||||
syncIntervalMs: 0
|
||||
}
|
||||
});
|
||||
client = await SyncClient.create({ fs: disk, persistence: store });
|
||||
await disk.userWrite("a.md", bytes("LOCAL"));
|
||||
await client.syncLocallyCreatedFile("a.md");
|
||||
await disk.userWrite("b.md", bytes("B"));
|
||||
await client.syncLocallyCreatedFile("b.md");
|
||||
await client.start();
|
||||
const files = [...disk.userFiles().keys()];
|
||||
assert.equal(files.length, 2);
|
||||
} finally {
|
||||
await client?.destroy();
|
||||
await server.stop();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"local undo survives a lost accepted reply followed by a rejected retry",
|
||||
{ timeout: 15000 },
|
||||
async () => {
|
||||
const server = new ServerControl(
|
||||
root + "/sync-server/target/release/sync_server",
|
||||
root + "/sync-server/config-e2e.yml",
|
||||
new Logger()
|
||||
);
|
||||
let client: SyncClient | undefined;
|
||||
class SilentWebSocket {
|
||||
static OPEN = 1;
|
||||
readyState = 1;
|
||||
onopen: ((event: object) => void) | null = null;
|
||||
onclose:
|
||||
| ((event: { code: number; reason: string }) => void)
|
||||
| null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
onerror: ((event: object) => void) | null = null;
|
||||
constructor(_url: string | URL) {
|
||||
queueMicrotask(() => this.onopen?.({}));
|
||||
}
|
||||
send(_data: string) { }
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
this.onclose?.({ code: 1000, reason: "" });
|
||||
}
|
||||
}
|
||||
try {
|
||||
await server.start();
|
||||
const vault = randomUUID(),
|
||||
id = randomUUID();
|
||||
const url = server.remoteUri + "/vaults/" + vault;
|
||||
await put(url + "/documents/" + id, content("base"));
|
||||
await put(url + "/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: 0,
|
||||
entries: { [id]: "a.md" }
|
||||
});
|
||||
const disk = new MemoryDisk();
|
||||
const store = new MemoryPersistence({
|
||||
settings: {
|
||||
remoteUri: server.remoteUri,
|
||||
vaultName: vault,
|
||||
token,
|
||||
isSyncEnabled: true,
|
||||
syncIntervalMs: 0,
|
||||
networkRetryIntervalMs: 0
|
||||
}
|
||||
});
|
||||
let mode: "normal" | "lose-reply" | "reject-retry" = "normal";
|
||||
let lostId: string | undefined;
|
||||
client = await SyncClient.create({
|
||||
fs: disk,
|
||||
persistence: store,
|
||||
webSocket: SilentWebSocket as unknown as typeof WebSocket,
|
||||
fetch: async (input, init) => {
|
||||
if (init?.method === "PUT" && mode === "reject-retry") {
|
||||
assert.equal(
|
||||
JSON.parse(String(init.body)).requestId,
|
||||
lostId
|
||||
);
|
||||
mode = "normal";
|
||||
return new Response("proxy body limit changed", {
|
||||
status: 413
|
||||
});
|
||||
}
|
||||
const response = await fetch(input, init);
|
||||
if (init?.method === "PUT" && mode === "lose-reply") {
|
||||
assert.equal(
|
||||
(await response.clone().json()).type,
|
||||
"Accepted"
|
||||
);
|
||||
lostId = JSON.parse(String(init.body)).requestId;
|
||||
mode = "normal";
|
||||
throw new Error("injected lost accepted response");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
});
|
||||
await client.start();
|
||||
mode = "lose-reply";
|
||||
await disk.userWrite("a.md", bytes("base A"));
|
||||
await client.syncLocallyUpdatedFile({ relativePath: "a.md" });
|
||||
await assert.rejects(client.waitUntilFinished(), /lost accepted/);
|
||||
const original = await (
|
||||
await fetch(url + "/documents/" + id, { headers })
|
||||
).json();
|
||||
await put(url + "/documents/" + id, {
|
||||
...content("base A REMOTE"),
|
||||
parentVersionId: original.vaultUpdateId
|
||||
});
|
||||
mode = "reject-retry";
|
||||
await disk.userWrite("a.md", bytes("base"));
|
||||
await client.syncLocallyUpdatedFile({ relativePath: "a.md" });
|
||||
await client.waitUntilFinished();
|
||||
const result = Buffer.from(
|
||||
(await disk.read("a.md"))!.content
|
||||
).toString();
|
||||
assert.equal(result, "base REMOTE");
|
||||
} finally {
|
||||
await client?.destroy();
|
||||
await server.stop();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"closing an old connection cannot erase its replacement's cursors",
|
||||
{ timeout: 15000 },
|
||||
async () => {
|
||||
const server = new ServerControl(
|
||||
root + "/sync-server/target/release/sync_server",
|
||||
root + "/sync-server/config-e2e.yml",
|
||||
new Logger()
|
||||
);
|
||||
const sockets: WebSocket[] = [];
|
||||
try {
|
||||
await server.start();
|
||||
const url = server.remoteUri + "/vaults/" + randomUUID();
|
||||
const id = randomUUID();
|
||||
await put(url + "/documents/" + id, content("example"));
|
||||
const connect = async (deviceId: string) => {
|
||||
const ws = new WebSocket(url.replace("http:", "ws:") + "/ws");
|
||||
sockets.push(ws);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.onopen = () =>
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "handshake",
|
||||
token,
|
||||
deviceId
|
||||
})
|
||||
);
|
||||
ws.onmessage = () => resolve();
|
||||
ws.onerror = () => reject(new Error("connection failed"));
|
||||
});
|
||||
return ws;
|
||||
};
|
||||
const observer = await connect("observer");
|
||||
const first = await connect("same-device");
|
||||
const sendCursor = (ws: WebSocket, start: number) =>
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "cursorPositions",
|
||||
documentsWithCursors: [
|
||||
{
|
||||
document_id: id,
|
||||
relative_path: "note.md",
|
||||
vault_update_id: 1,
|
||||
cursors: [{ start, end: start }]
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
const seen = (start: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
observer.onmessage = (event) => {
|
||||
const message = JSON.parse(String(event.data));
|
||||
if (
|
||||
message.type === "cursorPositions" &&
|
||||
message.clients.some(
|
||||
(client: {
|
||||
deviceId: string;
|
||||
documentsWithCursors: {
|
||||
cursors: { start: number }[];
|
||||
}[];
|
||||
}) =>
|
||||
client.deviceId === "same-device" &&
|
||||
client.documentsWithCursors[0]?.cursors[0]
|
||||
?.start === start
|
||||
)
|
||||
)
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
let observed = seen(1);
|
||||
sendCursor(first, 1);
|
||||
await observed;
|
||||
const replacement = await connect("same-device");
|
||||
observed = seen(2);
|
||||
sendCursor(replacement, 2);
|
||||
await observed;
|
||||
await new Promise<void>((resolve) => {
|
||||
first.onclose = () => resolve();
|
||||
first.close();
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const current = new Promise<
|
||||
{
|
||||
deviceId: string;
|
||||
documentsWithCursors: { cursors: { start: number }[] }[];
|
||||
}[]
|
||||
>((resolve) => {
|
||||
observer.onmessage = (event) => {
|
||||
const message = JSON.parse(String(event.data));
|
||||
if (message.type === "cursorPositions")
|
||||
resolve(message.clients);
|
||||
};
|
||||
});
|
||||
observer.send(
|
||||
JSON.stringify({
|
||||
type: "cursorPositions",
|
||||
documentsWithCursors: []
|
||||
})
|
||||
);
|
||||
const clients = await current;
|
||||
assert.equal(
|
||||
clients.find((client) => client.deviceId === "same-device")
|
||||
?.documentsWithCursors[0]?.cursors[0]?.start,
|
||||
2
|
||||
);
|
||||
} finally {
|
||||
for (const ws of sockets) ws.close();
|
||||
await server.stop();
|
||||
}
|
||||
}
|
||||
);
|
||||
431
frontend/test-support/protocol.test.ts
Normal file
431
frontend/test-support/protocol.test.ts
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import * as path from "node:path";
|
||||
import { Logger } from "sync-client";
|
||||
import { ServerControl } from "../deterministic-tests/src/server-control";
|
||||
import { MemoryDisk, type DiskImage, type StoredClient } from "./storage";
|
||||
import { getJson, type CanonicalSnapshot } from "./canonical";
|
||||
import type { RequestRecord } from "./network";
|
||||
import type { CrashWorkerInput } from "../deterministic-tests/src/crash-worker";
|
||||
|
||||
const root = path.resolve(__dirname, "../..");
|
||||
const token = "test-token-change-me";
|
||||
const headers = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Device-Id": "protocol-test",
|
||||
"Content-Type": "application/json"
|
||||
};
|
||||
const makeServer = () =>
|
||||
new ServerControl(
|
||||
path.join(root, "sync-server/target/release/sync_server"),
|
||||
path.join(root, "sync-server/config-e2e.yml"),
|
||||
new Logger()
|
||||
);
|
||||
async function put(url: string, body: unknown) {
|
||||
const response = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(5_000)
|
||||
});
|
||||
assert(
|
||||
response.ok,
|
||||
`HTTP ${response.status}: ${await response.clone().text()}`
|
||||
);
|
||||
return (await response.json()) as {
|
||||
type: string;
|
||||
vaultUpdateId: number;
|
||||
fileManifestId: number;
|
||||
};
|
||||
}
|
||||
const content = (text: string, parentVersionId: number | null = null) => ({
|
||||
requestId: randomUUID(),
|
||||
parentVersionId,
|
||||
content: { type: "Snapshot", value: Buffer.from(text).toString("base64") }
|
||||
});
|
||||
|
||||
interface WorkerSnapshot {
|
||||
type: string;
|
||||
disk: DiskImage;
|
||||
stored: StoredClient;
|
||||
requests: RequestRecord[];
|
||||
editedBase?: { documentId: string; vaultUpdateId: number };
|
||||
}
|
||||
async function runWorker(input: CrashWorkerInput): Promise<WorkerSnapshot> {
|
||||
const worker = new Worker(
|
||||
path.join(root, "frontend/deterministic-tests/dist/crash-worker.js"),
|
||||
{ workerData: input }
|
||||
);
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await new Promise<WorkerSnapshot>((resolve, reject) => {
|
||||
timeout = setTimeout(
|
||||
() =>
|
||||
reject(new Error("Worker checkpoint/completion timed out")),
|
||||
15_000
|
||||
);
|
||||
worker.once("error", reject);
|
||||
worker.once("exit", (code) =>
|
||||
reject(
|
||||
new Error(
|
||||
`Worker exited (${code}) before expected checkpoint`
|
||||
)
|
||||
)
|
||||
);
|
||||
worker.on(
|
||||
"message",
|
||||
(message: WorkerSnapshot & { error?: string }) => {
|
||||
if (message.type === "error")
|
||||
reject(new Error(message.error));
|
||||
else {
|
||||
assert.equal(
|
||||
message.type,
|
||||
input.stopAt ? "checkpoint" : "finished"
|
||||
);
|
||||
resolve(message);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
// This is the fault injection, not graceful shutdown masquerading as it.
|
||||
await worker.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
for (const [kind, format, originalPath] of [
|
||||
["create", "Snapshot", "original.md"],
|
||||
["content", "Snapshot", "original.bin"],
|
||||
["content", "Diff", "original.md"],
|
||||
["manifest", undefined, "original.md"]
|
||||
] as const) {
|
||||
test(
|
||||
`abrupt client death after accepted ${kind} ${format ?? ""} reply, newer head, server SIGKILL, durable retry`,
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
const server = makeServer();
|
||||
try {
|
||||
await server.start();
|
||||
const vault = `crash-${randomUUID()}`;
|
||||
const url = `${server.remoteUri}/vaults/${vault}`;
|
||||
const disk = new MemoryDisk();
|
||||
await disk.userWrite(
|
||||
originalPath,
|
||||
new TextEncoder().encode("preserve-original\n")
|
||||
);
|
||||
let input: CrashWorkerInput = {
|
||||
disk: disk.image(),
|
||||
stored: {
|
||||
settings: {
|
||||
isSyncEnabled: true,
|
||||
enableTelemetry: false,
|
||||
remoteUri: server.remoteUri,
|
||||
vaultName: vault,
|
||||
token,
|
||||
requestTimeoutMs: 5_000,
|
||||
syncIntervalMs: 0
|
||||
}
|
||||
} as StoredClient
|
||||
};
|
||||
let originalId: string | undefined;
|
||||
let originalParent: number | undefined;
|
||||
let uploaded = "preserve-original\n";
|
||||
if (kind === "content" && format === "Diff") {
|
||||
// Keep the established base in the worker's real content
|
||||
// cache; a cold restart intentionally falls back to Snapshot.
|
||||
uploaded += "local-update\n";
|
||||
input.editAfterSync = {
|
||||
path: originalPath,
|
||||
content: uploaded
|
||||
};
|
||||
} else if (kind === "content") {
|
||||
const initialized = await runWorker(input);
|
||||
originalId = Object.keys(
|
||||
initialized.stored.database!.local!
|
||||
)[0];
|
||||
originalParent =
|
||||
initialized.stored.database!.documents![originalId]
|
||||
.base!.vaultUpdateId;
|
||||
disk.restore(initialized.disk);
|
||||
uploaded += "local-update\n";
|
||||
await disk.userWrite(
|
||||
originalPath,
|
||||
new TextEncoder().encode(uploaded)
|
||||
);
|
||||
input = { disk: disk.image(), stored: initialized.stored };
|
||||
}
|
||||
const checkpoint = await runWorker({ ...input, stopAt: kind });
|
||||
if (format === "Diff") {
|
||||
assert(
|
||||
checkpoint.editedBase,
|
||||
"Warm update baseline was not recorded"
|
||||
);
|
||||
originalId = checkpoint.editedBase.documentId;
|
||||
originalParent = checkpoint.editedBase.vaultUpdateId;
|
||||
}
|
||||
const pending = checkpoint.stored.database!.pending!;
|
||||
assert(
|
||||
pending,
|
||||
"The uncertain request was not persisted before sending"
|
||||
);
|
||||
const interrupted = checkpoint.requests.at(-1)!;
|
||||
assert.equal(
|
||||
interrupted.kind,
|
||||
kind,
|
||||
"Wrong CAS was interrupted"
|
||||
);
|
||||
if (kind !== "manifest") {
|
||||
assert.equal(pending.type, "content");
|
||||
assert(pending.type === "content");
|
||||
assert.equal(pending.request.content.type, format);
|
||||
assert.equal(
|
||||
pending.request.parentVersionId,
|
||||
kind === "create" ? null : originalParent
|
||||
);
|
||||
if (originalId)
|
||||
assert.equal(
|
||||
pending.documentId,
|
||||
originalId,
|
||||
"Update created a replacement UUID"
|
||||
);
|
||||
}
|
||||
const accepted = await getJson<CanonicalSnapshot>(
|
||||
`${url}/vault-snapshot`,
|
||||
token
|
||||
);
|
||||
// Unpublished content has no manifest membership yet, so it is
|
||||
// deliberately absent from /vault-snapshot's document list.
|
||||
const doc =
|
||||
pending.type === "content"
|
||||
? await getJson<{
|
||||
documentId: string;
|
||||
vaultUpdateId: number;
|
||||
}>(`${url}/documents/${pending.documentId}`, token)
|
||||
: accepted.documents[0];
|
||||
assert(doc);
|
||||
const remote = await put(
|
||||
`${url}/documents/${doc.documentId}`,
|
||||
content(`${uploaded}remote-update\n`, doc.vaultUpdateId)
|
||||
);
|
||||
assert.equal(remote.type, "Accepted");
|
||||
const remotePath = originalPath.replace("original", "remote");
|
||||
const renamed = await put(`${url}/file-manifest`, {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: accepted.fileManifest.fileManifestId,
|
||||
entries: { [doc.documentId]: remotePath }
|
||||
});
|
||||
assert.equal(renamed.type, "Accepted");
|
||||
const before = await getJson<CanonicalSnapshot>(
|
||||
`${url}/vault-snapshot`,
|
||||
token
|
||||
);
|
||||
await server.crash();
|
||||
await server.restart();
|
||||
assert.deepEqual(
|
||||
await getJson(`${url}/vault-snapshot`, token),
|
||||
before,
|
||||
"Server restart lost acknowledged state"
|
||||
);
|
||||
const restarted = await runWorker({
|
||||
disk: checkpoint.disk,
|
||||
stored: checkpoint.stored
|
||||
});
|
||||
const replay = restarted.requests.find(
|
||||
(r) => r.body.requestId === pending.request.requestId
|
||||
);
|
||||
assert(replay, "Restart never retried the uncertain request");
|
||||
assert.equal(
|
||||
replay.url,
|
||||
interrupted.url,
|
||||
"Restart changed the request target"
|
||||
);
|
||||
assert.deepEqual(
|
||||
replay.body,
|
||||
pending.request,
|
||||
"Restart changed the persisted request"
|
||||
);
|
||||
assert.equal(restarted.stored.database!.pending, undefined);
|
||||
const recovered = new MemoryDisk();
|
||||
recovered.restore(restarted.disk);
|
||||
assert.deepEqual(
|
||||
recovered.userFiles(),
|
||||
new Map([
|
||||
[
|
||||
remotePath,
|
||||
new TextEncoder().encode(
|
||||
`${uploaded}remote-update\n`
|
||||
)
|
||||
]
|
||||
])
|
||||
);
|
||||
assert.deepEqual(restarted.stored.database!.local, {
|
||||
[doc.documentId]: remotePath
|
||||
});
|
||||
const events = await getJson<{
|
||||
events: { requestId: string }[];
|
||||
}>(`${url}/events-since?after=0`, token);
|
||||
assert.equal(
|
||||
events.events.filter(
|
||||
(e) => e.requestId === pending.request.requestId
|
||||
).length,
|
||||
1,
|
||||
"Retry emitted a duplicate event"
|
||||
);
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"WebSocket hints trigger HTTP catchup in commit order, including after reconnect",
|
||||
{ timeout: 20_000 },
|
||||
async () => {
|
||||
const server = makeServer();
|
||||
let ws: WebSocket | undefined;
|
||||
try {
|
||||
await server.start();
|
||||
const url = `${server.remoteUri}/vaults/order-${randomUUID()}`;
|
||||
const received: number[] = [];
|
||||
let catchup = Promise.resolve();
|
||||
let catchupError: unknown;
|
||||
const connect = async () => {
|
||||
ws = new WebSocket(`${url.replace(/^http/, "ws")}/ws`);
|
||||
ws.addEventListener("message", (message) => {
|
||||
const hint = JSON.parse(String(message.data));
|
||||
if (hint.type !== "vaultChanged") return;
|
||||
catchup = catchup
|
||||
.then(async () => {
|
||||
assert.deepEqual(hint, { type: "vaultChanged" });
|
||||
const batch = await getJson<{
|
||||
events: { eventId: number }[];
|
||||
}>(
|
||||
`${url}/events-since?after=${received.at(-1) ?? 0}`,
|
||||
token
|
||||
);
|
||||
received.push(
|
||||
...batch.events.map((event) => event.eventId)
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
catchupError = error;
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws!.onopen = () => resolve();
|
||||
ws!.onerror = () => reject(new Error("WebSocket failed"));
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "handshake",
|
||||
token,
|
||||
deviceId: "order-observer"
|
||||
})
|
||||
);
|
||||
};
|
||||
await connect();
|
||||
const ids = Array.from({ length: 12 }, () => randomUUID());
|
||||
const requests = ids.map((id, i) => ({
|
||||
id,
|
||||
body: content(`bytes-${i}`)
|
||||
}));
|
||||
const replies = await Promise.all(
|
||||
requests.map(({ id, body }) =>
|
||||
put(`${url}/documents/${id}`, body)
|
||||
)
|
||||
);
|
||||
assert.deepEqual(
|
||||
replies.map((r) => r.vaultUpdateId).sort((a, b) => a - b),
|
||||
ids.map((_, i) => i + 1)
|
||||
);
|
||||
const emptyManifest = await getJson<{
|
||||
entries: Record<string, string>;
|
||||
}>(`${url}/file-manifest`, token);
|
||||
assert.deepEqual(
|
||||
emptyManifest.entries,
|
||||
{},
|
||||
"Content-only CAS changed membership"
|
||||
);
|
||||
const entries = Object.fromEntries(
|
||||
ids.map((id, i) => [id, `${i}.md`])
|
||||
);
|
||||
await put(`${url}/file-manifest`, {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: 0,
|
||||
entries
|
||||
});
|
||||
// All retries must return original receipts without emitting events.
|
||||
assert.deepEqual(
|
||||
await Promise.all(
|
||||
requests.map(({ id, body }) =>
|
||||
put(`${url}/documents/${id}`, body)
|
||||
)
|
||||
),
|
||||
replies
|
||||
);
|
||||
const waitForEvents = async (head: number) => {
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (received.length < head && Date.now() < deadline)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
if (catchupError) throw catchupError;
|
||||
assert.deepEqual(
|
||||
received,
|
||||
Array.from({ length: head }, (_, i) => i + 1),
|
||||
"HTTP catchup after WebSocket hints lost or duplicated an event"
|
||||
);
|
||||
};
|
||||
await waitForEvents(13);
|
||||
await new Promise<void>((resolve) => {
|
||||
ws!.onclose = () => resolve();
|
||||
ws!.close();
|
||||
});
|
||||
await put(
|
||||
`${url}/documents/${ids[0]}`,
|
||||
content("next", replies[0].vaultUpdateId)
|
||||
);
|
||||
await connect();
|
||||
await waitForEvents(14);
|
||||
const log = await getJson<{ events: { eventId: number }[] }>(
|
||||
`${url}/events-since?after=0`,
|
||||
token
|
||||
);
|
||||
assert.deepEqual(
|
||||
received,
|
||||
log.events.map((event) => event.eventId)
|
||||
);
|
||||
const stale = await put(`${url}/file-manifest`, {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: 0,
|
||||
entries: {}
|
||||
});
|
||||
assert.equal(stale.type, "StaleBase");
|
||||
const invalid = await fetch(`${url}/file-manifest`, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: 13,
|
||||
entries: { [ids[0]]: "same", [ids[1]]: "same" }
|
||||
})
|
||||
});
|
||||
assert.equal(invalid.status, 422);
|
||||
assert.equal(
|
||||
(
|
||||
await getJson<CanonicalSnapshot>(
|
||||
`${url}/vault-snapshot`,
|
||||
token
|
||||
)
|
||||
).headEventId,
|
||||
14,
|
||||
"Rejected CAS emitted an event"
|
||||
);
|
||||
} finally {
|
||||
ws?.close();
|
||||
await server.stop();
|
||||
}
|
||||
}
|
||||
);
|
||||
25
frontend/test-support/random.ts
Normal file
25
frontend/test-support/random.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/** A seed controls harness choices; production UUIDs and OS scheduling remain
|
||||
* nondeterministic. The concrete action trace is the stronger replay artifact. */
|
||||
export class Random {
|
||||
private state: number;
|
||||
public constructor(seed: number) {
|
||||
if (!Number.isInteger(seed) || seed < 0 || seed > 0xffffffff)
|
||||
throw new Error("Seed must be a uint32");
|
||||
this.state = seed;
|
||||
}
|
||||
public next(): number {
|
||||
this.state = (this.state + 0x6d2b79f5) >>> 0;
|
||||
let n = this.state;
|
||||
n = Math.imul(n ^ (n >>> 15), n | 1);
|
||||
n ^= n + Math.imul(n ^ (n >>> 7), n | 61);
|
||||
return ((n ^ (n >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
public int(max: number): number {
|
||||
if (!Number.isSafeInteger(max) || max < 1)
|
||||
throw new Error("Empty random choice");
|
||||
return Math.floor(this.next() * max);
|
||||
}
|
||||
public pick<T>(values: readonly T[]): T {
|
||||
return values[this.int(values.length)];
|
||||
}
|
||||
}
|
||||
139
frontend/test-support/real-disk.test.ts
Normal file
139
frontend/test-support/real-disk.test.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { NodeFileSystemOperations } from "../local-client-cli/src/node-filesystem";
|
||||
import { Database, emptyState } from "../sync-client/src/persistence/database";
|
||||
import { FileOperations } from "../sync-client/src/file-operations/file-operations";
|
||||
import type { ServerConfig } from "../sync-client/src/services/server-config";
|
||||
import { toStoredSnapshot } from "../sync-client/src/sync-operations/content";
|
||||
|
||||
test(
|
||||
"real filesystem: direct updates, unsafe entries, and file swaps",
|
||||
{ timeout: 20_000 },
|
||||
async () => {
|
||||
const directory = await fs.mkdtemp(
|
||||
path.join(tmpdir(), "vault-link-real-fs-")
|
||||
);
|
||||
try {
|
||||
const root = path.join(directory, "vault");
|
||||
await fs.mkdir(root);
|
||||
const disk = new NodeFileSystemOperations(root);
|
||||
const snapshot = (text: string) => ({
|
||||
content: new TextEncoder().encode(text)
|
||||
});
|
||||
await disk.write("updated.bin", snapshot("first"));
|
||||
await disk.write("updated.bin", snapshot("second"));
|
||||
assert.equal(
|
||||
Buffer.from(
|
||||
(await disk.read("updated.bin"))!.content
|
||||
).toString(),
|
||||
"second"
|
||||
);
|
||||
for (const unsafe of [
|
||||
"../outside",
|
||||
"/absolute",
|
||||
"folder/../outside"
|
||||
]) {
|
||||
await assert.rejects(
|
||||
disk.write(unsafe, snapshot("blocked")),
|
||||
/Unsafe path/
|
||||
);
|
||||
await assert.rejects(disk.read(unsafe), /Unsafe path/);
|
||||
}
|
||||
// POSIX filenames can contain colons. The engine, not the adapter,
|
||||
// allocates their portable names for sync.
|
||||
if (process.platform !== "win32") {
|
||||
await fs.writeFile(
|
||||
path.join(root, "local:name.md"),
|
||||
"local name"
|
||||
);
|
||||
assert.equal(
|
||||
Buffer.from(
|
||||
(await disk.read("local:name.md"))!.content
|
||||
).toString(),
|
||||
"local name"
|
||||
);
|
||||
await disk.rename("local:name.md", "portable-name.md");
|
||||
}
|
||||
await disk.write("a.md", snapshot("A"));
|
||||
await disk.write("b.md", snapshot("B"));
|
||||
// A file in an ancestor position makes descendants absent.
|
||||
assert.equal(await disk.stat("a.md/absent.md"), undefined);
|
||||
assert.equal(await disk.read("a.md/absent.md"), undefined);
|
||||
await assert.rejects(disk.rename("a.md", "b.md"));
|
||||
await assert.rejects(disk.deleteDirectory("a.md"), /regular file/);
|
||||
await fs.symlink("a.md", path.join(root, "symlink"));
|
||||
await assert.rejects(disk.read("symlink"), /Symlink/);
|
||||
await fs.unlink(path.join(root, "symlink"));
|
||||
await fs.link(path.join(root, "a.md"), path.join(root, "hardlink"));
|
||||
await assert.rejects(disk.read("a.md"), /hardlink/);
|
||||
await fs.unlink(path.join(root, "hardlink"));
|
||||
// No-replace renames also protect case/normalization aliases.
|
||||
for (const [original, alias] of [
|
||||
["Case.bin", "case.bin"],
|
||||
["é.bin", "e\u0301.bin"]
|
||||
]) {
|
||||
await disk.write(original, snapshot("original bytes"));
|
||||
await disk.write("alias-source.bin", snapshot("alias bytes"));
|
||||
if (await disk.exists(alias))
|
||||
await assert.rejects(
|
||||
disk.rename("alias-source.bin", alias)
|
||||
);
|
||||
else {
|
||||
await disk.rename("alias-source.bin", alias);
|
||||
assert.equal(
|
||||
Buffer.from(
|
||||
(await disk.read(alias))!.content
|
||||
).toString(),
|
||||
"alias bytes"
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
Buffer.from(
|
||||
(await disk.read(original))!.content
|
||||
).toString(),
|
||||
"original bytes"
|
||||
);
|
||||
}
|
||||
const initial = emptyState("real");
|
||||
initial.local = { a: "a.md", b: "b.md" };
|
||||
for (const [id, relative] of Object.entries(initial.local))
|
||||
initial.documents[id] = {
|
||||
materialized: true,
|
||||
observedHash: (
|
||||
await toStoredSnapshot(
|
||||
(await disk.read(relative))!
|
||||
)
|
||||
).hash
|
||||
};
|
||||
const database = new Database(
|
||||
initial,
|
||||
async () => { },
|
||||
"real",
|
||||
async () => initial
|
||||
);
|
||||
const operations = new FileOperations(disk, database, {
|
||||
getConfig: async () => ({ mergeableFileExtensions: ["md"] })
|
||||
} as ServerConfig);
|
||||
const next = structuredClone(initial);
|
||||
next.local = { a: "b.md", b: "a.md" };
|
||||
await operations.apply(next);
|
||||
assert.equal(
|
||||
Buffer.from(
|
||||
(await disk.read("a.md"))!.content
|
||||
).toString(),
|
||||
"B"
|
||||
);
|
||||
assert.equal(
|
||||
Buffer.from(
|
||||
(await disk.read("b.md"))!.content
|
||||
).toString(),
|
||||
"A"
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(directory, { recursive: true });
|
||||
}
|
||||
}
|
||||
);
|
||||
580
frontend/test-support/recovery.test.ts
Normal file
580
frontend/test-support/recovery.test.ts
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { MemoryDisk, MemoryPersistence } from "./storage";
|
||||
import {
|
||||
Database,
|
||||
emptyState,
|
||||
type StoredDatabase
|
||||
} from "../sync-client/src/persistence/database";
|
||||
import { FileOperations } from "../sync-client/src/file-operations/file-operations";
|
||||
import { toStoredSnapshot } from "../sync-client/src/sync-operations/content";
|
||||
import type { ServerConfig } from "../sync-client/src/services/server-config";
|
||||
import { mergeFileManifests } from "../sync-client/src/sync-operations/file-manifest";
|
||||
import { assertManifest, pathDecision } from "./oracles";
|
||||
import {
|
||||
scanLocalFiles,
|
||||
type LocalChange
|
||||
} from "../sync-client/src/sync-operations/scan";
|
||||
|
||||
const bytes = (text: string) => new TextEncoder().encode(text);
|
||||
const config = {
|
||||
getConfig: async () => ({ mergeableFileExtensions: ["md"] })
|
||||
} as ServerConfig;
|
||||
|
||||
async function fixture(
|
||||
contents: Record<string, { path: string; text: string }> = {
|
||||
a: { path: "a.md", text: "A" },
|
||||
b: { path: "b.md", text: "B" }
|
||||
}
|
||||
) {
|
||||
const disk = new MemoryDisk();
|
||||
const initial = emptyState("recovery-test");
|
||||
for (const [id, { path, text }] of Object.entries(contents)) {
|
||||
await disk.userWrite(path, bytes(text));
|
||||
initial.local[id] = path;
|
||||
}
|
||||
for (const [id, path] of Object.entries(initial.local))
|
||||
initial.documents[id] = {
|
||||
materialized: true,
|
||||
observedHash: (
|
||||
await toStoredSnapshot((await disk.read(path))!)
|
||||
).hash
|
||||
};
|
||||
const persistence = new MemoryPersistence({ database: initial });
|
||||
const restart = (changes: LocalChange[] = []) => {
|
||||
const database = new Database(
|
||||
persistence.snapshot().database,
|
||||
async (next) => persistence.save({ database: next }),
|
||||
"recovery-test",
|
||||
async () => (await persistence.load()).database as StoredDatabase
|
||||
);
|
||||
return {
|
||||
database,
|
||||
files: new FileOperations(
|
||||
disk.session(),
|
||||
database,
|
||||
config,
|
||||
undefined,
|
||||
{ entries: () => changes, flush: async () => { } }
|
||||
)
|
||||
};
|
||||
};
|
||||
return {
|
||||
disk,
|
||||
persistence,
|
||||
restart,
|
||||
initial,
|
||||
permanentFault: (_label: string): void => { },
|
||||
expectedLocal: undefined as Record<string, string> | undefined
|
||||
};
|
||||
}
|
||||
|
||||
async function swapFixture() {
|
||||
const current = await fixture();
|
||||
const next = structuredClone(current.initial);
|
||||
next.local = { a: "b.md", b: "a.md", c: "nested/c.md" };
|
||||
next.documents.c = { materialized: false };
|
||||
const writes = {
|
||||
c: { replacement: await toStoredSnapshot({ content: bytes("C") }) }
|
||||
};
|
||||
const expected = new Map([
|
||||
["a.md", bytes("B")],
|
||||
["b.md", bytes("A")],
|
||||
["nested/c.md", bytes("C")]
|
||||
]);
|
||||
return { ...current, next, writes, expected };
|
||||
}
|
||||
|
||||
async function directoryCycleFixture() {
|
||||
const current = await fixture({
|
||||
a: { path: "a", text: "A" },
|
||||
b: { path: "b/deep/child", text: "B" }
|
||||
});
|
||||
const next = structuredClone(current.initial);
|
||||
next.local = { a: "b", b: "a/deep/child" };
|
||||
return {
|
||||
...current,
|
||||
next,
|
||||
writes: {},
|
||||
expected: new Map([
|
||||
["b", bytes("A")],
|
||||
["a/deep/child", bytes("B")]
|
||||
])
|
||||
};
|
||||
}
|
||||
|
||||
async function crossDirectoryFixture() {
|
||||
const current = await fixture({
|
||||
a: { path: "src/a.md", text: "A" },
|
||||
// Keep src nonempty: deleting an empty source directory would mask an
|
||||
// unflushed source unlink in the staging rename.
|
||||
b: { path: "src/keep.md", text: "untouched sibling" }
|
||||
});
|
||||
const next = structuredClone(current.initial);
|
||||
next.local.a = "dst/a.md";
|
||||
const expected = new Map([
|
||||
["dst/a.md", bytes("A")],
|
||||
["src/keep.md", bytes("untouched sibling")]
|
||||
]);
|
||||
return { ...current, next, writes: {}, expected };
|
||||
}
|
||||
|
||||
async function mergeFixture() {
|
||||
const base = "first paragraph\n\nlast paragraph\n";
|
||||
const local = "local first paragraph\n\nlast paragraph\n";
|
||||
const remote = "first paragraph\n\nremote last paragraph\n";
|
||||
const current = await fixture({ a: { path: "note.md", text: local } });
|
||||
return {
|
||||
...current,
|
||||
next: structuredClone(current.initial),
|
||||
writes: {
|
||||
a: {
|
||||
expected: await toStoredSnapshot({ content: bytes(base) }),
|
||||
replacement: await toStoredSnapshot({ content: bytes(remote) })
|
||||
}
|
||||
},
|
||||
expected: new Map([
|
||||
[
|
||||
"note.md",
|
||||
bytes("local first paragraph\n\nremote last paragraph\n")
|
||||
]
|
||||
])
|
||||
};
|
||||
}
|
||||
|
||||
async function deletionFixture() {
|
||||
const current = await fixture({
|
||||
a: { path: "a.bin", text: "private binary edit\0" },
|
||||
b: { path: "deleted.md", text: "unsent deleted bytes" }
|
||||
});
|
||||
const next = structuredClone(current.initial);
|
||||
delete next.local.b;
|
||||
return {
|
||||
...current,
|
||||
next,
|
||||
writes: {
|
||||
a: {
|
||||
replacement: await toStoredSnapshot({
|
||||
content: bytes("remote binary\0")
|
||||
})
|
||||
}
|
||||
},
|
||||
expected: new Map([["a.bin", bytes("remote binary\0")]])
|
||||
};
|
||||
}
|
||||
|
||||
async function permanentDestinationFixture() {
|
||||
const current = await fixture();
|
||||
const next = structuredClone(current.initial);
|
||||
next.local = { a: "unsupported/a.md", b: "good.md" };
|
||||
return {
|
||||
...current,
|
||||
next,
|
||||
permanentFault: (label: string) => {
|
||||
if (label === "before:mkdir:unsupported")
|
||||
throw Object.assign(new Error("Unsupported destination"), {
|
||||
code: "ENAMETOOLONG"
|
||||
});
|
||||
},
|
||||
writes: {
|
||||
a: {
|
||||
expected: await toStoredSnapshot({ content: bytes("A") }),
|
||||
replacement: await toStoredSnapshot({
|
||||
content: bytes("REMOTE")
|
||||
})
|
||||
}
|
||||
},
|
||||
expectedLocal: { a: "Recovered a.md", b: "good.md" },
|
||||
expected: new Map([
|
||||
["Recovered a.md", bytes("REMOTE")],
|
||||
["good.md", bytes("B")]
|
||||
])
|
||||
};
|
||||
}
|
||||
|
||||
for (const [name, create] of [
|
||||
["swap and create", swapFixture],
|
||||
["file/directory cycle", directoryCycleFixture],
|
||||
["cross-directory move", crossDirectoryFixture],
|
||||
["content merge", mergeFixture],
|
||||
["binary replacement and deletion", deletionFixture],
|
||||
["permanent destination fallback", permanentDestinationFixture]
|
||||
] as const) {
|
||||
test(`${name}: interrupted operations leave metadata usable by ordinary scans`, async (t) => {
|
||||
const baseline = await create();
|
||||
const labels: string[] = [];
|
||||
const trace = (label: string) => {
|
||||
if (!label.startsWith("read:")) labels.push(label);
|
||||
};
|
||||
baseline.disk.boundary = (label) => {
|
||||
baseline.permanentFault(label);
|
||||
trace(label);
|
||||
};
|
||||
baseline.persistence.boundary = trace;
|
||||
await baseline.restart().files.apply(baseline.next, baseline.writes);
|
||||
assert.deepEqual(baseline.disk.userFiles(), baseline.expected);
|
||||
assert.deepEqual(
|
||||
baseline.persistence.snapshot().database!.local,
|
||||
baseline.expectedLocal ?? baseline.next.local
|
||||
);
|
||||
assert(labels.length > 0);
|
||||
for (const powerLoss of [false, true])
|
||||
for (let cut = 0; cut < labels.length; cut++) {
|
||||
await t.test(
|
||||
`${powerLoss ? "power" : "process"} interruption at ${labels[cut]}`,
|
||||
async () => {
|
||||
const current = await create();
|
||||
let seen = 0;
|
||||
let fired = false;
|
||||
const interrupt = (label: string) => {
|
||||
if (label.startsWith("read:")) return;
|
||||
if (seen++ === cut) {
|
||||
fired = true;
|
||||
current.disk.crash(powerLoss);
|
||||
throw new Error("interrupted");
|
||||
}
|
||||
};
|
||||
current.disk.boundary = (label) => {
|
||||
current.permanentFault(label);
|
||||
interrupt(label);
|
||||
};
|
||||
current.persistence.boundary = interrupt;
|
||||
await assert.rejects(
|
||||
current
|
||||
.restart()
|
||||
.files.apply(current.next, current.writes),
|
||||
/interrupted/
|
||||
);
|
||||
assert(fired);
|
||||
current.disk.boundary = () => { };
|
||||
current.persistence.boundary = () => { };
|
||||
// The user may edit after the interruption, including a partial
|
||||
// write or a file whose metadata was never saved.
|
||||
await current.disk.userWrite(
|
||||
"after-restart.md",
|
||||
bytes("user edit")
|
||||
);
|
||||
const runtime = current.restart();
|
||||
const observed = current.disk.userFiles();
|
||||
await scanChanges(runtime, []);
|
||||
assert.deepEqual(
|
||||
current.disk.userFiles(),
|
||||
observed,
|
||||
"A scan must not replay old filesystem intentions"
|
||||
);
|
||||
assertManifest(runtime.database.state.local);
|
||||
assert.deepEqual(
|
||||
new Set(
|
||||
Object.values(runtime.database.state.local)
|
||||
),
|
||||
new Set(observed.keys())
|
||||
);
|
||||
for (const [id, path] of Object.entries(
|
||||
runtime.database.state.local
|
||||
)) {
|
||||
assert(
|
||||
runtime.database.state.documents[id]
|
||||
.materialized
|
||||
);
|
||||
assert.equal(
|
||||
runtime.database.state.documents[id]
|
||||
.observedHash,
|
||||
(await runtime.files.snapshot(path))!.hash
|
||||
);
|
||||
}
|
||||
assert(
|
||||
(await runtime.files.listFilesRecursively()).every(
|
||||
(path) => !path.startsWith(".vault-link-sync/")
|
||||
)
|
||||
);
|
||||
const state = structuredClone(runtime.database.state);
|
||||
await scanChanges(current.restart(), []);
|
||||
assert.deepEqual(
|
||||
current.persistence.snapshot().database,
|
||||
state,
|
||||
"Second startup must be stable"
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("production manifest merge obeys independently specified per-document policy", () => {
|
||||
const paths = [undefined, "a.md", "b.md", "nested/c.md"];
|
||||
const map = (path: string | undefined): Record<string, string> =>
|
||||
path === undefined
|
||||
? {}
|
||||
: { "00000000-0000-4000-8000-000000000001": path };
|
||||
for (const base of paths)
|
||||
for (const local of paths)
|
||||
for (const remote of paths) {
|
||||
assert.deepEqual(
|
||||
mergeFileManifests(map(base), map(local), map(remote)),
|
||||
map(pathDecision(base, local, remote))
|
||||
);
|
||||
}
|
||||
const conflicts = mergeFileManifests(
|
||||
{},
|
||||
{ a: "folder" },
|
||||
{ b: "folder/note.md" }
|
||||
);
|
||||
assertManifest(conflicts);
|
||||
assert.equal(
|
||||
conflicts.b,
|
||||
"folder/note.md",
|
||||
"Canonical path must win collision"
|
||||
);
|
||||
assert.equal(
|
||||
Object.keys(conflicts).length,
|
||||
2,
|
||||
"Collision must not discard an ID"
|
||||
);
|
||||
});
|
||||
|
||||
test("exhaustive two-document manifest states preserve IDs and canonical collision priority", () => {
|
||||
const ids = [
|
||||
"00000000-0000-4000-8000-000000000001",
|
||||
"00000000-0000-4000-8000-000000000002"
|
||||
];
|
||||
const states: Record<string, string>[] = [];
|
||||
for (const a of [undefined, "a.md", "b.md"])
|
||||
for (const b of [undefined, "a.md", "b.md"]) {
|
||||
if (a && a === b) continue;
|
||||
states.push({
|
||||
...(a ? { [ids[0]]: a } : {}),
|
||||
...(b ? { [ids[1]]: b } : {})
|
||||
});
|
||||
}
|
||||
for (const base of states)
|
||||
for (const local of states)
|
||||
for (const remote of states) {
|
||||
const expected = Object.fromEntries(
|
||||
ids
|
||||
.map((id) => [
|
||||
id,
|
||||
pathDecision(base[id], local[id], remote[id])
|
||||
])
|
||||
.filter(([, value]) => value !== undefined)
|
||||
);
|
||||
const merged = mergeFileManifests(base, local, remote);
|
||||
assertManifest(merged);
|
||||
assert.deepEqual(
|
||||
Object.keys(merged).sort(),
|
||||
Object.keys(expected).sort(),
|
||||
"Merge discarded or invented an identity"
|
||||
);
|
||||
for (const id of ids)
|
||||
if (
|
||||
expected[id] !== undefined &&
|
||||
remote[id] === expected[id]
|
||||
)
|
||||
assert.equal(
|
||||
merged[id],
|
||||
remote[id],
|
||||
"Canonical claimant lost its path"
|
||||
);
|
||||
assert.deepEqual(
|
||||
mergeFileManifests(remote, merged, remote),
|
||||
merged,
|
||||
"Manifest merge is not idempotent"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("scan failures cannot masquerade as deletions", async () => {
|
||||
for (const point of ["read:list:", "read:snapshot:a.md"]) {
|
||||
const current = await fixture();
|
||||
const runtime = current.restart();
|
||||
const original = structuredClone(runtime.database.state);
|
||||
let fired = false;
|
||||
current.disk.boundary = (label) => {
|
||||
if (label === point) {
|
||||
fired = true;
|
||||
throw new Error("permission denied");
|
||||
}
|
||||
};
|
||||
await assert.rejects(
|
||||
scanLocalFiles({
|
||||
next: structuredClone(original),
|
||||
changes: [],
|
||||
files: runtime.files,
|
||||
guard: () => { },
|
||||
ignored: () => false,
|
||||
oversized: () => false,
|
||||
commit: (next) => runtime.database.commit(next)
|
||||
}),
|
||||
/permission denied/
|
||||
);
|
||||
assert(fired);
|
||||
assert.deepEqual(runtime.database.state, original);
|
||||
assert.deepEqual(current.persistence.snapshot().database, original);
|
||||
}
|
||||
});
|
||||
|
||||
for (const phase of ["before", "durable"]) {
|
||||
test(`delete/recreate identity survives ${phase}-save failure and retry`, async () => {
|
||||
const current = await fixture();
|
||||
await current.disk.userDelete("a.md");
|
||||
await current.disk.userWrite("a.md", bytes("new incarnation"));
|
||||
const changes: LocalChange[] = [
|
||||
{ type: "delete", path: "a.md" },
|
||||
{ type: "create", path: "a.md" }
|
||||
];
|
||||
const scan = async () => {
|
||||
const runtime = current.restart();
|
||||
await scanLocalFiles({
|
||||
next: structuredClone(runtime.database.state),
|
||||
changes,
|
||||
files: runtime.files,
|
||||
guard: () => { },
|
||||
ignored: () => false,
|
||||
oversized: () => false,
|
||||
commit: (next) => runtime.database.commit(next)
|
||||
});
|
||||
return runtime.database.state;
|
||||
};
|
||||
current.persistence.boundary = (label) => {
|
||||
if (label === `${phase}:save`) throw new Error("save failure");
|
||||
};
|
||||
await assert.rejects(scan(), /save failure/);
|
||||
current.persistence.boundary = () => { };
|
||||
const state = await scan();
|
||||
assert.equal(
|
||||
state.local.a,
|
||||
undefined,
|
||||
"Retry reused the deleted document's UUID"
|
||||
);
|
||||
assert.equal(
|
||||
Object.values(state.local).filter((path) => path === "a.md").length,
|
||||
1
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function scanChanges(
|
||||
runtime: { database: Database; files: FileOperations },
|
||||
changes: LocalChange[]
|
||||
): Promise<void> {
|
||||
await runtime.database.recoverPersistence();
|
||||
await scanLocalFiles({
|
||||
next: structuredClone(runtime.database.state),
|
||||
changes,
|
||||
files: runtime.files,
|
||||
guard: () => { },
|
||||
ignored: () => false,
|
||||
oversized: () => false,
|
||||
commit: (next) => runtime.database.commit(next)
|
||||
});
|
||||
}
|
||||
|
||||
for (const editedContent of ["new bytes", "B"]) {
|
||||
test(`explicit rename then edit to ${JSON.stringify(editedContent)} preserves both UUIDs`, async () => {
|
||||
const current = await fixture();
|
||||
const runtime = current.restart();
|
||||
await current.disk.userRename("a.md", "moved.md");
|
||||
await current.disk.userWrite("moved.md", bytes(editedContent));
|
||||
const changes: LocalChange[] = [
|
||||
{ type: "move", oldPath: "a.md", relativePath: "moved.md" }
|
||||
];
|
||||
await scanChanges(runtime, changes);
|
||||
assert.deepEqual(
|
||||
runtime.database.state.local,
|
||||
{ a: "moved.md", b: "b.md" },
|
||||
"Matching another live document's bytes must not steal its UUID"
|
||||
);
|
||||
assert.deepEqual(
|
||||
current.disk.userFiles(),
|
||||
new Map([
|
||||
["moved.md", bytes(editedContent)],
|
||||
["b.md", bytes("B")]
|
||||
])
|
||||
);
|
||||
assert.equal(changes.length, 0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const equalContent of [false, true]) {
|
||||
for (const phase of ["before", "durable"]) {
|
||||
test(`${equalContent ? "equal" : "different"}-content swap retains UUIDs after ${phase}-save rejection`, async () => {
|
||||
const current = await fixture({
|
||||
a: { path: "a.md", text: "A" },
|
||||
b: { path: "b.md", text: equalContent ? "A" : "B" }
|
||||
});
|
||||
const runtime = current.restart();
|
||||
const changes: LocalChange[] = [];
|
||||
for (const [oldPath, relativePath] of [
|
||||
["a.md", "temporary.md"],
|
||||
["b.md", "a.md"],
|
||||
["temporary.md", "b.md"]
|
||||
]) {
|
||||
await current.disk.userRename(oldPath, relativePath);
|
||||
changes.push({ type: "move", oldPath, relativePath });
|
||||
}
|
||||
const expected = { a: "b.md", b: "a.md" };
|
||||
const expectedFiles = new Map([
|
||||
["a.md", bytes(equalContent ? "A" : "B")],
|
||||
["b.md", bytes("A")]
|
||||
]);
|
||||
let fired = false;
|
||||
current.persistence.boundary = (label) => {
|
||||
if (!fired && label === `${phase}:save`) {
|
||||
fired = true;
|
||||
throw new Error("uncertain save");
|
||||
}
|
||||
};
|
||||
await assert.rejects(
|
||||
scanChanges(runtime, changes),
|
||||
/uncertain save/
|
||||
);
|
||||
assert(fired, "The requested save failure must be exercised");
|
||||
assert.deepEqual(
|
||||
current.persistence.snapshot().database!.local,
|
||||
phase === "durable" ? expected : current.initial.local,
|
||||
"The fault must occur on the intended side of the durable save"
|
||||
);
|
||||
current.persistence.boundary = () => { };
|
||||
// Model an uncertain save in a live client: durable state reloads,
|
||||
// but the original editor notifications remain in memory. Do not
|
||||
// clear, reconstruct or deduplicate that queue in the harness.
|
||||
await scanChanges(runtime, changes);
|
||||
assert.deepEqual(
|
||||
runtime.database.state.local,
|
||||
expected,
|
||||
"Retry replayed an already committed swap and reversed its identities"
|
||||
);
|
||||
assert.equal(changes.length, 0);
|
||||
await scanChanges(runtime, changes);
|
||||
assert.deepEqual(runtime.database.state.local, expected);
|
||||
const restarted = current.restart();
|
||||
await scanChanges(restarted, []);
|
||||
assert.deepEqual(restarted.database.state.local, expected);
|
||||
assert.deepEqual(current.disk.userFiles(), expectedFiles);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("normalization-sensitive disk does not lose a distinct decomposed filename", async () => {
|
||||
const current = await fixture();
|
||||
await current.disk.userWrite("é.md", bytes("composed"));
|
||||
const runtime = current.restart();
|
||||
const next = structuredClone(runtime.database.state);
|
||||
next.local.composed = "é.md";
|
||||
next.documents.composed = { materialized: true };
|
||||
await runtime.database.commit(next);
|
||||
await current.disk.userWrite("e\u0301.md", bytes("decomposed"));
|
||||
await scanLocalFiles({
|
||||
next: structuredClone(runtime.database.state),
|
||||
changes: [],
|
||||
files: runtime.files,
|
||||
guard: () => { },
|
||||
ignored: () => false,
|
||||
oversized: () => false,
|
||||
commit: (next) => runtime.database.commit(next)
|
||||
});
|
||||
assert.equal(
|
||||
Object.keys(runtime.database.state.local).length,
|
||||
4,
|
||||
"Distinct Unicode file vanished from the scan"
|
||||
);
|
||||
});
|
||||
231
frontend/test-support/restart-reconciliation.test.ts
Normal file
231
frontend/test-support/restart-reconciliation.test.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { fixture, bytes, randomUUID } from "./sync-server-fixture";
|
||||
import { assertCanonical } from "./canonical";
|
||||
import { SyncClient } from "../sync-client/src/sync-client";
|
||||
import { MemoryDisk, MemoryPersistence } from "./storage";
|
||||
|
||||
for (const powerLoss of [false, true])
|
||||
for (const operation of ["create", "replace", "move", "metadata"] as const)
|
||||
test(
|
||||
`${operation}: a ${powerLoss ? "power" : "process"} interruption and later user edits converge without replay`,
|
||||
{ timeout: 20_000 },
|
||||
async () => {
|
||||
const f = await fixture();
|
||||
let peer: SyncClient | undefined;
|
||||
try {
|
||||
const id = randomUUID();
|
||||
const head = await f.put(
|
||||
`/documents/${id}`,
|
||||
f.content("BASE\n")
|
||||
);
|
||||
const manifest = await f.put("/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: 0,
|
||||
entries: { [id]: "note.md" }
|
||||
});
|
||||
if (operation !== "create") {
|
||||
await f.client.start();
|
||||
await f.client.setSetting("isSyncEnabled", false);
|
||||
if (operation === "move")
|
||||
await f.put("/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: manifest.fileManifestId,
|
||||
entries: { [id]: "moved.md" }
|
||||
});
|
||||
else
|
||||
await f.put(
|
||||
`/documents/${id}`,
|
||||
f.content("REMOTE\nBASE\n", head.vaultUpdateId)
|
||||
);
|
||||
}
|
||||
let fired = false;
|
||||
let wrote = false;
|
||||
const interrupt = () => {
|
||||
fired = true;
|
||||
f.disk.crash(powerLoss);
|
||||
throw new Error("injected interruption");
|
||||
};
|
||||
f.disk.boundary = (label) => {
|
||||
if (fired) return;
|
||||
if (label === "durable:write:note.md") wrote = true;
|
||||
if (
|
||||
operation === "move" &&
|
||||
label === "visible:rename:note.md->moved.md"
|
||||
)
|
||||
interrupt();
|
||||
if (
|
||||
(operation === "create" ||
|
||||
operation === "replace") &&
|
||||
label === "visible:write:note.md"
|
||||
)
|
||||
interrupt();
|
||||
};
|
||||
f.store.boundary = (label) => {
|
||||
if (
|
||||
!fired &&
|
||||
operation === "metadata" &&
|
||||
wrote &&
|
||||
label === "before:save"
|
||||
)
|
||||
interrupt();
|
||||
};
|
||||
await assert.rejects(
|
||||
operation === "create"
|
||||
? f.client.start()
|
||||
: f.client.setSetting("isSyncEnabled", true),
|
||||
/injected interruption/
|
||||
);
|
||||
assert(
|
||||
fired,
|
||||
"The intended filesystem/save boundary must be reached"
|
||||
);
|
||||
f.disk.boundary = () => {};
|
||||
f.store.boundary = () => {};
|
||||
// Model torn content and further independent edits. Neither is
|
||||
// recoverable from an old filesystem intention, nor should it be.
|
||||
await f.disk.userWrite(
|
||||
operation === "move" ? "moved.md" : "note.md",
|
||||
bytes("partial\nUSER EDIT\n")
|
||||
);
|
||||
await f.disk.userWrite(
|
||||
"after.md",
|
||||
bytes("AFTER INTERRUPTION\n")
|
||||
);
|
||||
await f.reopenClient();
|
||||
await f.client.start();
|
||||
const inspectable = {
|
||||
files: () => f.disk.userFiles(),
|
||||
database: () => f.store.snapshot().database
|
||||
};
|
||||
const peerDisk = new MemoryDisk();
|
||||
const peerStore = new MemoryPersistence({
|
||||
settings: f.store.snapshot().settings
|
||||
});
|
||||
peer = await SyncClient.create({
|
||||
fs: peerDisk,
|
||||
persistence: peerStore
|
||||
});
|
||||
await peer.start();
|
||||
const clients = [
|
||||
inspectable,
|
||||
{
|
||||
files: () => peerDisk.userFiles(),
|
||||
database: () => peerStore.snapshot().database
|
||||
}
|
||||
];
|
||||
await assertCanonical(
|
||||
clients,
|
||||
f.url,
|
||||
"test-token-change-me"
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles().get("after.md"),
|
||||
bytes("AFTER INTERRUPTION\n")
|
||||
);
|
||||
assert(
|
||||
(await f.disk.listFilesRecursively()).every(
|
||||
(path) => !path.startsWith(".vault-link-sync/")
|
||||
)
|
||||
);
|
||||
const files = f.disk.userFiles();
|
||||
await f.reopenClient();
|
||||
await f.client.start();
|
||||
await assertCanonical(
|
||||
clients,
|
||||
f.url,
|
||||
"test-token-change-me"
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles(),
|
||||
files,
|
||||
"A second restart must not replay old writes"
|
||||
);
|
||||
} finally {
|
||||
f.disk.boundary = () => {};
|
||||
f.store.boundary = () => {};
|
||||
await peer?.destroy();
|
||||
await f.dispose();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
for (const operation of ["create", "replace", "move"] as const)
|
||||
test(
|
||||
`${operation}: metadata can survive while completed user-file writes are lost`,
|
||||
{ timeout: 20_000 },
|
||||
async () => {
|
||||
const f = await fixture();
|
||||
let peer: SyncClient | undefined;
|
||||
try {
|
||||
const id = randomUUID();
|
||||
const head = await f.put(
|
||||
`/documents/${id}`,
|
||||
f.content("BASE\n")
|
||||
);
|
||||
const manifest = await f.put("/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: 0,
|
||||
entries: { [id]: "note.md" }
|
||||
});
|
||||
if (operation !== "create") {
|
||||
await f.client.start();
|
||||
await f.client.setSetting("isSyncEnabled", false);
|
||||
if (operation === "replace")
|
||||
await f.put(
|
||||
`/documents/${id}`,
|
||||
f.content("REMOTE\nBASE\n", head.vaultUpdateId)
|
||||
);
|
||||
else
|
||||
await f.put("/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: manifest.fileManifestId,
|
||||
entries: { [id]: "moved.md" }
|
||||
});
|
||||
}
|
||||
const oldDisk = f.disk.image();
|
||||
if (operation === "create") await f.client.start();
|
||||
else await f.client.setSetting("isSyncEnabled", true);
|
||||
await f.client.destroy();
|
||||
// Metadata is newer than disk: completed filesystem calls have no
|
||||
// flush contract. This is not covered by failing a call mid-flight.
|
||||
f.disk.restore(oldDisk);
|
||||
await f.disk.userWrite(
|
||||
"after.md",
|
||||
bytes("USER AFTER POWER LOSS\n")
|
||||
);
|
||||
await f.reopenClient();
|
||||
await f.client.start();
|
||||
const peerDisk = new MemoryDisk();
|
||||
const peerStore = new MemoryPersistence({
|
||||
settings: f.store.snapshot().settings
|
||||
});
|
||||
peer = await SyncClient.create({
|
||||
fs: peerDisk,
|
||||
persistence: peerStore
|
||||
});
|
||||
await peer.start();
|
||||
await assertCanonical(
|
||||
[
|
||||
{
|
||||
files: () => f.disk.userFiles(),
|
||||
database: () => f.store.snapshot().database
|
||||
},
|
||||
{
|
||||
files: () => peerDisk.userFiles(),
|
||||
database: () => peerStore.snapshot().database
|
||||
}
|
||||
],
|
||||
f.url,
|
||||
"test-token-change-me"
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles().get("after.md"),
|
||||
bytes("USER AFTER POWER LOSS\n")
|
||||
);
|
||||
} finally {
|
||||
await peer?.destroy();
|
||||
await f.dispose();
|
||||
}
|
||||
}
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue