vault-link/frontend/deterministic-tests/src/tests/remote-update-survives-user-rename.test.ts
Andras Schmelczer a33e4bbcb9 Add deterministic-tests workspace
Scripted multi-client harness against a real server (~110 scenario
tests, server-control, managed-websocket, test-runner). Wires the new
package into frontend/package.json workspaces and the lint script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:11:16 +01:00

84 lines
3.8 KiB
TypeScript

import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const remoteUpdateSurvivesUserRenameTest: TestDefinition = {
description:
"Client 0 updates a tracked doc; while Client 1 is processing the " +
"broadcast and parked on the GET for the new version's content, the " +
"user renames the doc on Client 1. Pre-fix: `processRemoteUpdate` " +
"captures `actualPath` before the await and, after the GET returns, " +
"calls `write(actualPath, …)` (no-op — file was renamed away), " +
"`updateCache(actualPath, …)`, and `setDocument(actualPath, …)`. " +
"`setDocument` mutates the same record in place so its `path` is " +
"yanked from the user's renamed slot back to the pre-rename path, " +
"wiping the rename out of the queue's documents map. The queued " +
"`LocalUpdate` then reads from the now-stale `record.path`, hits " +
"`FileNotFoundError`, and is silently dropped — the user's rename " +
"never reaches the server. Post-fix: the handler defers when a " +
"local event landed mid-await, so the rename drains first and " +
"the deferred remote update is folded into the broadcast that " +
"follows the rename round-trip.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "doc.md", content: "v1\n" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
// Buffer Client 1's incoming broadcasts so it doesn't see
// Client 0's update until we've paused the server.
{ type: "pause-websocket", client: 1 },
// Server now holds v=2 of doc.md.
{ type: "update", client: 0, path: "doc.md", content: "v2\n" },
{ type: "sync", client: 0 },
// Pause the server. Client 1's upcoming GET for the new version
// content blocks at the OS layer until resume.
{ type: "pause-server" },
// Release the buffered broadcast. Client 1's drain enters
// `processRemoteUpdate`, captures `actualPath`, fires the GET,
// and parks awaiting the response.
{ type: "resume-websocket", client: 1 },
// Yield long enough for the drain to traverse all microtask
// hops between the WS handler and the GET, so the HTTP request
// is queued at the (paused) server before the rename runs.
// Without this yield the rename would be enqueued before
// `processRemoteUpdate`'s entry-time `hasPendingLocalEvents`
// check and the early-defer branch would mask the bug.
{ type: "sleep", ms: 50 },
// While the GET is in flight the user renames the doc. The queue
// mutates `record.path` to "renamed.md" in place and pushes a
// LocalUpdate carrying the rename target.
{
type: "rename",
client: 1,
oldPath: "doc.md",
newPath: "renamed.md"
},
// Resume the server. The GET response unblocks
// `processRemoteUpdate`. With the fix in place it sees the
// queued LocalUpdate and defers; without the fix it walks past
// the rename and clobbers the documents map, dropping the
// pending LocalUpdate's read on the way back through.
{ type: "resume-server" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1);
s.assertFileExists("renamed.md");
s.assertFileNotExists("doc.md");
// Both edits survive: the user's rename and Client 0's
// content update at v=2.
s.assertContent("renamed.md", "v2\n");
}
}
]
};