@vitest-agent/ui 2.2.13 → 2.3.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.
@@ -36,7 +36,11 @@ const classifyRunShape = (state, projects) => {
36
36
  * run whose only non-passing signal is one or more timed-out tests
37
37
  * (`totals.timeoutCount > 0`, `totals.failCount === 0`) also
38
38
  * classifies as `some-fail` — timeouts are not passes, and must not
39
- * route to a passing cell (issue #224).
39
+ * route to a passing cell (issue #224). A run whose only non-passing
40
+ * signal is a process-level unhandled error (a non-empty
41
+ * `unhandledErrors`, all counts otherwise clean) also classifies as `some-fail` —
42
+ * a green-looking count must never hide an unhandled error behind an
43
+ * all-pass cell (issue #240).
40
44
  *
41
45
  * @param state - the fully-reduced render state
42
46
  * @returns the outcome classification
@@ -44,6 +48,7 @@ const classifyRunShape = (state, projects) => {
44
48
  */
45
49
  const classifyOutcome = (state) => {
46
50
  if (state.totals.failCount > 0) return "some-fail";
51
+ if (state.unhandledErrors.length > 0) return "some-fail";
47
52
  if (state.totals.timeoutCount > 0) return "some-fail";
48
53
  if (state.coverage !== null && state.coverage.violations.length > 0) return "threshold-violation";
49
54
  return "all-pass";
@@ -10,6 +10,9 @@ import { renderSingleTestThreshold } from "./cells/single-test-threshold.js";
10
10
  import { renderWorkspaceFail } from "./cells/workspace-fail.js";
11
11
  import { renderWorkspacePass } from "./cells/workspace-pass.js";
12
12
  import { renderWorkspaceThreshold } from "./cells/workspace-threshold.js";
13
+ import { formatScopedCoverageNote } from "@vitest-agent/sdk";
14
+ import { Box, Text } from "ink";
15
+ import { createElement } from "react";
13
16
 
14
17
  //#region src/dispatcher/dispatch.ts
15
18
  /**
@@ -51,7 +54,24 @@ const dispatcherTable = {
51
54
  * @public
52
55
  */
53
56
  const dispatch = (inputs, opts) => {
54
- return dispatcherTable[inputs.shape][inputs.outcome].agent(inputs, opts);
57
+ const body = dispatcherTable[inputs.shape][inputs.outcome].agent(inputs, opts);
58
+ const note = scopedCoverageNoteFor(inputs);
59
+ return note !== null ? `${body}\n${note}` : body;
60
+ };
61
+ /**
62
+ * Build the scoped-coverage note for this run, or `null` on a full
63
+ * (non-scoped) run. Shared by {@link dispatch} and {@link dispatchInk} so
64
+ * both render paths surface the same information (issue #160 gap 1) —
65
+ * Vitest's coverage thresholds are meaningless against a subset of the
66
+ * project's test files, so every cell's own threshold-flavored coverage
67
+ * text is followed by an explanation of why to disregard it.
68
+ *
69
+ * @internal
70
+ */
71
+ const scopedCoverageNoteFor = (inputs) => {
72
+ const cov = inputs.state.coverage;
73
+ if (cov === null || cov.scoped !== true) return null;
74
+ return formatScopedCoverageNote(cov.scopedFiles ?? 0, cov.totalFiles);
55
75
  };
56
76
  /**
57
77
  * Dispatch to the cell that matches `(inputs.shape, inputs.outcome)`
@@ -66,7 +86,11 @@ const dispatch = (inputs, opts) => {
66
86
  */
67
87
  const dispatchInk = (inputs, opts) => {
68
88
  const cell = dispatcherTable[inputs.shape][inputs.outcome];
69
- return cell.ink !== void 0 ? cell.ink(inputs, opts) : null;
89
+ if (cell.ink === void 0) return null;
90
+ const element = cell.ink(inputs, opts);
91
+ const note = scopedCoverageNoteFor(inputs);
92
+ if (note === null) return element;
93
+ return createElement(Box, { flexDirection: "column" }, element, createElement(Text, null, note));
70
94
  };
71
95
 
72
96
  //#endregion
@@ -102,9 +102,13 @@ const formatTrendLine = (trend) => {
102
102
  * project carrying failures or violations.
103
103
  */
104
104
  const formatProjectRow = (project, nameWidth) => {
105
- const total = project.passCount + project.failCount + project.skipCount;
106
- const glyph = project.failCount > 0 ? "✗" : "✓";
107
- const counts = project.failCount > 0 ? `${project.passCount}/${total} passed, ${project.failCount} failed` : `${project.passCount} passed`;
105
+ const timeoutCount = project.timeoutCount ?? 0;
106
+ const total = project.passCount + project.failCount + project.skipCount + timeoutCount;
107
+ const glyph = project.failCount > 0 || timeoutCount > 0 ? "✗" : "✓";
108
+ const countParts = [`${project.passCount}/${total} passed`];
109
+ if (project.failCount > 0) countParts.push(`${project.failCount} failed`);
110
+ if (timeoutCount > 0) countParts.push(`${timeoutCount} timed out`);
111
+ const counts = project.failCount > 0 || timeoutCount > 0 ? countParts.join(", ") : `${project.passCount} passed`;
108
112
  const tagSuffix = formatTagCountSuffix(project.tagCounts);
109
113
  const base = ` ${glyph} ${project.name.padEnd(nameWidth)} ${counts} (${formatDisplayDuration(project.durationMs)})`;
110
114
  return tagSuffix.length === 0 ? base : `${base} ${tagSuffix}`;
@@ -132,36 +136,46 @@ const formatWorkspaceTotal = (projects) => {
132
136
  let pass = 0;
133
137
  let fail = 0;
134
138
  let skip = 0;
139
+ let timeout = 0;
135
140
  let durationMs = 0;
136
141
  for (const p of projects) {
137
142
  pass += p.passCount;
138
143
  fail += p.failCount;
139
144
  skip += p.skipCount;
145
+ timeout += p.timeoutCount ?? 0;
140
146
  durationMs += p.durationMs;
141
147
  }
142
- const total = pass + fail + skip;
148
+ const total = pass + fail + skip + timeout;
143
149
  const parts = [`${pass}/${total} passed`];
144
150
  if (fail > 0) parts.push(`${fail} failed`);
151
+ if (timeout > 0) parts.push(`${timeout} timed out`);
145
152
  if (skip > 0) parts.push(`${skip} skipped`);
146
153
  return `Total: ${parts.join(", ")} (${formatDisplayDuration(durationMs)})`;
147
154
  };
148
- const TABLE_COL_FILE = 60;
155
+ const TABLE_COL_FILE_MIN = 60;
149
156
  /**
150
157
  * Format the `Files below aspirational target:` block — a pipe-delimited
151
158
  * table truncated to the first `limit` files with a "+N more" suffix.
152
159
  * Returns an empty array when `belowTarget` is empty.
160
+ *
161
+ * The file column is sized to the longest path among the printed rows
162
+ * (never narrower than {@link TABLE_COL_FILE_MIN}), so a printed path is
163
+ * never truncated (issue #237 follow-up) — only the omitted rows (past
164
+ * `limit`) are unaffected, since their width never entered the
165
+ * calculation.
153
166
  */
154
167
  const formatBelowTargetTable = (belowTarget, limit) => {
155
168
  if (belowTarget.length === 0) return [];
156
169
  const top = belowTarget.slice(0, limit);
157
170
  const omitted = belowTarget.length - top.length;
171
+ const fileColWidth = Math.max(TABLE_COL_FILE_MIN, ...top.map((f) => f.file.length + 2));
158
172
  const header = [
159
173
  "Files below aspirational target:",
160
- buildTableSeparator(),
161
- buildTableHeader(),
162
- buildTableSeparator()
174
+ buildTableSeparator(fileColWidth),
175
+ buildTableHeader(fileColWidth),
176
+ buildTableSeparator(fileColWidth)
163
177
  ];
164
- const rows = top.map((file) => buildTableRow(file));
178
+ const rows = top.map((file) => buildTableRow(file, fileColWidth));
165
179
  const footer = [];
166
180
  if (omitted > 0) footer.push(`… ${omitted} more (use the test_coverage MCP tool for the full list)`);
167
181
  return [
@@ -170,14 +184,14 @@ const formatBelowTargetTable = (belowTarget, limit) => {
170
184
  ...footer
171
185
  ];
172
186
  };
173
- const buildTableSeparator = () => {
174
- return `${"-".repeat(TABLE_COL_FILE)}|---------|---------|---------|---------|-------------------`;
187
+ const buildTableSeparator = (fileColWidth) => {
188
+ return `${"-".repeat(fileColWidth)}|---------|---------|---------|---------|-------------------`;
175
189
  };
176
- const buildTableHeader = () => {
177
- return ` ${"File".padEnd(59)}| % Stmts | % Branch| % Funcs | % Lines | Uncovered Line #s`;
190
+ const buildTableHeader = (fileColWidth) => {
191
+ return ` ${"File".padEnd(fileColWidth - 1)}| % Stmts | % Branch| % Funcs | % Lines | Uncovered Line #s`;
178
192
  };
179
- const buildTableRow = (file) => {
180
- return `${` ${truncate(file.file, 58).padEnd(59)}`}|${pctCell(file.summary.statements)}|${pctCell(file.summary.branches)}|${pctCell(file.summary.functions)}|${pctCell(file.summary.lines)}|${` ${file.uncoveredLines}`}`;
193
+ const buildTableRow = (file, fileColWidth) => {
194
+ return `${` ${file.file.padEnd(fileColWidth - 1)}`}|${pctCell(file.summary.statements)}|${pctCell(file.summary.branches)}|${pctCell(file.summary.functions)}|${pctCell(file.summary.lines)}|${` ${file.uncoveredLines}`}`;
181
195
  };
182
196
  const pctCell = (n) => {
183
197
  const text = `${Math.round(n)}`;
package/index.d.ts CHANGED
@@ -154,6 +154,9 @@ declare const CoverageRenderState: Schema.Struct<{
154
154
  readonly expected: Schema.Number;
155
155
  readonly actual: Schema.Number;
156
156
  }>>;
157
+ readonly scoped: Schema.optional<Schema.Boolean>;
158
+ readonly scopedFiles: Schema.optional<Schema.Number>;
159
+ readonly totalFiles: Schema.optional<Schema.Number>;
157
160
  }>;
158
161
  /** @public */
159
162
  type CoverageRenderState = typeof CoverageRenderState.Type;
@@ -272,6 +275,9 @@ declare const RenderState: Schema.Struct<{
272
275
  readonly expected: Schema.Number;
273
276
  readonly actual: Schema.Number;
274
277
  }>>;
278
+ readonly scoped: Schema.optional<Schema.Boolean>;
279
+ readonly scopedFiles: Schema.optional<Schema.Number>;
280
+ readonly totalFiles: Schema.optional<Schema.Number>;
275
281
  }>>;
276
282
  readonly trend: Schema.NullOr<Schema.Struct<{
277
283
  readonly direction: Schema.Literals<readonly ["improving", "regressing", "stable"]>;
@@ -307,6 +313,19 @@ declare const RenderState: Schema.Struct<{
307
313
  * fixtures) falls back to `moduleOrder.length`.
308
314
  */
309
315
  readonly collectedModules: Schema.optional<Schema.Number>;
316
+ /**
317
+ * Process-level errors with no owning module, folded from
318
+ * `RunFinished.unhandledErrors`. A run that never carried the field
319
+ * (older replay data, hand-built fixtures) defaults to `[]`. See
320
+ * issue #240.
321
+ */
322
+ readonly unhandledErrors: Schema.$Array<Schema.Struct<{
323
+ readonly message: Schema.String;
324
+ readonly stack: Schema.optional<Schema.String>;
325
+ readonly diff: Schema.optional<Schema.String>;
326
+ readonly expected: Schema.optional<Schema.String>;
327
+ readonly received: Schema.optional<Schema.String>;
328
+ }>>;
310
329
  }>;
311
330
  /** @public */
312
331
  type RenderState = typeof RenderState.Type;
@@ -356,6 +375,7 @@ interface ProjectSummary {
356
375
  readonly failCount: number;
357
376
  readonly skipCount: number;
358
377
  readonly durationMs: number;
378
+ readonly timeoutCount?: number;
359
379
  readonly tagCounts?: Record<string, number>;
360
380
  readonly belowTarget?: number;
361
381
  readonly violations?: number;
@@ -549,6 +569,7 @@ declare const AgentReport: Schema.Struct<{
549
569
  }>>;
550
570
  readonly scoped: Schema.withDecodingDefaultKey<Schema.Boolean, never>;
551
571
  readonly scopedFiles: Schema.optional<Schema.$Array<Schema.String>>;
572
+ readonly totalFiles: Schema.optional<Schema.Number>;
552
573
  readonly lowCoverage: Schema.$Array<Schema.Struct<{
553
574
  readonly file: Schema.String;
554
575
  readonly summary: Schema.Struct<{
@@ -587,6 +608,10 @@ declare const AgentReport: Schema.Struct<{
587
608
  readonly sample: Schema.optional<Schema.String>;
588
609
  }>>;
589
610
  readonly truncated: Schema.optional<Schema.Boolean>;
611
+ readonly fromFailingTests: Schema.optional<Schema.Struct<{
612
+ readonly total: Schema.Number;
613
+ readonly files: Schema.Number;
614
+ }>>;
590
615
  }>>;
591
616
  }>;
592
617
  /** @public */
@@ -758,6 +783,9 @@ declare const RunEvent: Schema.Union<readonly [Schema.TaggedStruct<"RunStarted",
758
783
  }>;
759
784
  readonly uncoveredLines: Schema.optional<Schema.String>;
760
785
  }>>;
786
+ readonly scoped: Schema.optional<Schema.Boolean>;
787
+ readonly scopedFiles: Schema.optional<Schema.Number>;
788
+ readonly totalFiles: Schema.optional<Schema.Number>;
761
789
  }>, Schema.TaggedStruct<"ThresholdViolation", {
762
790
  readonly metric: Schema.Literals<readonly ["lines", "branches", "functions", "statements"]>;
763
791
  readonly expected: Schema.Number;
@@ -787,6 +815,19 @@ declare const RunEvent: Schema.Union<readonly [Schema.TaggedStruct<"RunStarted",
787
815
  * tracks failing modules (report replay). See issue #204.
788
816
  */
789
817
  readonly collectedModules: Schema.optional<Schema.Number>;
818
+ /**
819
+ * Process-level errors with no owning module (an unhandled
820
+ * rejection, a worker crash) — Vitest's `unhandledErrors`
821
+ * argument to `onTestRunEnd`. Optional so hand-built fixtures
822
+ * and older replay data keep decoding. See issue #240.
823
+ */
824
+ readonly unhandledErrors: Schema.optional<Schema.$Array<Schema.Struct<{
825
+ readonly message: Schema.String;
826
+ readonly stack: Schema.optional<Schema.String>;
827
+ readonly diff: Schema.optional<Schema.String>;
828
+ readonly expected: Schema.optional<Schema.String>;
829
+ readonly received: Schema.optional<Schema.String>;
830
+ }>>>;
790
831
  }>]>;
791
832
  /** @public */
792
833
  type RunEvent = typeof RunEvent.Type;
@@ -1014,7 +1055,11 @@ declare const classifyRunShape: (state: RenderState, projects: ReadonlyArray<Pro
1014
1055
  * run whose only non-passing signal is one or more timed-out tests
1015
1056
  * (`totals.timeoutCount > 0`, `totals.failCount === 0`) also
1016
1057
  * classifies as `some-fail` — timeouts are not passes, and must not
1017
- * route to a passing cell (issue #224).
1058
+ * route to a passing cell (issue #224). A run whose only non-passing
1059
+ * signal is a process-level unhandled error (a non-empty
1060
+ * `unhandledErrors`, all counts otherwise clean) also classifies as `some-fail` —
1061
+ * a green-looking count must never hide an unhandled error behind an
1062
+ * all-pass cell (issue #240).
1018
1063
  *
1019
1064
  * @param state - the fully-reduced render state
1020
1065
  * @returns the outcome classification
@@ -1247,6 +1292,9 @@ declare const RunEventChannel_base: Context.ServiceClass<RunEventChannel, "vites
1247
1292
  };
1248
1293
  readonly uncoveredLines?: string | undefined;
1249
1294
  }[];
1295
+ readonly scoped?: boolean | undefined;
1296
+ readonly scopedFiles?: number | undefined;
1297
+ readonly totalFiles?: number | undefined;
1250
1298
  } | {
1251
1299
  readonly _tag: "ThresholdViolation";
1252
1300
  readonly metric: "branches" | "functions" | "lines" | "statements";
@@ -1273,6 +1321,13 @@ declare const RunEventChannel_base: Context.ServiceClass<RunEventChannel, "vites
1273
1321
  readonly durationMs: number;
1274
1322
  readonly timeoutCount?: number | undefined;
1275
1323
  readonly collectedModules?: number | undefined;
1324
+ readonly unhandledErrors?: readonly {
1325
+ readonly message: string;
1326
+ readonly stack?: string | undefined;
1327
+ readonly diff?: string | undefined;
1328
+ readonly expected?: string | undefined;
1329
+ readonly received?: string | undefined;
1330
+ }[] | undefined;
1276
1331
  }>>;
1277
1332
  /**
1278
1333
  * Service tag for the run-event channel. Consumers depend on this tag;
@@ -1833,6 +1888,12 @@ interface SynthesizedCoverage {
1833
1888
  readonly expected: number;
1834
1889
  readonly actual: number;
1835
1890
  }>;
1891
+ /** Set when this run's coverage was scoped to a subset of test files (issue #160). */
1892
+ readonly scoped?: boolean;
1893
+ /** Number of test files actually run, when `scoped` is true. */
1894
+ readonly scopedFiles?: number;
1895
+ /** Total test-file count for the project, when known. */
1896
+ readonly totalFiles?: number;
1836
1897
  }
1837
1898
  /**
1838
1899
  * Project a fully-completed Vitest run into the canonical
package/index.js CHANGED
@@ -33,7 +33,7 @@ import { ActionSeverity, CoverageFile, CoverageGap, CoverageMetric, CoverageRend
33
33
  *
34
34
  * @public
35
35
  */
36
- const CURRENT_UI_VERSION = "2.2.13";
36
+ const CURRENT_UI_VERSION = "2.3.0";
37
37
 
38
38
  //#endregion
39
39
  export { ActionSeverity, CURRENT_UI_VERSION, CountColumns, CoverageBlock, CoverageFile, CoverageGap, CoverageMetric, CoverageRenderState, DURATION_CELL_WIDTH, FailureRecord, FailureSection, FailuresSection, ModuleHeader, ModuleRecord, ProjectRow, RenderState, RenderTotals, RunEvent, RunEventChannel, RunEventChannelLive, SPINNER_FRAMES, SPINNER_FRAME_MS, SUITE_LOAD_FAILURE_LABEL, StatusIcon, StreamApp, SuggestedActionRecord, SuggestedActions, TagColumns, TestRecord, TestRow, TrendLine, accumulateUntilFinished, buildFooter, classifyOutcome, classifyRunShape, dispatch, dispatchInk, dispatcherTable, dominantClassification, forEachRenderState, formatDisplayDuration, initialRenderState, publish, publishAll, reduceRenderState, reduceRenderStateAll, renderAgent, renderStateStream, spinnerFrame, spinnerFrameForTime, subscribeRaw, synthesizeFromAgentReport, synthesizeRunEvents, tagUnion };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitest-agent/ui",
3
- "version": "2.2.13",
3
+ "version": "2.3.0",
4
4
  "private": false,
5
5
  "description": "Shared event-sourced renderer for vitest-agent. React Ink view for humans, plain-string view for agents, both fed by the same reducer over a RunEvent stream.",
6
6
  "keywords": [
@@ -39,7 +39,7 @@
39
39
  "./package.json": "./package.json"
40
40
  },
41
41
  "dependencies": {
42
- "@vitest-agent/sdk": "2.4.13",
42
+ "@vitest-agent/sdk": "2.5.0",
43
43
  "effect": "4.0.0-rc.109"
44
44
  },
45
45
  "peerDependencies": {
package/reducer.js CHANGED
@@ -175,7 +175,10 @@ const reduceRenderState = (state, event) => {
175
175
  metrics: e.metrics,
176
176
  thresholds: e.thresholds,
177
177
  gaps: e.gaps,
178
- violations: state.coverage?.violations ?? []
178
+ violations: state.coverage?.violations ?? [],
179
+ ...e.scoped !== void 0 ? { scoped: e.scoped } : {},
180
+ ...e.scopedFiles !== void 0 ? { scopedFiles: e.scopedFiles } : {},
181
+ ...e.totalFiles !== void 0 ? { totalFiles: e.totalFiles } : {}
179
182
  }
180
183
  }),
181
184
  ThresholdViolation: (e) => {
@@ -219,7 +222,8 @@ const reduceRenderState = (state, event) => {
219
222
  timeoutCount: e.timeoutCount ?? state.totals.timeoutCount,
220
223
  durationMs: e.durationMs
221
224
  },
222
- ...e.collectedModules !== void 0 && { collectedModules: e.collectedModules }
225
+ ...e.collectedModules !== void 0 && { collectedModules: e.collectedModules },
226
+ ...e.unhandledErrors !== void 0 && { unhandledErrors: e.unhandledErrors }
223
227
  }),
224
228
  RunTimedOut: () => ({
225
229
  ...state,
package/render-agent.js CHANGED
@@ -14,6 +14,10 @@ const formatHeader = (state) => {
14
14
  if (failCount > 0) parts.push(`${failCount} failed`);
15
15
  if (timeoutCount > 0) parts.push(`${timeoutCount} timed out`);
16
16
  if (skipCount > 0) parts.push(`${skipCount} skipped`);
17
+ if (state.unhandledErrors.length > 0) {
18
+ const noun = state.unhandledErrors.length === 1 ? "unhandled error" : "unhandled errors";
19
+ parts.push(`${state.unhandledErrors.length} ${noun}`);
20
+ }
17
21
  return `Tests: ${parts.join(", ")} (${formatDisplayDuration(durationMs)})`;
18
22
  };
19
23
  const formatModulesSection = (state) => {
@@ -61,6 +65,18 @@ const formatFailuresSection = (state, width, includeStack) => {
61
65
  for (const f of state.failures) lines.push(formatFailure(f, width, includeStack));
62
66
  return lines.join("\n");
63
67
  };
68
+ const formatUnhandledError = (err, width, includeStack) => {
69
+ const firstLine = err.message.split("\n", 1)[0] ?? "";
70
+ const lines = [`- ${truncate(firstLine, Math.max(20, width - 2))}`];
71
+ if (includeStack && err.stack !== void 0) for (const stackLine of err.stack.split("\n")) lines.push(` ${truncate(stackLine, Math.max(20, width - 2))}`);
72
+ return lines.join("\n");
73
+ };
74
+ const formatUnhandledErrorsSection = (state, width, includeStack) => {
75
+ if (state.unhandledErrors.length === 0) return null;
76
+ const lines = ["Unhandled errors:"];
77
+ for (const err of state.unhandledErrors) lines.push(formatUnhandledError(err, width, includeStack));
78
+ return lines.join("\n");
79
+ };
64
80
  const formatPercent = (n) => {
65
81
  const rounded = Math.round(n * 10) / 10;
66
82
  return Number.isInteger(rounded) ? `${rounded}%` : `${rounded}%`;
@@ -128,6 +144,8 @@ const renderAgent = (state, options = {}) => {
128
144
  const sections = [formatHeader(state)];
129
145
  const failures = formatFailuresSection(state, width, includeStack);
130
146
  if (failures !== null) sections.push(failures);
147
+ const unhandledErrors = formatUnhandledErrorsSection(state, width, includeStack);
148
+ if (unhandledErrors !== null) sections.push(unhandledErrors);
131
149
  const modules = formatModulesSection(state);
132
150
  if (modules !== null) sections.push(modules);
133
151
  const coverage = formatCoverageSection(state, maxGaps);
@@ -58,6 +58,21 @@ const sumCounts = (modules) => {
58
58
  timeoutCount
59
59
  };
60
60
  };
61
+ /**
62
+ * Build a `ProjectSummary` from a project's name and its rolled-up
63
+ * counts. Includes `timeoutCount` only when nonzero, matching
64
+ * `summarizeProject`'s convention in `@vitest-agent/reporter` (issue #242).
65
+ *
66
+ * @public
67
+ */
68
+ const buildProjectSummary = (name, counts) => ({
69
+ name,
70
+ passCount: counts.passCount,
71
+ failCount: counts.failCount,
72
+ skipCount: counts.skipCount,
73
+ durationMs: 0,
74
+ ...counts.timeoutCount > 0 ? { timeoutCount: counts.timeoutCount } : {}
75
+ });
61
76
  const mergeTagCounts = (modules) => {
62
77
  const out = {};
63
78
  for (const m of modules) for (const [tag, count] of Object.entries(m.tagCounts ?? {})) out[tag] = (out[tag] ?? 0) + count;
@@ -166,6 +181,21 @@ const InlineError = ({ failure }) => {
166
181
  });
167
182
  };
168
183
  const failureKey = (f) => `failure:${f.modulePath}::${f.suitePath.join("/")}::${f.testName}`;
184
+ /** A process-level unhandled error, rendered in the aggregate Unhandled errors block. */
185
+ const UnhandledErrorItem = ({ error }) => /* @__PURE__ */ jsxs(Box, {
186
+ flexDirection: "column",
187
+ children: [/* @__PURE__ */ jsxs(Text, { children: [
188
+ " ",
189
+ /* @__PURE__ */ jsx(Text, {
190
+ color: "red",
191
+ children: "✗"
192
+ }),
193
+ " unhandled error"
194
+ ] }), /* @__PURE__ */ jsxs(Text, {
195
+ dimColor: true,
196
+ children: [" ", error.message.split("\n", 1)[0] ?? ""]
197
+ })]
198
+ });
169
199
  const testKey = (modulePath, t) => `test:${modulePath}::${t.suitePath.join("/")}::${t.testName}`;
170
200
  const failurePath = (f) => [
171
201
  f.modulePath,
@@ -248,6 +278,13 @@ const liveRegion = (state, shape, nowMs, frame) => {
248
278
  const timedOut = state.phase === "timed-out";
249
279
  const finished = state.phase === "finished" || state.phase === "timed-out";
250
280
  const ordered = state.moduleOrder.map((p) => state.modules[p]).filter((m) => m !== void 0);
281
+ const unhandledErrorsSection = state.unhandledErrors.length > 0 ? /* @__PURE__ */ jsxs(Box, {
282
+ flexDirection: "column",
283
+ children: [/* @__PURE__ */ jsx(Text, {
284
+ bold: true,
285
+ children: "Unhandled errors:"
286
+ }), state.unhandledErrors.map((err, i) => /* @__PURE__ */ jsx(UnhandledErrorItem, { error: err }, `unhandled:${i}:${err.message}`))]
287
+ }) : null;
251
288
  if (shape === "single-test") {
252
289
  const sole = ordered[0];
253
290
  if (sole === void 0 || sole.tests.length === 0) return /* @__PURE__ */ jsx(Text, {
@@ -260,10 +297,14 @@ const liveRegion = (state, shape, nowMs, frame) => {
260
297
  children: "discovering tests…"
261
298
  });
262
299
  const failure = only.status === "failed" || only.status === "timed-out" ? state.failures.find((f) => f.testName === only.testName && f.modulePath === sole.modulePath && samePath(f.suitePath, only.suitePath)) : void 0;
263
- return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(TestRow, {
264
- test: only,
265
- indent: 0
266
- }), failure !== void 0 ? /* @__PURE__ */ jsx(InlineError, { failure }) : null] });
300
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
301
+ /* @__PURE__ */ jsx(TestRow, {
302
+ test: only,
303
+ indent: 0
304
+ }),
305
+ failure !== void 0 ? /* @__PURE__ */ jsx(InlineError, { failure }) : null,
306
+ unhandledErrorsSection
307
+ ] });
267
308
  }
268
309
  if (groups.length === 0) return /* @__PURE__ */ jsx(Text, {
269
310
  dimColor: true,
@@ -297,13 +338,7 @@ const liveRegion = (state, shape, nowMs, frame) => {
297
338
  if (runningVisible < MAX_LIVE_RUNNING_ROWS) {
298
339
  runningVisible++;
299
340
  const counts = sumCounts(g.modules);
300
- const summary = {
301
- name: g.name,
302
- passCount: counts.passCount,
303
- failCount: counts.failCount,
304
- skipCount: counts.skipCount,
305
- durationMs: 0
306
- };
341
+ const summary = buildProjectSummary(g.name, counts);
307
342
  rows.push(/* @__PURE__ */ jsx(ProjectRow, {
308
343
  project: summary,
309
344
  counts,
@@ -318,13 +353,7 @@ const liveRegion = (state, shape, nowMs, frame) => {
318
353
  } else runningOverflow++;
319
354
  } else {
320
355
  const counts = sumCounts(g.modules);
321
- const summary = {
322
- name: g.name,
323
- passCount: counts.passCount,
324
- failCount: counts.failCount,
325
- skipCount: counts.skipCount,
326
- durationMs: 0
327
- };
356
+ const summary = buildProjectSummary(g.name, counts);
328
357
  rows.push(/* @__PURE__ */ jsx(ProjectRow, {
329
358
  project: summary,
330
359
  counts,
@@ -356,6 +385,7 @@ const liveRegion = (state, shape, nowMs, frame) => {
356
385
  ]
357
386
  }) : null,
358
387
  failuresSection,
388
+ unhandledErrorsSection,
359
389
  coverageItem,
360
390
  trendItem,
361
391
  totalsItem(nameWidth + 4)
@@ -406,6 +436,7 @@ const liveRegion = (state, shape, nowMs, frame) => {
406
436
  ]
407
437
  }) : null,
408
438
  failuresSection,
439
+ unhandledErrorsSection,
409
440
  coverageItem,
410
441
  trendItem,
411
442
  totalsItem()
@@ -438,6 +469,7 @@ const liveRegion = (state, shape, nowMs, frame) => {
438
469
  }), failure !== void 0 ? /* @__PURE__ */ jsx(InlineError, { failure }) : null]
439
470
  }, testKey(sole.modulePath, t));
440
471
  }),
472
+ unhandledErrorsSection,
441
473
  coverageItem,
442
474
  trendItem,
443
475
  totalsItem()
@@ -452,16 +484,7 @@ const liveRegion = (state, shape, nowMs, frame) => {
452
484
  const StreamApp = ({ state, frameIndex, nowMs }) => {
453
485
  const now = nowMs ?? Date.now();
454
486
  const frame = spinnerFrame(frameIndex);
455
- const projects = groupByProject(state).map((g) => {
456
- const c = sumCounts(g.modules);
457
- return {
458
- name: g.name,
459
- passCount: c.passCount,
460
- failCount: c.failCount,
461
- skipCount: c.skipCount,
462
- durationMs: 0
463
- };
464
- });
487
+ const projects = groupByProject(state).map((g) => buildProjectSummary(g.name, sumCounts(g.modules)));
465
488
  const shape = classifyRunShape(state, projects);
466
489
  const liveContent = liveRegion(state, shape, now, frame);
467
490
  return /* @__PURE__ */ jsx(Box, {
@@ -471,4 +494,4 @@ const StreamApp = ({ state, frameIndex, nowMs }) => {
471
494
  };
472
495
 
473
496
  //#endregion
474
- export { StreamApp };
497
+ export { StreamApp, buildProjectSummary };
package/synthesize.js CHANGED
@@ -147,7 +147,10 @@ const synthesizeRunEvents = (modules, options = {}) => {
147
147
  _tag: "CoverageReady",
148
148
  metrics: cov.metrics,
149
149
  thresholds: cov.thresholds,
150
- gaps: cov.gaps
150
+ gaps: cov.gaps,
151
+ ...cov.scoped !== void 0 ? { scoped: cov.scoped } : {},
152
+ ...cov.scopedFiles !== void 0 ? { scopedFiles: cov.scopedFiles } : {},
153
+ ...cov.totalFiles !== void 0 ? { totalFiles: cov.totalFiles } : {}
151
154
  });
152
155
  if (cov.violations !== void 0) for (const v of cov.violations) events.push({
153
156
  _tag: "ThresholdViolation",
@@ -189,7 +192,7 @@ const coverageReportToBlock = (report) => {
189
192
  const cov = report.coverage;
190
193
  if (cov === void 0) return void 0;
191
194
  const violations = [];
192
- for (const metric of [
195
+ if (!cov.scoped) for (const metric of [
193
196
  "lines",
194
197
  "branches",
195
198
  "functions",
@@ -212,7 +215,10 @@ const coverageReportToBlock = (report) => {
212
215
  metrics: cov.totals,
213
216
  thresholds: cov.thresholds.global,
214
217
  gaps,
215
- violations
218
+ violations,
219
+ ...cov.scoped ? { scoped: cov.scoped } : {},
220
+ ...cov.scopedFiles !== void 0 ? { scopedFiles: cov.scopedFiles.length } : {},
221
+ ...cov.totalFiles !== void 0 ? { totalFiles: cov.totalFiles } : {}
216
222
  };
217
223
  };
218
224
  /**
@@ -331,7 +337,10 @@ const synthesizeFromAgentReport = (report, options = {}) => {
331
337
  _tag: "CoverageReady",
332
338
  metrics: coverage.metrics,
333
339
  thresholds: coverage.thresholds,
334
- gaps: coverage.gaps
340
+ gaps: coverage.gaps,
341
+ ...coverage.scoped !== void 0 ? { scoped: coverage.scoped } : {},
342
+ ...coverage.scopedFiles !== void 0 ? { scopedFiles: coverage.scopedFiles } : {},
343
+ ...coverage.totalFiles !== void 0 ? { totalFiles: coverage.totalFiles } : {}
335
344
  });
336
345
  if (coverage.violations !== void 0) for (const v of coverage.violations) events.push({
337
346
  _tag: "ThresholdViolation",
@@ -362,7 +371,8 @@ const synthesizeFromAgentReport = (report, options = {}) => {
362
371
  skipCount: report.summary.skipped,
363
372
  durationMs: report.summary.duration,
364
373
  ...totalTimeoutCount > 0 && { timeoutCount: totalTimeoutCount },
365
- ...report.summary.modules !== void 0 && { collectedModules: report.summary.modules }
374
+ ...report.summary.modules !== void 0 && { collectedModules: report.summary.modules },
375
+ ...report.unhandledErrors.length > 0 && { unhandledErrors: report.unhandledErrors }
366
376
  });
367
377
  return events;
368
378
  };