@durable-streams/client-conformance-tests 0.1.8 → 0.2.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 (35) hide show
  1. package/dist/adapters/typescript-adapter.cjs +72 -22
  2. package/dist/adapters/typescript-adapter.js +72 -22
  3. package/dist/{benchmark-runner-CrE6JkbX.js → benchmark-runner-81waaCzs.js} +89 -9
  4. package/dist/{benchmark-runner-Db4he452.cjs → benchmark-runner-DliEfq9k.cjs} +93 -8
  5. package/dist/cli.cjs +41 -5
  6. package/dist/cli.js +41 -5
  7. package/dist/index.cjs +2 -2
  8. package/dist/index.d.cts +50 -3
  9. package/dist/index.d.ts +50 -3
  10. package/dist/index.js +2 -2
  11. package/dist/{protocol-qb83AeUH.js → protocol-1p0soayz.js} +2 -1
  12. package/dist/{protocol-D37G3c4e.d.cts → protocol-BxZTqJmO.d.cts} +67 -5
  13. package/dist/{protocol-XeAOKBD-.cjs → protocol-IioVPNaP.cjs} +2 -1
  14. package/dist/{protocol-Mcbiq3nQ.d.ts → protocol-JuFzdV5x.d.ts} +67 -5
  15. package/dist/protocol.cjs +1 -1
  16. package/dist/protocol.d.cts +2 -2
  17. package/dist/protocol.d.ts +2 -2
  18. package/dist/protocol.js +1 -1
  19. package/package.json +8 -3
  20. package/src/adapters/typescript-adapter.ts +110 -32
  21. package/src/benchmark-runner.ts +75 -1
  22. package/src/benchmark-scenarios.ts +4 -4
  23. package/src/cli.ts +46 -5
  24. package/src/protocol.ts +75 -2
  25. package/src/runner.ts +72 -1
  26. package/src/test-cases.ts +55 -0
  27. package/test-cases/consumer/error-context.yaml +67 -0
  28. package/test-cases/consumer/json-parsing-errors.yaml +115 -0
  29. package/test-cases/consumer/read-auto.yaml +155 -0
  30. package/test-cases/consumer/read-sse.yaml +24 -0
  31. package/test-cases/consumer/retry-resilience.yaml +28 -0
  32. package/test-cases/consumer/sse-parsing-errors.yaml +121 -0
  33. package/test-cases/producer/error-context.yaml +72 -0
  34. package/test-cases/producer/idempotent-json-batching.yaml +40 -0
  35. package/test-cases/validation/input-validation.yaml +192 -0
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
  const require_chunk = require('../chunk-BCwAaXi7.cjs');
4
- const require_protocol = require('../protocol-XeAOKBD-.cjs');
4
+ const require_protocol = require('../protocol-IioVPNaP.cjs');
5
5
  const node_readline = require_chunk.__toESM(require("node:readline"));
6
6
  const __durable_streams_client = require_chunk.__toESM(require("@durable-streams/client"));
7
7
 
@@ -79,8 +79,10 @@ async function handleCommand(command) {
79
79
  batching: true,
80
80
  sse: true,
81
81
  longPoll: true,
82
+ auto: true,
82
83
  streaming: true,
83
- dynamicHeaders: true
84
+ dynamicHeaders: true,
85
+ strictZeroValidation: true
84
86
  }
85
87
  };
86
88
  }
