@durable-streams/client-conformance-tests 0.1.0

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 (39) hide show
  1. package/README.md +451 -0
  2. package/dist/adapters/typescript-adapter.d.ts +1 -0
  3. package/dist/adapters/typescript-adapter.js +586 -0
  4. package/dist/benchmark-runner-C_Yghc8f.js +1333 -0
  5. package/dist/cli.d.ts +1 -0
  6. package/dist/cli.js +265 -0
  7. package/dist/index.d.ts +508 -0
  8. package/dist/index.js +4 -0
  9. package/dist/protocol-DyEvTHPF.d.ts +472 -0
  10. package/dist/protocol-qb83AeUH.js +120 -0
  11. package/dist/protocol.d.ts +2 -0
  12. package/dist/protocol.js +3 -0
  13. package/package.json +53 -0
  14. package/src/adapters/typescript-adapter.ts +848 -0
  15. package/src/benchmark-runner.ts +860 -0
  16. package/src/benchmark-scenarios.ts +311 -0
  17. package/src/cli.ts +294 -0
  18. package/src/index.ts +50 -0
  19. package/src/protocol.ts +656 -0
  20. package/src/runner.ts +1191 -0
  21. package/src/test-cases.ts +475 -0
  22. package/test-cases/consumer/cache-headers.yaml +150 -0
  23. package/test-cases/consumer/error-handling.yaml +108 -0
  24. package/test-cases/consumer/message-ordering.yaml +209 -0
  25. package/test-cases/consumer/offset-handling.yaml +209 -0
  26. package/test-cases/consumer/offset-resumption.yaml +197 -0
  27. package/test-cases/consumer/read-catchup.yaml +173 -0
  28. package/test-cases/consumer/read-longpoll.yaml +132 -0
  29. package/test-cases/consumer/read-sse.yaml +145 -0
  30. package/test-cases/consumer/retry-resilience.yaml +160 -0
  31. package/test-cases/consumer/streaming-equivalence.yaml +226 -0
  32. package/test-cases/lifecycle/dynamic-headers.yaml +147 -0
  33. package/test-cases/lifecycle/headers-params.yaml +117 -0
  34. package/test-cases/lifecycle/stream-lifecycle.yaml +148 -0
  35. package/test-cases/producer/append-data.yaml +142 -0
  36. package/test-cases/producer/batching.yaml +112 -0
  37. package/test-cases/producer/create-stream.yaml +87 -0
  38. package/test-cases/producer/error-handling.yaml +90 -0
  39. package/test-cases/producer/sequence-ordering.yaml +148 -0
