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:
@@ -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 */ }
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user