From 1d704d4ec512afe2105c54223c2dee246d58af29 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sat, 11 Jul 2026 07:49:30 +0300 Subject: [PATCH] =?UTF-8?q?test(mcp):=20=D0=BF=D0=BE=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D1=8C=20write-safe-point=20=C2=AB=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=BB=D0=B5=20Stop=20=D0=BD=D0=BE=D0=B2=D0=B0=D1=8F=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=B8=D1=81=D1=8C=20=D0=BD=D0=B5=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D1=83=D0=B5=D1=82=C2=BB=20(#487,=20F4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit paginate-abort-safepoint покрывал только READ-safe-point; WRITE-шов (collaboration.mutatePageContent / context.mutateLiveContentUnlocked — signal?.throwIfAborted() перед session.mutate) был без теста. Добавляю мок-collab юнит через тест-сим __setCollabProviderFactory (фейковый провайдер с мгновенным onSynced): предварительно abort-нутый сигнал → оба шва реджектят ДО session.mutate, трансформ не вызывается; контрольные кейсы (живой сигнал) подтверждают, что session.mutate достижим. MUTATION-VERIFY: снятие throwIfAborted в обоих швах делает красными ровно два abort-кейса, контрольные остаются зелёными. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/mock/write-abort-safepoint.test.mjs | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 packages/mcp/test/mock/write-abort-safepoint.test.mjs diff --git a/packages/mcp/test/mock/write-abort-safepoint.test.mjs b/packages/mcp/test/mock/write-abort-safepoint.test.mjs new file mode 100644 index 00000000..1002b34b --- /dev/null +++ b/packages/mcp/test/mock/write-abort-safepoint.test.mjs @@ -0,0 +1,164 @@ +// #487 F4 — the WRITE-side cancellation safe-point. +// +// Every content-mutating collab write (collaboration.mutatePageContent and the +// reentrant twin client.mutateLiveContentUnlocked used by replaceImage) checks +// the in-app tool abort signal at a PRE-COMMIT safe-point — after the collab +// session is acquired but immediately BEFORE the atomic read->write +// (session.mutate). So a Stop (or the per-call cap) that lands during the +// connect/lock window stops THIS write from landing: no new commit starts once +// aborted. paginate-abort-safepoint.test.mjs pins the READ half; this pins the +// integrity-critical WRITE half — remove the `throwIfAborted()` and the transform +// would run and the doc would be mutated past a Stop. +// +// There is no collab server in the unit env, so we swap the provider factory +// (__setCollabProviderFactory) for a fake that reports an immediate successful +// sync. That makes acquireCollabSession SUCCEED and hand back a live, ready +// session, so the ONLY thing standing between the call and session.mutate is the +// safe-point under test. The transform is instrumented to prove it never runs. +import { test, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mutatePageContent } from "../../build/lib/collaboration.js"; +import { + __setCollabProviderFactory, + destroyAllSessions, +} from "../../build/lib/collab-session.js"; +import { DocmostClient } from "../../build/client.js"; + +const BASE_URL = "http://127.0.0.1:1/api"; +// mutatePageContent locks via withPageLock, which demands a canonical page UUID +// (resolve-then-lock invariant, #260/#449); the unlocked twin does not. +const PAGE_UUID = "11111111-1111-4111-8111-111111111111"; + +// A fake HocuspocusProvider that immediately reports a successful initial sync so +// CollabSession.open() resolves to a ready session, and stays "synced" with zero +// unsynced changes so a reached session.mutate() would resolve at once. It speaks +// only the tiny CollabProviderLike surface the session depends on. +function syncedProviderFactory(config) { + // Fire the initial-sync callback so open() settles as ready. + config.onSynced(); + return { + synced: true, + unsyncedChanges: 0, + destroy() {}, + on() {}, + off() {}, + }; +} + +// Disable the session cache so every acquire opens (and the failure path destroys) +// its own ephemeral session — no cross-test session reuse. +process.env.MCP_COLLAB_SESSION_IDLE_MS = "0"; + +afterEach(() => { + __setCollabProviderFactory(null); // restore the real factory + destroyAllSessions(); +}); + +// --- collaboration.mutatePageContent (the page-locked write path) ------------- + +test("mutatePageContent rejects at the pre-commit safe-point BEFORE session.mutate when the signal is already aborted", async () => { + __setCollabProviderFactory(syncedProviderFactory); + let transformCalls = 0; + const ac = new AbortController(); + ac.abort(new Error("user stop")); + + await assert.rejects( + () => + mutatePageContent( + PAGE_UUID, + "collab-jwt", + BASE_URL, + (liveDoc) => { + transformCalls++; + return liveDoc; + }, + ac.signal, + ), + /user stop/, + "the aborted safe-point rejects with the signal's reason before committing", + ); + assert.equal( + transformCalls, + 0, + "the transform (and therefore session.mutate) must NEVER run once aborted", + ); +}); + +test("mutatePageContent (control) DOES reach session.mutate and invoke the transform when the signal is live", async () => { + __setCollabProviderFactory(syncedProviderFactory); + let transformCalls = 0; + const ac = new AbortController(); // never aborted + + const result = await mutatePageContent( + PAGE_UUID, + "collab-jwt", + BASE_URL, + (liveDoc) => { + transformCalls++; + return null; // null -> no-op write; still proves the transform was invoked + }, + ac.signal, + ); + assert.equal( + transformCalls, + 1, + "with a live signal the safe-point is a no-op and session.mutate runs the transform", + ); + assert.ok(result && result.verify, "a MutationResult is returned"); +}); + +// --- client.mutateLiveContentUnlocked (the reentrant twin, replaceImage) ------ + +test("mutateLiveContentUnlocked rejects at the pre-commit safe-point BEFORE session.mutate when the tool signal is already aborted", async () => { + __setCollabProviderFactory(syncedProviderFactory); + const client = new DocmostClient({ + apiUrl: BASE_URL, + getToken: async () => "access", + getCollabToken: async () => "collab-jwt", + }); + let transformCalls = 0; + const ac = new AbortController(); + ac.abort(new Error("cap fired")); + client.setToolAbortSignal(ac.signal); + + await assert.rejects( + () => + client.mutateLiveContentUnlocked("page-1", "collab-jwt", (liveDoc) => { + transformCalls++; + return liveDoc; + }), + /cap fired/, + "the aborted safe-point rejects with the signal's reason before committing", + ); + assert.equal( + transformCalls, + 0, + "the transform (and therefore session.mutate) must NEVER run once aborted", + ); +}); + +test("mutateLiveContentUnlocked (control) DOES reach session.mutate and invoke the transform when the tool signal is live", async () => { + __setCollabProviderFactory(syncedProviderFactory); + const client = new DocmostClient({ + apiUrl: BASE_URL, + getToken: async () => "access", + getCollabToken: async () => "collab-jwt", + }); + let transformCalls = 0; + client.setToolAbortSignal(new AbortController().signal); // live + + const result = await client.mutateLiveContentUnlocked( + "page-1", + "collab-jwt", + (liveDoc) => { + transformCalls++; + return null; + }, + ); + assert.equal( + transformCalls, + 1, + "with a live signal the safe-point is a no-op and session.mutate runs the transform", + ); + assert.ok(result && result.verify, "a MutationResult is returned"); +});