Skip to content

Commit 2cd2a99

Browse files
KyleAMathewsclaude
andcommitted
test(client): add URL uniqueness and state invariant checks to model tests
Adds global invariant assertions checked after every command: - URL length bounded at 2000 chars (catches -next suffix accumulation) - isUpToDate + lastSyncedAt consistency (catches silent stuck states) - Post-409 URL differs from pre-409 URL (catches identity loops) Also tracks request URLs in FetchGate and exposes stream instance for observable state assertions. These invariants are drawn from 25 historical bugs identified in the client's git history. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4dde726 commit 2cd2a99

File tree

1 file changed

+47
-2
lines changed

1 file changed

+47
-2
lines changed

packages/typescript-client/test/model-based.test.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,13 @@ class FetchGate {
134134
private _fetchResolve: ((r: Response) => void) | null = null
135135
private _onRequest: (() => void) | null = null
136136

137+
/** Last two request URLs — used to verify no identity loops. */
138+
lastUrl: string | null = null
139+
prevUrl: string | null = null
140+
141+
/** Maximum URL length seen across all requests. */
142+
maxUrlLength = 0
143+
137144
get requestCount(): number {
138145
return this._requestCount
139146
}
@@ -143,12 +150,18 @@ class FetchGate {
143150
}
144151

145152
readonly fetchClient = async (
146-
_input: RequestInfo | URL,
153+
input: RequestInfo | URL,
147154
init?: RequestInit
148155
): Promise<Response> => {
149156
if (init?.signal?.aborted) return Response.error()
150157
this._requestCount++
151158

159+
// Track URLs for invariant checking
160+
const urlStr = input.toString()
161+
this.prevUrl = this.lastUrl
162+
this.lastUrl = urlStr
163+
if (urlStr.length > this.maxUrlLength) this.maxUrlLength = urlStr.length
164+
152165
if (this._onRequest) {
153166
const cb = this._onRequest
154167
this._onRequest = null
@@ -189,6 +202,7 @@ class FetchGate {
189202

190203
interface StreamReal {
191204
gate: FetchGate
205+
stream: ShapeStream
192206
subscriberError: Error | null
193207
currentHandle: string
194208
respond(response: Response): Promise<void>
@@ -250,6 +264,7 @@ async function createStreamReal(): Promise<StreamReal> {
250264

251265
return {
252266
gate,
267+
stream,
253268
get subscriberError() {
254269
return errorRef.error
255270
},
@@ -278,6 +293,24 @@ interface StreamModel {
278293
}
279294

280295
const MAX_CONSECUTIVE_ERROR_RETRIES = 50
296+
const MAX_URL_LENGTH = 2000
297+
298+
/**
299+
* Invariants checked after every command. These catch historical bugs
300+
* like URL identity loops (bug #1), `-next` suffix growth (bug #3),
301+
* and handle/offset mismatches (bug #10).
302+
*/
303+
function assertGlobalInvariants(r: StreamReal): void {
304+
// URL length is bounded (catches unbounded suffix growth like -next-next-next)
305+
expect(r.gate.maxUrlLength).toBeLessThan(MAX_URL_LENGTH)
306+
307+
// After any successful (non-error) response, isUpToDate state should be
308+
// consistent with what we observe (no silent stuck states)
309+
if (!r.subscriberError && r.stream.isUpToDate) {
310+
// If the stream thinks it's up-to-date, it should have synced at some point
311+
expect(r.stream.lastSyncedAt()).toBeDefined()
312+
}
313+
}
281314

282315
// ─── Commands ───────────────────────────────────────────────────────
283316
//
@@ -295,6 +328,7 @@ class Respond200DataCmd implements fc.AsyncCommand<StreamModel, StreamReal> {
295328
m.consecutiveErrors = 0
296329
await r.respond(make200WithData(r.currentHandle))
297330
expect(r.subscriberError).toBeNull()
331+
assertGlobalInvariants(r)
298332
}
299333
toString(): string {
300334
return `Respond200Data`
@@ -312,6 +346,7 @@ class Respond200UpToDateCmd
312346
m.consecutiveErrors = 0
313347
await r.respond(make200UpToDate(r.currentHandle))
314348
expect(r.subscriberError).toBeNull()
349+
assertGlobalInvariants(r)
315350
}
316351
toString(): string {
317352
return `Respond200UpToDate`
@@ -327,9 +362,9 @@ class Respond200EmptyCmd implements fc.AsyncCommand<StreamModel, StreamReal> {
327362
return !m.terminated
328363
}
329364
async run(_m: StreamModel, r: StreamReal): Promise<void> {
330-
// Empty batch → early return → counter unchanged
331365
await r.respond(make200Empty(r.currentHandle))
332366
expect(r.subscriberError).toBeNull()
367+
assertGlobalInvariants(r)
333368
}
334369
toString(): string {
335370
return `Respond200Empty`
@@ -345,6 +380,7 @@ class Respond204Cmd implements fc.AsyncCommand<StreamModel, StreamReal> {
345380
m.consecutiveErrors = 0
346381
await r.respond(make204(r.currentHandle))
347382
expect(r.subscriberError).toBeNull()
383+
assertGlobalInvariants(r)
348384
}
349385
toString(): string {
350386
return `Respond204`
@@ -367,6 +403,7 @@ class Respond400Cmd implements fc.AsyncCommand<StreamModel, StreamReal> {
367403
expect(r.subscriberError).not.toBeNull()
368404
} else {
369405
expect(r.subscriberError).toBeNull()
406+
assertGlobalInvariants(r)
370407
}
371408
}
372409
toString(): string {
@@ -384,10 +421,17 @@ class Respond409Cmd implements fc.AsyncCommand<StreamModel, StreamReal> {
384421
return !m.terminated
385422
}
386423
async run(_m: StreamModel, r: StreamReal): Promise<void> {
424+
const prevUrl = r.gate.lastUrl
387425
const newHandle = `handle-${nextSeq()}`
388426
await r.respond(make409(newHandle))
389427
r.currentHandle = newHandle
390428
expect(r.subscriberError).toBeNull()
429+
assertGlobalInvariants(r)
430+
// After 409, the retry URL must differ from the pre-409 URL
431+
// (catches identity-loop bugs like bug #1 and #6)
432+
if (prevUrl) {
433+
expect(r.gate.lastUrl).not.toBe(prevUrl)
434+
}
391435
}
392436
toString(): string {
393437
return `Respond409`
@@ -412,6 +456,7 @@ class RespondMalformed200Cmd
412456
expect(r.subscriberError).not.toBeNull()
413457
} else {
414458
expect(r.subscriberError).toBeNull()
459+
assertGlobalInvariants(r)
415460
}
416461
}
417462
toString(): string {

0 commit comments

Comments
 (0)