@durable-streams/client-conformance-tests 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/adapters/typescript-adapter.cjs +75 -2
  2. package/dist/adapters/typescript-adapter.js +76 -3
  3. package/dist/{benchmark-runner-C_Yghc8f.js → benchmark-runner-CrE6JkbX.js} +106 -12
  4. package/dist/{benchmark-runner-CLAR9oLd.cjs → benchmark-runner-Db4he452.cjs} +107 -12
  5. package/dist/cli.cjs +1 -1
  6. package/dist/cli.js +1 -1
  7. package/dist/index.cjs +1 -1
  8. package/dist/index.d.cts +126 -11
  9. package/dist/index.d.ts +126 -11
  10. package/dist/index.js +1 -1
  11. package/dist/{protocol-3cf94Xyb.d.cts → protocol-D37G3c4e.d.cts} +80 -4
  12. package/dist/{protocol-DyEvTHPF.d.ts → protocol-Mcbiq3nQ.d.ts} +80 -4
  13. package/dist/protocol.d.cts +2 -2
  14. package/dist/protocol.d.ts +2 -2
  15. package/package.json +3 -3
  16. package/src/adapters/typescript-adapter.ts +127 -5
  17. package/src/protocol.ts +85 -1
  18. package/src/runner.ts +202 -17
  19. package/src/test-cases.ts +130 -8
  20. package/test-cases/consumer/error-handling.yaml +42 -0
  21. package/test-cases/consumer/fault-injection.yaml +202 -0
  22. package/test-cases/consumer/offset-handling.yaml +209 -0
  23. package/test-cases/producer/idempotent/autoclaim.yaml +214 -0
  24. package/test-cases/producer/idempotent/batching.yaml +98 -0
  25. package/test-cases/producer/idempotent/concurrent-requests.yaml +100 -0
  26. package/test-cases/producer/idempotent/epoch-management.yaml +333 -0
  27. package/test-cases/producer/idempotent/error-handling.yaml +194 -0
  28. package/test-cases/producer/idempotent/multi-producer.yaml +322 -0
  29. package/test-cases/producer/idempotent/sequence-validation.yaml +339 -0
  30. package/test-cases/producer/idempotent-json-batching.yaml +134 -0
package/src/runner.ts CHANGED
@@ -366,6 +366,71 @@ async function executeOperation(
366
366
  }
367
367
  }
368
368
 
