@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.
- package/README.md +451 -0
- package/dist/adapters/typescript-adapter.d.ts +1 -0
- package/dist/adapters/typescript-adapter.js +586 -0
- package/dist/benchmark-runner-C_Yghc8f.js +1333 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +265 -0
- package/dist/index.d.ts +508 -0
- package/dist/index.js +4 -0
- package/dist/protocol-DyEvTHPF.d.ts +472 -0
- package/dist/protocol-qb83AeUH.js +120 -0
- package/dist/protocol.d.ts +2 -0
- package/dist/protocol.js +3 -0
- package/package.json +53 -0
- package/src/adapters/typescript-adapter.ts +848 -0
- package/src/benchmark-runner.ts +860 -0
- package/src/benchmark-scenarios.ts +311 -0
- package/src/cli.ts +294 -0
- package/src/index.ts +50 -0
- package/src/protocol.ts +656 -0
- package/src/runner.ts +1191 -0
- package/src/test-cases.ts +475 -0
- package/test-cases/consumer/cache-headers.yaml +150 -0
- package/test-cases/consumer/error-handling.yaml +108 -0
- package/test-cases/consumer/message-ordering.yaml +209 -0
- package/test-cases/consumer/offset-handling.yaml +209 -0
- package/test-cases/consumer/offset-resumption.yaml +197 -0
- package/test-cases/consumer/read-catchup.yaml +173 -0
- package/test-cases/consumer/read-longpoll.yaml +132 -0
- package/test-cases/consumer/read-sse.yaml +145 -0
- package/test-cases/consumer/retry-resilience.yaml +160 -0
- package/test-cases/consumer/streaming-equivalence.yaml +226 -0
- package/test-cases/lifecycle/dynamic-headers.yaml +147 -0
- package/test-cases/lifecycle/headers-params.yaml +117 -0
- package/test-cases/lifecycle/stream-lifecycle.yaml +148 -0
- package/test-cases/producer/append-data.yaml +142 -0
- package/test-cases/producer/batching.yaml +112 -0
- package/test-cases/producer/create-stream.yaml +87 -0
- package/test-cases/producer/error-handling.yaml +90 -0
- 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 }
|