executable-stories-formatters 0.7.0 → 0.7.2

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/dist/cli.js CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { parseArgs } from "util";
5
- import * as fs3 from "fs";
6
- import * as path3 from "path";
5
+ import * as fs5 from "fs";
6
+ import * as path5 from "path";
7
7
 
8
8
  // src/validation/schema-validator.ts
9
9
  import Ajv from "ajv/dist/2020.js";
@@ -175,8 +175,21 @@ var raw_run_schema_default = {
175
175
  },
176
176
  tickets: {
177
177
  type: "array",
178
- items: { type: "string" },
179
- description: "Ticket/issue references for requirements traceability (e.g., ['JIRA-123'])."
178
+ items: {
179
+ oneOf: [
180
+ { type: "string" },
181
+ {
182
+ type: "object",
183
+ properties: {
184
+ id: { type: "string" },
185
+ url: { type: "string" }
186
+ },
187
+ required: ["id"],
188
+ additionalProperties: false
189
+ }
190
+ ]
191
+ },
192
+ description: "Ticket/issue references. Each item is either a string ID or an object with id and optional url."
180
193
  },
181
194
  meta: {
182
195
  type: "object",
@@ -261,7 +274,8 @@ var raw_run_schema_default = {
261
274
  properties: {
262
275
  kind: { const: "note" },
263
276
  text: { type: "string" },
264
- phase: { $ref: "#/$defs/DocPhase" }
277
+ phase: { $ref: "#/$defs/DocPhase" },
278
+ children: { type: "array", items: { $ref: "#/$defs/DocEntry" }, description: "Nested child doc entries for grouping." }
265
279
  },
266
280
  required: ["kind", "text", "phase"],
267
281
  additionalProperties: false
@@ -275,7 +289,8 @@ var raw_run_schema_default = {
275
289
  type: "array",
276
290
  items: { type: "string" }
277
291
  },
278
- phase: { $ref: "#/$defs/DocPhase" }
292
+ phase: { $ref: "#/$defs/DocPhase" },
293
+ children: { type: "array", items: { $ref: "#/$defs/DocEntry" }, description: "Nested child doc entries for grouping." }
279
294
  },
280
295
  required: ["kind", "names", "phase"],
281
296
  additionalProperties: false
@@ -287,7 +302,8 @@ var raw_run_schema_default = {
287
302
  kind: { const: "kv" },
288
303
  label: { type: "string" },
289
304
  value: {},
290
- phase: { $ref: "#/$defs/DocPhase" }
305
+ phase: { $ref: "#/$defs/DocPhase" },
306
+ children: { type: "array", items: { $ref: "#/$defs/DocEntry" }, description: "Nested child doc entries for grouping." }
291
307
  },
292
308
  required: ["kind", "label", "value", "phase"],
293
309
  additionalProperties: false
@@ -300,7 +316,8 @@ var raw_run_schema_default = {
300
316
  label: { type: "string" },
301
317
  content: { type: "string" },
302
318
  lang: { type: "string" },
303
- phase: { $ref: "#/$defs/DocPhase" }
319
+ phase: { $ref: "#/$defs/DocPhase" },
320
+ children: { type: "array", items: { $ref: "#/$defs/DocEntry" }, description: "Nested child doc entries for grouping." }
304
321
  },
305
322
  required: ["kind", "label", "content", "phase"],
306
323
  additionalProperties: false
@@ -322,7 +339,8 @@ var raw_run_schema_default = {
322
339
  items: { type: "string" }
323
340
  }
324
341
  },
325
- phase: { $ref: "#/$defs/DocPhase" }
342
+ phase: { $ref: "#/$defs/DocPhase" },
343
+ children: { type: "array", items: { $ref: "#/$defs/DocEntry" }, description: "Nested child doc entries for grouping." }
326
344
  },
327
345
  required: ["kind", "label", "columns", "rows", "phase"],
328
346
  additionalProperties: false
@@ -334,7 +352,8 @@ var raw_run_schema_default = {
334
352
  kind: { const: "link" },
335
353
  label: { type: "string" },
336
354
  url: { type: "string" },
337
- phase: { $ref: "#/$defs/DocPhase" }
355
+ phase: { $ref: "#/$defs/DocPhase" },
356
+ children: { type: "array", items: { $ref: "#/$defs/DocEntry" }, description: "Nested child doc entries for grouping." }
338
357
  },
339
358
  required: ["kind", "label", "url", "phase"],
340
359
  additionalProperties: false
@@ -346,7 +365,8 @@ var raw_run_schema_default = {
346
365
  kind: { const: "section" },
347
366
  title: { type: "string" },
348
367
  markdown: { type: "string" },
349
- phase: { $ref: "#/$defs/DocPhase" }
368
+ phase: { $ref: "#/$defs/DocPhase" },
369
+ children: { type: "array", items: { $ref: "#/$defs/DocEntry" }, description: "Nested child doc entries for grouping." }
350
370
  },
351
371
  required: ["kind", "title", "markdown", "phase"],
352
372
  additionalProperties: false
@@ -358,7 +378,8 @@ var raw_run_schema_default = {
358
378
  kind: { const: "mermaid" },
359
379
  code: { type: "string" },
360
380
  title: { type: "string" },
361
- phase: { $ref: "#/$defs/DocPhase" }
381
+ phase: { $ref: "#/$defs/DocPhase" },
382
+ children: { type: "array", items: { $ref: "#/$defs/DocEntry" }, description: "Nested child doc entries for grouping." }
362
383
  },
363
384
  required: ["kind", "code", "phase"],
364
385
  additionalProperties: false
@@ -370,7 +391,8 @@ var raw_run_schema_default = {
370
391
  kind: { const: "screenshot" },
371
392
  path: { type: "string" },
372
393
  alt: { type: "string" },
373
- phase: { $ref: "#/$defs/DocPhase" }
394
+ phase: { $ref: "#/$defs/DocPhase" },
395
+ children: { type: "array", items: { $ref: "#/$defs/DocEntry" }, description: "Nested child doc entries for grouping." }
374
396
  },
375
397
  required: ["kind", "path", "phase"],
376
398
  additionalProperties: false