@@ -168,6 +170,7 @@ async function handleCommand(command) {
168
170
  let live;
169
171
  if (command.live === `long-poll`) live = `long-poll`;
170
172
  else if (command.live === `sse`) live = `sse`;
173
+ else if (command.live === true) live = true;
171
174
  else live = false;
172
175
  const abortController = new AbortController();
173
176
  const timeoutMs = command.timeoutMs ?? 5e3;
@@ -202,21 +205,30 @@ async function handleCommand(command) {
202
205
  let finalOffset = command.offset ?? response.offset;
203
206
  let upToDate = response.upToDate;
204
207
  const maxChunks = command.maxChunks ?? 100;
205
- if (!live) try {
206
- const data = await response.body();
207
- if (data.length > 0) chunks.push({
208
- data: new TextDecoder().decode(data),
209
- offset: response.offset
210
- });
208
+ const contentType = streamContentTypes.get(command.path);
209
+ const isJson = contentType?.includes(`application/json`) ?? false;
210
+ if (!live) {
211
+ if (isJson) {
212
+ const items = await response.json();
213
+ if (items.length > 0) chunks.push({
214
+ data: JSON.stringify(items),
215
+ offset: response.offset
216
+ });
217
+ } else {
218
+ const data = await response.body();
219
+ if (data.length > 0) chunks.push({
220
+ data: new TextDecoder().decode(data),
221
+ offset: response.offset
222
+ });
223
+ }
211
224
  finalOffset = response.offset;
212
225
  upToDate = response.upToDate;
213
- } catch {}
214
- else {
226
+ } else {
215
227
  const decoder = new TextDecoder();
216
228
  const startTime = Date.now();
217
229
  let chunkCount = 0;
218
230
  let done = false;
219
- await new Promise((resolve) => {
231
+ await new Promise((resolve, reject) => {
220
232
  const subscriptionTimeoutId = setTimeout(() => {
221
233
  done = true;
222
234
  abortController.abort();
@@ -262,11 +274,11 @@ async function handleCommand(command) {
262
274
  finalOffset = response.offset;
263
275
  resolve();
264
276
  }
265
- }).catch(() => {
277
+ }).catch((err) => {
266
278
  if (!done) {
267
279
  done = true;
268
280
  clearTimeout(subscriptionTimeoutId);
269
- resolve();
281
+ reject(err);
270
282
  }
271
283
  });
272
284
  });
@@ -364,11 +376,8 @@ async function handleCommand(command) {
364
376
  maxInFlight: 1,
365
377
  lingerMs: 0
366
378
  });
367
- const normalizedContentType = contentType.split(`;`)[0]?.trim().toLowerCase();
368
- const isJson = normalizedContentType === `application/json`;
369
- const data = isJson ? JSON.parse(command.data) : command.data;
370
379
  try {
371
- producer.append(data);
380
+ producer.append(command.data);
372
381
  await producer.flush();
373
382
  await producer.close();
374
383
  return {
@@ -399,11 +408,8 @@ async function handleCommand(command) {
399
408
  lingerMs: testingConcurrency ? 0 : 1e3,
400
409
  maxBatchBytes: testingConcurrency ? 1 : 1024 * 1024
401
410
  });
402
- const normalizedContentType = contentType.split(`;`)[0]?.trim().toLowerCase();
403
- const isJson = normalizedContentType === `application/json`;
404
- const items = isJson ? command.items.map((item) => JSON.parse(item)) : command.items;
405
411
  try {
406
- for (const item of items) producer.append(item);
412
+ for (const item of command.items) producer.append(item);
407
413
  await producer.flush();
408
414
  await producer.close();
409
415
  return {
@@ -418,6 +424,43 @@ async function handleCommand(command) {
418
424
  } catch (err) {
419
425
  return errorResult(`idempotent-append-batch`, err);
420
426
  }
427
+ case `validate`: {
428
+ const { target } = command;
429
+ try {
430
+ switch (target.target) {
431
+ case `retry-options`: return {
432
+ type: `validate`,
433
+ success: true
434
+ };
435
+ case `idempotent-producer`: {
436
+ const ds = new __durable_streams_client.DurableStream({ url: `${serverUrl}/test-validate` });
437
+ new __durable_streams_client.IdempotentProducer(ds, target.producerId ?? `test-producer`, {
438
+ epoch: target.epoch,
439
+ maxBatchBytes: target.maxBatchBytes
440
+ });
441
+ return {
442
+ type: `validate`,
443
+ success: true
444
+ };
445
+ }
446
+ default: return {
447
+ type: `error`,
448
+ success: false,
449
+ commandType: `validate`,
450
+ errorCode: require_protocol.ErrorCodes.NOT_SUPPORTED,
451
+ message: `Unknown validation target: ${target.target}`
452
+ };
453
+ }
454
+ } catch (err) {
455
+ return {
456
+ type: `error`,
457
+ success: false,
458
+ commandType: `validate`,
459
+ errorCode: require_protocol.ErrorCodes.INVALID_ARGUMENT,
460
+ message: err instanceof Error ? err.message : String(err)
461
+ };
462
+ }
463
+ }
421
464
  default: return {
422
465
  type: `error`,
423
466
  success: false,
@@ -443,7 +486,7 @@ function errorResult(commandType, err) {
443
486
  } else if (err.code === `BAD_REQUEST`) {
444
487
  errorCode = require_protocol.ErrorCodes.INVALID_OFFSET;
445
488
  status = 400;
446
- }
489
+ } else if (err.code === `PARSE_ERROR`) errorCode = require_protocol.ErrorCodes.PARSE_ERROR;
447
490
  return {
448
491
  type: `error`,
449
492
  success: false,
@@ -479,6 +522,13 @@ function errorResult(commandType, err) {
479
522
  errorCode: require_protocol.ErrorCodes.NETWORK_ERROR,
480
523
  message: err.message
481
524
  };
525
+ if (err instanceof SyntaxError || err.name === `SyntaxError` || err.message.includes(`JSON`) || err.message.includes(`parse`) || err.message.includes(`SSE`) || err.message.includes(`control event`)) return {
526
+ type: `error`,
527
+ success: false,
528
+ commandType,
529
+ errorCode: require_protocol.ErrorCodes.PARSE_ERROR,
530
+ message: err.message
531
+ };
482
532
  return {
483
533
  type: `error`,
484
534
  success: false,
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { ErrorCodes, decodeBase64, parseCommand, serializeResult } from "../protocol-qb83AeUH.js";
2
+ import { ErrorCodes, decodeBase64, parseCommand, serializeResult } from "../protocol-1p0soayz.js";
3
3
  import { createInterface } from "node:readline";
4
4
  import { DurableStream, DurableStreamError, FetchError, IdempotentProducer, stream } from "@durable-streams/client";
5
5
 
@@ -77,8 +77,10 @@ async function handleCommand(command) {
77
77
  batching: true,
78
78
  sse: true,
79
79
  longPoll: true,
80
+ auto: true,
80
81
  streaming: true,
81
- dynamicHeaders: true
82
+ dynamicHeaders: true,
83
+ strictZeroValidation: true
82
84
  }
83
85
  };
84
86
  }
@@ -166,6 +168,7 @@ async function handleCommand(command) {
166
168
  let live;
167
169
  if (command.live === `long-poll`) live = `long-poll`;
168
170
  else if (command.live === `sse`) live = `sse`;
171
+ else if (command.live === true) live = true;
169
172
  else live = false;
170
173
  const abortController = new AbortController();
171
174
  const timeoutMs = command.timeoutMs ?? 5e3;
@@ -200,21 +203,30 @@ async function handleCommand(command) {
200
203
  let finalOffset = command.offset ?? response.offset;
201
204
  let upToDate = response.upToDate;
202
205
  const maxChunks = command.maxChunks ?? 100;
203
- if (!live) try {
204
- const data = await response.body();
205
- if (data.length > 0) chunks.push({
206
- data: new TextDecoder().decode(data),
207
- offset: response.offset
208
- });
206
+ const contentType = streamContentTypes.get(command.path);
207
+ const isJson = contentType?.includes(`application/json`) ?? false;
208
+ if (!live) {
209
+ if (isJson) {
210
+ const items = await response.json();
211
+ if (items.length > 0) chunks.push({
212
+ data: JSON.stringify(items),
213
+ offset: response.offset
214
+ });
215
+ } else {
216
+ const data = await response.body();
217
+ if (data.length > 0) chunks.push({
218
+ data: new TextDecoder().decode(data),
219
+ offset: response.offset
220
+ });
221
+ }
209
222
  finalOffset = response.offset;
210
223
  upToDate = response.upToDate;
211
- } catch {}
212
- else {
224
+ } else {
213
225
  const decoder = new TextDecoder();
214
226
  const startTime = Date.now();
215
227
  let chunkCount = 0;
216
228
  let done = false;
217
- await new Promise((resolve) => {
229
+ await new Promise((resolve, reject) => {
218
230
  const subscriptionTimeoutId = setTimeout(() => {
219
231
  done = true;
220
232
  abortController.abort();
@@ -260,11 +272,11 @@ async function handleCommand(command) {
260
272
  finalOffset = response.offset;
261
273
  resolve();
262
274
  }
263
- }).catch(() => {
275
+ }).catch((err) => {
264
276
  if (!done) {
265
277
  done = true;
266
278
  clearTimeout(subscriptionTimeoutId);
267
- resolve();
279
+ reject(err);
268
280
  }
269
281
  });
270
282
  });
@@ -362,11 +374,8 @@ async function handleCommand(command) {
362
374
  maxInFlight: 1,
363
375
  lingerMs: 0
364
376
  });
365
- const normalizedContentType = contentType.split(`;`)[0]?.trim().toLowerCase();
366
- const isJson = normalizedContentType === `application/json`;
367
- const data = isJson ? JSON.parse(command.data) : command.data;
368
377
  try {
369
- producer.append(data);
378
+ producer.append(command.data);
370
379
  await producer.flush();
371
380
  await producer.close();
372
381
  return {
@@ -397,11 +406,8 @@ async function handleCommand(command) {
397
406
  lingerMs: testingConcurrency ? 0 : 1e3,
398
407
  maxBatchBytes: testingConcurrency ? 1 : 1024 * 1024
399
408
  });
400
- const normalizedContentType = contentType.split(`;`)[0]?.trim().toLowerCase();
401
- const isJson = normalizedContentType === `application/json`;
402
- const items = isJson ? command.items.map((item) => JSON.parse(item)) : command.items;
403
409
  try {
404
- for (const item of items) producer.append(item);
410
+ for (const item of command.items) producer.append(item);
405
411
  await producer.flush();
406
412
  await producer.close();
407
413
  return {
@@ -416,6 +422,43 @@ async function handleCommand(command) {
416
422
  } catch (err) {
417
423
  return errorResult(`idempotent-append-batch`, err);
418
424
  }
425
+ case `validate`: {
426
+ const { target } = command;
427
+ try {
428
+ switch (target.target) {
429
+ case `retry-options`: return {
430
+ type: `validate`,
431
+ success: true
432
+ };
433
+ case `idempotent-producer`: {
434
+ const ds = new DurableStream({ url: `${serverUrl}/test-validate` });
435
+ new IdempotentProducer(ds, target.producerId ?? `test-producer`, {
436
+ epoch: target.epoch,
437
+ maxBatchBytes: target.maxBatchBytes
438
+ });
439
+ return {
440
+ type: `validate`,
441
+ success: true
442
+ };
443
+ }
444
+ default: return {
445
+ type: `error`,
446
+ success: false,
447
+ commandType: `validate`,
448
+ errorCode: ErrorCodes.NOT_SUPPORTED,
449
+ message: `Unknown validation target: ${target.target}`
450
+ };
451
+ }
452
+ } catch (err) {
453
+ return {
454
+ type: `error`,
455
+ success: false,
456
+ commandType: `validate`,
457
+ errorCode: ErrorCodes.INVALID_ARGUMENT,
458
+ message: err instanceof Error ? err.message : String(err)
459
+ };
460
+ }
461
+ }
419
462
  default: return {
420
463
  type: `error`,
421
464
  success: false,
@@ -441,7 +484,7 @@ function errorResult(commandType, err) {
441
484
  } else if (err.code === `BAD_REQUEST`) {
442
485
  errorCode = ErrorCodes.INVALID_OFFSET;
443
486
  status = 400;
444
- }
487
+ } else if (err.code === `PARSE_ERROR`) errorCode = ErrorCodes.PARSE_ERROR;
445
488
  return {
446
489
  type: `error`,
447
490
  success: false,
@@ -477,6 +520,13 @@ function errorResult(commandType, err) {
477
520
  errorCode: ErrorCodes.NETWORK_ERROR,
478
521
  message: err.message
479
522
  };
523
+ if (err instanceof SyntaxError || err.name === `SyntaxError` || err.message.includes(`JSON`) || err.message.includes(`parse`) || err.message.includes(`SSE`) || err.message.includes(`control event`)) return {
524
+ type: `error`,
525
+ success: false,
526
+ commandType,
527
+ errorCode: ErrorCodes.PARSE_ERROR,
528
+ message: err.message
529
+ };
480
530
  return {
481
531
  type: `error`,
482
532
  success: false,
@@ -1,11 +1,13 @@
1
- import { calculateStats, formatStats, parseResult, serializeCommand } from "./protocol-qb83AeUH.js";
1
+ import { calculateStats, formatStats, parseResult, serializeCommand } from "./protocol-1p0soayz.js";
2
2
  import { spawn } from "node:child_process";
3
3
  import { createInterface } from "node:readline";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { DurableStreamTestServer } from "@durable-streams/server";
6
6
  import * as fs from "node:fs";
7
7
  import * as path from "node:path";
8
+ import { join } from "node:path";
8
9
  import YAML from "yaml";
10
+ import { readFile, readdir } from "node:fs/promises";
9
11
  import { DurableStream } from "@durable-streams/client";
10
12
 
11
13
  //#region src/test-cases.ts
@@ -176,7 +178,7 @@ async function executeOperation(op, ctx) {
176
178
  }
177
179
  case `append`: {
178
180
  const path$1 = resolveVariables(op.path, variables);
179
- const data = op.data ? resolveVariables(op.data, variables) : ``;
181
+ const data = op.json !== void 0 ? JSON.stringify(op.json) : op.data ? resolveVariables(op.data, variables) : ``;
180
182
  const result = await client.send({
181
183
  type: `append`,
182
184
  path: path$1,
@@ -411,7 +413,8 @@ async function executeOperation(op, ctx) {
411
413
  probability: op.probability,
412
414
  method: op.method,
413
415
  corruptBody: op.corruptBody,
414
- jitterMs: op.jitterMs
416
+ jitterMs: op.jitterMs,
417
+ injectSseEvent: op.injectSseEvent
415
418
  })
416
419
  });
417
420
  const faultTypes = [];
@@ -422,6 +425,7 @@ async function executeOperation(op, ctx) {
422
425
  if (op.truncateBodyBytes != null) faultTypes.push(`truncate=${op.truncateBodyBytes}b`);
423
426
  if (op.corruptBody) faultTypes.push(`corrupt`);
424
427
  if (op.probability != null) faultTypes.push(`p=${op.probability}`);
428
+ if (op.injectSseEvent) faultTypes.push(`sse:${op.injectSseEvent.eventType}`);
425
429
  const faultDesc = faultTypes.join(`,`) || `unknown`;
426
430
  if (verbose) console.log(` inject-error ${path$1} [${faultDesc}]x${op.count ?? 1}: ${response.ok ? `ok` : `failed`}`);
427
431
  if (!response.ok) return { error: `Failed to inject fault: ${response.status}` };
@@ -461,6 +465,17 @@ async function executeOperation(op, ctx) {
461
465
  if (verbose) console.log(` clear-dynamic: ${result.success ? `ok` : `failed`}`);
462
466
  return { result };
463
467
  }
468
+ case `validate`: {
469
+ const result = await client.send({
470
+ type: `validate`,
471
+ target: op.target
472
+ }, commandTimeout);
473
+ if (verbose) {
474
+ const targetType = op.target.target;
475
+ console.log(` validate ${targetType}: ${result.success ? `ok` : `failed`}`);
476
+ }
477
+ return { result };
478
+ }
464
479
  default: return { error: `Unknown operation: ${op.action}` };
465
480
  }
466
481
  }
@@ -485,6 +500,13 @@ function validateExpectation(result, expect) {
485
500
  if (result.success) return `Expected error ${expect.errorCode}, but operation succeeded`;
486
501
  if (isErrorResult(result) && result.errorCode !== expect.errorCode) return `Expected error code ${expect.errorCode}, got ${result.errorCode}`;
487
502
  }
503
+ if (expect.messageContains !== void 0) {
504
+ if (result.success) return `Expected error with message containing ${JSON.stringify(expect.messageContains)}, but operation succeeded`;
505
+ if (isErrorResult(result)) {
506
+ const missing = expect.messageContains.filter((s) => !result.message.toLowerCase().includes(s.toLowerCase()));
507
+ if (missing.length > 0) return `Expected error message to contain [${expect.messageContains.join(`, `)}], missing: [${missing.join(`, `)}]. Actual message: "${result.message}"`;
508
+ }
509
+ }
488
510
  if (expect.data !== void 0 && isReadResult(result)) {
489
511
  const actualData = result.chunks.map((c) => c.data).join(``);
490
512
  if (actualData !== expect.data) return `Expected data "${expect.data}", got "${actualData}"`;
@@ -557,6 +579,13 @@ function validateExpectation(result, expect) {
557
579
  if (expect.producerReceivedSeq !== void 0 && isAppendResult(result)) {
558
580
  if (result.producerReceivedSeq !== expect.producerReceivedSeq) return `Expected producerReceivedSeq=${expect.producerReceivedSeq}, got ${result.producerReceivedSeq}`;
559
581
  }
582
+ if (expect.valid !== void 0) {
583
+ if (expect.valid === true && !result.success) return `Expected validation to pass, but it failed`;
584
+ if (expect.valid === false && result.success) return `Expected validation to fail, but it passed`;
585
+ }
586
+ if (expect.errorContains !== void 0 && isErrorResult(result)) {
587
+ if (!result.message.includes(expect.errorContains)) return `Expected error message to contain "${expect.errorContains}", got "${result.message}"`;
588
+ }
560
589
  return null;
561
590
  }
562
591
  /**
@@ -568,9 +597,16 @@ function featureToProperty(feature) {
568
597
  sse: `sse`,
569
598
  "long-poll": `longPoll`,
570
599
  longPoll: `longPoll`,
600
+ auto: `auto`,
571
601
  streaming: `streaming`,
572
602
  dynamicHeaders: `dynamicHeaders`,
573
- "dynamic-headers": `dynamicHeaders`
603
+ "dynamic-headers": `dynamicHeaders`,
604
+ retryOptions: `retryOptions`,
605
+ "retry-options": `retryOptions`,
606
+ batchItems: `batchItems`,
607
+ "batch-items": `batchItems`,
608
+ strictZeroValidation: `strictZeroValidation`,
609
+ "strict-zero-validation": `strictZeroValidation`
574
610
  };
575
611
  return map[feature];
576
612
  }
@@ -868,7 +904,7 @@ const smallMessageThroughputScenario = {
868
904
  createOperation: (ctx) => ({
869
905
  op: `throughput_append`,
870
906
  path: `${ctx.basePath}/throughput-small`,
871
- count: 1e4,
907
+ count: 1e5,
872
908
  size: 100,
873
909
  concurrency: 200
874
910
  })
@@ -911,8 +947,8 @@ const readThroughputScenario = {
911
947
  expectedCount: ctx.setupData.expectedCount
912
948
  }),
913
949
  setup: (ctx) => {
914
- ctx.setupData.expectedCount = 1e4;
915
- return Promise.resolve({ data: { expectedCount: 1e4 } });
950
+ ctx.setupData.expectedCount = 1e5;
951
+ return Promise.resolve({ data: { expectedCount: 1e5 } });
916
952
  }
917
953
  };
918
954
  const sseLatencyScenario = {
@@ -1104,7 +1140,7 @@ async function runScenario(scenario, client, serverUrl, clientFeatures, verbose,
1104
1140
  n: i,
1105
1141
  data: `message-${i}-padding-for-size`
1106
1142
  });
1107
- await Promise.all(messages.map((msg) => ds.append(msg)));
1143
+ await Promise.all(messages.map((msg) => ds.append(JSON.stringify(msg))));
1108
1144
  }
1109
1145
  }
1110
1146
  if (verbose) log(` Warmup: ${scenario.config.warmupIterations} iterations...`);
@@ -1422,6 +1458,50 @@ async function runBenchmarks(options) {
1422
1458
  }
1423
1459
  return summary;
1424
1460
  }
1461
+ /**
1462
+ * Reads benchmark JSON files from a directory and generates a combined markdown report.
1463
+ * Each subdirectory should contain a benchmark-results.json file.
1464
+ */
1465
+ async function aggregateBenchmarkResults(resultsDir) {
1466
+ const entries = await readdir(resultsDir, { withFileTypes: true });
1467
+ const summaries = [];
1468
+ for (const entry of entries) {
1469
+ if (!entry.isDirectory()) continue;
1470
+ const jsonPath = join(resultsDir, entry.name, `benchmark-results.json`);
1471
+ try {
1472
+ const content = await readFile(jsonPath, `utf-8`);
1473
+ const summary = JSON.parse(content);
1474
+ summary.results = summary.results.map((r) => ({
1475
+ ...r,
1476
+ scenario: r.scenario
1477
+ }));
1478
+ summaries.push(summary);
1479
+ } catch {
1480
+ console.error(`Skipping ${entry.name}: no valid benchmark-results.json`);
1481
+ }
1482
+ }
1483
+ if (summaries.length === 0) return `# Client Benchmark Results\n\nNo benchmark results found.\n`;
1484
+ summaries.sort((a, b) => a.adapter.localeCompare(b.adapter));
1485
+ const lines = [];
1486
+ lines.push(`# Client Benchmark Results`);
1487
+ lines.push(``);
1488
+ const totalPassed = summaries.reduce((sum, s) => sum + s.passed, 0);
1489
+ const totalFailed = summaries.reduce((sum, s) => sum + s.failed, 0);
1490
+ const totalSkipped = summaries.reduce((sum, s) => sum + s.skipped, 0);
1491
+ lines.push(`| Client | Passed | Failed | Skipped | Status |`);
1492
+ lines.push(`|--------|--------|--------|---------|--------|`);
1493
+ for (const summary of summaries) {
1494
+ const status = summary.failed === 0 ? `✓` : `✗`;
1495
+ lines.push(`| ${summary.adapter} | ${summary.passed} | ${summary.failed} | ${summary.skipped} | ${status} |`);
1496
+ }
1497
+ lines.push(`| **Total** | **${totalPassed}** | **${totalFailed}** | **${totalSkipped}** | ${totalFailed === 0 ? `✓` : `✗`} |`);
1498
+ lines.push(``);
1499
+ for (const summary of summaries) {
1500
+ lines.push(generateMarkdownReport(summary));
1501
+ lines.push(``);
1502
+ }
1503
+ return lines.join(`\n`);
1504
+ }
1425
1505
 
1426
1506
  //#endregion
1427
- export { allScenarios, countTests, filterByCategory, getScenarioById, getScenariosByCategory, loadEmbeddedTestSuites, loadTestSuites, runBenchmarks, runConformanceTests, scenariosByCategory };
1507
+ export { aggregateBenchmarkResults, allScenarios, countTests, filterByCategory, getScenarioById, getScenariosByCategory, loadEmbeddedTestSuites, loadTestSuites, runBenchmarks, runConformanceTests, scenariosByCategory };