as-test 1.2.0 → 1.4.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.
@@ -7,6 +7,12 @@ import { getCliVersion } from "../util.js";
7
7
  import { buildWebRunnerSource } from "./web-runner-source.js";
8
8
  const TARGETS = ["wasi", "bindings", "web"];
9
9
  const EXAMPLE_MODES = ["minimal", "full", "none"];
10
+ const FEATURE_KEYS = ["coverage", "tryAs"];
11
+ const FEATURE_LABELS = {
12
+ coverage: "coverage (runtime coverage points + report)",
13
+ tryAs:
14
+ "try-as (try/catch/finally + toThrow assertions + throwable rewriting)",
15
+ };
10
16
  export async function init(rawArgs) {
11
17
  const options = parseInitArgs(rawArgs);
12
18
  const rl = options.yes
@@ -23,6 +29,10 @@ export async function init(rawArgs) {
23
29
  target: options.target ?? "wasi",
24
30
  example: options.example ?? "minimal",
25
31
  fuzzExample: options.fuzzExample ?? false,
32
+ features: resolveFeatures(options.features, {
33
+ coverage: false,
34
+ tryAs: false,
35
+ }),
26
36
  installDependenciesNow: options.install ?? false,
27
37
  }
28
38
  : await runInteractiveOnboarding(options, rl);
@@ -35,6 +45,7 @@ export async function init(rawArgs) {
35
45
  answers.target,
36
46
  answers.example,
37
47
  answers.fuzzExample,
48
+ answers.features,
38
49
  answers.installDependenciesNow,
39
50
  );
40
51
  if (!options.yes) {
@@ -49,6 +60,7 @@ export async function init(rawArgs) {
49
60
  answers.target,
50
61
  answers.example,
51
62
  answers.fuzzExample,
63
+ answers.features,
52
64
  options.force,
53
65
  );
54
66
  printSummary(summary);
@@ -67,6 +79,7 @@ export async function init(rawArgs) {
67
79
  }
68
80
  function parseInitArgs(rawArgs) {
69
81
  const options = {
82
+ features: {},
70
83
  yes: false,
71
84
  force: false,
72
85
  dir: ".",
@@ -95,6 +108,30 @@ function parseInitArgs(rawArgs) {
95
108
  options.fuzzExample = false;
96
109
  continue;
97
110
  }
111
+ if (arg == "--enable" || arg == "--disable") {
112
+ const next = rawArgs[i + 1];
113
+ if (!next || next.startsWith("-")) {
114
+ throw new Error(`${arg} requires a value: coverage|try-as`);
115
+ }
116
+ for (const name of splitInitFeatureList(next)) {
117
+ applyInitFeatureToggle(options.features, name, arg == "--enable");
118
+ }
119
+ i++;
120
+ continue;
121
+ }
122
+ if (arg.startsWith("--enable=") || arg.startsWith("--disable=")) {
123
+ const eq = arg.indexOf("=");
124
+ const flag = arg.slice(0, eq);
125
+ const value = arg.slice(eq + 1);
126
+ const names = splitInitFeatureList(value);
127
+ if (!names.length) {
128
+ throw new Error(`${flag} requires a value: coverage|try-as`);
129
+ }
130
+ for (const name of names) {
131
+ applyInitFeatureToggle(options.features, name, flag == "--enable");
132
+ }
133
+ continue;
134
+ }
98
135
  if (arg == "--target") {
99
136
  const next = rawArgs[i + 1];
100
137
  if (next && !next.startsWith("-")) {
@@ -158,11 +195,37 @@ function parseInitArgs(rawArgs) {
158
195
  }
159
196
  if (positional.length > 0) {
160
197
  throw new Error(
161
- `Unknown init argument(s): ${positional.join(", ")}. Usage: init [dir] [--target wasi|bindings|web] [--example minimal|full|none] [--fuzz-example|--no-fuzz-example] [--install] [--yes] [--force] [--dir <path>]`,
198
+ `Unknown init argument(s): ${positional.join(", ")}. Usage: init [dir] [--target wasi|bindings|web] [--example minimal|full|none] [--fuzz-example|--no-fuzz-example] [--enable coverage|try-as] [--disable coverage|try-as] [--install] [--yes] [--force] [--dir <path>]`,
162
199
  );
163
200
  }
164
201
  return options;
165
202
  }
203
+ function splitInitFeatureList(value) {
204
+ return value
205
+ .split(",")
206
+ .map((part) => part.trim())
207
+ .filter((part) => part.length > 0);
208
+ }
209
+ function applyInitFeatureToggle(out, rawFeature, enabled) {
210
+ const key = rawFeature.trim().toLowerCase();
211
+ if (key == "coverage") {
212
+ out.coverage = enabled;
213
+ return;
214
+ }
215
+ if (key == "try-as" || key == "try_as" || key == "tryas") {
216
+ out.tryAs = enabled;
217
+ return;
218
+ }
219
+ throw new Error(
220
+ `unknown feature "${rawFeature}". Supported features: coverage, try-as`,
221
+ );
222
+ }
223
+ function resolveFeatures(overrides, defaults) {
224
+ return {
225
+ coverage: overrides.coverage ?? defaults.coverage,
226
+ tryAs: overrides.tryAs ?? defaults.tryAs,
227
+ };
228
+ }
166
229
  async function runInteractiveOnboarding(options, face) {
167
230
  printOnboardingIntro();
168
231
  const acknowledged = await askYesNo(
@@ -231,6 +294,25 @@ async function runInteractiveOnboarding(options, face) {
231
294
  if (options.target || onboardingMode == "quick") {
232
295
  printPromptAndSelectionLine("Build target", target);
233
296
  }
297
+ const featureDefaults = { coverage: false, tryAs: false };
298
+ const explicitFeatures =
299
+ options.features.coverage !== undefined ||
300
+ options.features.tryAs !== undefined;
301
+ const features =
302
+ explicitFeatures || onboardingMode == "quick"
303
+ ? resolveFeatures(options.features, featureDefaults)
304
+ : await askMultiToggle(
305
+ "Features (↑/↓ to move, space to toggle, enter to confirm)",
306
+ FEATURE_KEYS.map((key) => ({
307
+ value: key,
308
+ label: FEATURE_LABELS[key],
309
+ })),
310
+ face,
311
+ resolveFeatures(options.features, featureDefaults),
312
+ );
313
+ if (explicitFeatures || onboardingMode == "quick") {
314
+ printPromptAndSelectionLine("Features", formatFeatureSelection(features));
315
+ }
234
316
  const example =
235
317
  options.example ??
236
318
  (onboardingMode == "quick"
@@ -275,9 +357,16 @@ async function runInteractiveOnboarding(options, face) {
275
357
  target,
276
358
  example,
277
359
  fuzzExample,
360
+ features,
278
361
  installDependenciesNow,
279
362
  };
280
363
  }
364
+ function formatFeatureSelection(features) {
365
+ const labels = [];
366
+ if (features.coverage) labels.push("coverage");
367
+ if (features.tryAs) labels.push("try-as");
368
+ return labels.length ? labels.join(", ") : "none";
369
+ }
281
370
  function printOnboardingHeader() {
282
371
  // console.log(
283
372
  // chalk.bold.cyan(
@@ -375,7 +464,7 @@ function isTarget(value) {
375
464
  function isExampleMode(value) {
376
465
  return EXAMPLE_MODES.includes(value);
377
466
  }
378
- function printPlan(root, target, example, fuzzExample, install) {
467
+ function printPlan(root, target, example, fuzzExample, features, install) {
379
468
  const displayRoot = () => {
380
469
  const rel = path.relative(process.cwd(), root).split(path.sep).join("/");
381
470
  if (!rel || rel == ".") return "./";
@@ -483,6 +572,9 @@ function printPlan(root, target, example, fuzzExample, install) {
483
572
  console.log(
484
573
  "│" + chalk.dim(` - Fuzzer example: ${fuzzExample ? "yes" : "no"}`),
485
574
  );
575
+ console.log(
576
+ "│" + chalk.dim(` - Features: ${formatFeatureSelection(features)}`),
577
+ );
486
578
  console.log("│" + chalk.dim(` - Directory: ${displayRoot()}`));
487
579
  console.log(
488
580
  "│" + chalk.dim(` - Install dependencies: ${install ? "yes" : "no"}`),
@@ -494,7 +586,7 @@ function printPlan(root, target, example, fuzzExample, install) {
494
586
  }
495
587
  console.log("│");
496
588
  }
497
- function applyInit(root, target, example, fuzzExample, force) {
589
+ function applyInit(root, target, example, fuzzExample, features, force) {
498
590
  const summary = {
499
591
  created: [],
500
592
  updated: [],
@@ -518,13 +610,16 @@ function applyInit(root, target, example, fuzzExample, force) {
518
610
  summary,
519
611
  "assembly/tsconfig.json",
520
612
  );
613
+ const featuresArray = [];
614
+ if (features.tryAs) featuresArray.push("try-as");
521
615
  const configPath = path.join(root, "as-test.config.json");
522
616
  const config = {
523
617
  $schema: "node_modules/as-test/as-test.config.schema.json",
524
618
  input: ["assembly/__tests__/*.spec.ts"],
525
619
  output: ".as-test/",
526
620
  config: "none",
527
- coverage: false,
621
+ coverage: features.coverage,
622
+ features: featuresArray,
528
623
  env: {},
529
624
  ...(fuzzExample
530
625
  ? {
@@ -650,15 +745,15 @@ function applyInit(root, target, example, fuzzExample, force) {
650
745
  if (!devDependencies["as-test"]) {
651
746
  devDependencies["as-test"] = "^" + getCliVersion();
652
747
  }
653
- if (!hasDependency(pkg, "json-as")) {
654
- devDependencies["json-as"] = "^1.3.5";
655
- }
656
748
  if (!hasDependency(pkg, "assemblyscript")) {
657
749
  devDependencies["assemblyscript"] = "^0.28.9";
658
750
  }
659
751
  if (target == "wasi" && !devDependencies["@assemblyscript/wasi-shim"]) {
660
752
  devDependencies["@assemblyscript/wasi-shim"] = "^0.1.0";
661
753
  }
754
+ if (features.tryAs && !hasDependency(pkg, "try-as")) {
755
+ devDependencies["try-as"] = "^1.1.0";
756
+ }
662
757
  if (target == "bindings" && !pkg.type) {
663
758
  pkg.type = "module";
664
759
  }
@@ -814,6 +909,21 @@ async function askMenuChoice(label, choices, face, fallback) {
814
909
  }
815
910
  return askMenuChoiceWithArrows(label, choices, face, fallbackValue);
816
911
  }
912
+ async function askMultiToggle(label, choices, face, initial) {
913
+ if (!face) return { ...initial };
914
+ if (canUseArrowMenu(face)) {
915
+ return askMultiToggleWithArrows(label, choices, face, initial);
916
+ }
917
+ const result = { ...initial };
918
+ for (const choice of choices) {
919
+ result[choice.value] = await askYesNo(
920
+ `${label} — enable ${choice.label}?`,
921
+ face,
922
+ initial[choice.value],
923
+ );
924
+ }
925
+ return result;
926
+ }
817
927
  async function askYesNo(label, face, fallback) {
818
928
  if (!face) return fallback;
819
929
  if (canUseArrowMenu(face)) {
@@ -887,7 +997,7 @@ async function askMenuChoiceWithArrows(label, choices, face, fallback) {
887
997
  }
888
998
  const totalLineCount = Math.max(lines.length, renderedLineCount);
889
999
  for (let i = 0; i < totalLineCount; i++) {
890
- process.stdout.write("\x1b[2K");
1000
+ process.stdout.write("\r\x1b[2K");
891
1001
  if (i < lines.length) {
892
1002
  process.stdout.write(lines[i]);
893
1003
  }
@@ -987,6 +1097,141 @@ async function askMenuChoiceWithArrows(label, choices, face, fallback) {
987
1097
  writeLines(menuLines());
988
1098
  });
989
1099
  }
1100
+ async function askMultiToggleWithArrows(label, choices, face, initial) {
1101
+ const stdin = process.stdin;
1102
+ const stdout = process.stdout;
1103
+ const selected = { ...initial };
1104
+ let cursorIndex = 0;
1105
+ let renderedLineCount = 0;
1106
+ const previousRawMode = Boolean(stdin.isRaw);
1107
+ const lineWidth = Math.max(20, (stdout.columns ?? 80) - 2);
1108
+ const clamp = (value, max) => {
1109
+ if (value.length <= max) return value;
1110
+ if (max <= 1) return value.slice(0, max);
1111
+ return `${value.slice(0, max - 1)}…`;
1112
+ };
1113
+ const titleLine = () =>
1114
+ chalk.bold.blue(`◆ ${clamp(label, Math.max(8, lineWidth - 3))}`);
1115
+ const menuLines = () => {
1116
+ const lines = [titleLine()];
1117
+ for (let i = 0; i < choices.length; i++) {
1118
+ const choice = choices[i];
1119
+ const isOn = Boolean(selected[choice.value]);
1120
+ const cursor = i == cursorIndex ? chalk.blue("›") : " ";
1121
+ const marker = isOn ? chalk.blue("●") : chalk.dim("○");
1122
+ const text = clamp(choice.label, Math.max(8, lineWidth - 6));
1123
+ const painted = i == cursorIndex ? chalk.bold(text) : text;
1124
+ lines.push(`│ ${cursor} ${marker} ${painted}`);
1125
+ }
1126
+ lines.push("│");
1127
+ return lines;
1128
+ };
1129
+ const collapsedLines = () => {
1130
+ const enabled = choices
1131
+ .filter((choice) => selected[choice.value])
1132
+ .map((choice) => choice.value);
1133
+ const summary = enabled.length ? enabled.join(", ") : "none";
1134
+ return [`│ ${chalk.gray(clamp(summary, Math.max(8, lineWidth - 4)))}`];
1135
+ };
1136
+ const writeLines = (lines) => {
1137
+ if (renderedLineCount > 0) {
1138
+ process.stdout.write(`\x1b[${renderedLineCount}A`);
1139
+ }
1140
+ const totalLineCount = Math.max(lines.length, renderedLineCount);
1141
+ for (let i = 0; i < totalLineCount; i++) {
1142
+ process.stdout.write("\r\x1b[2K");
1143
+ if (i < lines.length) {
1144
+ process.stdout.write(lines[i]);
1145
+ }
1146
+ process.stdout.write("\n");
1147
+ }
1148
+ renderedLineCount = lines.length;
1149
+ };
1150
+ const collapseInPlace = () => {
1151
+ const lines = collapsedLines();
1152
+ if (renderedLineCount > 0) {
1153
+ process.stdout.write(`\x1b[${renderedLineCount}A`);
1154
+ }
1155
+ const totalLineCount = Math.max(renderedLineCount, lines.length);
1156
+ for (let i = 0; i < totalLineCount; i++) {
1157
+ process.stdout.write("\r\x1b[2K");
1158
+ if (i < lines.length) {
1159
+ process.stdout.write(lines[i]);
1160
+ }
1161
+ process.stdout.write("\n");
1162
+ }
1163
+ const extraLines = totalLineCount - lines.length;
1164
+ if (extraLines > 0) {
1165
+ process.stdout.write(`\x1b[${extraLines}A`);
1166
+ }
1167
+ renderedLineCount = 0;
1168
+ };
1169
+ return new Promise((resolve, reject) => {
1170
+ let settled = false;
1171
+ const cleanup = () => {
1172
+ stdin.off("data", onData);
1173
+ if (stdin.isTTY) {
1174
+ stdin.setRawMode(previousRawMode);
1175
+ }
1176
+ const isClosed = Boolean(face.closed);
1177
+ if (!isClosed) {
1178
+ try {
1179
+ face.resume();
1180
+ } catch {
1181
+ // noop: readline may already be closed during shutdown/cancel paths.
1182
+ }
1183
+ }
1184
+ };
1185
+ const finish = () => {
1186
+ if (settled) return;
1187
+ settled = true;
1188
+ collapseInPlace();
1189
+ cleanup();
1190
+ resolve(selected);
1191
+ };
1192
+ const fail = (error) => {
1193
+ if (settled) return;
1194
+ settled = true;
1195
+ cleanup();
1196
+ reject(error);
1197
+ };
1198
+ const onData = (chunk) => {
1199
+ const input = typeof chunk == "string" ? chunk : chunk.toString("utf8");
1200
+ if (!input.length) return;
1201
+ if (input == "\u0003") {
1202
+ fail(new Error(chalk.bold.red("◆ Cancelled")));
1203
+ return;
1204
+ }
1205
+ if (input == "\x1b[A" || input == "\x1bOA") {
1206
+ cursorIndex = (cursorIndex - 1 + choices.length) % choices.length;
1207
+ writeLines(menuLines());
1208
+ return;
1209
+ }
1210
+ if (input == "\x1b[B" || input == "\x1bOB") {
1211
+ cursorIndex = (cursorIndex + 1) % choices.length;
1212
+ writeLines(menuLines());
1213
+ return;
1214
+ }
1215
+ if (input == " ") {
1216
+ const key = choices[cursorIndex].value;
1217
+ selected[key] = !selected[key];
1218
+ writeLines(menuLines());
1219
+ return;
1220
+ }
1221
+ if (input == "\r" || input == "\n") {
1222
+ finish();
1223
+ return;
1224
+ }
1225
+ };
1226
+ face.pause();
1227
+ if (stdin.isTTY) {
1228
+ stdin.setRawMode(true);
1229
+ }
1230
+ stdin.resume();
1231
+ stdin.on("data", onData);
1232
+ writeLines(menuLines());
1233
+ });
1234
+ }
990
1235
  function installDependencies(root) {
991
1236
  const install = resolveInstallCommand(root);
992
1237
  console.log(
@@ -9,6 +9,7 @@ import {
9
9
  getExec,
10
10
  loadConfig,
11
11
  resolveArtifactPath,
12
+ resolveSnapshotPath,
12
13
  resolveSpecRelativePath,
13
14
  tokenizeCommand,
14
15
  } from "../util.js";
@@ -33,12 +34,7 @@ class SnapshotStore {
33
34
  this.failed = 0;
34
35
  this.warnedMissing = new Set();
35
36
  this.specBasename = path.basename(specFile);
36
- const dir = path.join(process.cwd(), snapshotDir);
37
- const relative = resolveSpecRelativePath(specFile, inputPatterns).replace(
38
- /\.ts$/i,
39
- ".snap",
40
- );
41
- this.filePath = path.join(dir, relative);
37
+ this.filePath = resolveSnapshotPath(specFile, snapshotDir, inputPatterns);
42
38
  const sourcePath = existsSync(this.filePath) ? this.filePath : null;
43
39
  const loaded = sourcePath
44
40
  ? readSnapshotFile(sourcePath, specFile)
@@ -543,7 +539,7 @@ function collectReadableLogs(suites) {
543
539
  const suiteAny = suite;
544
540
  const logs = Array.isArray(suiteAny.logs) ? suiteAny.logs : [];
545
541
  for (const log of logs) {
546
- const value = String(log.value ?? log.message ?? "");
542
+ const value = String(log.text ?? log.value ?? log.message ?? "");
547
543
  if (value.length) out.push(value);
548
544
  }
549
545
  const childSuites = Array.isArray(suiteAny.suites) ? suiteAny.suites : [];
@@ -551,6 +547,133 @@ function collectReadableLogs(suites) {
551
547
  }
552
548
  return out;
553
549
  }
550
+ // Walk a suite tree, accumulating each suite's `log()` output keyed by the
551
+ // describe/test description path it was emitted under.
552
+ function walkSuiteLogs(suites, pathParts, out) {
553
+ for (const suite of suites) {
554
+ const suiteAny = suite;
555
+ const description = String(suiteAny.description ?? "");
556
+ const nextPath = description.length
557
+ ? [...pathParts, description]
558
+ : pathParts;
559
+ const logs = Array.isArray(suiteAny.logs) ? suiteAny.logs : [];
560
+ const lines = logs.map((log) =>
561
+ String(log.text ?? log.value ?? log.message ?? ""),
562
+ );
563
+ if (lines.length) out.push({ path: nextPath, lines });
564
+ const childSuites = Array.isArray(suiteAny.suites) ? suiteAny.suites : [];
565
+ walkSuiteLogs(childSuites, nextPath, out);
566
+ }
567
+ }
568
+ // Group every captured log across all file reports into a per-spec tree (one
569
+ // entry per `log()` call). Feeds the process-wide collector that backs the
570
+ // aggregated `latest.log` and the `--show-logs` dump.
571
+ function collectGroupedLogs(reports) {
572
+ let count = 0;
573
+ const groups = [];
574
+ for (const report of reports) {
575
+ const reportAny = report;
576
+ const suites = Array.isArray(reportAny.suites) ? reportAny.suites : [];
577
+ const entries = [];
578
+ walkSuiteLogs(suites, [], entries);
579
+ if (!entries.length) continue;
580
+ for (const entry of entries) count += entry.lines.length;
581
+ groups.push({ file: String(reportAny.file ?? "unknown"), entries });
582
+ }
583
+ return { count, groups };
584
+ }
585
+ // Process-lived collector backing the aggregated `latest.log`. Keyed by spec
586
+ // file, then by mode label, holding that mode's flat list of `log()` lines.
587
+ // Persisting across run() calls lets a multi-mode run accumulate every mode
588
+ // before the file is rendered, so identical output can be de-duplicated.
589
+ const collectedLogsBySpec = new Map();
590
+ // Clear the collector. Useful for watch mode, where each cycle should start
591
+ // fresh rather than accumulate stale specs.
592
+ export function resetCollectedLogs() {
593
+ collectedLogsBySpec.clear();
594
+ }
595
+ function recordModeLogs(modeLabel, groups) {
596
+ for (const group of groups) {
597
+ const lines = [];
598
+ for (const entry of group.entries) lines.push(...entry.lines);
599
+ if (!lines.length) continue;
600
+ let byMode = collectedLogsBySpec.get(group.file);
601
+ if (!byMode) {
602
+ byMode = new Map();
603
+ collectedLogsBySpec.set(group.file, byMode);
604
+ }
605
+ byMode.set(modeLabel, lines);
606
+ }
607
+ }
608
+ // Render the collected logs as the `latest.log` body. Within a spec, modes that
609
+ // produced byte-identical output are merged into one block tagged with every
610
+ // mode that emitted it:
611
+ //
612
+ // [LOG] log.spec.ts (node:bindings, node:wasi):
613
+ //
614
+ // {"a":1}
615
+ // ...
616
+ //
617
+ // `count` is the number of de-duplicated `log()` calls (one entry per call —
618
+ // stringify escapes newlines, so a call is a single line), not counting the
619
+ // same call again per mode.
620
+ function renderCollectedLogs() {
621
+ const blocks = [];
622
+ let count = 0;
623
+ const specs = [...collectedLogsBySpec.keys()].sort((a, b) =>
624
+ a.localeCompare(b),
625
+ );
626
+ for (const spec of specs) {
627
+ const byMode = collectedLogsBySpec.get(spec);
628
+ // Group modes by identical content so duplicate output collapses into one
629
+ // block; `calls` is that block's log() count, tallied once regardless of
630
+ // how many modes produced it.
631
+ const byContent = new Map();
632
+ for (const [mode, lines] of byMode) {
633
+ const content = lines.join("\n");
634
+ const existing = byContent.get(content);
635
+ if (existing) existing.modes.push(mode);
636
+ else byContent.set(content, { modes: [mode], calls: lines.length });
637
+ }
638
+ for (const [content, { modes, calls }] of byContent) {
639
+ const named = modes.filter((mode) => mode !== "default").sort();
640
+ const suffix = named.length ? ` (${named.join(", ")})` : "";
641
+ count += calls;
642
+ blocks.push(
643
+ `[LOG] ${formatSpecDisplayPath(spec)}${suffix}:\n\n${content}`,
644
+ );
645
+ }
646
+ }
647
+ return { text: blocks.length ? blocks.join("\n\n") + "\n" : "", count };
648
+ }
649
+ // Render the collector and (re)write the single aggregated `latest.log` at the
650
+ // base (un-mode-qualified) logs dir, so every mode shares one file. Returns the
651
+ // resulting LogSummary. Called by run() after recording its own logs, so the
652
+ // last run() of a multi-mode pass leaves a file covering — and de-duplicating —
653
+ // every mode.
654
+ function flushLatestLog(baseLogsDir) {
655
+ const rendered = renderCollectedLogs();
656
+ if (rendered.count <= 0)
657
+ return { count: 0, file: null, groups: [], text: "" };
658
+ if (!baseLogsDir || baseLogsDir === "none") {
659
+ return {
660
+ count: rendered.count,
661
+ file: null,
662
+ groups: [],
663
+ text: rendered.text,
664
+ };
665
+ }
666
+ const logRoot = path.join(process.cwd(), baseLogsDir);
667
+ if (!existsSync(logRoot)) mkdirSync(logRoot, { recursive: true });
668
+ const latestLogPath = path.join(logRoot, "latest.log");
669
+ writeFileSync(latestLogPath, rendered.text);
670
+ return {
671
+ count: rendered.count,
672
+ file: path.relative(process.cwd(), latestLogPath) || latestLogPath,
673
+ groups: [],
674
+ text: rendered.text,
675
+ };
676
+ }
554
677
  export async function run(
555
678
  flags = {},
556
679
  configPath = DEFAULT_CONFIG_PATH,
@@ -766,6 +889,7 @@ export async function run(
766
889
  } finally {
767
890
  await ownedWebSession?.close();
768
891
  }
892
+ const groupedLogs = collectGroupedLogs(reports);
769
893
  if (config.logs && config.logs != "none") {
770
894
  const logRoot = path.join(process.cwd(), config.logs);
771
895
  if (!existsSync(logRoot)) {
@@ -786,6 +910,14 @@ export async function run(
786
910
  );
787
911
  }
788
912
  }
913
+ // Record this run's logs (tagged with its mode) into the process-wide
914
+ // collector, then rewrite the single aggregated `latest.log` covering every
915
+ // mode seen so far. The collector persists across run() calls, so the last
916
+ // run() of a multi-mode pass produces the complete, de-duplicated file. The
917
+ // file lives at the base (un-mode-qualified) logs dir — `loadedConfig.logs`
918
+ // before `applyMode` appended the per-mode subdirectory.
919
+ recordModeLogs(options.modeName ?? "default", groupedLogs.groups);
920
+ const logSummary = flushLatestLog(loadedConfig.logs);
789
921
  const stats = collectRunStats(reports);
790
922
  if (options.fileSummaryTotal != undefined) {
791
923
  applyConfiguredFileTotalToStats(stats, options.fileSummaryTotal);
@@ -824,6 +956,8 @@ export async function run(
824
956
  showCoverage,
825
957
  showCoverageAll: Boolean(flags.showCoverageAll),
826
958
  verbose: Boolean(flags.verbose),
959
+ showLogs: Boolean(flags.showLogs),
960
+ logSummary,
827
961
  buildTime,
828
962
  snapshotSummary,
829
963
  coverageSummary,
@@ -849,6 +983,7 @@ export async function run(
849
983
  snapshotSummary,
850
984
  coverageSummary,
851
985
  reports,
986
+ logSummary,
852
987
  };
853
988
  }
854
989
  function applyConfiguredFileTotalToStats(stats, fileSummaryTotal) {
@@ -2593,10 +2728,27 @@ function readFileReport(stats, fileReport) {
2593
2728
  const buildCommand = String(fileReportAny.buildCommand ?? "");
2594
2729
  let fileVerdict = "none";
2595
2730
  for (const suite of suites) {
2596
- fileVerdict = mergeVerdict(
2597
- fileVerdict,
2598
- readSuite(stats, suite, file, modeName, runCommand, buildCommand),
2731
+ const suiteVerdict = readSuite(
2732
+ stats,
2733
+ suite,
2734
+ file,
2735
+ modeName,
2736
+ runCommand,
2737
+ buildCommand,
2599
2738
  );
2739
+ fileVerdict = mergeVerdict(fileVerdict, suiteVerdict);
2740
+ // Record each failed top-level suite once. The failure summary recurses into
2741
+ // it to find every failed assertion (so nested failures aren't pushed again,
2742
+ // and a top-level it()/test() failure is captured too).
2743
+ if (suiteVerdict == "fail") {
2744
+ stats.failedEntries.push({
2745
+ ...suite,
2746
+ file,
2747
+ modeName,
2748
+ runCommand,
2749
+ buildCommand,
2750
+ });
2751
+ }
2600
2752
  }
2601
2753
  if (fileVerdict == "fail") {
2602
2754
  stats.failedFiles++;
@@ -2608,7 +2760,6 @@ function readFileReport(stats, fileReport) {
2608
2760
  }
2609
2761
  function readSuite(stats, suite, file, modeName, runCommand, buildCommand) {
2610
2762
  const suiteAny = suite;
2611
- const kind = String(suiteAny.kind ?? "");
2612
2763
  let verdict = normalizeVerdict(suiteAny.verdict);
2613
2764
  const time = suiteAny.time;
2614
2765
  const start = Number(time?.start ?? 0);
@@ -2633,27 +2784,11 @@ function readSuite(stats, suite, file, modeName, runCommand, buildCommand) {
2633
2784
  stats.skippedTests++;
2634
2785
  }
2635
2786
  }
2636
- if (isTestCaseSuiteKind(kind)) {
2637
- if (!subSuites.length && !tests.length) {
2638
- if (verdict == "fail") {
2639
- stats.failedTests++;
2640
- } else if (verdict == "ok") {
2641
- stats.passedTests++;
2642
- } else if (verdict == "skip") {
2643
- stats.skippedTests++;
2644
- }
2645
- }
2646
- return verdict;
2647
- }
2787
+ // Every grouping block — describe, test, it, only and their skip variants —
2788
+ // is a suite; the expect() assertions counted above are the tests. (Failed
2789
+ // entries for the summary are collected per top-level suite in readFileReport.)
2648
2790
  if (verdict == "fail") {
2649
2791
  stats.failedSuites++;
2650
- stats.failedEntries.push({
2651
- ...suiteAny,
2652
- file,
2653
- modeName,
2654
- runCommand,
2655
- buildCommand,
2656
- });
2657
2792
  } else if (verdict == "ok") {
2658
2793
  stats.passedSuites++;
2659
2794
  } else {
@@ -2661,17 +2796,6 @@ function readSuite(stats, suite, file, modeName, runCommand, buildCommand) {
2661
2796
  }
2662
2797
  return verdict;
2663
2798
  }
2664
- function isTestCaseSuiteKind(kind) {
2665
- return (
2666
- kind == "test" ||
2667
- kind == "it" ||
2668
- kind == "only" ||
2669
- kind == "xtest" ||
2670
- kind == "xit" ||
2671
- kind == "xonly" ||
2672
- kind == "todo"
2673
- );
2674
- }
2675
2799
  function normalizeVerdict(value) {
2676
2800
  const verdict = String(value ?? "none");
2677
2801
  if (verdict == "fail") return "fail";
@@ -1,4 +1,4 @@
1
- export { createRunReporter, run } from "./run-core.js";
1
+ export { createRunReporter, resetCollectedLogs, run } from "./run-core.js";
2
2
  export async function executeRunCommand(
3
3
  rawArgs,
4
4
  flags,
@@ -19,6 +19,7 @@ export async function executeRunCommand(
19
19
  showCoverage: showCoverageMode != undefined,
20
20
  showCoverageAll: showCoverageMode == "all",
21
21
  verbose: flags.includes("--verbose"),
22
+ showLogs: flags.includes("--show-logs"),
22
23
  ...deps.resolveParallelJobs(rawArgs, "run"),
23
24
  coverage: featureToggles.coverage,
24
25
  browser: deps.resolveBrowserOverride(rawArgs, "run"),