@@ -382,7 +404,8 @@ var raw_run_schema_default = {
382
404
  kind: { const: "custom" },
383
405
  type: { type: "string" },
384
406
  data: {},
385
- phase: { $ref: "#/$defs/DocPhase" }
407
+ phase: { $ref: "#/$defs/DocPhase" },
408
+ children: { type: "array", items: { $ref: "#/$defs/DocEntry" }, description: "Nested child doc entries for grouping." }
386
409
  },
387
410
  required: ["kind", "type", "data", "phase"],
388
411
  additionalProperties: false
@@ -469,17 +492,17 @@ function validateRawRun(data) {
469
492
  return { valid: true, errors: [] };
470
493
  }
471
494
  const errors = (validate.errors ?? []).map((err) => {
472
- const path4 = err.instancePath || "/";
495
+ const path6 = err.instancePath || "/";
473
496
  const message = err.message ?? "unknown error";
474
497
  if (err.keyword === "additionalProperties") {
475
498
  const extra = err.params.additionalProperty;
476
- return `${path4}: ${message} \u2014 '${extra}'`;
499
+ return `${path6}: ${message} \u2014 '${extra}'`;
477
500
  }
478
501
  if (err.keyword === "enum") {
479
502
  const allowed = err.params.allowedValues;
480
- return `${path4}: ${message} \u2014 allowed: ${JSON.stringify(allowed)}`;
503
+ return `${path6}: ${message} \u2014 allowed: ${JSON.stringify(allowed)}`;
481
504
  }
482
- return `${path4}: ${message}`;
505
+ return `${path6}: ${message}`;
483
506
  });
484
507
  return { valid: false, errors };
485
508
  }
@@ -793,6 +816,9 @@ function canonicalizeTestCase(raw, options, projectRoot) {
793
816
  projectRoot
794
817
  });
795
818
  const tags = normalizeTags(story);
819
+ if (story.tickets) {
820
+ story.tickets = normalizeTickets(story.tickets);
821
+ }
796
822
  const titlePath = buildTitlePath(raw, story);
