test(client/e2e): CONTRAT — le 1er State d'un abonnement = barrière de sync

Épingle empiriquement (broker réel) le contrat implicite sur lequel open-repo
repose : après le 1er événement `State` d'un `doc_subscribe`, la PRÉSENCE d'une
donnée est GARANTIE (le triple écrit est déjà dans ce State, sans attente
supplémentaire) et l'ABSENCE est DÉFINITIVE (doc vide reste vide, +5s de grâce).
Si ce contrat casse (changement de version broker), ce test le détecte.

Constats mesurés : l'abonnement pousse `TabInfo` (~1-3ms) PUIS `State` (~2-3ms)
— le State est le 2e événement, pas le 1er ; latence 1er State ~2-3ms sur profil
frais. IMPORTANT (à corriger phase 2) : open-repo résout son attente sur le 1er
push (= TabInfo), PAS sur le State → il rend la main avant la vraie barrière ; le
commentaire « resolves on the FIRST push (the initial State) » est faux.

gate : test:e2e 39 passed ; bun test 117 ; tsc propre. src/ non touché.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-09 09:33:59 +02:00
parent d98777e6e3
commit 956a2228cc
2 changed files with 282 additions and 0 deletions
+148
View File
@@ -530,6 +530,154 @@ async function main(): Promise<void> {
try { if (rp) await rp.close(); } catch { /* ignore */ }
}
});
// ── CONTRACT 3: first-State barrier (doc_subscribe sync-point) ──────────────
//
// Empirical pin of the implicit contract that open-repo.ts relies on:
// "the 1st event emitted by doc_subscribe is a State that marks the end of
// the initial broker sync — after it, presence is guaranteed and absence
// is definitive."
//
// Three sub-contracts:
// (3a) PRESENCE GUARANTEED — write a triple in session, subscribe, capture
// the FIRST event; it must be a `State`, and an anchored SPARQL query
// immediately after must find the triple (no second wait needed).
// (3b) ABSENCE DEFINITIVE — subscribe to an empty-but-valid doc, capture
// the FIRST event; it must be a `State` that reflects 0 triples AND
// must NOT be followed by a late Patch within a grace window.
// (3c) STATE vs TIMEOUT — the event log carries the real event-type key from
// the raw AppResponse (`{ V0: { State | Patch | TabInfo | … } }`), so
// we can distinguish a genuine first-State from a silent timeout.
//
// Every wait here is event-driven (waitForFunction on the event count) with a
// 30s timeout that produces a FAIL, not a silent green.
console.log("\n── CONTRACT 3: first-State barrier (doc_subscribe sync-point) ──");
// 3a — PRESENCE GUARANTEED AT FIRST STATE
await step("(3a) presence guaranteed at first State", async () => {
const triple = { s: "urn:e2e:state:s", p: "urn:e2e:state:p", o: "state-contract-present" };
// Write the triple first (same session, write is already committed broker-side).
const doc = await sdk<string>(frame, "stateProbeWrite", triple);
// Subscribe in a fresh call and start timing.
const tSubscribe = Date.now();
await sdk(frame, "stateProbeSubscribe", doc);
// Wait event-driven for the FIRST event of any type — reveals the push ordering.
await frame.waitForFunction(
() => (window as any).__sdk.stateProbeEvents().length >= 1,
{ timeout: 30000 },
);
const firstAnyEventMs = Date.now() - tSubscribe;
const eventsAfterAny = await sdkGet<Array<{ typeKey: string; elapsedMs: number }>>(frame, "stateProbeEvents");
const firstAnyEvent = eventsAfterAny[0];
// 3c: first event MUST have a recognised type key — distinguishes a real push
// from a synthetic timeout/parse failure. The broker emits TabInfo first, then
// State (VERIFIED empirically: TabInfo at ~2-5ms, State at ~5-15ms).
check(
"(3c) first event has a recognised type key (not a synthetic timeout)",
firstAnyEvent !== undefined && firstAnyEvent.typeKey !== "unknown" && firstAnyEvent.typeKey !== "parse-error",
`first-event typeKey=${firstAnyEvent?.typeKey ?? "none"} elapsedMs=${firstAnyEvent?.elapsedMs ?? "?"}ms (wall-clock: ${firstAnyEventMs}ms)`,
);
// Now wait specifically for the FIRST State event (may be the 2nd+ overall push —
// the broker pushes TabInfo before State).
await frame.waitForFunction(
() => (window as any).__sdk.stateProbeStateCount() >= 1,
{ timeout: 30000 },
);
const firstStateWallMs = Date.now() - tSubscribe;
const eventsAfterState = await sdkGet<Array<{ typeKey: string; elapsedMs: number }>>(frame, "stateProbeEvents");
const firstStateEvent = eventsAfterState.find((e) => e.typeKey === "State");
// 3a-i: a State event MUST arrive (not just TabInfo). This is the sync-point
// barrier — the broker delivers State after syncing up to the broker's heads.
check(
"(3a-i) a State event arrives (sync-point barrier confirmed)",
firstStateEvent !== undefined,
`stateElapsedMs=${firstStateEvent?.elapsedMs ?? "never"} events=${JSON.stringify(eventsAfterState.map((e) => e.typeKey))}`,
);
// 3a-ii: AFTER the State, an anchored SPARQL query must find the triple
// WITHOUT any additional wait. The State is the sync barrier.
const q = await sdk<{ rows: number; found: boolean }>(frame, "stateProbeQuery", triple.s, triple.p);
check(
"(3a-ii) triple is present in SPARQL query immediately after first State (no extra wait)",
q.found === true,
`rows=${q.rows} found=${q.found} stateMs=${firstStateEvent?.elapsedMs ?? "?"}ms`,
);
console.log(
` [INFO] push ordering: ${eventsAfterState.map((e) => `${e.typeKey}@${e.elapsedMs}ms`).join(" → ")}`,
);
console.log(
` [INFO] first-State latency: ${firstStateEvent?.elapsedMs ?? "?"}ms (wall-clock: ${firstStateWallMs}ms since subscribe call)`,
);
await sdk(frame, "stateProbeStop");
});
// 3b — ABSENCE DEFINITIVE AT FIRST STATE
await step("(3b) absence definitive at first State (empty doc stays empty)", async () => {
// Create an empty doc and subscribe atomically.
await sdk<string>(frame, "stateProbeEmptyDoc");
// Wait for the first State event (TabInfo arrives first, State second).
const tSubscribe = Date.now();
await frame.waitForFunction(
() => (window as any).__sdk.stateProbeStateCount() >= 1,
{ timeout: 30000 },
);
const firstStateWallMs = Date.now() - tSubscribe;
const eventsAfterState = await sdkGet<Array<{ typeKey: string; elapsedMs: number }>>(frame, "stateProbeEvents");
const firstStateEvent = eventsAfterState.find((e) => e.typeKey === "State");
check(
"(3b-i) a State event arrives for an empty doc (sync barrier fires even for empty)",
firstStateEvent !== undefined,
`stateMs=${firstStateEvent?.elapsedMs ?? "never"} events=${JSON.stringify(eventsAfterState.map((e) => e.typeKey))}`,
);
// Verify the doc is empty via SPARQL immediately after the State.
const qEmpty = await sdk<{ rows: number; found: boolean }>(
frame,
"stateProbeQuery",
"urn:e2e:state:s",
"urn:e2e:state:p",
);
check(
"(3b-ii) SPARQL query immediately after first State confirms the doc is empty",
qEmpty.found === false && qEmpty.rows === 0,
`rows=${qEmpty.rows} found=${qEmpty.found}`,
);
// Grace window: wait 5s and verify no data-bearing Patch arrives after the State.
// A second State is normal (broker may re-push); only a Patch with actual data
// would violate "absence is definitive". We check the SPARQL result, not event types,
// because a Patch on an empty doc that stays empty is also fine.
await page!.waitForTimeout(5000);
const qAfterGrace = await sdk<{ rows: number; found: boolean }>(
frame,
"stateProbeQuery",
"urn:e2e:state:s",
"urn:e2e:state:p",
);
const eventsAfterGrace = await sdkGet<Array<{ typeKey: string; elapsedMs: number }>>(frame, "stateProbeEvents");
check(
"(3b-iii) SPARQL still empty after 5s grace window (absence at first State is definitive)",
!qAfterGrace.found && qAfterGrace.rows === 0,
`foundAfterGrace=${qAfterGrace.found} events=${JSON.stringify(eventsAfterGrace.map((e) => e.typeKey))}`,
);
console.log(
` [INFO] push ordering: ${eventsAfterGrace.map((e) => `${e.typeKey}@${e.elapsedMs}ms`).join(" → ")}`,
);
console.log(
` [INFO] first-State latency (empty doc): ${firstStateEvent?.elapsedMs ?? "?"}ms (wall-clock: ${firstStateWallMs}ms since subscribe call)`,
);
await sdk(frame, "stateProbeStop");
});
} finally {
try { if (page) await page.close(); } catch { /* ignore */ }
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
+134
View File
@@ -606,6 +606,140 @@ const identity = new IdentityStore(
identitySet(id: string) { return identity.set(id); },
identityGet() { return identity.get(); },
identityClear() { identity.clear(); return identity.get(); },
// ── CONTRACT: first-State barrier (doc_subscribe sync-point) ────────────
//
// The contract under test: the 1st event emitted by doc_subscribe is a `State`
// that marks the end of the initial broker sync. After that push:
// - if the doc has data → it MUST be present in the State (no "not yet synced")
// - if the doc is empty → it IS definitively empty (no later arrival)
//
// We drive this via the polyfill's subscribeDoc (which calls ng.doc_subscribe
// directly), but we capture the RAW AppResponse for each push so we can:
// (a) identify the event type (State vs Patch vs TabInfo vs other)
// (b) measure the time-to-first-State
// (c) correlate "State received" with the SPARQL content query result
//
// Bridge state for a single active first-State probe.
_stateProbe: null as null | {
doc: string;
startMs: number;
events: Array<{ typeKey: string; elapsedMs: number }>;
unsub: () => void;
},
/**
* PRESENCE probe — phase 1: write a triple into `doc` (same session, so the
* write is already committed on the broker before we subscribe). Returns the doc
* NURI so the test can hand it to stateProbeSubscribe. Split into write + subscribe
* so the test controls the ordering precisely.
*/
async stateProbeWrite(triple: { s: string; p: string; o: string }): Promise<string> {
const s = await sessionReady;
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <${triple.s}> <${triple.p}> "${triple.o}" }`,
doc,
);
return doc;
},
/**
* Subscribe to `doc` and record every raw event (type key + elapsed ms from
* subscribe call). The state probe slot is single-use per call; a previous probe
* is torn down first. `subscribeDoc` is the polyfill wrapper — it passes the raw
* AppResponse straight through to the callback.
*/
stateProbeSubscribe(doc: string): void {
// Tear down any prior probe.
const prev = (window as any).__sdk._stateProbe;
if (prev) { try { prev.unsub(); } catch { /* ignore */ } }
const probe = {
doc,
startMs: Date.now(),
events: [] as Array<{ typeKey: string; elapsedMs: number }>,
unsub: () => {},
};
(window as any).__sdk._stateProbe = probe;
probe.unsub = subscribeDoc(doc, (resp: any) => {
const elapsedMs = Date.now() - probe.startMs;
// AppResponse shape: { V0: { State: … } } | { V0: { Patch: … } } | { V0: { TabInfo: … } } | …
let typeKey = "unknown";
try {
const v0 = resp?.V0 ?? resp?.v0 ?? resp;
if (v0 && typeof v0 === "object") {
const keys = Object.keys(v0);
typeKey = keys[0] ?? "empty";
} else {
typeKey = String(v0);
}
} catch { typeKey = "parse-error"; }
probe.events.push({ typeKey, elapsedMs });
});
},
/** Return the accumulated event log (snapshot — safe to call at any time). */
stateProbeEvents(): Array<{ typeKey: string; elapsedMs: number }> {
return (window as any).__sdk._stateProbe?.events ?? [];
},
/**
* Return the count of events with typeKey === `State` received so far.
* Used by waitForFunction to wait for the first State (not just any event —
* the broker pushes TabInfo first, State second).
*/
stateProbeStateCount(): number {
const events: Array<{ typeKey: string }> = (window as any).__sdk._stateProbe?.events ?? [];
return events.filter((e) => e.typeKey === "State").length;
},
/**
* After the first State has been received, run an anchored SPARQL query on the
* probe doc to read back its triples. Returns the raw rows so the test can assert
* presence/absence without re-querying from the test side.
*/
async stateProbeQuery(s_uri: string, p_uri: string): Promise<{ rows: number; found: boolean }> {
const s = await sessionReady;
const doc = (window as any).__sdk._stateProbe?.doc;
if (!doc) return { rows: -1, found: false };
try {
const res: any = await docs.sparqlQuery(
s.session_id,
`SELECT ?o WHERE { <${s_uri}> <${p_uri}> ?o }`,
undefined,
doc,
);
const rows = Array.isArray(res)
? res.length
: (res?.results?.bindings?.length ?? 0);
return { rows, found: rows > 0 };
} catch (e: any) {
return { rows: -2, found: false };
}
},
/** Stop the active probe's subscription. */
stateProbeStop(): void {
const probe = (window as any).__sdk._stateProbe;
if (probe) { try { probe.unsub(); } catch { /* ignore */ } }
(window as any).__sdk._stateProbe = null;
},
/**
* ABSENCE probe — subscribe to a doc NURI that was never written (non-existent).
* Returns the doc immediately after creating it (empty), then subscribes.
* The test waits for the first State, then verifies the doc is empty AND stays
* empty for a grace window (no late Patch arrival).
*/
async stateProbeEmptyDoc(): Promise<string> {
// Create a real doc so it's valid for doc_subscribe, but write NOTHING into it.
const s = await sessionReady;
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
// Subscribe immediately (no data written).
(window as any).__sdk.stateProbeSubscribe(doc);
return doc;
},
};
// ── Connect to the real broker ─────────────────────────────────────────────