package/src/runner.ts ADDED
@@ -0,0 +1,1191 @@
1
+ /**
2
+ * Test runner for client conformance tests.
3
+ *
4
+ * Orchestrates:
5
+ * - Reference server lifecycle
6
+ * - Client adapter process spawning
7
+ * - Test case execution
8
+ * - Result validation
9
+ */
10
+
11
+ import { spawn } from "node:child_process"
12
+ import { createInterface } from "node:readline"
13
+ import { randomUUID } from "node:crypto"
14
+ import { DurableStreamTestServer } from "@durable-streams/server"
15
+ import { parseResult, serializeCommand } from "./protocol.js"
16
+ import {
17
+ countTests,
18
+ filterByCategory,
19
+ loadEmbeddedTestSuites,
20
+ } from "./test-cases.js"
21
+ import type { Interface as ReadlineInterface } from "node:readline"
22
+ import type {
23
+ AppendResult,
24
+ ErrorResult,
25
+ HeadResult,
26
+ ReadResult,
27
+ TestCommand,
28
+ TestResult,
29
+ } from "./protocol.js"
30
+ import type { ChildProcess } from "node:child_process"
31
+ import type { TestCase, TestOperation } from "./test-cases.js"
32
+
33
+ // =============================================================================
34
+ // Types
35
+ // =============================================================================
36
+
37
+ export interface RunnerOptions {
38
+ /** Path to client adapter executable, or "ts" for built-in TypeScript adapter */
39
+ clientAdapter: string
40
+ /** Arguments to pass to client adapter */
41
+ clientArgs?: Array<string>
42
+ /** Test suites to run (default: all) */
43
+ suites?: Array<`producer` | `consumer` | `lifecycle`>
44
+ /** Tags to filter tests */
45
+ tags?: Array<string>
46
+ /** Verbose output */
47
+ verbose?: boolean
48
+ /** Stop on first failure */
49
+ failFast?: boolean
50
+ /** Timeout for each test in ms */
51
+ testTimeout?: number
52
+ /** Port for reference server (0 for random) */
53
+ serverPort?: number
54
+ }
55
+
56
+ export interface TestRunResult {
57
+ suite: string
58
+ test: string
59
+ passed: boolean
60
+ duration: number
61
+ error?: string
62
+ skipped?: boolean
63
+ skipReason?: string
64
+ }
65
+
66
+ export interface RunSummary {
67
+ total: number
68
+ passed: number
69
+ failed: number
70
+ skipped: number
71
+ duration: number
72
+ results: Array<TestRunResult>
73
+ }
74
+
75
+ /** Client feature flags reported by the adapter */
76
+ interface ClientFeatures {
77
+ batching?: boolean
78
+ sse?: boolean
79
+ longPoll?: boolean
80
+ streaming?: boolean
81
+ dynamicHeaders?: boolean
82
+ }
83
+
84
+ interface ExecutionContext {
85
+ serverUrl: string
86
+ variables: Map<string, unknown>
87
+ client: ClientAdapter
88
+ verbose: boolean
89
+ /** Features supported by the client adapter */
90
+ clientFeatures: ClientFeatures
91
+ /** Background operations pending completion */
92
+ backgroundOps: Map<string, Promise<TestResult>>
93
+ /** Timeout for adapter commands in ms */
94
+ commandTimeout: number
95
+ }
96
+
97
+ // =============================================================================
98
+ // Client Adapter Communication
99
+ // =============================================================================
100
+
101
+ class ClientAdapter {
102
+ private process: ChildProcess
103
+ private readline: ReadlineInterface
104
+ private pendingResponse: {
105
+ resolve: (result: TestResult) => void
106
+ reject: (error: Error) => void
107
+ } | null = null
108
+ private initialized = false
109
+
110
+ constructor(executable: string, args: Array<string> = []) {
111
+ this.process = spawn(executable, args, {
112
+ stdio: [`pipe`, `pipe`, `pipe`],
113
+ })
114
+
115
+ if (!this.process.stdout || !this.process.stdin) {
116
+ throw new Error(`Failed to create client adapter process`)
117
+ }
118
+
119
+ this.readline = createInterface({
120
+ input: this.process.stdout,
121
+ crlfDelay: Infinity,
122
+ })
123
+
124
+ this.readline.on(`line`, (line) => {
125
+ if (this.pendingResponse) {
126
+ try {
127
+ const result = parseResult(line)
128
+ this.pendingResponse.resolve(result)
129
+ } catch {
130
+ this.pendingResponse.reject(
131
+ new Error(`Failed to parse client response: ${line}`)
132
+ )
133
+ }
134
+ this.pendingResponse = null
135
+ }
136
+ })
137
+
138
+ this.process.stderr?.on(`data`, (data) => {
139
+ console.error(`[client stderr] ${data.toString().trim()}`)
140
+ })
141
+
142
+ this.process.on(`error`, (err) => {
143
+ if (this.pendingResponse) {
144
+ this.pendingResponse.reject(err)
145
+ this.pendingResponse = null
146
+ }
147
+ })
148
+
149
+ this.process.on(`exit`, (code) => {
150
+ if (this.pendingResponse) {
151
+ this.pendingResponse.reject(
152
+ new Error(`Client adapter exited with code ${code}`)
153
+ )
154
+ this.pendingResponse = null
155
+ }
156
+ })
157
+ }
158
+
159
+ async send(command: TestCommand, timeoutMs = 30000): Promise<TestResult> {
160
+ if (!this.process.stdin) {
161
+ throw new Error(`Client adapter stdin not available`)
162
+ }
163
+
164
+ return new Promise((resolve, reject) => {
165
+ const timeout = setTimeout(() => {
166
+ this.pendingResponse = null
167
+ reject(
168
+ new Error(`Command timed out after ${timeoutMs}ms: ${command.type}`)
169
+ )
170
+ }, timeoutMs)
171
+
172
+ this.pendingResponse = {
173
+ resolve: (result) => {
174
+ clearTimeout(timeout)
175
+ resolve(result)
176
+ },
177
+ reject: (error) => {
178
+ clearTimeout(timeout)
179
+ reject(error)
180
+ },
181
+ }
182
+
183
+ const line = serializeCommand(command) + `\n`
184
+ this.process.stdin!.write(line)
185
+ })
186
+ }
187
+
188
+ async init(serverUrl: string): Promise<TestResult> {
189
+ const result = await this.send({ type: `init`, serverUrl })
190
+ if (result.success) {
191
+ this.initialized = true
192
+ }
193
+ return result
194
+ }
195
+
196
+ async shutdown(): Promise<void> {
197
+ if (this.initialized) {
198
+ try {
199
+ await this.send({ type: `shutdown` }, 5000)
200
+ } catch {
201
+ // Ignore shutdown errors
202
+ }
203
+ }
204
+ this.process.kill()
205
+ this.readline.close()
206
+ }
207
+
208
+ isInitialized(): boolean {
209
+ return this.initialized
210
+ }
211
+ }
212
+
213
+ // =============================================================================
214
+ // Test Execution
215
+ // =============================================================================
216
+
217
+ function resolveVariables(
218
+ value: string,
219
+ variables: Map<string, unknown>
220
+ ): string {
221
+ return value.replace(/\$\{([^}]+)\}/g, (match, expr) => {
222
+ // Handle special built-in variables
223
+ if (expr === `randomUUID`) {
224
+ return randomUUID()
225
+ }
226
+
227
+ // Handle property access like ${result.offset}
228
+ const parts = expr.split(`.`)
229
+ let current: unknown = variables.get(parts[0])
230
+
231
+ // Throw on missing variables to catch typos and configuration errors
232
+ if (current === undefined) {
233
+ throw new Error(`Undefined variable: ${parts[0]} (in ${match})`)
234
+ }
235
+
236
+ for (let i = 1; i < parts.length && current != null; i++) {
237
+ current = (current as Record<string, unknown>)[parts[i]!]
238
+ }
239
+
240
+ return String(current ?? ``)
241
+ })
242
+ }
243
+
244
+ function generateStreamPath(): string {
245
+ return `/test-stream-${randomUUID()}`
246
+ }
247
+
248
+ async function executeOperation(
249
+ op: TestOperation,
250
+ ctx: ExecutionContext
251
+ ): Promise<{ result?: TestResult; error?: string }> {
252
+ const { client, variables, verbose, commandTimeout } = ctx
253
+
254
+ switch (op.action) {
255
+ case `create`: {
256
+ const path = op.path
257
+ ? resolveVariables(op.path, variables)
258
+ : generateStreamPath()
259
+
260
+ if (op.as) {
261
+ variables.set(op.as, path)
262
+ }
263
+
264
+ const result = await client.send(
265
+ {
266
+ type: `create`,
267
+ path,
268
+ contentType: op.contentType,
269
+ ttlSeconds: op.ttlSeconds,
270
+ expiresAt: op.expiresAt,
271
+ headers: op.headers,
272
+ },
273
+ commandTimeout
274
+ )
275
+
276
+ if (verbose) {
277
+ console.log(` create ${path}: ${result.success ? `ok` : `failed`}`)
278
+ }
279
+
280
+ return { result }
281
+ }
282
+
283
+ case `connect`: {
284
+ const path = resolveVariables(op.path, variables)
285
+ const result = await client.send(
286
+ {
287
+ type: `connect`,
288
+ path,
289
+ headers: op.headers,
290
+ },
291
+ commandTimeout
292
+ )
293
+
294
+ if (verbose) {
295
+ console.log(` connect ${path}: ${result.success ? `ok` : `failed`}`)
296
+ }
297
+
298
+ return { result }
299
+ }
300
+
301
+ case `append`: {
302
+ const path = resolveVariables(op.path, variables)
303
+ const data = op.data ? resolveVariables(op.data, variables) : ``
304
+
305
+ const result = await client.send(
306
+ {
307
+ type: `append`,
308
+ path,
309
+ data: op.binaryData ?? data,
310
+ binary: !!op.binaryData,
311
+ seq: op.seq,
312
+ headers: op.headers,
313
+ },
314
+ commandTimeout
315
+ )
316
+
317
+ if (verbose) {
318
+ console.log(` append ${path}: ${result.success ? `ok` : `failed`}`)
319
+ }
320
+
321
+ if (
322
+ result.success &&
323
+ result.type === `append` &&
324
+ op.expect?.storeOffsetAs
325
+ ) {
326
+ variables.set(op.expect.storeOffsetAs, result.offset)
327
+ }
328
+
329
+ return { result }
330
+ }
331
+
332
+ case `append-batch`: {
333
+ const path = resolveVariables(op.path, variables)
334
+ // Send appends sequentially (adapter processes one command at a time)
335
+ const results: Array<TestResult> = []
336
+ for (const item of op.items) {
337
+ const result = await client.send(
338
+ {
339
+ type: `append`,
340
+ path,
341
+ data: item.binaryData ?? item.data ?? ``,
342
+ binary: !!item.binaryData,
343
+ seq: item.seq,
344
+ headers: op.headers,
345
+ },
346
+ commandTimeout
347
+ )
348
+ results.push(result)
349
+ }
350
+
351
+ if (verbose) {
352
+ const succeeded = results.filter((r) => r.success).length
353
+ console.log(
354
+ ` append-batch ${path}: ${succeeded}/${results.length} succeeded`
355
+ )
356
+ }
357
+
358
+ // Return composite result
359
+ const allSucceeded = results.every((r) => r.success)
360
+ return {
361
+ result: {
362
+ type: `append`,
363
+ success: allSucceeded,
364
+ status: allSucceeded ? 200 : 207, // Multi-status
365
+ } as TestResult,
366
+ }
367
+ }
368
+
369
+ case `read`: {
370
+ const path = resolveVariables(op.path, variables)
371
+ const offset = op.offset
372
+ ? resolveVariables(op.offset, variables)
373
+ : undefined
374
+
375
+ // For background operations, send command but don't wait
376
+ if (op.background && op.as) {
377
+ const resultPromise = client.send(
378
+ {
379
+ type: `read`,
380
+ path,
381
+ offset,
382
+ live: op.live,
383
+ timeoutMs: op.timeoutMs,
384
+ maxChunks: op.maxChunks,
385
+ waitForUpToDate: op.waitForUpToDate,
386
+ headers: op.headers,
387
+ },
388
+ commandTimeout
389
+ )
390
+
391
+ // Store the promise for later await
392
+ ctx.backgroundOps.set(op.as, resultPromise)
393
+
394
+ if (verbose) {
395
+ console.log(` read ${path}: started in background as ${op.as}`)
396
+ }
397
+
398
+ return {} // No result yet - will be retrieved via await
399
+ }
400
+
401
+ const result = await client.send(
402
+ {
403
+ type: `read`,
404
+ path,
405
+ offset,
406
+ live: op.live,
407
+ timeoutMs: op.timeoutMs,
408
+ maxChunks: op.maxChunks,
409
+ waitForUpToDate: op.waitForUpToDate,
410
+ headers: op.headers,
411
+ },
412
+ commandTimeout
413
+ )
414
+
415
+ if (verbose) {
416
+ console.log(` read ${path}: ${result.success ? `ok` : `failed`}`)
417
+ }
418
+
419
+ if (result.success && result.type === `read`) {
420
+ if (op.expect?.storeOffsetAs) {
421
+ variables.set(op.expect.storeOffsetAs, result.offset)
422
+ }
423
+ if (op.expect?.storeDataAs) {
424
+ const data = result.chunks.map((c) => c.data).join(``)
425
+ variables.set(op.expect.storeDataAs, data)
426
+ }
427
+ }
428
+
429
+ return { result }
430
+ }
431
+
432
+ case `head`: {
433
+ const path = resolveVariables(op.path, variables)
434
+
435
+ const result = await client.send(
436
+ {
437
+ type: `head`,
438
+ path,
439
+ headers: op.headers,
440
+ },
441
+ commandTimeout
442
+ )
443
+
444
+ if (verbose) {
445
+ console.log(` head ${path}: ${result.success ? `ok` : `failed`}`)
446
+ }
447
+
448
+ if (result.success && op.expect?.storeAs) {
449
+ variables.set(op.expect.storeAs, result)
450
+ }
451
+
452
+ return { result }
453
+ }
454
+
455
+ case `delete`: {
456
+ const path = resolveVariables(op.path, variables)
457
+
458
+ const result = await client.send(
459
+ {
460
+ type: `delete`,
461
+ path,
462
+ headers: op.headers,
463
+ },
464
+ commandTimeout
465
+ )
466
+
467
+ if (verbose) {
468
+ console.log(` delete ${path}: ${result.success ? `ok` : `failed`}`)
469
+ }
470
+
471
+ return { result }
472
+ }
473
+
474
+ case `wait`: {
475
+ await new Promise((resolve) => setTimeout(resolve, op.ms))
476
+ return {}
477
+ }
478
+
479
+ case `set`: {
480
+ const value = resolveVariables(op.value, variables)
481
+ variables.set(op.name, value)
482
+ return {}
483
+ }
484
+
485
+ case `assert`: {
486
+ // Structured assertions - no eval for safety
487
+ if (op.equals) {
488
+ const left = resolveVariables(op.equals.left, variables)
489
+ const right = resolveVariables(op.equals.right, variables)
490
+ if (left !== right) {
491
+ return {
492
+ error:
493
+ op.message ??
494
+ `Assertion failed: expected "${left}" to equal "${right}"`,
495
+ }
496
+ }
497
+ }
498
+ if (op.notEquals) {
499
+ const left = resolveVariables(op.notEquals.left, variables)
500
+ const right = resolveVariables(op.notEquals.right, variables)
501
+ if (left === right) {
502
+ return {
503
+ error:
504
+ op.message ??
505
+ `Assertion failed: expected "${left}" to not equal "${right}"`,
506
+ }
507
+ }
508
+ }
509
+ if (op.contains) {
510
+ const value = resolveVariables(op.contains.value, variables)
511
+ const substring = resolveVariables(op.contains.substring, variables)
512
+ if (!value.includes(substring)) {
513
+ return {
514
+ error:
515
+ op.message ??
516
+ `Assertion failed: expected "${value}" to contain "${substring}"`,
517
+ }
518
+ }
519
+ }
520
+ if (op.matches) {
521
+ const value = resolveVariables(op.matches.value, variables)
522
+ const pattern = op.matches.pattern
523
+ try {
524
+ const regex = new RegExp(pattern)
525
+ if (!regex.test(value)) {
526
+ return {
527
+ error:
528
+ op.message ??
529
+ `Assertion failed: expected "${value}" to match /${pattern}/`,
530
+ }
531
+ }
532
+ } catch {
533
+ return { error: `Invalid regex pattern: ${pattern}` }
534
+ }
535
+ }
536
+ return {}
537
+ }
538
+
539
+ case `server-append`: {
540
+ // Direct HTTP append to server, bypassing client adapter
541
+ // Used for concurrent operations when adapter is blocked on a read
542
+ const path = resolveVariables(op.path, variables)
543
+ const data = resolveVariables(op.data, variables)
544
+
545
+ try {
546
+ // First, get the stream's content-type via HEAD
547
+ const headResponse = await fetch(`${ctx.serverUrl}${path}`, {
548
+ method: `HEAD`,
549
+ })
550
+ const contentType =
551
+ headResponse.headers.get(`content-type`) ?? `application/octet-stream`
552
+
553
+ const response = await fetch(`${ctx.serverUrl}${path}`, {
554
+ method: `POST`,
555
+ body: data,
556
+ headers: {
557
+ "content-type": contentType,
558
+ ...op.headers,
559
+ },
560
+ })
561
+
562
+ if (verbose) {
563
+ console.log(
564
+ ` server-append ${path}: ${response.ok ? `ok` : `failed (${response.status})`}`
565
+ )
566
+ }
567
+
568
+ if (!response.ok) {
569
+ return {
570
+ error: `Server append failed with status ${response.status}`,
571
+ }
572
+ }
573
+
574
+ return {}
575
+ } catch (err) {
576
+ return {
577
+ error: `Server append failed: ${err instanceof Error ? err.message : String(err)}`,
578
+ }
579
+ }
580
+ }
581
+
582
+ case `await`: {
583
+ // Wait for a background operation to complete
584
+ const ref = op.ref
585
+ const promise = ctx.backgroundOps.get(ref)
586
+
587
+ if (!promise) {
588
+ return { error: `No background operation found with ref: ${ref}` }
589
+ }
590
+
591
+ const result = await promise
592
+ ctx.backgroundOps.delete(ref) // Clean up
593
+
594
+ if (verbose) {
595
+ console.log(` await ${ref}: ${result.success ? `ok` : `failed`}`)
596
+ }
597
+
598
+ return { result }
599
+ }
600
+
601
+ case `inject-error`: {
602
+ // Inject an error via the test server's control endpoint
603
+ const path = resolveVariables(op.path, variables)
604
+
605
+ try {
606
+ const response = await fetch(`${ctx.serverUrl}/_test/inject-error`, {
607
+ method: `POST`,
608
+ headers: { "content-type": `application/json` },
609
+ body: JSON.stringify({
610
+ path,
611
+ status: op.status,
612
+ count: op.count ?? 1,
613
+ retryAfter: op.retryAfter,
614
+ }),
615
+ })
616
+
617
+ if (verbose) {
618
+ console.log(
619
+ ` inject-error ${path} ${op.status}x${op.count ?? 1}: ${response.ok ? `ok` : `failed`}`
620
+ )
621
+ }
622
+
623
+ if (!response.ok) {
624
+ return { error: `Failed to inject error: ${response.status}` }
625
+ }
626
+
627
+ return {}
628
+ } catch (err) {
629
+ return {
630
+ error: `Failed to inject error: ${err instanceof Error ? err.message : String(err)}`,
631
+ }
632
+ }
633
+ }
634
+
635
+ case `clear-errors`: {
636
+ // Clear all injected errors via the test server's control endpoint
637
+ try {
638
+ const response = await fetch(`${ctx.serverUrl}/_test/inject-error`, {
639
+ method: `DELETE`,
640
+ })
641
+
642
+ if (verbose) {
643
+ console.log(` clear-errors: ${response.ok ? `ok` : `failed`}`)
644
+ }
645
+
646
+ return {}
647
+ } catch (err) {
648
+ return {
649
+ error: `Failed to clear errors: ${err instanceof Error ? err.message : String(err)}`,
650
+ }
651
+ }
652
+ }
653
+
654
+ case `set-dynamic-header`: {
655
+ const result = await client.send(
656
+ {
657
+ type: `set-dynamic-header`,
658
+ name: op.name,
659
+ valueType: op.valueType,
660
+ initialValue: op.initialValue,
661
+ },
662
+ commandTimeout
663
+ )
664
+
665
+ if (verbose) {
666
+ console.log(
667
+ ` set-dynamic-header ${op.name}: ${result.success ? `ok` : `failed`}`
668
+ )
669
+ }
670
+
671
+ return { result }
672
+ }
673
+
674
+ case `set-dynamic-param`: {
675
+ const result = await client.send(
676
+ {
677
+ type: `set-dynamic-param`,
678
+ name: op.name,
679
+ valueType: op.valueType,
680
+ },
681
+ commandTimeout
682
+ )
683
+
684
+ if (verbose) {
685
+ console.log(
686
+ ` set-dynamic-param ${op.name}: ${result.success ? `ok` : `failed`}`
687
+ )
688
+ }
689
+
690
+ return { result }
691
+ }
692
+
693
+ case `clear-dynamic`: {
694
+ const result = await client.send(
695
+ {
696
+ type: `clear-dynamic`,
697
+ },
698
+ commandTimeout
699
+ )
700
+
701
+ if (verbose) {
702
+ console.log(` clear-dynamic: ${result.success ? `ok` : `failed`}`)
703
+ }
704
+
705
+ return { result }
706
+ }
707
+
708
+ default:
709
+ return { error: `Unknown operation: ${(op as TestOperation).action}` }
710
+ }
711
+ }
712
+
713
+ function isReadResult(result: TestResult): result is ReadResult {
714
+ return result.type === `read` && result.success
715
+ }
716
+
717
+ function isAppendResult(result: TestResult): result is AppendResult {
718
+ return result.type === `append` && result.success
719
+ }
720
+
721
+ function isHeadResult(result: TestResult): result is HeadResult {
722
+ return result.type === `head` && result.success
723
+ }
724
+
725
+ function isErrorResult(result: TestResult): result is ErrorResult {
726
+ return result.type === `error` && !result.success
727
+ }
728
+
729
+ function validateExpectation(
730
+ result: TestResult,
731
+ expect: Record<string, unknown> | undefined
732
+ ): string | null {
733
+ if (!expect) return null
734
+
735
+ // Check status
736
+ if (expect.status !== undefined && `status` in result) {
737
+ if (result.status !== expect.status) {
738
+ return `Expected status ${expect.status}, got ${result.status}`
739
+ }
740
+ }
741
+
742
+ // Check error code
743
+ if (expect.errorCode !== undefined) {
744
+ if (result.success) {
745
+ return `Expected error ${expect.errorCode}, but operation succeeded`
746
+ }
747
+ if (isErrorResult(result) && result.errorCode !== expect.errorCode) {
748
+ return `Expected error code ${expect.errorCode}, got ${result.errorCode}`
749
+ }
750
+ }
751
+
752
+ // Check data (for read results)
753
+ if (expect.data !== undefined && isReadResult(result)) {
754
+ const actualData = result.chunks.map((c) => c.data).join(``)
755
+ if (actualData !== expect.data) {
756
+ return `Expected data "${expect.data}", got "${actualData}"`
757
+ }
758
+ }
759
+
760
+ // Check dataContains
761
+ if (expect.dataContains !== undefined && isReadResult(result)) {
762
+ const actualData = result.chunks.map((c) => c.data).join(``)
763
+ if (!actualData.includes(expect.dataContains as string)) {
764
+ return `Expected data to contain "${expect.dataContains}", got "${actualData}"`
765
+ }
766
+ }
767
+
768
+ // Check dataContainsAll
769
+ if (expect.dataContainsAll !== undefined && isReadResult(result)) {
770
+ const actualData = result.chunks.map((c) => c.data).join(``)
771
+ const missing = (expect.dataContainsAll as Array<string>).filter(
772
+ (s) => !actualData.includes(s)
773
+ )
774
+ if (missing.length > 0) {
775
+ return `Expected data to contain all of [${(expect.dataContainsAll as Array<string>).join(`, `)}], missing: [${missing.join(`, `)}]`
776
+ }
777
+ }
778
+
779
+ // Check upToDate
780
+ if (expect.upToDate !== undefined && isReadResult(result)) {
781
+ if (result.upToDate !== expect.upToDate) {
782
+ return `Expected upToDate=${expect.upToDate}, got ${result.upToDate}`
783
+ }
784
+ }
785
+
786
+ // Check chunkCount
787
+ if (expect.chunkCount !== undefined && isReadResult(result)) {
788
+ if (result.chunks.length !== expect.chunkCount) {
789
+ return `Expected ${expect.chunkCount} chunks, got ${result.chunks.length}`
790
+ }
791
+ }
792
+
793
+ // Check minChunks
794
+ if (expect.minChunks !== undefined && isReadResult(result)) {
795
+ if (result.chunks.length < (expect.minChunks as number)) {
796
+ return `Expected at least ${expect.minChunks} chunks, got ${result.chunks.length}`
797
+ }
798
+ }
799
+
800
+ // Check contentType
801
+ if (expect.contentType !== undefined && isHeadResult(result)) {
802
+ if (result.contentType !== expect.contentType) {
803
+ return `Expected contentType "${expect.contentType}", got "${result.contentType}"`
804
+ }
805
+ }
806
+
807
+ // Check hasOffset
808
+ if (expect.hasOffset !== undefined && isHeadResult(result)) {
809
+ const hasOffset = result.offset !== undefined && result.offset !== ``
810
+ if (hasOffset !== expect.hasOffset) {
811
+ return `Expected hasOffset=${expect.hasOffset}, got ${hasOffset}`
812
+ }
813
+ }
814
+
815
+ // Check headersSent (for append or read results with dynamic headers)
816
+ if (expect.headersSent !== undefined) {
817
+ const expectedHeaders = expect.headersSent as Record<string, string>
818
+ let actualHeaders: Record<string, string> | undefined
819
+
820
+ if (isAppendResult(result)) {
821
+ actualHeaders = result.headersSent
822
+ } else if (isReadResult(result)) {
823
+ actualHeaders = result.headersSent
824
+ }
825
+
826
+ if (!actualHeaders) {
827
+ return `Expected headersSent but result does not contain headersSent`
828
+ }
829
+
830
+ for (const [key, expectedValue] of Object.entries(expectedHeaders)) {
831
+ const actualValue = actualHeaders[key]
832
+ if (actualValue !== expectedValue) {
833
+ return `Expected headersSent[${key}]="${expectedValue}", got "${actualValue ?? `undefined`}"`
834
+ }
835
+ }
836
+ }
837
+
838
+ // Check paramsSent (for append or read results with dynamic params)
839
+ if (expect.paramsSent !== undefined) {
840
+ const expectedParams = expect.paramsSent as Record<string, string>
841
+ let actualParams: Record<string, string> | undefined
842
+
843
+ if (isAppendResult(result)) {
844
+ actualParams = result.paramsSent
845
+ } else if (isReadResult(result)) {
846
+ actualParams = result.paramsSent
847
+ }
848
+
849
+ if (!actualParams) {
850
+ return `Expected paramsSent but result does not contain paramsSent`
851
+ }
852
+
853
+ for (const [key, expectedValue] of Object.entries(expectedParams)) {
854
+ const actualValue = actualParams[key]
855
+ if (actualValue !== expectedValue) {
856
+ return `Expected paramsSent[${key}]="${expectedValue}", got "${actualValue ?? `undefined`}"`
857
+ }
858
+ }
859
+ }
860
+
861
+ return null
862
+ }
863
+
864
+ /**
865
+ * Map feature names from YAML (kebab-case) to client feature property names (camelCase).
866
+ */
867
+ function featureToProperty(feature: string): keyof ClientFeatures | undefined {
868
+ const map: Record<string, keyof ClientFeatures> = {
869
+ batching: `batching`,
870
+ sse: `sse`,
871
+ "long-poll": `longPoll`,
872
+ longPoll: `longPoll`,
873
+ streaming: `streaming`,
874
+ dynamicHeaders: `dynamicHeaders`,
875
+ "dynamic-headers": `dynamicHeaders`,
876
+ }
877
+ return map[feature]
878
+ }
879
+
880
+ /**
881
+ * Check if client supports all required features.
882
+ * Returns list of missing features, or empty array if all satisfied.
883
+ */
884
+ function getMissingFeatures(
885
+ requires: Array<string> | undefined,
886
+ clientFeatures: ClientFeatures
887
+ ): Array<string> {
888
+ if (!requires || requires.length === 0) {
889
+ return []
890
+ }
891
+ return requires.filter((feature) => {
892
+ const prop = featureToProperty(feature)
893
+ return !prop || !clientFeatures[prop]
894
+ })
895
+ }
896
+
897
+ async function runTestCase(
898
+ test: TestCase,
899
+ ctx: ExecutionContext
900
+ ): Promise<TestRunResult> {
901
+ const startTime = Date.now()
902
+
903
+ // Check if test should be skipped
904
+ if (test.skip) {
905
+ return {
906
+ suite: ``,
907
+ test: test.id,
908
+ passed: true,
909
+ duration: 0,
910
+ skipped: true,
911
+ skipReason: typeof test.skip === `string` ? test.skip : undefined,
912
+ }
913
+ }
914
+
915
+ // Check if test requires features the client doesn't support
916
+ const missingFeatures = getMissingFeatures(test.requires, ctx.clientFeatures)
917
+ if (missingFeatures.length > 0) {
918
+ return {
919
+ suite: ``,
920
+ test: test.id,
921
+ passed: true,
922
+ duration: 0,
923
+ skipped: true,
924
+ skipReason: `missing features: ${missingFeatures.join(`, `)}`,
925
+ }
926
+ }
927
+
928
+ // Clear variables and background ops for this test
929
+ ctx.variables.clear()
930
+ ctx.backgroundOps.clear()
931
+
932
+ try {
933
+ // Run setup operations
934
+ if (test.setup) {
935
+ for (const op of test.setup) {
936
+ const { error } = await executeOperation(op, ctx)
937
+ if (error) {
938
+ return {
939
+ suite: ``,
940
+ test: test.id,
941
+ passed: false,
942
+ duration: Date.now() - startTime,
943
+ error: `Setup failed: ${error}`,
944
+ }
945
+ }
946
+ }
947
+ }
948
+
949
+ // Run test operations
950
+ for (const op of test.operations) {
951
+ const { result, error } = await executeOperation(op, ctx)
952
+
953
+ if (error) {
954
+ return {
955
+ suite: ``,
956
+ test: test.id,
957
+ passed: false,
958
+ duration: Date.now() - startTime,
959
+ error,
960
+ }
961
+ }
962
+
963
+ // Validate expectations
964
+ if (result && `expect` in op && op.expect) {
965
+ const validationError = validateExpectation(
966
+ result,
967
+ op.expect as Record<string, unknown>
968
+ )
969
+ if (validationError) {
970
+ return {
971
+ suite: ``,
972
+ test: test.id,
973
+ passed: false,
974
+ duration: Date.now() - startTime,
975
+ error: validationError,
976
+ }
977
+ }
978
+ }
979
+ }
980
+
981
+ // Run cleanup operations (best effort)
982
+ if (test.cleanup) {
983
+ for (const op of test.cleanup) {
984
+ try {
985
+ await executeOperation(op, ctx)
986
+ } catch {
987
+ // Ignore cleanup errors
988
+ }
989
+ }
990
+ }
991
+
992
+ return {
993
+ suite: ``,
994
+ test: test.id,
995
+ passed: true,
996
+ duration: Date.now() - startTime,
997
+ }
998
+ } catch (err) {
999
+ return {
1000
+ suite: ``,
1001
+ test: test.id,
1002
+ passed: false,
1003
+ duration: Date.now() - startTime,
1004
+ error: err instanceof Error ? err.message : String(err),
1005
+ }
1006
+ }
1007
+ }
1008
+
1009
+ // =============================================================================
1010
+ // Public API
1011
+ // =============================================================================
1012
+
1013
+ export async function runConformanceTests(
1014
+ options: RunnerOptions
1015
+ ): Promise<RunSummary> {
1016
+ const startTime = Date.now()
1017
+ const results: Array<TestRunResult> = []
1018
+
1019
+ // Load test suites
1020
+ let suites = loadEmbeddedTestSuites()
1021
+
1022
+ // Filter by category
1023
+ if (options.suites) {
1024
+ suites = suites.filter((s) => options.suites!.includes(s.category))
1025
+ }
1026
+
1027
+ // Filter by tags
1028
+ if (options.tags) {
1029
+ suites = suites
1030
+ .map((suite) => ({
1031
+ ...suite,
1032
+ tests: suite.tests.filter(
1033
+ (test) =>
1034
+ test.tags?.some((t) => options.tags!.includes(t)) ||
1035
+ suite.tags?.some((t) => options.tags!.includes(t))
1036
+ ),
1037
+ }))
1038
+ .filter((suite) => suite.tests.length > 0)
1039
+ }
1040
+
1041
+ const totalTests = countTests(suites)
1042
+ console.log(`\nRunning ${totalTests} client conformance tests...\n`)
1043
+
1044
+ // Start reference server
1045
+ const server = new DurableStreamTestServer({ port: options.serverPort ?? 0 })
1046
+ await server.start()
1047
+ const serverUrl = server.url
1048
+
1049
+ console.log(`Reference server started at ${serverUrl}\n`)
1050
+
1051
+ // Resolve client adapter path
1052
+ let adapterPath = options.clientAdapter
1053
+ let adapterArgs = options.clientArgs ?? []
1054
+
1055
+ if (adapterPath === `ts` || adapterPath === `typescript`) {
1056
+ // Use built-in TypeScript adapter via tsx
1057
+ adapterPath = `npx`
1058
+ adapterArgs = [
1059
+ `tsx`,
1060
+ new URL(`./adapters/typescript-adapter.ts`, import.meta.url).pathname,
1061
+ ]
1062
+ }
1063
+
1064
+ // Start client adapter
1065
+ const client = new ClientAdapter(adapterPath, adapterArgs)
1066
+
1067
+ try {
1068
+ // Initialize client
1069
+ const initResult = await client.init(serverUrl)
1070
+ if (!initResult.success) {
1071
+ throw new Error(
1072
+ `Failed to initialize client adapter: ${(initResult as { message?: string }).message}`
1073
+ )
1074
+ }
1075
+
1076
+ // Extract client features from init result
1077
+ let clientFeatures: ClientFeatures = {}
1078
+ if (initResult.type === `init`) {
1079
+ console.log(
1080
+ `Client: ${initResult.clientName} v${initResult.clientVersion}`
1081
+ )
1082
+ if (initResult.features) {
1083
+ clientFeatures = initResult.features
1084
+ const featureList = Object.entries(initResult.features)
1085
+ .filter(([, v]) => v)
1086
+ .map(([k]) => k)
1087
+ console.log(`Features: ${featureList.join(`, `) || `none`}\n`)
1088
+ }
1089
+ }
1090
+
1091
+ const ctx: ExecutionContext = {
1092
+ serverUrl,
1093
+ variables: new Map(),
1094
+ client,
1095
+ verbose: options.verbose ?? false,
1096
+ clientFeatures,
1097
+ backgroundOps: new Map(),
1098
+ commandTimeout: options.testTimeout ?? 30000,
1099
+ }
1100
+
1101
+ // Run test suites
1102
+ for (const suite of suites) {
1103
+ console.log(`\n${suite.name}`)
1104
+ console.log(`─`.repeat(suite.name.length))
1105
+
1106
+ // Check if suite requires features the client doesn't support
1107
+ const suiteMissingFeatures = getMissingFeatures(
1108
+ suite.requires,
1109
+ clientFeatures
1110
+ )
1111
+
1112
+ for (const test of suite.tests) {
1113
+ // If suite has missing features, skip all tests in it
1114
+ if (suiteMissingFeatures.length > 0) {
1115
+ const result: TestRunResult = {
1116
+ suite: suite.id,
1117
+ test: test.id,
1118
+ passed: true,
1119
+ duration: 0,
1120
+ skipped: true,
1121
+ skipReason: `missing features: ${suiteMissingFeatures.join(`, `)}`,
1122
+ }
1123
+ results.push(result)
1124
+ console.log(
1125
+ ` ○ ${test.name} (skipped: missing features: ${suiteMissingFeatures.join(`, `)})`
1126
+ )
1127
+ continue
1128
+ }
1129
+
1130
+ const result = await runTestCase(test, ctx)
1131
+ result.suite = suite.id
1132
+ results.push(result)
1133
+
1134
+ const icon = result.passed ? (result.skipped ? `○` : `✓`) : `✗`
1135
+ const status = result.skipped
1136
+ ? `skipped${result.skipReason ? `: ${result.skipReason}` : ``}`
1137
+ : result.passed
1138
+ ? `${result.duration}ms`
1139
+ : result.error
1140
+
1141
+ console.log(` ${icon} ${test.name} (${status})`)
1142
+
1143
+ if (options.failFast && !result.passed && !result.skipped) {
1144
+ break
1145
+ }
1146
+ }
1147
+
1148
+ if (options.failFast && results.some((r) => !r.passed && !r.skipped)) {
1149
+ break
1150
+ }
1151
+ }
1152
+ } finally {
1153
+ await client.shutdown()
1154
+ await server.stop()
1155
+ }
1156
+
1157
+ // Calculate summary
1158
+ const passed = results.filter((r) => r.passed && !r.skipped).length
1159
+ const failed = results.filter((r) => !r.passed).length
1160
+ const skipped = results.filter((r) => r.skipped).length
1161
+
1162
+ const summary: RunSummary = {
1163
+ total: results.length,
1164
+ passed,
1165
+ failed,
1166
+ skipped,
1167
+ duration: Date.now() - startTime,
1168
+ results,
1169
+ }
1170
+
1171
+ // Print summary
1172
+ console.log(`\n` + `═`.repeat(40))
1173
+ console.log(`Total: ${summary.total} tests`)
1174
+ console.log(`Passed: ${summary.passed}`)
1175
+ console.log(`Failed: ${summary.failed}`)
1176
+ console.log(`Skipped: ${summary.skipped}`)
1177
+ console.log(`Duration: ${(summary.duration / 1000).toFixed(2)}s`)
1178
+ console.log(`═`.repeat(40) + `\n`)
1179
+
1180
+ if (failed > 0) {
1181
+ console.log(`Failed tests:`)
1182
+ for (const result of results.filter((r) => !r.passed)) {
1183
+ console.log(` - ${result.suite}/${result.test}: ${result.error}`)
1184
+ }
1185
+ console.log()
1186
+ }
1187
+
1188
+ return summary
1189
+ }
1190
+
1191
+ export { loadEmbeddedTestSuites, filterByCategory, countTests }