797
823
  return {
798
824
  id,
@@ -816,6 +842,9 @@ function normalizeTags(story) {
816
842
  const tags = story.tags ?? [];
817
843
  return [...new Set(tags)].sort();
818
844
  }
845
+ function normalizeTickets(raw) {
846
+ return raw.map((t) => typeof t === "string" ? { id: t } : t);
847
+ }
819
848
  function buildTitlePath(raw, story) {
820
849
  if (story.suitePath && story.suitePath.length > 0) {
821
850
  return story.suitePath;
@@ -937,7 +966,7 @@ ${result.errors.join("\n")}`);
937
966
 
938
967
  // src/index.ts
939
968
  import "fs";
940
- import * as path2 from "path";
969
+ import * as path4 from "path";
941
970
  import * as fsPromises from "fs/promises";
942
971
 
943
972
  // src/converters/acl/lines.ts
@@ -3247,6 +3276,16 @@ body {
3247
3276
  background: none;
3248
3277
  }
3249
3278
 
3279
+ /* ============================================================================
3280
+ Documentation Entries - Children
3281
+ ============================================================================ */
3282
+ .doc-children {
3283
+ margin-left: 1rem;
3284
+ padding-left: 1rem;
3285
+ border-left: 2px solid var(--border);
3286
+ margin-top: 0.25rem;
3287
+ }
3288
+
3250
3289
  /* ============================================================================
3251
3290
  Trace View - OTel span waterfall
3252
3291
  ============================================================================ */
@@ -12958,30 +12997,46 @@ function renderDocCustom(entry, deps) {
12958
12997
  </div>`;
12959
12998
  }
12960
12999
  function renderDocEntry(entry, deps) {
13000
+ let html;
12961
13001
  switch (entry.kind) {
12962
13002
  case "note":
12963
- return renderDocNote(entry, deps);
13003
+ html = renderDocNote(entry, deps);
13004
+ break;
12964
13005
  case "tag":
12965
- return renderDocTag(entry, deps);
13006
+ html = renderDocTag(entry, deps);
13007
+ break;
12966
13008
  case "kv":
12967
- return renderDocKv(entry, deps);
13009
+ html = renderDocKv(entry, deps);
13010
+ break;
12968
13011
  case "code":
12969
- return renderDocCode(entry, deps);
13012
+ html = renderDocCode(entry, deps);
13013
+ break;
12970
13014
  case "table":
12971
- return renderDocTable(entry, deps);
13015
+ html = renderDocTable(entry, deps);
13016
+ break;
12972
13017
  case "link":
12973
- return renderDocLink(entry, deps);
13018
+ html = renderDocLink(entry, deps);
13019
+ break;
12974
13020
  case "section":
12975
- return renderDocSection(entry, deps);
13021
+ html = renderDocSection(entry, deps);
13022
+ break;
12976
13023
  case "mermaid":
12977
- return renderDocMermaid(entry, deps);
13024
+ html = renderDocMermaid(entry, deps);
13025
+ break;
12978
13026
  case "screenshot":
12979
- return renderDocScreenshot(entry, deps);
13027
+ html = renderDocScreenshot(entry, deps);
13028
+ break;
12980
13029
  case "custom":
12981
- return renderDocCustom(entry, deps);
13030
+ html = renderDocCustom(entry, deps);
13031
+ break;
12982
13032
  default:
12983
- return "";
13033
+ html = "";
13034
+ }
13035
+ if (entry.children && entry.children.length > 0) {
13036
+ const childrenHtml = entry.children.map((child) => renderDocEntry(child, deps)).join("");
13037
+ html += `<div class="doc-children">${childrenHtml}</div>`;
12984
13038
  }
13039
+ return html;
12985
13040
  }
12986
13041
 
12987
13042
  // src/formatters/html/renderers/steps.ts
@@ -13038,12 +13093,20 @@ function highlightStepParams(text, deps) {
13038
13093
  var MIN_METRIC_SAMPLES = 5;
13039
13094
 
13040
13095
  // src/formatters/html/renderers/scenario.ts
13096
+ function renderTicket(ticket, template, escapeHtml3) {
13097
+ const url = ticket.url ?? (template ? template.replace("{ticket}", ticket.id) : void 0);
13098
+ if (url) {
13099
+ return `<a class="tag ticket-tag" href="${escapeHtml3(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml3(ticket.id)}</a>`;
13100
+ }
13101
+ return `<span class="tag ticket-tag">${escapeHtml3(ticket.id)}</span>`;
13102
+ }
13041
13103
  function renderScenario(args, deps) {
13042
13104
  const { tc } = args;
13043
13105
  const statusIcon = deps.getStatusIcon(tc.status);
13044
13106
  const statusClass = `status-${tc.status}`;
13045
13107
  const duration = tc.durationMs > 0 ? `${(tc.durationMs / 1e3).toFixed(2)}s` : "";
13046
13108
  const tags = tc.tags.map((t) => `<span class="tag">${deps.escapeHtml(t)}</span>`).join("");
13109
+ const tickets = (tc.story.tickets ?? []).map((t) => renderTicket(t, deps.ticketUrlTemplate, deps.escapeHtml)).join("");
13047
13110
  const otelMeta = tc.story.meta?.otel;
13048
13111
  let traceBadge = "";
13049
13112
  if (otelMeta?.traceId) {
@@ -13111,7 +13174,7 @@ function renderScenario(args, deps) {
13111
13174
  <span class="status-icon ${statusClass}">${statusIcon}</span>
13112
13175
  <span class="scenario-name">${deps.escapeHtml(tc.story.scenario)}</span>
13113
13176
  </div>
13114
- <div class="scenario-meta">${tags}${sourceLink}${traceBadge}${metricBadges}</div>
13177
+ <div class="scenario-meta">${tags}${tickets}${sourceLink}${traceBadge}${metricBadges}</div>
13115
13178
  </div>
13116
13179
  <span class="scenario-duration">${duration}</span>
13117
13180
  </div>
@@ -13456,6 +13519,7 @@ function normalizeOptions(options = {}) {
13456
13519
  mermaidEnabled: options.mermaidEnabled ?? true,
13457
13520
  markdownEnabled: options.markdownEnabled ?? true,
13458
13521
  permalinkBaseUrl: options.permalinkBaseUrl,
13522
+ ticketUrlTemplate: options.ticketUrlTemplate,
13459
13523
  theme: options.theme ?? "default"
13460
13524
  };
13461
13525
  }
@@ -13488,7 +13552,8 @@ function createHtmlFormatter(options = {}) {
13488
13552
  renderAttachments: (args, d) => renderAttachments(args, d),
13489
13553
  renderTraceView: (args, d) => renderTraceView(args, d),
13490
13554
  embedScreenshots: opts.embedScreenshots,
13491
- permalinkBaseUrl: opts.permalinkBaseUrl
13555
+ permalinkBaseUrl: opts.permalinkBaseUrl,
13556
+ ticketUrlTemplate: opts.ticketUrlTemplate
13492
13557
  };
13493
13558
  const featureDeps = {
13494
13559
  escapeHtml,
@@ -14001,14 +14066,16 @@ var MarkdownFormatter = class {
14001
14066
  }
14002
14067
  if (tc.story.tickets && tc.story.tickets.length > 0) {
14003
14068
  const ticketTemplate = this.options.ticketUrlTemplate;
14004
- if (ticketTemplate) {
14005
- const ticketLinks = tc.story.tickets.map(
14006
- (t) => `[${t}](${ticketTemplate.replace("{ticket}", t)})`
14007
- );
14008
- meta.push(`Tickets: ${ticketLinks.join(", ")}`);
14009
- } else {
14010
- meta.push(`Tickets: ${tc.story.tickets.map((t) => `\`${t}\``).join(", ")}`);
14011
- }
14069
+ const ticketLinks = tc.story.tickets.map((t) => {
14070
+ if (t.url) {
14071
+ return `[${t.id}](${t.url})`;
14072
+ }
14073
+ if (ticketTemplate) {
14074
+ return `[${t.id}](${ticketTemplate.replace("{ticket}", t.id)})`;
14075
+ }
14076
+ return `\`${t.id}\``;
14077
+ });
14078
+ meta.push(`Tickets: ${ticketLinks.join(", ")}`);
14012
14079
  }
14013
14080
  const otelMeta = tc.story.meta?.otel;
14014
14081
  if (otelMeta?.traceId) {
@@ -14182,6 +14249,12 @@ var MarkdownFormatter = class {
14182
14249
  lines.push(`${indent}`);
14183
14250
  break;
14184
14251
  }
14252
+ if (entry.children && entry.children.length > 0) {
14253
+ const childIndent = indent + " ";
14254
+ for (const child of entry.children) {
14255
+ this.renderDocEntry(lines, child, childIndent);
14256
+ }
14257
+ }
14185
14258
  }
14186
14259
  /**
14187
14260
  * Get status icon for a status.
@@ -14254,8 +14327,8 @@ function extractFeatureName(testCases, uri) {
14254
14327
  return tc.titlePath[0];
14255
14328
  }
14256
14329
  }
14257
- const basename2 = uri.replace(/^.*[\\/]/, "").replace(/\.[^.]+$/, "");
14258
- return basename2.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
14330
+ const basename3 = uri.replace(/^.*[\\/]/, "").replace(/\.[^.]+$/, "");
14331
+ return basename3.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
14259
14332
  }
14260
14333
  function synthesizeFeature(uri, testCases) {
14261
14334
  const featureName = extractFeatureName(testCases, uri);
@@ -14867,8 +14940,8 @@ function extractDocAttachments(step) {
14867
14940
  }
14868
14941
  return attachments;
14869
14942
  }
14870
- function guessMediaType(path4) {
14871
- const lower = path4.toLowerCase();
14943
+ function guessMediaType(path6) {
14944
+ const lower = path6.toLowerCase();
14872
14945
  if (lower.endsWith(".png")) return "image/png";
14873
14946
  if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
14874
14947
  if (lower.endsWith(".gif")) return "image/gif";
@@ -15009,11 +15082,11 @@ var CucumberHtmlFormatter = class {
15009
15082
  for (const envelope of envelopes) {
15010
15083
  const accepted = htmlStream.write(envelope);
15011
15084
  if (!accepted) {
15012
- await new Promise((resolve3) => htmlStream.once("drain", resolve3));
15085
+ await new Promise((resolve4) => htmlStream.once("drain", resolve4));
15013
15086
  }
15014
15087
  }
15015
- await new Promise((resolve3, reject) => {
15016
- collector.on("finish", resolve3);
15088
+ await new Promise((resolve4, reject) => {
15089
+ collector.on("finish", resolve4);
15017
15090
  collector.on("error", reject);
15018
15091
  htmlStream.end();
15019
15092
  });
@@ -15161,7 +15234,7 @@ function buildFlags(baseline, current) {
15161
15234
  steps: stableJson(baseline.story.steps) !== stableJson(current.story.steps),
15162
15235
  docs: stableJson(baselineDocs) !== stableJson(currentDocs),
15163
15236
  tags: !compareStringArrays(baseline.tags, current.tags),
15164
- tickets: !compareStringArrays(baseline.story.tickets ?? [], current.story.tickets ?? []),
15237
+ tickets: stableJson(baseline.story.tickets ?? []) !== stableJson(current.story.tickets ?? []),
15165
15238
  source: baseline.sourceFile !== current.sourceFile || baseline.sourceLine !== current.sourceLine,
15166
15239
  duration: baseline.durationMs !== current.durationMs,
15167
15240
  attachments: stableJson(baseline.attachments) !== stableJson(current.attachments),
@@ -15384,7 +15457,7 @@ function renderScenarioCard(scenario) {
15384
15457
  <dd>${escapeHtml2(before.errorMessage ?? "") || "&nbsp;"}</dd>
15385
15458
  ${scenario.flags.steps ? `<dt>Steps</dt><dd>${formatSteps(before.steps)}</dd>` : ""}
15386
15459
  ${scenario.flags.docs ? `<dt>Docs</dt><dd>${formatDocs(before.docs)}</dd>` : ""}
15387
- ${scenario.flags.tickets ? `<dt>Tickets</dt><dd>${escapeHtml2(before.tickets.join(", ")) || "&nbsp;"}</dd>` : ""}
15460
+ ${scenario.flags.tickets ? `<dt>Tickets</dt><dd>${escapeHtml2(before.tickets.map((t) => t.id).join(", ")) || "&nbsp;"}</dd>` : ""}
15388
15461
  </dl>
15389
15462
  </section>
15390
15463
  <section>
@@ -15398,7 +15471,7 @@ function renderScenarioCard(scenario) {
15398
15471
  <dd>${escapeHtml2(after.errorMessage ?? "") || "&nbsp;"}</dd>
15399
15472
  ${scenario.flags.steps ? `<dt>Steps</dt><dd>${formatSteps(after.steps)}</dd>` : ""}
15400
15473
  ${scenario.flags.docs ? `<dt>Docs</dt><dd>${formatDocs(after.docs)}</dd>` : ""}
15401
- ${scenario.flags.tickets ? `<dt>Tickets</dt><dd>${escapeHtml2(after.tickets.join(", ")) || "&nbsp;"}</dd>` : ""}
15474
+ ${scenario.flags.tickets ? `<dt>Tickets</dt><dd>${escapeHtml2(after.tickets.map((t) => t.id).join(", ")) || "&nbsp;"}</dd>` : ""}
15402
15475
  </dl>
15403
15476
  </section>
15404
15477
  </div>` : (() => {
@@ -15412,7 +15485,7 @@ function renderScenarioCard(scenario) {
15412
15485
  return `<div class="snapshot-detail">
15413
15486
  <dl>
15414
15487
  ${hasTags ? `<dt>Tags</dt><dd>${escapeHtml2(snapshot.tags.join(", "))}</dd>` : ""}
15415
- ${hasTickets ? `<dt>Tickets</dt><dd>${escapeHtml2(snapshot.tickets.join(", "))}</dd>` : ""}
15488
+ ${hasTickets ? `<dt>Tickets</dt><dd>${escapeHtml2(snapshot.tickets.map((t) => t.id).join(", "))}</dd>` : ""}
15416
15489
  ${hasSteps ? `<dt>Steps</dt><dd>${formatSteps(snapshot.steps)}</dd>` : ""}
15417
15490
  ${hasDocs ? `<dt>Docs</dt><dd>${formatDocs(snapshot.docs)}</dd>` : ""}
15418
15491
  </dl>
@@ -15727,7 +15800,7 @@ function renderScenario2(lines, scenario) {
15727
15800
  lines.push(`| Docs | ${escapeCell(formatDocs2(before.docs))} | ${escapeCell(formatDocs2(after.docs))} |`);
15728
15801
  }
15729
15802
  if (scenario.flags.tickets) {
15730
- lines.push(`| Tickets | ${escapeCell(before.tickets.join(", "))} | ${escapeCell(after.tickets.join(", "))} |`);
15803
+ lines.push(`| Tickets | ${escapeCell(before.tickets.map((t) => t.id).join(", "))} | ${escapeCell(after.tickets.map((t) => t.id).join(", "))} |`);
15731
15804
  }
15732
15805
  lines.push("");
15733
15806
  } else {
@@ -15743,7 +15816,7 @@ function renderSnapshotDetail(lines, snapshot) {
15743
15816
  lines.push("");
15744
15817
  }
15745
15818
  if (snapshot.tickets.length > 0) {
15746
- lines.push(`**Tickets:** ${snapshot.tickets.join(", ")}`);
15819
+ lines.push(`**Tickets:** ${snapshot.tickets.map((t) => t.id).join(", ")}`);
15747
15820
  lines.push("");
15748
15821
  }
15749
15822
  if (snapshot.steps.length > 0) {
@@ -15941,6 +16014,110 @@ function selectTestCases(args, deps) {
15941
16014
  return sortTestCases(selected, sortMode);
15942
16015
  }
15943
16016
 
16017
+ // src/bundler/bundle-assets.ts
16018
+ import * as fs3 from "fs";
16019
+ import * as path3 from "path";
16020
+
16021
+ // src/bundler/scan-html-assets.ts
16022
+ function scanHtmlAssets(html) {
16023
+ const seen = /* @__PURE__ */ new Set();
16024
+ const patterns = [
16025
+ /<(?:img|video)\b[^>]*?\bsrc=["']([^"']+)["']/g,
16026
+ /<a\b[^>]*?\bclass=["']attachment["'][^>]*?\bhref=["']([^"']+)["']/g,
16027
+ /<a\b[^>]*?\bhref=["']([^"']+)["'][^>]*?\bclass=["']attachment["']/g
16028
+ ];
16029
+ for (const pattern of patterns) {
16030
+ let match;
16031
+ while ((match = pattern.exec(html)) !== null) {
16032
+ const ref = match[1];
16033
+ if (isLocalAssetRef(ref) && !seen.has(ref)) {
16034
+ seen.add(ref);
16035
+ }
16036
+ }
16037
+ }
16038
+ return [...seen];
16039
+ }
16040
+ function isLocalAssetRef(ref) {
16041
+ if (!ref) return false;
16042
+ if (ref.startsWith("data:")) return false;
16043
+ if (ref.startsWith("http://") || ref.startsWith("https://")) return false;
16044
+ if (ref.startsWith("#")) return false;
16045
+ return true;
16046
+ }
16047
+
16048
+ // src/bundler/copy-asset.ts
16049
+ import * as fs2 from "fs";
16050
+ import * as path2 from "path";
16051
+ import * as crypto from "crypto";
16052
+ function copyAsset(sourcePath, assetsDir) {
16053
+ if (!fs2.existsSync(assetsDir)) {
16054
+ fs2.mkdirSync(assetsDir, { recursive: true });
16055
+ }
16056
+ const content = fs2.readFileSync(sourcePath);
16057
+ const hash = crypto.createHash("sha256").update(content).digest("hex").slice(0, 8);
16058
+ const ext = path2.extname(sourcePath);
16059
+ const baseName = sanitize(path2.basename(sourcePath, ext));
16060
+ const destName = `${baseName}-${hash}${ext}`;
16061
+ const destPath = path2.join(assetsDir, destName);
16062
+ if (!fs2.existsSync(destPath)) {
16063
+ fs2.copyFileSync(sourcePath, destPath);
16064
+ }
16065
+ return `assets/${destName}`;
16066
+ }
16067
+ function sanitize(name) {
16068
+ return name.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-{2,}/g, "-").replace(/^-|-$/g, "");
16069
+ }
16070
+
16071
+ // src/bundler/bundle-assets.ts
16072
+ function bundleAssets(htmlPath, options = {}) {
16073
+ const htmlDir = path3.dirname(htmlPath);
16074
+ const assetsDir = path3.join(htmlDir, "assets");
16075
+ let html = fs3.readFileSync(htmlPath, "utf8");
16076
+ const refs = scanHtmlAssets(html);
16077
+ let copiedCount = 0;
16078
+ const missing = [];
16079
+ for (const ref of refs) {
16080
+ const absolutePath = path3.resolve(htmlDir, ref);
16081
+ if (!fs3.existsSync(absolutePath)) {
16082
+ missing.push(ref);
16083
+ continue;
16084
+ }
16085
+ const newRelPath = copyAsset(absolutePath, assetsDir);
16086
+ html = replaceAssetRef(html, ref, newRelPath);
16087
+ copiedCount++;
16088
+ }
16089
+ if (missing.length > 0 && !options.allowMissing) {
16090
+ throw new Error(
16091
+ `Missing asset${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}`
16092
+ );
16093
+ }
16094
+ fs3.writeFileSync(htmlPath, html, "utf8");
16095
+ return {
16096
+ copiedCount,
16097
+ missingCount: missing.length,
16098
+ missing
16099
+ };
16100
+ }
16101
+ function replaceAssetRef(html, original, replacement) {
16102
+ const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
16103
+ const srcPattern = new RegExp(
16104
+ `(<(?:img|video)\\b[^>]*?\\bsrc=["'])${escaped}(["'])`,
16105
+ "g"
16106
+ );
16107
+ html = html.replace(srcPattern, `$1${replacement}$2`);
16108
+ const hrefClassFirst = new RegExp(
16109
+ `(<a\\b[^>]*?\\bclass=["']attachment["'][^>]*?\\bhref=["'])${escaped}(["'])`,
16110
+ "g"
16111
+ );
16112
+ html = html.replace(hrefClassFirst, `$1${replacement}$2`);
16113
+ const hrefHrefFirst = new RegExp(
16114
+ `(<a\\b[^>]*?\\bhref=["'])${escaped}(["'][^>]*?\\bclass=["']attachment["'])`,
16115
+ "g"
16116
+ );
16117
+ html = html.replace(hrefHrefFirst, `$1${replacement}$2`);
16118
+ return html;
16119
+ }
16120
+
15944
16121
  // src/converters/ndjson-parser.ts
15945
16122
  function parseNdjson(ndjson) {
15946
16123
  const lines = ndjson.trim().split("\n").filter(Boolean);
@@ -16967,11 +17144,11 @@ function computeOutputPath(sourceFile, format, mode, colocatedStyle, baseOutputD
16967
17144
  const ext = FORMAT_EXTENSIONS[format];
16968
17145
  const effectiveName = outputName + (outputNameSuffix ?? "");
16969
17146
  if (mode === "aggregated") {
16970
- return toPosix(path2.join(baseOutputDir, `${effectiveName}${ext}`));
17147
+ return toPosix(path4.join(baseOutputDir, `${effectiveName}${ext}`));
16971
17148
  }
16972
17149
  const normalizedSource = toPosix(sourceFile);
16973
- const dirOfSource = path2.posix.dirname(normalizedSource);
16974
- let baseName = path2.posix.basename(normalizedSource);
17150
+ const dirOfSource = path4.posix.dirname(normalizedSource);
17151
+ let baseName = path4.posix.basename(normalizedSource);
16975
17152
  for (const testExt of TEST_EXTENSIONS) {
16976
17153
  if (baseName.endsWith(testExt)) {
16977
17154
  baseName = baseName.slice(0, -testExt.length);
@@ -16980,9 +17157,9 @@ function computeOutputPath(sourceFile, format, mode, colocatedStyle, baseOutputD
16980
17157
  }
16981
17158
  const fileName = `${baseName}.${effectiveName}${ext}`;
16982
17159
  if (colocatedStyle === "adjacent") {
16983
- return toPosix(path2.posix.join(dirOfSource, fileName));
17160
+ return toPosix(path4.posix.join(dirOfSource, fileName));
16984
17161
  }
16985
- return toPosix(path2.posix.join(baseOutputDir, dirOfSource, fileName));
17162
+ return toPosix(path4.posix.join(baseOutputDir, dirOfSource, fileName));
16986
17163
  }
16987
17164
  function groupTestCasesByOutput(testCases, format, options, logger, outputNameSuffix) {
16988
17165
  const groups = /* @__PURE__ */ new Map();
@@ -17081,6 +17258,7 @@ var ReportGenerator = class {
17081
17258
  mermaidEnabled: options.html?.mermaidEnabled ?? true,
17082
17259
  markdownEnabled: options.html?.markdownEnabled ?? true,
17083
17260
  permalinkBaseUrl: options.html?.permalinkBaseUrl,
17261
+ ticketUrlTemplate: options.html?.ticketUrlTemplate,
17084
17262
  theme: options.html?.theme ?? "default"
17085
17263
  },
17086
17264
  junit: {
@@ -17104,7 +17282,9 @@ var ReportGenerator = class {
17104
17282
  traceUrlTemplate: options.markdown?.traceUrlTemplate,
17105
17283
  includeSourceLinks: options.markdown?.includeSourceLinks ?? true,
17106
17284
  customRenderers: options.markdown?.customRenderers
17107
- }
17285
+ },
17286
+ assetMode: options.assetMode ?? "none",
17287
+ allowMissingAssets: options.allowMissingAssets ?? false
17108
17288
  };
17109
17289
  }
17110
17290
  /**
@@ -17131,6 +17311,16 @@ var ReportGenerator = class {
17131
17311
  const paths = await this.generateFormat(filteredRun, format);
17132
17312
  results.set(format, paths);
17133
17313
  }
17314
+ if (this.options.assetMode === "copy") {
17315
+ const htmlPaths = results.get("html");
17316
+ if (htmlPaths) {
17317
+ for (const htmlPath of htmlPaths) {
17318
+ bundleAssets(htmlPath, {
17319
+ allowMissing: this.options.allowMissingAssets
17320
+ });
17321
+ }
17322
+ }
17323
+ }
17134
17324
  return results;
17135
17325
  }
17136
17326
  /**
@@ -17148,9 +17338,9 @@ var ReportGenerator = class {
17148
17338
  if (groups.size === 0 && this.options.output.mode === "aggregated") {
17149
17339
  const ext = FORMAT_EXTENSIONS[format];
17150
17340
  const effectiveName = this.options.outputName + (outputNameSuffix ?? "");
17151
- const outputPath = toPosix(path2.join(this.options.outputDir, `${effectiveName}${ext}`));
17341
+ const outputPath = toPosix(path4.join(this.options.outputDir, `${effectiveName}${ext}`));
17152
17342
  const content = await this.formatContent(run, format);
17153
- const dir = path2.dirname(outputPath);
17343
+ const dir = path4.dirname(outputPath);
17154
17344
  await fsPromises.mkdir(dir, { recursive: true });
17155
17345
  await this.deps.writeFile(outputPath, content);
17156
17346
  return [outputPath];
@@ -17162,7 +17352,7 @@ var ReportGenerator = class {
17162
17352
  testCases
17163
17353
  };
17164
17354
  const content = await this.formatContent(groupRun, format);
17165
- const dir = path2.dirname(outputPath);
17355
+ const dir = path4.dirname(outputPath);
17166
17356
  await fsPromises.mkdir(dir, { recursive: true });
17167
17357
  await this.deps.writeFile(outputPath, content);
17168
17358
  writtenPaths.push(outputPath);
@@ -17191,7 +17381,8 @@ var ReportGenerator = class {
17191
17381
  syntaxHighlighting: this.options.html.syntaxHighlighting,
17192
17382
  mermaidEnabled: this.options.html.mermaidEnabled,
17193
17383
  markdownEnabled: this.options.html.markdownEnabled,
17194
- permalinkBaseUrl: this.options.html.permalinkBaseUrl
17384
+ permalinkBaseUrl: this.options.html.permalinkBaseUrl,
17385
+ ticketUrlTemplate: this.options.html.ticketUrlTemplate
17195
17386
  });
17196
17387
  return formatter.format(run);
17197
17388
  }
@@ -17256,7 +17447,7 @@ async function generateRunComparison(args) {
17256
17447
  await fsPromises.mkdir(outputDir, { recursive: true });
17257
17448
  for (const format of args.formats) {
17258
17449
  const ext = format === "html" ? ".html" : ".md";
17259
- const outputPath = toPosix(path2.join(outputDir, `${outputName}${ext}`));
17450
+ const outputPath = toPosix(path4.join(outputDir, `${outputName}${ext}`));
17260
17451
  const content = format === "html" ? new RunDiffHtmlFormatter({ title: args.title }).format(diff) : new RunDiffMarkdownFormatter({ title: args.title }).format(diff);
17261
17452
  await fsPromises.writeFile(outputPath, content, "utf8");
17262
17453
  files.push(outputPath);
@@ -17312,6 +17503,9 @@ OPTIONS
17312
17503
  --html-no-mermaid Disable mermaid diagrams in HTML (enabled by default)
17313
17504
  --html-no-markdown Disable markdown parsing in HTML (enabled by default)
17314
17505
  --html-permalink-base-url <url> Base URL for source permalinks in HTML (e.g. "https://github.com/org/repo/blob/main")
17506
+ --html-ticket-url-template <url> URL template for ticket links in HTML (use {ticket} as placeholder)
17507
+ --asset-mode <mode> Asset bundling: "none" (default) or "copy"
17508
+ --allow-missing-assets Warn on missing assets instead of failing
17315
17509
  --stdin Read JSON from stdin instead of file
17316
17510
  --json-summary Print machine-parsable JSON summary
17317
17511
  --baseline <path|auto> Compare baseline file, or auto-pick a prior run for compare
@@ -17391,6 +17585,7 @@ function parseCliArgs(argv) {
17391
17585
  "html-no-mermaid": { type: "boolean", default: false },
17392
17586
  "html-no-markdown": { type: "boolean", default: false },
17393
17587
  "html-permalink-base-url": { type: "string" },
17588
+ "html-ticket-url-template": { type: "string" },
17394
17589
  stdin: { type: "boolean", default: false },
17395
17590
  "json-summary": { type: "boolean", default: false },
17396
17591
  "emit-canonical": { type: "string" },
@@ -17407,6 +17602,8 @@ function parseCliArgs(argv) {
17407
17602
  "webhook-hmac-secret": { type: "string" },
17408
17603
  "webhook-hmac-header": { type: "string" },
17409
17604
  "webhook-hmac-timestamp": { type: "boolean", default: false },
17605
+ "asset-mode": { type: "string", default: "none" },
17606
+ "allow-missing-assets": { type: "boolean", default: false },
17410
17607
  "pr-summary": { type: "boolean", default: false },
17411
17608
  "pr-summary-file": { type: "string" },
17412
17609
  help: { type: "boolean", default: false }
@@ -17516,6 +17713,12 @@ function parseCliArgs(argv) {
17516
17713
  console.error(`Error: --sort-test-cases must be id, source, or none, got "${sortTestCasesRaw}".`);
17517
17714
  process.exit(EXIT_USAGE);
17518
17715
  }
17716
+ const assetModeRaw = values["asset-mode"];
17717
+ const validAssetModes = /* @__PURE__ */ new Set(["none", "copy"]);
17718
+ if (!validAssetModes.has(assetModeRaw)) {
17719
+ console.error(`Error: --asset-mode must be "none" or "copy", got "${assetModeRaw}".`);
17720
+ process.exit(EXIT_USAGE);
17721
+ }
17519
17722
  return {
17520
17723
  subcommand,
17521
17724
  inputFile,
@@ -17541,6 +17744,7 @@ function parseCliArgs(argv) {
17541
17744
  htmlNoMermaid: values["html-no-mermaid"],
17542
17745
  htmlNoMarkdown: values["html-no-markdown"],
17543
17746
  htmlPermalinkBaseUrl: values["html-permalink-base-url"],
17747
+ htmlTicketUrlTemplate: values["html-ticket-url-template"],
17544
17748
  jsonSummary: values["json-summary"],
17545
17749
  emitCanonical: values["emit-canonical"],
17546
17750
  slackWebhook,
@@ -17556,6 +17760,8 @@ function parseCliArgs(argv) {
17556
17760
  webhookHmacSecret: values["webhook-hmac-secret"],
17557
17761
  webhookHmacHeader: values["webhook-hmac-header"] ?? "X-Signature",
17558
17762
  webhookHmacTimestamp: values["webhook-hmac-timestamp"],
17763
+ assetMode: assetModeRaw,
17764
+ allowMissingAssets: values["allow-missing-assets"],
17559
17765
  prSummary: values["pr-summary"],
17560
17766
  prSummaryFile: values["pr-summary-file"]
17561
17767
  };
@@ -17564,27 +17770,27 @@ async function readInput(args) {
17564
17770
  if (args.stdin) {
17565
17771
  return readStdin();
17566
17772
  }
17567
- const filePath = path3.resolve(args.inputFile);
17568
- if (!fs3.existsSync(filePath)) {
17773
+ const filePath = path5.resolve(args.inputFile);
17774
+ if (!fs5.existsSync(filePath)) {
17569
17775
  console.error(`Error: File not found: ${filePath}`);
17570
17776
  process.exit(EXIT_USAGE);
17571
17777
  }
17572
- return fs3.readFileSync(filePath, "utf8");
17778
+ return fs5.readFileSync(filePath, "utf8");
17573
17779
  }
17574
17780
  function readFileInput(filePath) {
17575
- const resolved = path3.resolve(filePath);
17576
- if (!fs3.existsSync(resolved)) {
17781
+ const resolved = path5.resolve(filePath);
17782
+ if (!fs5.existsSync(resolved)) {
17577
17783
  console.error(`Error: File not found: ${resolved}`);
17578
17784
  process.exit(EXIT_USAGE);
17579
17785
  }
17580
- return fs3.readFileSync(resolved, "utf8");
17786
+ return fs5.readFileSync(resolved, "utf8");
17581
17787
  }
17582
17788
  function readStdin() {
17583
- return new Promise((resolve3, reject) => {
17789
+ return new Promise((resolve4, reject) => {
17584
17790
  const chunks = [];
17585
17791
  process.stdin.setEncoding("utf8");
17586
17792
  process.stdin.on("data", (chunk) => chunks.push(chunk));
17587
- process.stdin.on("end", () => resolve3(chunks.join("")));
17793
+ process.stdin.on("end", () => resolve4(chunks.join("")));
17588
17794
  process.stdin.on("error", reject);
17589
17795
  });
17590
17796
  }
@@ -17710,14 +17916,14 @@ function tryNormalizeRunFromText(text, args) {
17710
17916
  }
17711
17917
  }
17712
17918
  function listBaselineCandidates(currentFile, args) {
17713
- const baselineDir = path3.resolve(args.baselineDir ?? path3.dirname(currentFile));
17714
- const currentResolved = path3.resolve(currentFile);
17715
- if (!fs3.existsSync(baselineDir)) {
17919
+ const baselineDir = path5.resolve(args.baselineDir ?? path5.dirname(currentFile));
17920
+ const currentResolved = path5.resolve(currentFile);
17921
+ if (!fs5.existsSync(baselineDir)) {
17716
17922
  console.error(`Error: baseline directory not found: ${baselineDir}`);
17717
17923
  process.exit(EXIT_USAGE);
17718
17924
  }
17719
- const entries = fs3.readdirSync(baselineDir, { withFileTypes: true });
17720
- return entries.filter((entry) => entry.isFile()).map((entry) => path3.join(baselineDir, entry.name)).filter((candidate) => path3.resolve(candidate) !== currentResolved).filter(
17925
+ const entries = fs5.readdirSync(baselineDir, { withFileTypes: true });
17926
+ return entries.filter((entry) => entry.isFile()).map((entry) => path5.join(baselineDir, entry.name)).filter((candidate) => path5.resolve(candidate) !== currentResolved).filter(
17721
17927
  (candidate) => args.inputType === "ndjson" ? candidate.endsWith(".ndjson") : candidate.endsWith(".json")
17722
17928
  );
17723
17929
  }
@@ -17725,14 +17931,14 @@ function resolveBaselineAuto(currentFile, currentRun, args) {
17725
17931
  const candidates = listBaselineCandidates(currentFile, args);
17726
17932
  const comparable = [];
17727
17933
  for (const candidate of candidates) {
17728
- const run = tryNormalizeRunFromText(fs3.readFileSync(candidate, "utf8"), args);
17934
+ const run = tryNormalizeRunFromText(fs5.readFileSync(candidate, "utf8"), args);
17729
17935
  if (run) {
17730
17936
  comparable.push({ file: candidate, run });
17731
17937
  }
17732
17938
  }
17733
17939
  if (comparable.length === 0) {
17734
17940
  console.error(
17735
- `Error: no compatible baseline files found in ${path3.resolve(args.baselineDir ?? path3.dirname(currentFile))}.`
17941
+ `Error: no compatible baseline files found in ${path5.resolve(args.baselineDir ?? path5.dirname(currentFile))}.`
17736
17942
  );
17737
17943
  process.exit(EXIT_USAGE);
17738
17944
  }
@@ -17813,9 +18019,9 @@ async function main() {
17813
18019
  process.exit(EXIT_SCHEMA_VALIDATION);
17814
18020
  }
17815
18021
  if (args.emitCanonical) {
17816
- const outPath = path3.resolve(args.emitCanonical);
17817
- fs3.mkdirSync(path3.dirname(outPath), { recursive: true });
17818
- fs3.writeFileSync(outPath, JSON.stringify(run, null, 2), "utf8");
18022
+ const outPath = path5.resolve(args.emitCanonical);
18023
+ fs5.mkdirSync(path5.dirname(outPath), { recursive: true });
18024
+ fs5.writeFileSync(outPath, JSON.stringify(run, null, 2), "utf8");
17819
18025
  }
17820
18026
  try {
17821
18027
  const result = await generateReports(run, args);
@@ -17871,9 +18077,9 @@ ${msg}`);
17871
18077
  }
17872
18078
  const run = data;
17873
18079
  if (args.emitCanonical) {
17874
- const outPath = path3.resolve(args.emitCanonical);
17875
- fs3.mkdirSync(path3.dirname(outPath), { recursive: true });
17876
- fs3.writeFileSync(outPath, JSON.stringify(run, null, 2), "utf8");
18080
+ const outPath = path5.resolve(args.emitCanonical);
18081
+ fs5.mkdirSync(path5.dirname(outPath), { recursive: true });
18082
+ fs5.writeFileSync(outPath, JSON.stringify(run, null, 2), "utf8");
17877
18083
  }
17878
18084
  try {
17879
18085
  const result = await generateReports(run, args);
@@ -17928,9 +18134,9 @@ ${msg}`);
17928
18134
  process.exit(EXIT_CANONICAL_VALIDATION);
17929
18135
  }
17930
18136
  if (args.emitCanonical) {
17931
- const outPath = path3.resolve(args.emitCanonical);
17932
- fs3.mkdirSync(path3.dirname(outPath), { recursive: true });
17933
- fs3.writeFileSync(outPath, JSON.stringify(canonical, null, 2), "utf8");
18137
+ const outPath = path5.resolve(args.emitCanonical);
18138
+ fs5.mkdirSync(path5.dirname(outPath), { recursive: true });
18139
+ fs5.writeFileSync(outPath, JSON.stringify(canonical, null, 2), "utf8");
17934
18140
  }
17935
18141
  try {
17936
18142
  const result = await generateReports(canonical, args, droppedMissingStory);
@@ -17987,13 +18193,13 @@ async function dispatchNotifications(run, args) {
17987
18193
  }
17988
18194
  function runHistoryPipeline(run, args) {
17989
18195
  if (!args.historyFile) return;
17990
- const historyPath = path3.resolve(args.historyFile);
18196
+ const historyPath = path5.resolve(args.historyFile);
17991
18197
  const store = loadHistory(
17992
18198
  { filePath: historyPath },
17993
18199
  {
17994
18200
  readFile: (p) => {
17995
18201
  try {
17996
- return fs3.readFileSync(p, "utf8");
18202
+ return fs5.readFileSync(p, "utf8");
17997
18203
  } catch {
17998
18204
  return void 0;
17999
18205
  }
@@ -18006,11 +18212,11 @@ function runHistoryPipeline(run, args) {
18006
18212
  run,
18007
18213
  maxRuns: args.maxHistoryRuns
18008
18214
  });
18009
- const dir = path3.dirname(historyPath);
18010
- fs3.mkdirSync(dir, { recursive: true });
18215
+ const dir = path5.dirname(historyPath);
18216
+ fs5.mkdirSync(dir, { recursive: true });
18011
18217
  saveHistory(
18012
18218
  { filePath: historyPath, store: updated },
18013
- { writeFile: (p, content) => fs3.writeFileSync(p, content, "utf8") }
18219
+ { writeFile: (p, content) => fs5.writeFileSync(p, content, "utf8") }
18014
18220
  );
18015
18221
  let metricsCount = 0;
18016
18222
  for (const testId of Object.keys(updated.tests)) {
@@ -18040,8 +18246,11 @@ async function generateReports(run, args, _droppedMissingStory = 0) {
18040
18246
  syntaxHighlighting: !args.htmlNoSyntaxHighlighting,
18041
18247
  mermaidEnabled: !args.htmlNoMermaid,
18042
18248
  markdownEnabled: !args.htmlNoMarkdown,
18043
- permalinkBaseUrl: args.htmlPermalinkBaseUrl
18044
- }
18249
+ permalinkBaseUrl: args.htmlPermalinkBaseUrl,
18250
+ ticketUrlTemplate: args.htmlTicketUrlTemplate
18251
+ },
18252
+ assetMode: args.assetMode,
18253
+ allowMissingAssets: args.allowMissingAssets
18045
18254
  });
18046
18255
  const resultMap = await generator.generate(run);
18047
18256
  const files = [];
@@ -18103,9 +18312,9 @@ function printResult(result, args, startMs, droppedMissingStory = 0) {
18103
18312
  function printCompareResult(result, args, startMs) {
18104
18313
  const durationMs = Date.now() - startMs;
18105
18314
  if (result.prSummary && args.prSummaryFile) {
18106
- const outputPath = path3.resolve(args.prSummaryFile);
18107
- fs3.mkdirSync(path3.dirname(outputPath), { recursive: true });
18108
- fs3.writeFileSync(outputPath, result.prSummary, "utf8");
18315
+ const outputPath = path5.resolve(args.prSummaryFile);
18316
+ fs5.mkdirSync(path5.dirname(outputPath), { recursive: true });
18317
+ fs5.writeFileSync(outputPath, result.prSummary, "utf8");
18109
18318
  }
18110
18319
  if (args.jsonSummary) {
18111
18320
  console.log(