@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
@@ -0,0 +1,475 @@
1
+ /**
2
+ * Test case types for client conformance testing.
3
+ *
4
+ * Test cases are defined in YAML files and describe operations to perform
5
+ * and expectations to verify.
6
+ */
7
+
8
+ // =============================================================================
9
+ // Test Case Structure
10
+ // =============================================================================
11
+
12
+ /**
13
+ * A suite of related test cases.
14
+ */
15
+ // =============================================================================
16
+ // Test Case Loader
17
+ // =============================================================================
18
+
19
+ import * as fs from "node:fs"
20
+ import * as path from "node:path"
21
+ import YAML from "yaml"
22
+
23
+ export interface TestSuite {
24
+ /** Unique identifier for this suite */
25
+ id: string
26
+ /** Human-readable name */
27
+ name: string
28
+ /** Description of what this suite tests */
29
+ description: string
30
+ /** Category: producer, consumer, or lifecycle */
31
+ category: `producer` | `consumer` | `lifecycle`
32
+ /** Tags for filtering tests */
33
+ tags?: Array<string>
34
+ /** Required client features for all tests in this suite */
35
+ requires?: Array<ClientFeature>
36
+ /** Test cases in this suite */
37
+ tests: Array<TestCase>
38
+ }
39
+
40
+ /**
41
+ * A single test case with operations and expectations.
42
+ */
43
+ export interface TestCase {
44
+ /** Unique identifier within the suite */
45
+ id: string
46
+ /** Human-readable name */
47
+ name: string
48
+ /** Description of what this test verifies */
49
+ description?: string
50
+ /** Tags for filtering */
51
+ tags?: Array<string>
52
+ /** Skip this test (with optional reason) */
53
+ skip?: boolean | string
54
+ /** Required client features for this test */
55
+ requires?: Array<ClientFeature>
56
+ /** Setup operations to run before the test */
57
+ setup?: Array<TestOperation>
58
+ /** Test operations to execute */
59
+ operations: Array<TestOperation>
60
+ /** Cleanup operations to run after the test */
61
+ cleanup?: Array<TestOperation>
62
+ }
63
+
64
+ /**
65
+ * Client features that may be required for certain tests.
66
+ */
67
+ export type ClientFeature =
68
+ | `batching`
69
+ | `sse`
70
+ | `long-poll`
71
+ | `streaming`
72
+ | `dynamicHeaders`
73
+
74
+ // =============================================================================
75
+ // Test Operations
76
+ // =============================================================================
77
+
78
+ /**
79
+ * Create a stream.
80
+ */
81
+ export interface CreateOperation {
82
+ action: `create`
83
+ /** Stream path (a unique path will be generated if not specified) */
84
+ path?: string
85
+ /** Variable name to store the generated path */
86
+ as?: string
87
+ /** Content type */
88
+ contentType?: string
89
+ /** TTL in seconds */
90
+ ttlSeconds?: number
91
+ /** Absolute expiry (ISO 8601) */
92
+ expiresAt?: string
93
+ /** Custom headers */
94
+ headers?: Record<string, string>
95
+ /** Expected result */
96
+ expect?: CreateExpectation
97
+ }
98
+
99
+ /**
100
+ * Connect to an existing stream.
101
+ */
102
+ export interface ConnectOperation {
103
+ action: `connect`
104
+ /** Stream path or variable reference like ${streamPath} */
105
+ path: string
106
+ headers?: Record<string, string>
107
+ expect?: ConnectExpectation
108
+ }
109
+
110
+ /**
111
+ * Append data to a stream.
112
+ */
113
+ export interface AppendOperation {
114
+ action: `append`
115
+ /** Stream path or variable reference */
116
+ path: string
117
+ /** Data to append (string) */
118
+ data?: string
119
+ /** Binary data (base64 encoded) */
120
+ binaryData?: string
121
+ /** Sequence number for ordering */
122
+ seq?: number
123
+ headers?: Record<string, string>
124
+ expect?: AppendExpectation
125
+ }
126
+
127
+ /**
128
+ * Append multiple items (tests batching behavior).
129
+ */
130
+ export interface AppendBatchOperation {
131
+ action: `append-batch`
132
+ path: string
133
+ /** Items to append concurrently */
134
+ items: Array<{
135
+ data?: string
136
+ binaryData?: string
137
+ seq?: number
138
+ }>
139
+ headers?: Record<string, string>
140
+ expect?: AppendBatchExpectation
141
+ }
142
+
143
+ /**
144
+ * Read from a stream.
145
+ */
146
+ export interface ReadOperation {
147
+ action: `read`
148
+ path: string
149
+ /** Starting offset or variable reference like ${lastOffset} */
150
+ offset?: string
151
+ /** Live mode */
152
+ live?: false | `long-poll` | `sse`
153
+ /** Timeout for long-poll in ms */
154
+ timeoutMs?: number
155
+ /** Maximum chunks to read */
156
+ maxChunks?: number
157
+ /** Wait until up-to-date */
158
+ waitForUpToDate?: boolean
159
+ headers?: Record<string, string>
160
+ expect?: ReadExpectation
161
+ /** Run in background (don't wait for completion) */
162
+ background?: boolean
163
+ /** Store reference for later await (required if background: true) */
164
+ as?: string
165
+ }
166
+
167
+ /**
168
+ * Get stream metadata.
169
+ */
170
+ export interface HeadOperation {
171
+ action: `head`
172
+ path: string
173
+ headers?: Record<string, string>
174
+ expect?: HeadExpectation
175
+ }
176
+
177
+ /**
178
+ * Delete a stream.
179
+ */
180
+ export interface DeleteOperation {
181
+ action: `delete`
182
+ path: string
183
+ headers?: Record<string, string>
184
+ expect?: DeleteExpectation
185
+ }
186
+
187
+ /**
188
+ * Wait for a duration (for timing-sensitive tests).
189
+ */
190
+ export interface WaitOperation {
191
+ action: `wait`
192
+ /** Duration in milliseconds */
193
+ ms: number
194
+ }
195
+
196
+ /**
197
+ * Store a value in a variable for later use.
198
+ */
199
+ export interface SetOperation {
200
+ action: `set`
201
+ /** Variable name */
202
+ name: string
203
+ /** Value (can reference other variables) */
204
+ value: string
205
+ }
206
+
207
+ /**
208
+ * Assert a condition using structured assertions (no eval).
209
+ */
210
+ export interface AssertOperation {
211
+ action: `assert`
212
+ /** Check that two values are equal */
213
+ equals?: { left: string; right: string }
214
+ /** Check that two values are not equal */
215
+ notEquals?: { left: string; right: string }
216
+ /** Check that a string contains a substring */
217
+ contains?: { value: string; substring: string }
218
+ /** Check that a value matches a regex pattern */
219
+ matches?: { value: string; pattern: string }
220
+ /** Message if assertion fails */
221
+ message?: string
222
+ }
223
+
224
+ /**
225
+ * Append to stream via direct server HTTP (bypasses client adapter).
226
+ * Used for concurrent operations when adapter is blocked on a read.
227
+ */
228
+ export interface ServerAppendOperation {
229
+ action: `server-append`
230
+ path: string
231
+ data: string
232
+ headers?: Record<string, string>
233
+ }
234
+
235
+ /**
236
+ * Wait for a background operation to complete.
237
+ */
238
+ export interface AwaitOperation {
239
+ action: `await`
240
+ /** Reference to the background operation (from 'as' field) */
241
+ ref: string
242
+ expect?: ReadExpectation
243
+ }
244
+
245
+ /**
246
+ * Inject an error to be returned on the next N requests to a path.
247
+ * Used for testing retry/resilience behavior.
248
+ */
249
+ export interface InjectErrorOperation {
250
+ action: `inject-error`
251
+ /** Stream path to inject error for */
252
+ path: string
253
+ /** HTTP status code to return */
254
+ status: number
255
+ /** Number of times to return this error (default: 1) */
256
+ count?: number
257
+ /** Optional Retry-After header value (seconds) */
258
+ retryAfter?: number
259
+ }
260
+
261
+ /**
262
+ * Clear all injected errors.
263
+ */
264
+ export interface ClearErrorsOperation {
265
+ action: `clear-errors`
266
+ }
267
+
268
+ /**
269
+ * Set a dynamic header that is evaluated per-request.
270
+ * Useful for testing token refresh scenarios.
271
+ */
272
+ export interface SetDynamicHeaderOperation {
273
+ action: `set-dynamic-header`
274
+ /** Header name */
275
+ name: string
276
+ /** Type of dynamic value */
277
+ valueType: `counter` | `timestamp` | `token`
278
+ /** Initial value (for token type) */
279
+ initialValue?: string
280
+ }
281
+
282
+ /**
283
+ * Set a dynamic URL parameter that is evaluated per-request.
284
+ */
285
+ export interface SetDynamicParamOperation {
286
+ action: `set-dynamic-param`
287
+ /** Param name */
288
+ name: string
289
+ /** Type of dynamic value */
290
+ valueType: `counter` | `timestamp`
291
+ }
292
+
293
+ /**
294
+ * Clear all dynamic headers and params.
295
+ */
296
+ export interface ClearDynamicOperation {
297
+ action: `clear-dynamic`
298
+ }
299
+
300
+ /**
301
+ * All possible test operations.
302
+ */
303
+ export type TestOperation =
304
+ | CreateOperation
305
+ | ConnectOperation
306
+ | AppendOperation
307
+ | AppendBatchOperation
308
+ | ReadOperation
309
+ | HeadOperation
310
+ | DeleteOperation
311
+ | WaitOperation
312
+ | SetOperation
313
+ | AssertOperation
314
+ | ServerAppendOperation
315
+ | AwaitOperation
316
+ | InjectErrorOperation
317
+ | ClearErrorsOperation
318
+ | SetDynamicHeaderOperation
319
+ | SetDynamicParamOperation
320
+ | ClearDynamicOperation
321
+
322
+ // =============================================================================
323
+ // Expectations
324
+ // =============================================================================
325
+
326
+ /**
327
+ * Base expectation fields.
328
+ */
329
+ interface BaseExpectation {
330
+ /** Expected HTTP status code */
331
+ status?: number
332
+ /** Expected error code (if operation should fail) */
333
+ errorCode?: string
334
+ /** Store result in variable */
335
+ storeAs?: string
336
+ }
337
+
338
+ export interface CreateExpectation extends BaseExpectation {
339
+ /** Status should be 201 for new, 200 for existing */
340
+ status?: 200 | 201 | 409 | number
341
+ }
342
+
343
+ export interface ConnectExpectation extends BaseExpectation {
344
+ status?: 200 | 404 | number
345
+ }
346
+
347
+ export interface AppendExpectation extends BaseExpectation {
348
+ status?: 200 | 404 | 409 | number
349
+ /** Store the returned offset */
350
+ storeOffsetAs?: string
351
+ /** Expected headers that were sent (for dynamic header testing) */
352
+ headersSent?: Record<string, string>
353
+ /** Expected params that were sent (for dynamic param testing) */
354
+ paramsSent?: Record<string, string>
355
+ }
356
+
357
+ export interface AppendBatchExpectation extends BaseExpectation {
358
+ /** All items should succeed */
359
+ allSucceed?: boolean
360
+ /** Specific items should succeed (by index) */
361
+ succeedIndices?: Array<number>
362
+ /** Specific items should fail (by index) */
363
+ failIndices?: Array<number>
364
+ }
365
+
366
+ export interface ReadExpectation extends BaseExpectation {
367
+ status?: 200 | 204 | 404 | number
368
+ /** Expected data content (exact match) */
369
+ data?: string
370
+ /** Expected data to contain (substring) */
371
+ dataContains?: string
372
+ /** Expected data to contain all of these substrings */
373
+ dataContainsAll?: Array<string>
374
+ /** Expected number of chunks */
375
+ chunkCount?: number
376
+ /** Minimum number of chunks */
377
+ minChunks?: number
378
+ /** Maximum number of chunks */
379
+ maxChunks?: number
380
+ /** Should be up-to-date after read */
381
+ upToDate?: boolean
382
+ /** Store final offset */
383
+ storeOffsetAs?: string
384
+ /** Store all data concatenated */
385
+ storeDataAs?: string
386
+ /** Expected headers that were sent (for dynamic header testing) */
387
+ headersSent?: Record<string, string>
388
+ /** Expected params that were sent (for dynamic param testing) */
389
+ paramsSent?: Record<string, string>
390
+ }
391
+
392
+ export interface HeadExpectation extends BaseExpectation {
393
+ status?: 200 | 404 | number
394
+ /** Expected content type */
395
+ contentType?: string
396
+ /** Should have an offset */
397
+ hasOffset?: boolean
398
+ }
399
+
400
+ export interface DeleteExpectation extends BaseExpectation {
401
+ status?: 200 | 204 | 404 | number
402
+ }
403
+
404
+ /**
405
+ * Load all test suites from a directory.
406
+ */
407
+ export function loadTestSuites(dir: string): Array<TestSuite> {
408
+ const suites: Array<TestSuite> = []
409
+
410
+ function walkDir(currentDir: string): void {
411
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true })
412
+
413
+ for (const entry of entries) {
414
+ const fullPath = path.join(currentDir, entry.name)
415
+
416
+ if (entry.isDirectory()) {
417
+ walkDir(fullPath)
418
+ } else if (
419
+ entry.isFile() &&
420
+ (entry.name.endsWith(`.yaml`) || entry.name.endsWith(`.yml`))
421
+ ) {
422
+ const content = fs.readFileSync(fullPath, `utf-8`)
423
+ const suite = YAML.parse(content) as TestSuite
424
+ suites.push(suite)
425
+ }
426
+ }
427
+ }
428
+
429
+ walkDir(dir)
430
+ return suites
431
+ }
432
+
433
+ /**
434
+ * Load test suites from the embedded test-cases directory.
435
+ */
436
+ export function loadEmbeddedTestSuites(): Array<TestSuite> {
437
+ const testCasesDir = path.join(import.meta.dirname, `..`, `test-cases`)
438
+ return loadTestSuites(testCasesDir)
439
+ }
440
+
441
+ /**
442
+ * Filter test suites by category.
443
+ */
444
+ export function filterByCategory(
445
+ suites: Array<TestSuite>,
446
+ category: TestSuite[`category`]
447
+ ): Array<TestSuite> {
448
+ return suites.filter((s) => s.category === category)
449
+ }
450
+
451
+ /**
452
+ * Filter test cases by tags.
453
+ */
454
+ export function filterByTags(
455
+ suites: Array<TestSuite>,
456
+ tags: Array<string>
457
+ ): Array<TestSuite> {
458
+ return suites
459
+ .map((suite) => ({
460
+ ...suite,
461
+ tests: suite.tests.filter(
462
+ (test) =>
463
+ test.tags?.some((t) => tags.includes(t)) ||
464
+ suite.tags?.some((t) => tags.includes(t))
465
+ ),
466
+ }))
467
+ .filter((suite) => suite.tests.length > 0)
468
+ }
469
+
470
+ /**
471
+ * Get total test count.
472
+ */
473
+ export function countTests(suites: Array<TestSuite>): number {
474
+ return suites.reduce((sum, suite) => sum + suite.tests.length, 0)
475
+ }
@@ -0,0 +1,150 @@
1
+ id: consumer-cache-headers
2
+ name: HTTP Cache Headers
3
+ description: Tests for HTTP caching behavior (ETag, conditional requests)
4
+ category: consumer
5
+ tags:
6
+ - cache
7
+ - headers
8
+ - protocol
9
+
10
+ tests:
11
+ - id: cache-head-returns-etag
12
+ name: HEAD returns ETag header
13
+ description: HEAD request should include an ETag for caching
14
+ setup:
15
+ - action: create
16
+ as: streamPath
17
+ - action: append
18
+ path: ${streamPath}
19
+ data: "cached-data"
20
+ operations:
21
+ - action: head
22
+ path: ${streamPath}
23
+ expect:
24
+ status: 200
25
+ hasOffset: true
26
+
27
+ - id: cache-read-idempotent
28
+ name: Repeated reads return same data
29
+ description: Multiple reads should return identical data (cache-friendly)
30
+ setup:
31
+ - action: create
32
+ as: streamPath
33
+ - action: append
34
+ path: ${streamPath}
35
+ data: "stable-content"
36
+ operations:
37
+ - action: read
38
+ path: ${streamPath}
39
+ expect:
40
+ data: "stable-content"
41
+ storeDataAs: firstRead
42
+ - action: read
43
+ path: ${streamPath}
44
+ expect:
45
+ data: "stable-content"
46
+ # Verify data is identical
47
+ - action: assert
48
+ equals:
49
+ left: ${firstRead}
50
+ right: "stable-content"
51
+ message: "Repeated reads should return identical data"
52
+
53
+ - id: cache-offset-deterministic
54
+ name: Same offset returns same data
55
+ description: Reading from a specific offset should always return the same data
56
+ setup:
57
+ - action: create
58
+ as: streamPath
59
+ - action: append
60
+ path: ${streamPath}
61
+ data: "chunk-a"
62
+ expect:
63
+ storeOffsetAs: offsetA
64
+ - action: append
65
+ path: ${streamPath}
66
+ data: "chunk-b"
67
+ operations:
68
+ # Multiple reads from same offset
69
+ - action: read
70
+ path: ${streamPath}
71
+ offset: ${offsetA}
72
+ expect:
73
+ data: "chunk-b"
74
+ - action: read
75
+ path: ${streamPath}
76
+ offset: ${offsetA}
77
+ expect:
78
+ data: "chunk-b"
79
+
80
+ - id: cache-immutable-historical-data
81
+ name: Historical data is immutable
82
+ description: Data at a given offset should never change after more appends
83
+ setup:
84
+ - action: create
85
+ as: streamPath
86
+ - action: append
87
+ path: ${streamPath}
88
+ data: "original"
89
+ operations:
90
+ # Read original data
91
+ - action: read
92
+ path: ${streamPath}
93
+ expect:
94
+ data: "original"
95
+ storeOffsetAs: afterOriginal
96
+ # Append more data
97
+ - action: append
98
+ path: ${streamPath}
99
+ data: "added"
100
+ # Read from start should still include original
101
+ - action: read
102
+ path: ${streamPath}
103
+ expect:
104
+ data: "originaladded"
105
+ # Read from afterOriginal should only get new data
106
+ - action: read
107
+ path: ${streamPath}
108
+ offset: ${afterOriginal}
109
+ expect:
110
+ data: "added"
111
+
112
+ - id: cache-content-type-preserved
113
+ name: Content-Type is preserved for caching
114
+ description: Stream content-type should be consistent across reads
115
+ setup:
116
+ - action: create
117
+ as: streamPath
118
+ contentType: application/json
119
+ - action: append
120
+ path: ${streamPath}
121
+ data: '{"key":"value"}'
122
+ operations:
123
+ - action: head
124
+ path: ${streamPath}
125
+ expect:
126
+ contentType: application/json
127
+
128
+ - id: cache-uptodate-stable
129
+ name: Up-to-date flag is consistent
130
+ description: Reading to end of stream should consistently report up-to-date
131
+ setup:
132
+ - action: create
133
+ as: streamPath
134
+ - action: append
135
+ path: ${streamPath}
136
+ data: "final"
137
+ operations:
138
+ # Multiple reads should all report up-to-date at end
139
+ - action: read
140
+ path: ${streamPath}
141
+ expect:
142
+ upToDate: true
143
+ - action: read
144
+ path: ${streamPath}
145
+ expect:
146
+ upToDate: true
147
+ - action: read
148
+ path: ${streamPath}
149
+ expect:
150
+ upToDate: true