diff --git a/packages/client/e2e/run.ts b/packages/client/e2e/run.ts index 68af906..260747e 100644 --- a/packages/client/e2e/run.ts +++ b/packages/client/e2e/run.ts @@ -530,6 +530,154 @@ async function main(): Promise { 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(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>(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>(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(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>(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>(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 */ } diff --git a/packages/client/e2e/sdk-entry.ts b/packages/client/e2e/sdk-entry.ts index 17c1de4..8c0e00d 100644 --- a/packages/client/e2e/sdk-entry.ts +++ b/packages/client/e2e/sdk-entry.ts @@ -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 { + 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 { + // 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 ─────────────────────────────────────────────