369
+ case `idempotent-append`: {
370
+ const path = resolveVariables(op.path, variables)
371
+ const data = resolveVariables(op.data, variables)
372
+
373
+ const result = await client.send(
374
+ {
375
+ type: `idempotent-append`,
376
+ path,
377
+ data,
378
+ producerId: op.producerId,
379
+ epoch: op.epoch ?? 0,
380
+ autoClaim: op.autoClaim ?? false,
381
+ headers: op.headers,
382
+ },
383
+ commandTimeout
384
+ )
385
+
386
+ if (verbose) {
387
+ console.log(
388
+ ` idempotent-append ${path}: ${result.success ? `ok` : `failed`}`
389
+ )
390
+ }
391
+
392
+ if (
393
+ result.success &&
394
+ result.type === `idempotent-append` &&
395
+ op.expect?.storeOffsetAs
396
+ ) {
397
+ variables.set(op.expect.storeOffsetAs, result.offset ?? ``)
398
+ }
399
+
400
+ return { result }
401
+ }
402
+
403
+ case `idempotent-append-batch`: {
404
+ const path = resolveVariables(op.path, variables)
405
+
406
+ // Send items to client which will batch them internally
407
+ const items = op.items.map((item) =>
408
+ resolveVariables(item.data, variables)
409
+ )
410
+
411
+ const result = await client.send(
412
+ {
413
+ type: `idempotent-append-batch`,
414
+ path,
415
+ items,
416
+ producerId: op.producerId,
417
+ epoch: op.epoch ?? 0,
418
+ autoClaim: op.autoClaim ?? false,
419
+ maxInFlight: op.maxInFlight,
420
+ headers: op.headers,
421
+ },
422
+ commandTimeout
423
+ )
424
+
425
+ if (verbose) {
426
+ console.log(
427
+ ` idempotent-append-batch ${path}: ${result.success ? `ok` : `failed`}`
428
+ )
429
+ }
430
+
431
+ return { result }
432
+ }
433
+
369
434
  case `read`: {
370
435
  const path = resolveVariables(op.path, variables)
371
436
  const offset = op.offset
@@ -538,7 +603,8 @@ async function executeOperation(
538
603
 
539
604
  case `server-append`: {
540
605
  // Direct HTTP append to server, bypassing client adapter
541
- // Used for concurrent operations when adapter is blocked on a read
606
+ // Used for concurrent operations when adapter is blocked on a read,
607
+ // and for testing protocol-level behavior like idempotent producers
542
608
  const path = resolveVariables(op.path, variables)
543
609
  const data = resolveVariables(op.data, variables)
544
610
 
@@ -550,28 +616,72 @@ async function executeOperation(
550
616
  const contentType =
551
617
  headResponse.headers.get(`content-type`) ?? `application/octet-stream`
552
618
 
619
+ // Build headers, including producer headers if present
620
+ const headers: Record<string, string> = {
621
+ "content-type": contentType,
622
+ ...op.headers,
623
+ }
624
+ if (op.producerId !== undefined) {
625
+ headers[`Producer-Id`] = op.producerId
626
+ }
627
+ if (op.producerEpoch !== undefined) {
628
+ headers[`Producer-Epoch`] = op.producerEpoch.toString()
629
+ }
630
+ if (op.producerSeq !== undefined) {
631
+ headers[`Producer-Seq`] = op.producerSeq.toString()
632
+ }
633
+
553
634
  const response = await fetch(`${ctx.serverUrl}${path}`, {
554
635
  method: `POST`,
555
636
  body: data,
556
- headers: {
557
- "content-type": contentType,
558
- ...op.headers,
559
- },
637
+ headers,
560
638
  })
561
639
 
640
+ const status = response.status
641
+ const offset = response.headers.get(`Stream-Next-Offset`) ?? undefined
642
+ const duplicate = status === 204
643
+ const producerEpoch = response.headers.get(`Producer-Epoch`)
644
+ const producerSeq = response.headers.get(`Producer-Seq`)
645
+ const producerExpectedSeq = response.headers.get(
646
+ `Producer-Expected-Seq`
647
+ )
648
+ const producerReceivedSeq = response.headers.get(
649
+ `Producer-Received-Seq`
650
+ )
651
+
562
652
  if (verbose) {
563
653
  console.log(
564
- ` server-append ${path}: ${response.ok ? `ok` : `failed (${response.status})`}`
654
+ ` server-append ${path}: status=${status}${duplicate ? ` (duplicate)` : ``}`
565
655
  )
566
656
  }
567
657
 
568
- if (!response.ok) {
569
- return {
570
- error: `Server append failed with status ${response.status}`,
571
- }
658
+ // Build result for expectation verification
659
+ // success: true means we got a valid protocol response (even 403/409)
660
+ // The status field indicates the actual operation result
661
+ const result: TestResult = {
662
+ type: `append`,
663
+ success: true,
664
+ status,
665
+ offset,
666
+ duplicate,
667
+ producerEpoch: producerEpoch
668
+ ? parseInt(producerEpoch, 10)
669
+ : undefined,
670
+ producerSeq: producerSeq ? parseInt(producerSeq, 10) : undefined,
671
+ producerExpectedSeq: producerExpectedSeq
672
+ ? parseInt(producerExpectedSeq, 10)
673
+ : undefined,
674
+ producerReceivedSeq: producerReceivedSeq
675
+ ? parseInt(producerReceivedSeq, 10)
676
+ : undefined,
572
677
  }
573
678
 
574
- return {}
679
+ // Store offset if requested
680
+ if (op.expect?.storeOffsetAs && offset) {
681
+ variables.set(op.expect.storeOffsetAs, offset)
682
+ }
683
+
684
+ return { result }
575
685
  } catch (err) {
576
686
  return {
577
687
  error: `Server append failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -599,7 +709,7 @@ async function executeOperation(
599
709
  }
600
710
 
601
711
  case `inject-error`: {
602
- // Inject an error via the test server's control endpoint
712
+ // Inject a fault via the test server's control endpoint
603
713
  const path = resolveVariables(op.path, variables)
604
714
 
605
715
  try {
@@ -611,23 +721,43 @@ async function executeOperation(
611
721
  status: op.status,
612
722
  count: op.count ?? 1,
613
723
  retryAfter: op.retryAfter,
724
+ // New fault injection parameters
725
+ delayMs: op.delayMs,
726
+ dropConnection: op.dropConnection,
727
+ truncateBodyBytes: op.truncateBodyBytes,
728
+ probability: op.probability,
729
+ method: op.method,
730
+ corruptBody: op.corruptBody,
731
+ jitterMs: op.jitterMs,
614
732
  }),
615
733
  })
616
734
 
735
+ // Build descriptive log message
736
+ const faultTypes = []
737
+ if (op.status != null) faultTypes.push(`status=${op.status}`)
738
+ if (op.delayMs != null) faultTypes.push(`delay=${op.delayMs}ms`)
739
+ if (op.jitterMs != null) faultTypes.push(`jitter=${op.jitterMs}ms`)
740
+ if (op.dropConnection) faultTypes.push(`dropConnection`)
741
+ if (op.truncateBodyBytes != null)
742
+ faultTypes.push(`truncate=${op.truncateBodyBytes}b`)
743
+ if (op.corruptBody) faultTypes.push(`corrupt`)
744
+ if (op.probability != null) faultTypes.push(`p=${op.probability}`)
745
+ const faultDesc = faultTypes.join(`,`) || `unknown`
746
+
617
747
  if (verbose) {
618
748
  console.log(
619
- ` inject-error ${path} ${op.status}x${op.count ?? 1}: ${response.ok ? `ok` : `failed`}`
749
+ ` inject-error ${path} [${faultDesc}]x${op.count ?? 1}: ${response.ok ? `ok` : `failed`}`
620
750
  )
621
751
  }
622
752
 
623
753
  if (!response.ok) {
624
- return { error: `Failed to inject error: ${response.status}` }
754
+ return { error: `Failed to inject fault: ${response.status}` }
625
755
  }
626
756
 
627
757
  return {}
628
758
  } catch (err) {
629
759
  return {
630
- error: `Failed to inject error: ${err instanceof Error ? err.message : String(err)}`,
760
+ error: `Failed to inject fault: ${err instanceof Error ? err.message : String(err)}`,
631
761
  }
632
762
  }
633
763
  }
@@ -776,6 +906,22 @@ function validateExpectation(
776
906
  }
777
907
  }
778
908
 
909
+ // Check dataExact - verifies exact messages in order
910
+ if (expect.dataExact !== undefined && isReadResult(result)) {
911
+ const expectedMessages = expect.dataExact as Array<string>
912
+ const actualMessages = result.chunks.map((c) => c.data)
913
+
914
+ if (actualMessages.length !== expectedMessages.length) {
915
+ return `Expected ${expectedMessages.length} messages, got ${actualMessages.length}. Expected: [${expectedMessages.join(`, `)}], got: [${actualMessages.join(`, `)}]`
916
+ }
917
+
918
+ for (let i = 0; i < expectedMessages.length; i++) {
919
+ if (actualMessages[i] !== expectedMessages[i]) {
920
+ return `Message ${i} mismatch: expected "${expectedMessages[i]}", got "${actualMessages[i]}"`
921
+ }
922
+ }
923
+ }
924
+
779
925
  // Check upToDate
780
926
  if (expect.upToDate !== undefined && isReadResult(result)) {
781
927
  if (result.upToDate !== expect.upToDate) {
@@ -858,6 +1004,41 @@ function validateExpectation(
858
1004
  }
859
1005
  }
860
1006
 
1007
+ // Check duplicate (for idempotent producer 204 responses)
1008
+ if (expect.duplicate !== undefined && isAppendResult(result)) {
1009
+ if (result.duplicate !== expect.duplicate) {
1010
+ return `Expected duplicate=${expect.duplicate}, got ${result.duplicate}`
1011
+ }
1012
+ }
1013
+
1014
+ // Check producerEpoch (returned on 200/403)
1015
+ if (expect.producerEpoch !== undefined && isAppendResult(result)) {
1016
+ if (result.producerEpoch !== expect.producerEpoch) {
1017
+ return `Expected producerEpoch=${expect.producerEpoch}, got ${result.producerEpoch}`
1018
+ }
1019
+ }
1020
+
1021
+ // Check producerSeq (returned on 200/204 - highest accepted sequence)
1022
+ if (expect.producerSeq !== undefined && isAppendResult(result)) {
1023
+ if (result.producerSeq !== expect.producerSeq) {
1024
+ return `Expected producerSeq=${expect.producerSeq}, got ${result.producerSeq}`
1025
+ }
1026
+ }
1027
+
1028
+ // Check producerExpectedSeq (returned on 409 sequence gap)
1029
+ if (expect.producerExpectedSeq !== undefined && isAppendResult(result)) {
1030
+ if (result.producerExpectedSeq !== expect.producerExpectedSeq) {
1031
+ return `Expected producerExpectedSeq=${expect.producerExpectedSeq}, got ${result.producerExpectedSeq}`
1032
+ }
1033
+ }
1034
+
1035
+ // Check producerReceivedSeq (returned on 409 sequence gap)
1036
+ if (expect.producerReceivedSeq !== undefined && isAppendResult(result)) {
1037
+ if (result.producerReceivedSeq !== expect.producerReceivedSeq) {
1038
+ return `Expected producerReceivedSeq=${expect.producerReceivedSeq}, got ${result.producerReceivedSeq}`
1039
+ }
1040
+ }
1041
+
861
1042
  return null
862
1043
  }
863
1044
 
@@ -1041,8 +1222,12 @@ export async function runConformanceTests(
1041
1222
  const totalTests = countTests(suites)
1042
1223
  console.log(`\nRunning ${totalTests} client conformance tests...\n`)
1043
1224
 
1044
- // Start reference server
1045
- const server = new DurableStreamTestServer({ port: options.serverPort ?? 0 })
1225
+ // Start reference server with short long-poll timeout for testing
1226
+ // Tests use timeoutMs: 1000, so server timeout should be shorter
1227
+ const server = new DurableStreamTestServer({
1228
+ port: options.serverPort ?? 0,
1229
+ longPollTimeout: 500, // 500ms timeout for testing
1230
+ })
1046
1231
  await server.start()
1047
1232
  const serverUrl = server.url
1048
1233
 
package/src/test-cases.ts CHANGED
@@ -118,10 +118,16 @@ export interface AppendOperation {
118
118
  data?: string
119
119
  /** Binary data (base64 encoded) */
120
120
  binaryData?: string
121
- /** Sequence number for ordering */
121
+ /** Sequence number for ordering (Stream-Seq header) */
122
122
  seq?: number
123
123
  headers?: Record<string, string>
124
124
  expect?: AppendExpectation
125
+ /** Producer ID for idempotent producers */
126
+ producerId?: string
127
+ /** Producer epoch for idempotent producers */
128
+ producerEpoch?: number
129
+ /** Producer sequence for idempotent producers */
130
+ producerSeq?: number
125
131
  }
126
132
 
127
133
  /**
@@ -140,6 +146,64 @@ export interface AppendBatchOperation {
140
146
  expect?: AppendBatchExpectation
141
147
  }
142
148
 
149
+ /**
150
+ * Append via IdempotentProducer client (tests client-side exactly-once semantics).
151
+ */
152
+ export interface IdempotentAppendOperation {
153
+ action: `idempotent-append`
154
+ path: string
155
+ /** Producer ID */
156
+ producerId: string
157
+ /** Producer epoch */
158
+ epoch?: number
159
+ /** Data to append (string or JSON for JSON streams) */
160
+ data: string
161
+ /** Auto-claim epoch on 403 */
162
+ autoClaim?: boolean
163
+ headers?: Record<string, string>
164
+ expect?: IdempotentAppendExpectation
165
+ }
166
+
167
+ /**
168
+ * Batch append via IdempotentProducer client (tests client-side JSON batching).
169
+ */
170
+ export interface IdempotentAppendBatchOperation {
171
+ action: `idempotent-append-batch`
172
+ path: string
173
+ /** Producer ID */
174
+ producerId: string
175
+ /** Producer epoch */
176
+ epoch?: number
177
+ /** Items to append (will be batched by the client) */
178
+ items: Array<{
179
+ data: string
180
+ }>
181
+ /** Auto-claim epoch on 403 */
182
+ autoClaim?: boolean
183
+ /** Max concurrent batches in flight (default 1, set higher to test 409 retry) */
184
+ maxInFlight?: number
185
+ headers?: Record<string, string>
186
+ expect?: IdempotentAppendBatchExpectation
187
+ }
188
+
189
+ /**
190
+ * Expectation for idempotent-append operation.
191
+ */
192
+ export interface IdempotentAppendExpectation extends BaseExpectation {
193
+ /** Expected duplicate flag */
194
+ duplicate?: boolean
195
+ /** Store the returned offset */
196
+ storeOffsetAs?: string
197
+ }
198
+
199
+ /**
200
+ * Expectation for idempotent-append-batch operation.
201
+ */
202
+ export interface IdempotentAppendBatchExpectation extends BaseExpectation {
203
+ /** All items should succeed */
204
+ allSucceed?: boolean
205
+ }
206
+
143
207
  /**
144
208
  * Read from a stream.
145
209
  */
@@ -223,13 +287,42 @@ export interface AssertOperation {
223
287
 
224
288
  /**
225
289
  * Append to stream via direct server HTTP (bypasses client adapter).
226
- * Used for concurrent operations when adapter is blocked on a read.
290
+ * Used for concurrent operations when adapter is blocked on a read,
291
+ * and for testing protocol-level behavior like idempotent producers.
227
292
  */
228
293
  export interface ServerAppendOperation {
229
294
  action: `server-append`
230
295
  path: string
231
296
  data: string
232
297
  headers?: Record<string, string>
298
+ /** Producer ID for idempotent producers */
299
+ producerId?: string
300
+ /** Producer epoch for idempotent producers */
301
+ producerEpoch?: number
302
+ /** Producer sequence for idempotent producers */
303
+ producerSeq?: number
304
+ /** Expected result */
305
+ expect?: ServerAppendExpectation
306
+ }
307
+
308
+ /**
309
+ * Expectation for server-append operation.
310
+ */
311
+ export interface ServerAppendExpectation {
312
+ /** Expected HTTP status code */
313
+ status?: number
314
+ /** Store the returned offset */
315
+ storeOffsetAs?: string
316
+ /** Expected duplicate flag (true for 204 idempotent success) */
317
+ duplicate?: boolean
318
+ /** Expected producer epoch in response */
319
+ producerEpoch?: number
320
+ /** Expected producer seq in response (highest accepted sequence) */
321
+ producerSeq?: number
322
+ /** Expected producer expected seq (on 409 sequence gap) */
323
+ producerExpectedSeq?: number
324
+ /** Expected producer received seq (on 409 sequence gap) */
325
+ producerReceivedSeq?: number
233
326
  }
234
327
 
235
328
  /**
@@ -243,19 +336,34 @@ export interface AwaitOperation {
243
336
  }
244
337
 
245
338
  /**
246
- * Inject an error to be returned on the next N requests to a path.
339
+ * Inject a fault to be triggered on the next N requests to a path.
247
340
  * Used for testing retry/resilience behavior.
341
+ * Supports various fault types: errors, delays, connection drops, body corruption, etc.
248
342
  */
249
343
  export interface InjectErrorOperation {
250
344
  action: `inject-error`
251
- /** Stream path to inject error for */
345
+ /** Stream path to inject fault for */
252
346
  path: string
253
- /** HTTP status code to return */
254
- status: number
255
- /** Number of times to return this error (default: 1) */
347
+ /** HTTP status code to return (if set, returns error response) */
348
+ status?: number
349
+ /** Number of times to trigger this fault (default: 1) */
256
350
  count?: number
257
351
  /** Optional Retry-After header value (seconds) */
258
352
  retryAfter?: number
353
+ /** Delay in milliseconds before responding */
354
+ delayMs?: number
355
+ /** Drop the connection after sending headers (simulates network failure) */
356
+ dropConnection?: boolean
357
+ /** Truncate response body to this many bytes */
358
+ truncateBodyBytes?: number
359
+ /** Probability of triggering fault (0-1, default 1.0 = always) */
360
+ probability?: number
361
+ /** Only match specific HTTP method (GET, POST, PUT, DELETE) */
362
+ method?: string
363
+ /** Corrupt the response body by flipping random bits */
364
+ corruptBody?: boolean
365
+ /** Add jitter to delay (random 0-jitterMs added to delayMs) */
366
+ jitterMs?: number
259
367
  }
260
368
 
261
369
  /**
@@ -305,6 +413,8 @@ export type TestOperation =
305
413
  | ConnectOperation
306
414
  | AppendOperation
307
415
  | AppendBatchOperation
416
+ | IdempotentAppendOperation
417
+ | IdempotentAppendBatchOperation
308
418
  | ReadOperation
309
419
  | HeadOperation
310
420
  | DeleteOperation
@@ -345,13 +455,23 @@ export interface ConnectExpectation extends BaseExpectation {
345
455
  }
346
456
 
347
457
  export interface AppendExpectation extends BaseExpectation {
348
- status?: 200 | 404 | 409 | number
458
+ status?: 200 | 204 | 400 | 403 | 404 | 409 | number
349
459
  /** Store the returned offset */
350
460
  storeOffsetAs?: string
351
461
  /** Expected headers that were sent (for dynamic header testing) */
352
462
  headersSent?: Record<string, string>
353
463
  /** Expected params that were sent (for dynamic param testing) */
354
464
  paramsSent?: Record<string, string>
465
+ /** Expected duplicate flag (true for 204 idempotent success) */
466
+ duplicate?: boolean
467
+ /** Expected producer epoch in response */
468
+ producerEpoch?: number
469
+ /** Expected producer seq in response (highest accepted sequence) */
470
+ producerSeq?: number
471
+ /** Expected producer expected seq (on 409 sequence gap) */
472
+ producerExpectedSeq?: number
473
+ /** Expected producer received seq (on 409 sequence gap) */
474
+ producerReceivedSeq?: number
355
475
  }
356
476
 
357
477
  export interface AppendBatchExpectation extends BaseExpectation {
@@ -371,6 +491,8 @@ export interface ReadExpectation extends BaseExpectation {
371
491
  dataContains?: string
372
492
  /** Expected data to contain all of these substrings */
373
493
  dataContainsAll?: Array<string>
494
+ /** Expected exact messages in order (for JSON streams, verifies each chunk) */
495
+ dataExact?: Array<string>
374
496
  /** Expected number of chunks */
375
497
  chunkCount?: number
376
498
  /** Minimum number of chunks */
@@ -56,6 +56,48 @@ tests:
56
56
  status: 400
57
57
  errorCode: INVALID_OFFSET
58
58
 
59
+ - id: read-offset-now-nonexistent
60
+ name: Read with offset=now on non-existent stream
61
+ description: offset=now should not mask a missing stream - must return 404
62
+ operations:
63
+ - action: read
64
+ path: /nonexistent-offset-now-stream
65
+ offset: "now"
66
+ live: false
67
+ expect:
68
+ status: 404
69
+ errorCode: NOT_FOUND
70
+
71
+ - id: read-offset-now-nonexistent-longpoll
72
+ name: Long-poll with offset=now on non-existent stream
73
+ description: offset=now with long-poll should not mask a missing stream - must return 404
74
+ requires:
75
+ - longPoll
76
+ operations:
77
+ - action: read
78
+ path: /nonexistent-offset-now-longpoll
79
+ offset: "now"
80
+ live: long-poll
81
+ timeoutMs: 1000
82
+ expect:
83
+ status: 404
84
+ errorCode: NOT_FOUND
85
+
86
+ - id: read-offset-now-nonexistent-sse
87
+ name: SSE with offset=now on non-existent stream
88
+ description: offset=now with SSE should not mask a missing stream - must return 404
89
+ requires:
90
+ - sse
91
+ operations:
92
+ - action: read
93
+ path: /nonexistent-offset-now-sse
94
+ offset: "now"
95
+ live: sse
96
+ timeoutMs: 1000
97
+ expect:
98
+ status: 404
99
+ errorCode: NOT_FOUND
100
+
59
101
  - id: read-future-offset
60
102
  name: Read with future offset
61
103
  description: Client should handle offset beyond stream end