@vitest-agent/ui 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/dispatcher/cells/single-file-fail.js +28 -0
- package/dispatcher/cells/single-file-pass.js +19 -0
- package/dispatcher/cells/single-file-threshold.js +22 -0
- package/dispatcher/cells/single-project-fail.js +34 -0
- package/dispatcher/cells/single-project-pass.js +17 -0
- package/dispatcher/cells/single-project-threshold.js +25 -0
- package/dispatcher/cells/single-test-fail.js +22 -0
- package/dispatcher/cells/single-test-pass.js +19 -0
- package/dispatcher/cells/single-test-threshold.js +12 -0
- package/dispatcher/cells/workspace-fail.js +27 -0
- package/dispatcher/cells/workspace-pass.js +25 -0
- package/dispatcher/cells/workspace-threshold.js +28 -0
- package/dispatcher/classify.js +48 -0
- package/dispatcher/dispatch.js +73 -0
- package/dispatcher/footer.js +55 -0
- package/dispatcher/helpers.js +186 -0
- package/dispatcher/ink-helpers.js +79 -0
- package/format-duration.js +34 -0
- package/index.d.ts +1843 -0
- package/index.js +40 -0
- package/package.json +59 -0
- package/pubsub/Channel.js +30 -0
- package/pubsub/Publisher.js +37 -0
- package/pubsub/Subscriber.js +77 -0
- package/pubsub/index.js +5 -0
- package/reducer.js +260 -0
- package/render-agent.js +137 -0
- package/render-ink/CountColumns.js +34 -0
- package/render-ink/CoverageBlock.js +80 -0
- package/render-ink/FailureSection.js +69 -0
- package/render-ink/FailuresSection.js +85 -0
- package/render-ink/ModuleHeader.js +43 -0
- package/render-ink/ProjectRow.js +56 -0
- package/render-ink/StatusIcon.js +44 -0
- package/render-ink/StreamApp.js +453 -0
- package/render-ink/SuggestedActions.js +49 -0
- package/render-ink/TestRow.js +33 -0
- package/render-ink/TrendLine.js +42 -0
- package/render-ink/index.js +14 -0
- package/render-ink/spinner.js +62 -0
- package/render-ink/tag-suffix.js +18 -0
- package/synthesize.js +335 -0
- package/tsdoc-metadata.json +11 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 C. Spencer Beggs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @vitest-agent/ui
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@vitest-agent/ui)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://www.typescriptlang.org/)
|
|
6
|
+
|
|
7
|
+
> **Part of the [vitest-agent](https://vitest-agent.dev) ecosystem.** Most users want **[@vitest-agent/plugin](https://www.npmjs.com/package/@vitest-agent/plugin)**, which pulls this package in automatically. Install `@vitest-agent/ui` directly only if you build custom rendering on the primitives.
|
|
8
|
+
|
|
9
|
+
Pure rendering primitives for vitest-agent. Owns the `RunEvent` reducer, the shape-tailored 12-cell dispatcher matrix, two render paths (agent string and React Ink tree), a PubSub channel for live event transport, and the synthesizers that bridge live Vitest data and stored reports into the event taxonomy. React and Ink are required peer dependencies.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Reducer** — pure `(state, event) => state` fold over the `RunEvent` discriminated union; `reduceRenderStateAll` for one-shot replay
|
|
14
|
+
- **Dispatcher matrix** — `dispatch` and `dispatchInk` route a `DispatchInputs` to the matching `(RunShape, RunOutcome)` cell; `classifyRunShape` and `classifyOutcome` derive the coordinates
|
|
15
|
+
- **Ink components** — `StreamApp` and the supporting primitives (`ModuleHeader`, `TestRow`, `CoverageBlock`, `TrendLine`, `FailureSection`, etc.) for live `stream`-mode frames
|
|
16
|
+
- **PubSub channel** — `RunEventChannel` Effect tag and helpers (`accumulateUntilFinished`, `forEachRenderState`, `renderStateStream`) for live event transport
|
|
17
|
+
- **Synthesizers** — `synthesizeRunEvents` from live Vitest modules; `synthesizeFromAgentReport` from a stored `AgentReport`
|
|
18
|
+
- **Footer builder** — `buildFooter` assembles the L1 MCP-tool-pointer footer; `dominantClassification` picks the most actionable failure class
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install @vitest-agent/ui
|
|
24
|
+
# or
|
|
25
|
+
pnpm add @vitest-agent/ui
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
React and Ink are required peers:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install react ink
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { reduceRenderStateAll } from "@vitest-agent/ui";
|
|
38
|
+
import { dispatch } from "@vitest-agent/ui";
|
|
39
|
+
|
|
40
|
+
const state = reduceRenderStateAll(events);
|
|
41
|
+
// state is a fully reduced RenderState
|
|
42
|
+
|
|
43
|
+
const agentString = dispatch(buildDispatchInputs(state, input), opts);
|
|
44
|
+
// agentString is the markdown-flavored final-frame string for the run
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Documentation
|
|
48
|
+
|
|
49
|
+
Package reference at [vitest-agent.dev/ui](https://vitest-agent.dev/ui).
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { buildFooter } from "../footer.js";
|
|
2
|
+
import { formatDisplayDuration } from "../../format-duration.js";
|
|
3
|
+
import { formatFailure, soleModulePath } from "../helpers.js";
|
|
4
|
+
import { renderAgentStringAsInk } from "../ink-helpers.js";
|
|
5
|
+
|
|
6
|
+
//#region src/dispatcher/cells/single-file-fail.ts
|
|
7
|
+
const DEFAULT_WIDTH = 80;
|
|
8
|
+
const renderAgent = (inputs) => {
|
|
9
|
+
const modulePath = soleModulePath(inputs.state);
|
|
10
|
+
if (modulePath === void 0) return "";
|
|
11
|
+
const { passCount, failCount, skipCount, durationMs } = inputs.state.totals;
|
|
12
|
+
const parts = [`${passCount}/${passCount + failCount + skipCount} passed`];
|
|
13
|
+
if (failCount > 0) parts.push(`${failCount} failed`);
|
|
14
|
+
if (skipCount > 0) parts.push(`${skipCount} skipped`);
|
|
15
|
+
const sections = [`${modulePath}: ${parts.join(", ")} (${formatDisplayDuration(durationMs)})`];
|
|
16
|
+
if (inputs.state.failures.length > 0) {
|
|
17
|
+
sections.push("Failures:");
|
|
18
|
+
for (const failure of inputs.state.failures) sections.push(...formatFailure(failure, DEFAULT_WIDTH));
|
|
19
|
+
}
|
|
20
|
+
return `${sections.join("\n")}\n${buildFooter(inputs)}`;
|
|
21
|
+
};
|
|
22
|
+
const renderSingleFileFail = {
|
|
23
|
+
agent: renderAgent,
|
|
24
|
+
ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
//#endregion
|
|
28
|
+
export { renderSingleFileFail };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { buildFooter } from "../footer.js";
|
|
2
|
+
import { formatDisplayDuration } from "../../format-duration.js";
|
|
3
|
+
import { soleModulePath } from "../helpers.js";
|
|
4
|
+
import { renderAgentStringAsInk } from "../ink-helpers.js";
|
|
5
|
+
|
|
6
|
+
//#region src/dispatcher/cells/single-file-pass.ts
|
|
7
|
+
const renderAgent = (inputs) => {
|
|
8
|
+
const modulePath = soleModulePath(inputs.state);
|
|
9
|
+
if (modulePath === void 0) return "";
|
|
10
|
+
const { passCount, durationMs } = inputs.state.totals;
|
|
11
|
+
return `${modulePath}: ${passCount} ${passCount === 1 ? "test" : "tests"} passed (${formatDisplayDuration(durationMs)})\n${buildFooter(inputs)}`;
|
|
12
|
+
};
|
|
13
|
+
const renderSingleFilePass = {
|
|
14
|
+
agent: renderAgent,
|
|
15
|
+
ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
//#endregion
|
|
19
|
+
export { renderSingleFilePass };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { buildFooter } from "../footer.js";
|
|
2
|
+
import { formatDisplayDuration } from "../../format-duration.js";
|
|
3
|
+
import { formatCoverageJudgmentLine, soleModulePath } from "../helpers.js";
|
|
4
|
+
import { renderAgentStringAsInk } from "../ink-helpers.js";
|
|
5
|
+
|
|
6
|
+
//#region src/dispatcher/cells/single-file-threshold.ts
|
|
7
|
+
const renderAgent = (inputs) => {
|
|
8
|
+
const modulePath = soleModulePath(inputs.state);
|
|
9
|
+
if (modulePath === void 0) return "";
|
|
10
|
+
const { passCount, durationMs } = inputs.state.totals;
|
|
11
|
+
const lines = [`${modulePath}: ${passCount} ${passCount === 1 ? "test" : "tests"} passed (${formatDisplayDuration(durationMs)})`];
|
|
12
|
+
const coverage = formatCoverageJudgmentLine(inputs.state);
|
|
13
|
+
if (coverage !== null) lines.push(coverage);
|
|
14
|
+
return `${lines.join("\n")}\n${buildFooter(inputs)}`;
|
|
15
|
+
};
|
|
16
|
+
const renderSingleFileThreshold = {
|
|
17
|
+
agent: renderAgent,
|
|
18
|
+
ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
//#endregion
|
|
22
|
+
export { renderSingleFileThreshold };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { buildFooter } from "../footer.js";
|
|
2
|
+
import { formatFailure, formatTotals } from "../helpers.js";
|
|
3
|
+
import { renderAgentStringAsInk } from "../ink-helpers.js";
|
|
4
|
+
|
|
5
|
+
//#region src/dispatcher/cells/single-project-fail.ts
|
|
6
|
+
const DEFAULT_WIDTH = 80;
|
|
7
|
+
const renderAgent = (inputs) => {
|
|
8
|
+
const sections = [[formatTotals(inputs.state)]];
|
|
9
|
+
if (inputs.state.failures.length > 0) {
|
|
10
|
+
const failureLines = ["Failures:"];
|
|
11
|
+
for (const failure of inputs.state.failures) failureLines.push(...formatFailure(failure, DEFAULT_WIDTH));
|
|
12
|
+
sections.push(failureLines);
|
|
13
|
+
}
|
|
14
|
+
const moduleLines = ["Modules:"];
|
|
15
|
+
for (const path of inputs.state.moduleOrder) {
|
|
16
|
+
const m = inputs.state.modules[path];
|
|
17
|
+
if (!m) continue;
|
|
18
|
+
const parts = [];
|
|
19
|
+
if (m.passCount > 0) parts.push(`${m.passCount} passed`);
|
|
20
|
+
if (m.failCount > 0) parts.push(`${m.failCount} failed`);
|
|
21
|
+
if (m.skipCount > 0) parts.push(`${m.skipCount} skipped`);
|
|
22
|
+
const summary = parts.length > 0 ? parts.join(", ") : `status=${m.status}`;
|
|
23
|
+
moduleLines.push(`- ${m.modulePath}: ${summary}`);
|
|
24
|
+
}
|
|
25
|
+
if (moduleLines.length > 1) sections.push(moduleLines);
|
|
26
|
+
return `${sections.map((section) => section.join("\n")).join("\n\n")}\n${buildFooter(inputs)}`;
|
|
27
|
+
};
|
|
28
|
+
const renderSingleProjectFail = {
|
|
29
|
+
agent: renderAgent,
|
|
30
|
+
ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
34
|
+
export { renderSingleProjectFail };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { buildFooter } from "../footer.js";
|
|
2
|
+
import { formatTotals } from "../helpers.js";
|
|
3
|
+
import { renderAgentStringAsInk } from "../ink-helpers.js";
|
|
4
|
+
|
|
5
|
+
//#region src/dispatcher/cells/single-project-pass.ts
|
|
6
|
+
const renderAgent = (inputs) => {
|
|
7
|
+
const modules = inputs.state.moduleOrder.length;
|
|
8
|
+
const noun = modules === 1 ? "module" : "modules";
|
|
9
|
+
return `${[formatTotals(inputs.state), `${modules} ${noun} all-passed.`].join("\n\n")}\n${buildFooter(inputs)}`;
|
|
10
|
+
};
|
|
11
|
+
const renderSingleProjectPass = {
|
|
12
|
+
agent: renderAgent,
|
|
13
|
+
ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
//#endregion
|
|
17
|
+
export { renderSingleProjectPass };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { buildFooter } from "../footer.js";
|
|
2
|
+
import { formatBelowTargetTable, formatCoverageJudgmentLine, formatTotals, formatTrendLine } from "../helpers.js";
|
|
3
|
+
import { renderAgentStringAsInk } from "../ink-helpers.js";
|
|
4
|
+
|
|
5
|
+
//#region src/dispatcher/cells/single-project-threshold.ts
|
|
6
|
+
const BELOW_TARGET_LIMIT = 5;
|
|
7
|
+
const renderAgent = (inputs) => {
|
|
8
|
+
const sections = [[formatTotals(inputs.state)]];
|
|
9
|
+
const coverage = formatCoverageJudgmentLine(inputs.state);
|
|
10
|
+
const trend = formatTrendLine(inputs.trend);
|
|
11
|
+
const summaryLines = [];
|
|
12
|
+
if (coverage !== null) summaryLines.push(coverage);
|
|
13
|
+
if (trend !== null) summaryLines.push(trend);
|
|
14
|
+
if (summaryLines.length > 0) sections.push(summaryLines);
|
|
15
|
+
const below = formatBelowTargetTable(inputs.belowTarget, BELOW_TARGET_LIMIT);
|
|
16
|
+
if (below.length > 0) sections.push([...below]);
|
|
17
|
+
return `${sections.map((section) => section.join("\n")).join("\n\n")}\n${buildFooter(inputs)}`;
|
|
18
|
+
};
|
|
19
|
+
const renderSingleProjectThreshold = {
|
|
20
|
+
agent: renderAgent,
|
|
21
|
+
ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
//#endregion
|
|
25
|
+
export { renderSingleProjectThreshold };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { buildFooter } from "../footer.js";
|
|
2
|
+
import { formatDisplayDuration } from "../../format-duration.js";
|
|
3
|
+
import { formatFailure, formatTestName, soleTest } from "../helpers.js";
|
|
4
|
+
import { renderAgentStringAsInk } from "../ink-helpers.js";
|
|
5
|
+
|
|
6
|
+
//#region src/dispatcher/cells/single-test-fail.ts
|
|
7
|
+
const DEFAULT_WIDTH = 80;
|
|
8
|
+
const renderAgent = (inputs) => {
|
|
9
|
+
const test = soleTest(inputs.state);
|
|
10
|
+
if (test === void 0) return "";
|
|
11
|
+
const duration = test.durationMs ?? 0;
|
|
12
|
+
const header = `✗ ${formatTestName(test)} (${formatDisplayDuration(duration)})`;
|
|
13
|
+
const failure = inputs.state.failures[0];
|
|
14
|
+
return `${failure === void 0 ? `${header}\n` : `${header}\n${formatFailure(failure, DEFAULT_WIDTH).join("\n")}\n`}${buildFooter(inputs)}`;
|
|
15
|
+
};
|
|
16
|
+
const renderSingleTestFail = {
|
|
17
|
+
agent: renderAgent,
|
|
18
|
+
ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
//#endregion
|
|
22
|
+
export { renderSingleTestFail };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { buildFooter } from "../footer.js";
|
|
2
|
+
import { formatDisplayDuration } from "../../format-duration.js";
|
|
3
|
+
import { formatTestName, soleTest } from "../helpers.js";
|
|
4
|
+
import { renderAgentStringAsInk } from "../ink-helpers.js";
|
|
5
|
+
|
|
6
|
+
//#region src/dispatcher/cells/single-test-pass.ts
|
|
7
|
+
const renderAgent = (inputs) => {
|
|
8
|
+
const test = soleTest(inputs.state);
|
|
9
|
+
if (test === void 0) return "";
|
|
10
|
+
const duration = test.durationMs ?? 0;
|
|
11
|
+
return `✓ ${formatTestName(test)} (${formatDisplayDuration(duration)})\n${buildFooter(inputs)}`;
|
|
12
|
+
};
|
|
13
|
+
const renderSingleTestPass = {
|
|
14
|
+
agent: renderAgent,
|
|
15
|
+
ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
//#endregion
|
|
19
|
+
export { renderSingleTestPass };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/dispatcher/cells/single-test-threshold.ts
|
|
2
|
+
/**
|
|
3
|
+
* No-op cell. The `single-test × threshold-violation` combination has
|
|
4
|
+
* no observable difference from `single-test × all-pass` because a
|
|
5
|
+
* single-test run never exercises the full file's coverage. The cell
|
|
6
|
+
* exists so the dispatcher matrix is total; calling it falls through
|
|
7
|
+
* to the empty string.
|
|
8
|
+
*/
|
|
9
|
+
const renderSingleTestThreshold = { agent: () => "" };
|
|
10
|
+
|
|
11
|
+
//#endregion
|
|
12
|
+
export { renderSingleTestThreshold };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { buildFooter } from "../footer.js";
|
|
2
|
+
import { formatFailure, formatProjectsTable, formatTrendLine, formatWorkspaceTotal } from "../helpers.js";
|
|
3
|
+
import { renderAgentStringAsInk } from "../ink-helpers.js";
|
|
4
|
+
|
|
5
|
+
//#region src/dispatcher/cells/workspace-fail.ts
|
|
6
|
+
const DEFAULT_WIDTH = 80;
|
|
7
|
+
const renderAgent = (inputs) => {
|
|
8
|
+
const sections = [];
|
|
9
|
+
const projects = formatProjectsTable(inputs.projects);
|
|
10
|
+
if (projects.length > 0) sections.push([...projects]);
|
|
11
|
+
if (inputs.state.failures.length > 0) {
|
|
12
|
+
const failureLines = ["Failures:"];
|
|
13
|
+
for (const failure of inputs.state.failures) failureLines.push(...formatFailure(failure, DEFAULT_WIDTH));
|
|
14
|
+
sections.push(failureLines);
|
|
15
|
+
}
|
|
16
|
+
const trend = formatTrendLine(inputs.trend);
|
|
17
|
+
if (trend !== null) sections.push([trend]);
|
|
18
|
+
if (inputs.projects.length > 0) sections.push([formatWorkspaceTotal(inputs.projects)]);
|
|
19
|
+
return `${sections.map((section) => section.join("\n")).join("\n\n")}\n${buildFooter(inputs)}`;
|
|
20
|
+
};
|
|
21
|
+
const renderWorkspaceFail = {
|
|
22
|
+
agent: renderAgent,
|
|
23
|
+
ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
//#endregion
|
|
27
|
+
export { renderWorkspaceFail };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { buildFooter } from "../footer.js";
|
|
2
|
+
import { formatCoverageJudgmentLine, formatProjectsTable, formatTrendLine, formatWorkspaceTotal } from "../helpers.js";
|
|
3
|
+
import { renderAgentStringAsInk } from "../ink-helpers.js";
|
|
4
|
+
|
|
5
|
+
//#region src/dispatcher/cells/workspace-pass.ts
|
|
6
|
+
const renderAgent = (inputs) => {
|
|
7
|
+
const sections = [];
|
|
8
|
+
const projects = formatProjectsTable(inputs.projects);
|
|
9
|
+
if (projects.length > 0) sections.push([...projects]);
|
|
10
|
+
const summaryLines = [];
|
|
11
|
+
const coverage = formatCoverageJudgmentLine(inputs.state);
|
|
12
|
+
if (coverage !== null) summaryLines.push(coverage);
|
|
13
|
+
const trend = formatTrendLine(inputs.trend);
|
|
14
|
+
if (trend !== null) summaryLines.push(trend);
|
|
15
|
+
if (summaryLines.length > 0) sections.push(summaryLines);
|
|
16
|
+
if (inputs.projects.length > 0) sections.push([formatWorkspaceTotal(inputs.projects)]);
|
|
17
|
+
return `${sections.map((section) => section.join("\n")).join("\n\n")}\n${buildFooter(inputs)}`;
|
|
18
|
+
};
|
|
19
|
+
const renderWorkspacePass = {
|
|
20
|
+
agent: renderAgent,
|
|
21
|
+
ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
//#endregion
|
|
25
|
+
export { renderWorkspacePass };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { buildFooter } from "../footer.js";
|
|
2
|
+
import { formatBelowTargetTable, formatCoverageJudgmentLine, formatProjectsTable, formatTrendLine, formatWorkspaceTotal } from "../helpers.js";
|
|
3
|
+
import { renderAgentStringAsInk } from "../ink-helpers.js";
|
|
4
|
+
|
|
5
|
+
//#region src/dispatcher/cells/workspace-threshold.ts
|
|
6
|
+
const BELOW_TARGET_LIMIT = 10;
|
|
7
|
+
const renderAgent = (inputs) => {
|
|
8
|
+
const sections = [];
|
|
9
|
+
const projects = formatProjectsTable(inputs.projects);
|
|
10
|
+
if (projects.length > 0) sections.push([...projects]);
|
|
11
|
+
const summaryLines = [];
|
|
12
|
+
const coverage = formatCoverageJudgmentLine(inputs.state);
|
|
13
|
+
if (coverage !== null) summaryLines.push(coverage);
|
|
14
|
+
const trend = formatTrendLine(inputs.trend);
|
|
15
|
+
if (trend !== null) summaryLines.push(trend);
|
|
16
|
+
if (summaryLines.length > 0) sections.push(summaryLines);
|
|
17
|
+
const below = formatBelowTargetTable(inputs.belowTarget, BELOW_TARGET_LIMIT);
|
|
18
|
+
if (below.length > 0) sections.push([...below]);
|
|
19
|
+
if (inputs.projects.length > 0) sections.push([formatWorkspaceTotal(inputs.projects)]);
|
|
20
|
+
return `${sections.map((section) => section.join("\n")).join("\n\n")}\n${buildFooter(inputs)}`;
|
|
21
|
+
};
|
|
22
|
+
const renderWorkspaceThreshold = {
|
|
23
|
+
agent: renderAgent,
|
|
24
|
+
ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
//#endregion
|
|
28
|
+
export { renderWorkspaceThreshold };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
//#region src/dispatcher/classify.ts
|
|
2
|
+
/**
|
|
3
|
+
* Compute the run shape from the reduced state plus the per-project
|
|
4
|
+
* aggregates the plugin carries through `DispatchInputs.projects`.
|
|
5
|
+
*
|
|
6
|
+
* The classification follows the state-shape signal described in the
|
|
7
|
+
* UI rewrite spec §7 open question 1:
|
|
8
|
+
*
|
|
9
|
+
* 1. More than one project → `workspace`.
|
|
10
|
+
* 2. One module with exactly one test → `single-test`.
|
|
11
|
+
* 3. One module with more than one test → `single-file`.
|
|
12
|
+
* 4. Otherwise → `single-project`.
|
|
13
|
+
*
|
|
14
|
+
* @param state - the fully-reduced render state
|
|
15
|
+
* @param projects - per-project summaries from the dispatch inputs
|
|
16
|
+
* @returns the run shape classification
|
|
17
|
+
* @public
|
|
18
|
+
*/
|
|
19
|
+
const classifyRunShape = (state, projects) => {
|
|
20
|
+
if (projects.length > 1) return "workspace";
|
|
21
|
+
const moduleEntries = Object.values(state.modules);
|
|
22
|
+
if (moduleEntries.length === 1) {
|
|
23
|
+
const sole = moduleEntries[0];
|
|
24
|
+
if (sole && sole.tests.length === 1) return "single-test";
|
|
25
|
+
return "single-file";
|
|
26
|
+
}
|
|
27
|
+
return "single-project";
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Compute the outcome class from the reduced state.
|
|
31
|
+
*
|
|
32
|
+
* Precedence: failures win over threshold violations. A run with one
|
|
33
|
+
* failing test and a coverage gap classifies as `some-fail`; the
|
|
34
|
+
* threshold-violation cell is reserved for runs where the test suite
|
|
35
|
+
* itself is clean but coverage policy is not.
|
|
36
|
+
*
|
|
37
|
+
* @param state - the fully-reduced render state
|
|
38
|
+
* @returns the outcome classification
|
|
39
|
+
* @public
|
|
40
|
+
*/
|
|
41
|
+
const classifyOutcome = (state) => {
|
|
42
|
+
if (state.totals.failCount > 0) return "some-fail";
|
|
43
|
+
if (state.coverage !== null && state.coverage.violations.length > 0) return "threshold-violation";
|
|
44
|
+
return "all-pass";
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
//#endregion
|
|
48
|
+
export { classifyOutcome, classifyRunShape };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { renderSingleFileFail } from "./cells/single-file-fail.js";
|
|
2
|
+
import { renderSingleFilePass } from "./cells/single-file-pass.js";
|
|
3
|
+
import { renderSingleFileThreshold } from "./cells/single-file-threshold.js";
|
|
4
|
+
import { renderSingleProjectFail } from "./cells/single-project-fail.js";
|
|
5
|
+
import { renderSingleProjectPass } from "./cells/single-project-pass.js";
|
|
6
|
+
import { renderSingleProjectThreshold } from "./cells/single-project-threshold.js";
|
|
7
|
+
import { renderSingleTestFail } from "./cells/single-test-fail.js";
|
|
8
|
+
import { renderSingleTestPass } from "./cells/single-test-pass.js";
|
|
9
|
+
import { renderSingleTestThreshold } from "./cells/single-test-threshold.js";
|
|
10
|
+
import { renderWorkspaceFail } from "./cells/workspace-fail.js";
|
|
11
|
+
import { renderWorkspacePass } from "./cells/workspace-pass.js";
|
|
12
|
+
import { renderWorkspaceThreshold } from "./cells/workspace-threshold.js";
|
|
13
|
+
|
|
14
|
+
//#region src/dispatcher/dispatch.ts
|
|
15
|
+
/**
|
|
16
|
+
* The 4×3 cell table. Exported for the test harness so spy-based
|
|
17
|
+
* routing tests can introspect the wiring without re-deriving it from
|
|
18
|
+
* source.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
const dispatcherTable = {
|
|
23
|
+
"single-test": {
|
|
24
|
+
"all-pass": renderSingleTestPass,
|
|
25
|
+
"some-fail": renderSingleTestFail,
|
|
26
|
+
"threshold-violation": renderSingleTestThreshold
|
|
27
|
+
},
|
|
28
|
+
"single-file": {
|
|
29
|
+
"all-pass": renderSingleFilePass,
|
|
30
|
+
"some-fail": renderSingleFileFail,
|
|
31
|
+
"threshold-violation": renderSingleFileThreshold
|
|
32
|
+
},
|
|
33
|
+
"single-project": {
|
|
34
|
+
"all-pass": renderSingleProjectPass,
|
|
35
|
+
"some-fail": renderSingleProjectFail,
|
|
36
|
+
"threshold-violation": renderSingleProjectThreshold
|
|
37
|
+
},
|
|
38
|
+
workspace: {
|
|
39
|
+
"all-pass": renderWorkspacePass,
|
|
40
|
+
"some-fail": renderWorkspaceFail,
|
|
41
|
+
"threshold-violation": renderWorkspaceThreshold
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Dispatch to the cell that matches `(inputs.shape, inputs.outcome)`
|
|
46
|
+
* and return its agent-half string output.
|
|
47
|
+
*
|
|
48
|
+
* @param inputs - the classified dispatch inputs
|
|
49
|
+
* @param opts - cell rendering options
|
|
50
|
+
* @returns the agent-string output for the matched cell
|
|
51
|
+
* @public
|
|
52
|
+
*/
|
|
53
|
+
const dispatch = (inputs, opts) => {
|
|
54
|
+
return dispatcherTable[inputs.shape][inputs.outcome].agent(inputs, opts);
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Dispatch to the cell that matches `(inputs.shape, inputs.outcome)`
|
|
58
|
+
* and return its Ink-half React tree. Returns `null` when the matched
|
|
59
|
+
* cell does not expose an `ink` half — callers should fall back to
|
|
60
|
+
* the agent string in that case.
|
|
61
|
+
*
|
|
62
|
+
* @param inputs - the classified dispatch inputs
|
|
63
|
+
* @param opts - cell rendering options
|
|
64
|
+
* @returns the Ink React element, or `null` when the cell has no Ink half
|
|
65
|
+
* @public
|
|
66
|
+
*/
|
|
67
|
+
const dispatchInk = (inputs, opts) => {
|
|
68
|
+
const cell = dispatcherTable[inputs.shape][inputs.outcome];
|
|
69
|
+
return cell.ink !== void 0 ? cell.ink(inputs, opts) : null;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
//#endregion
|
|
73
|
+
export { dispatch, dispatchInk, dispatcherTable };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
//#region src/dispatcher/footer.ts
|
|
2
|
+
const FOOTER_COVERAGE_GAP = "Use `file_coverage` to find uncovered functions.";
|
|
3
|
+
const FOOTER_FAILURE_PERSISTENT = "Use `test_errors` for failure detail; `failure_signature_get` to check known patterns.";
|
|
4
|
+
const FOOTER_FAILURE_FLAKY = "Use `failure_signature_get` to confirm the flakiness signature.";
|
|
5
|
+
const FOOTER_THRESHOLD = "Use `test_coverage` for the workspace coverage breakdown.";
|
|
6
|
+
/**
|
|
7
|
+
* Pick the dominant classification from a state's failure list.
|
|
8
|
+
*
|
|
9
|
+
* Priority order (most actionable first): `new-failure`, `persistent`,
|
|
10
|
+
* `flaky`, `recovered`, `stable`. Returns `null` when the failure list
|
|
11
|
+
* is empty or every failure is unclassified.
|
|
12
|
+
*
|
|
13
|
+
* @param state - the fully-reduced render state
|
|
14
|
+
* @returns the most actionable classification, or `null` when none applies
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
const dominantClassification = (state) => {
|
|
18
|
+
if (state.failures.length === 0) return null;
|
|
19
|
+
const priority = [
|
|
20
|
+
"new-failure",
|
|
21
|
+
"persistent",
|
|
22
|
+
"flaky",
|
|
23
|
+
"recovered",
|
|
24
|
+
"stable"
|
|
25
|
+
];
|
|
26
|
+
const present = /* @__PURE__ */ new Set();
|
|
27
|
+
for (const f of state.failures) if (f.classification !== null) present.add(f.classification);
|
|
28
|
+
for (const cls of priority) if (present.has(cls)) return cls;
|
|
29
|
+
return null;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Compose the trailing footer lines for a dispatcher cell. Returns an
|
|
33
|
+
* empty string when no pointer applies. The footer starts with a
|
|
34
|
+
* leading newline so cells can append it directly without crafting
|
|
35
|
+
* their own separator.
|
|
36
|
+
*
|
|
37
|
+
* @param inputs - the classified dispatch inputs
|
|
38
|
+
* @returns the footer string, or an empty string when no pointer applies
|
|
39
|
+
* @public
|
|
40
|
+
*/
|
|
41
|
+
const buildFooter = (inputs) => {
|
|
42
|
+
const lines = [];
|
|
43
|
+
if (inputs.outcome === "all-pass" && inputs.belowTarget.length > 0) lines.push(FOOTER_COVERAGE_GAP);
|
|
44
|
+
if (inputs.outcome === "some-fail") {
|
|
45
|
+
const cls = dominantClassification(inputs.state);
|
|
46
|
+
if (cls === "new-failure" || cls === "persistent") lines.push(FOOTER_FAILURE_PERSISTENT);
|
|
47
|
+
else if (cls === "flaky") lines.push(FOOTER_FAILURE_FLAKY);
|
|
48
|
+
}
|
|
49
|
+
if (inputs.outcome === "threshold-violation") lines.push(FOOTER_THRESHOLD);
|
|
50
|
+
if (lines.length === 0) return "";
|
|
51
|
+
return `\n${lines.join("\n")}\n`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
//#endregion
|
|
55
|
+
export { buildFooter, dominantClassification };
|