@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/index.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { classifyOutcome, classifyRunShape } from "./dispatcher/classify.js";
|
|
2
|
+
import { buildFooter, dominantClassification } from "./dispatcher/footer.js";
|
|
3
|
+
import { formatDisplayDuration } from "./format-duration.js";
|
|
4
|
+
import { dispatch, dispatchInk, dispatcherTable } from "./dispatcher/dispatch.js";
|
|
5
|
+
import { RunEventChannel, RunEventChannelLive } from "./pubsub/Channel.js";
|
|
6
|
+
import { publish, publishAll } from "./pubsub/Publisher.js";
|
|
7
|
+
import { reduceRenderState, reduceRenderStateAll } from "./reducer.js";
|
|
8
|
+
import { accumulateUntilFinished, forEachRenderState, renderStateStream, subscribeRaw } from "./pubsub/Subscriber.js";
|
|
9
|
+
import "./pubsub/index.js";
|
|
10
|
+
import { renderAgent } from "./render-agent.js";
|
|
11
|
+
import { CountColumns } from "./render-ink/CountColumns.js";
|
|
12
|
+
import { CoverageBlock } from "./render-ink/CoverageBlock.js";
|
|
13
|
+
import { FailureSection } from "./render-ink/FailureSection.js";
|
|
14
|
+
import { FailuresSection } from "./render-ink/FailuresSection.js";
|
|
15
|
+
import { StatusIcon } from "./render-ink/StatusIcon.js";
|
|
16
|
+
import { ModuleHeader } from "./render-ink/ModuleHeader.js";
|
|
17
|
+
import { ProjectRow } from "./render-ink/ProjectRow.js";
|
|
18
|
+
import { SPINNER_FRAMES, SPINNER_FRAME_MS, spinnerFrame, spinnerFrameForTime } from "./render-ink/spinner.js";
|
|
19
|
+
import { TestRow } from "./render-ink/TestRow.js";
|
|
20
|
+
import { TrendLine } from "./render-ink/TrendLine.js";
|
|
21
|
+
import { StreamApp } from "./render-ink/StreamApp.js";
|
|
22
|
+
import { SuggestedActions } from "./render-ink/SuggestedActions.js";
|
|
23
|
+
import "./render-ink/index.js";
|
|
24
|
+
import { synthesizeFromAgentReport, synthesizeRunEvents } from "./synthesize.js";
|
|
25
|
+
import { ActionSeverity, CoverageFile, CoverageGap, CoverageMetric, CoverageRenderState, FailureRecord, ModuleRecord, RenderState, RenderTotals, RunEvent, SuggestedActionRecord, TestRecord, initialRenderState } from "@vitest-agent/sdk";
|
|
26
|
+
|
|
27
|
+
//#region src/index.ts
|
|
28
|
+
/**
|
|
29
|
+
* The version of this package, inlined at build time from
|
|
30
|
+
* `package.json#version` via rslib-builder's `__PACKAGE_VERSION__`
|
|
31
|
+
* substitution. The UI package is consumed through the plugin so it does not
|
|
32
|
+
* run its own init-time drift check, but the constant is exported so the
|
|
33
|
+
* plugin can compare against it.
|
|
34
|
+
*
|
|
35
|
+
* @public
|
|
36
|
+
*/
|
|
37
|
+
const CURRENT_UI_VERSION = "1.0.0";
|
|
38
|
+
|
|
39
|
+
//#endregion
|
|
40
|
+
export { ActionSeverity, CURRENT_UI_VERSION, CountColumns, CoverageBlock, CoverageFile, CoverageGap, CoverageMetric, CoverageRenderState, FailureRecord, FailureSection, FailuresSection, ModuleHeader, ModuleRecord, ProjectRow, RenderState, RenderTotals, RunEvent, RunEventChannel, RunEventChannelLive, SPINNER_FRAMES, SPINNER_FRAME_MS, StatusIcon, StreamApp, SuggestedActionRecord, SuggestedActions, 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 };
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vitest-agent/ui",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": false,
|
|
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
|
+
"keywords": [
|
|
7
|
+
"vitest",
|
|
8
|
+
"ui",
|
|
9
|
+
"ink",
|
|
10
|
+
"react",
|
|
11
|
+
"llm",
|
|
12
|
+
"ai",
|
|
13
|
+
"agent",
|
|
14
|
+
"testing",
|
|
15
|
+
"coverage"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://github.com/spencerbeggs/vitest-agent#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/spencerbeggs/vitest-agent/issues"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/spencerbeggs/vitest-agent.git",
|
|
24
|
+
"directory": "packages/ui"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"author": {
|
|
28
|
+
"name": "C. Spencer Beggs",
|
|
29
|
+
"email": "spencer@beggs.codes",
|
|
30
|
+
"url": "https://spencerbeg.gs"
|
|
31
|
+
},
|
|
32
|
+
"type": "module",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"types": "./index.d.ts",
|
|
36
|
+
"import": "./index.js"
|
|
37
|
+
},
|
|
38
|
+
"./package.json": "./package.json"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@vitest-agent/sdk": "1.0.0",
|
|
42
|
+
"effect": "^3.21.4"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"ink": "^7.1.0",
|
|
46
|
+
"react": "^19.2.0"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"ink": {
|
|
50
|
+
"optional": false
|
|
51
|
+
},
|
|
52
|
+
"react": {
|
|
53
|
+
"optional": false
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=24.11.0"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Context, Layer, PubSub } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/pubsub/Channel.ts
|
|
4
|
+
/**
|
|
5
|
+
* Service tag for the run-event channel. Consumers depend on this tag;
|
|
6
|
+
* the {@link RunEventChannelLive} layer is the single point at which
|
|
7
|
+
* the underlying Effect `PubSub` is constructed.
|
|
8
|
+
*
|
|
9
|
+
* @public
|
|
10
|
+
*/
|
|
11
|
+
var RunEventChannel = class extends Context.Tag("vitest-agent-ui/RunEventChannel")() {};
|
|
12
|
+
/**
|
|
13
|
+
* Default live layer providing an unbounded Effect `PubSub` for
|
|
14
|
+
* the run-event channel. The PubSub is scoped — when the surrounding
|
|
15
|
+
* scope closes, the channel shuts down and any pending subscribers
|
|
16
|
+
* receive their dequeue closure.
|
|
17
|
+
*
|
|
18
|
+
* Unbounded is the right default for the plugin and CLI: events arrive
|
|
19
|
+
* faster than a slow renderer can drain (e.g., several `TestFinished`
|
|
20
|
+
* per millisecond during a large suite), and dropping events would
|
|
21
|
+
* leave the rendered state inconsistent with the runner's truth.
|
|
22
|
+
* If memory pressure becomes a concern, swap in `PubSub.sliding`
|
|
23
|
+
* with a tuned capacity at the call site.
|
|
24
|
+
*
|
|
25
|
+
* @public
|
|
26
|
+
*/
|
|
27
|
+
const RunEventChannelLive = Layer.scoped(RunEventChannel, PubSub.unbounded());
|
|
28
|
+
|
|
29
|
+
//#endregion
|
|
30
|
+
export { RunEventChannel, RunEventChannelLive };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { RunEventChannel } from "./Channel.js";
|
|
2
|
+
import { Effect, PubSub } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/pubsub/Publisher.ts
|
|
5
|
+
/**
|
|
6
|
+
* Emit one event onto the channel.
|
|
7
|
+
*
|
|
8
|
+
* Resolves when the event is enqueued. Returns the success boolean
|
|
9
|
+
* from `PubSub.publish` so callers can detect a shutdown
|
|
10
|
+
* channel if needed; in normal operation the result is always `true`.
|
|
11
|
+
*
|
|
12
|
+
* @param event - the run event to publish
|
|
13
|
+
* @returns an Effect that resolves to the publish success boolean
|
|
14
|
+
* @public
|
|
15
|
+
*/
|
|
16
|
+
const publish = (event) => Effect.gen(function* () {
|
|
17
|
+
const channel = yield* RunEventChannel;
|
|
18
|
+
return yield* PubSub.publish(channel, event);
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* Emit a batch of events onto the channel in order.
|
|
22
|
+
*
|
|
23
|
+
* Useful for the CLI replay path where the entire event sequence is
|
|
24
|
+
* materialized synchronously from a database query before any
|
|
25
|
+
* subscriber needs to read it.
|
|
26
|
+
*
|
|
27
|
+
* @param events - iterable of run events to publish in order
|
|
28
|
+
* @returns an Effect that resolves when all events are enqueued
|
|
29
|
+
* @public
|
|
30
|
+
*/
|
|
31
|
+
const publishAll = (events) => Effect.gen(function* () {
|
|
32
|
+
const channel = yield* RunEventChannel;
|
|
33
|
+
yield* PubSub.publishAll(channel, events);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
//#endregion
|
|
37
|
+
export { publish, publishAll };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { RunEventChannel } from "./Channel.js";
|
|
2
|
+
import { reduceRenderState } from "../reducer.js";
|
|
3
|
+
import { initialRenderState } from "@vitest-agent/sdk";
|
|
4
|
+
import { Effect, PubSub, Queue, Stream } from "effect";
|
|
5
|
+
|
|
6
|
+
//#region src/pubsub/Subscriber.ts
|
|
7
|
+
/**
|
|
8
|
+
* Subscribe, fold the run-event stream into a terminal
|
|
9
|
+
* `RenderState`, and return when `RunFinished` arrives.
|
|
10
|
+
*
|
|
11
|
+
* The subscription is scoped — when this effect completes (or is
|
|
12
|
+
* interrupted), the underlying PubSub releases its slot.
|
|
13
|
+
*
|
|
14
|
+
* @param initial - the initial render state to fold from
|
|
15
|
+
* @returns an Effect that resolves to the terminal render state
|
|
16
|
+
* @public
|
|
17
|
+
*/
|
|
18
|
+
const accumulateUntilFinished = (initial = initialRenderState) => Effect.scoped(Effect.gen(function* () {
|
|
19
|
+
const channel = yield* RunEventChannel;
|
|
20
|
+
const dequeue = yield* PubSub.subscribe(channel);
|
|
21
|
+
let state = initial;
|
|
22
|
+
while (state.phase !== "finished" && state.phase !== "timed-out") {
|
|
23
|
+
const event = yield* Queue.take(dequeue);
|
|
24
|
+
state = reduceRenderState(state, event);
|
|
25
|
+
}
|
|
26
|
+
return state;
|
|
27
|
+
}));
|
|
28
|
+
/**
|
|
29
|
+
* Run a callback for every projected `RenderState` as events
|
|
30
|
+
* arrive. The stream terminates when the PubSub shuts down (typically
|
|
31
|
+
* when the surrounding scope closes).
|
|
32
|
+
*
|
|
33
|
+
* The host runs this in a forked fiber so the publisher and the
|
|
34
|
+
* renderer can make progress concurrently. Returning an Effect from
|
|
35
|
+
* the callback lets the host suspend further rendering on backpressure
|
|
36
|
+
* (e.g. throttling redraws to the next animation frame).
|
|
37
|
+
*
|
|
38
|
+
* @param onState - callback invoked with each projected render state
|
|
39
|
+
* @param initial - the initial render state to fold from
|
|
40
|
+
* @returns an Effect that completes when the PubSub shuts down
|
|
41
|
+
* @public
|
|
42
|
+
*/
|
|
43
|
+
const forEachRenderState = (onState, initial = initialRenderState) => Effect.scoped(Effect.gen(function* () {
|
|
44
|
+
const channel = yield* RunEventChannel;
|
|
45
|
+
yield* (yield* Stream.fromPubSub(channel, { scoped: true })).pipe(Stream.scan(initial, reduceRenderState), Stream.runForEach(onState));
|
|
46
|
+
}));
|
|
47
|
+
/**
|
|
48
|
+
* Build an Effect `Stream` of accumulated states over the channel.
|
|
49
|
+
*
|
|
50
|
+
* Lower-level than {@link forEachRenderState} — useful when the host
|
|
51
|
+
* wants to compose with other stream operators (debouncing, throttling,
|
|
52
|
+
* tee-to-file) before running the terminal sink.
|
|
53
|
+
*
|
|
54
|
+
* @param initial - the initial render state to fold from
|
|
55
|
+
* @returns an Effect that resolves to a Stream of render states
|
|
56
|
+
* @public
|
|
57
|
+
*/
|
|
58
|
+
const renderStateStream = (initial = initialRenderState) => Effect.gen(function* () {
|
|
59
|
+
const channel = yield* RunEventChannel;
|
|
60
|
+
return (yield* Stream.fromPubSub(channel, { scoped: true })).pipe(Stream.scan(initial, reduceRenderState));
|
|
61
|
+
});
|
|
62
|
+
/**
|
|
63
|
+
* Convenience: subscribe and produce a low-level dequeue of raw
|
|
64
|
+
* `RunEvent` values. Mostly for tests and ad-hoc consumers; most
|
|
65
|
+
* application code should prefer {@link forEachRenderState} or
|
|
66
|
+
* {@link renderStateStream}.
|
|
67
|
+
*
|
|
68
|
+
* @returns an Effect that resolves to a dequeue of raw run events
|
|
69
|
+
* @public
|
|
70
|
+
*/
|
|
71
|
+
const subscribeRaw = () => Effect.gen(function* () {
|
|
72
|
+
const channel = yield* RunEventChannel;
|
|
73
|
+
return yield* PubSub.subscribe(channel);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
//#endregion
|
|
77
|
+
export { accumulateUntilFinished, forEachRenderState, renderStateStream, subscribeRaw };
|
package/pubsub/index.js
ADDED
package/reducer.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { initialRenderState } from "@vitest-agent/sdk";
|
|
2
|
+
import { Match } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/reducer.ts
|
|
5
|
+
const queuedModule = (modulePath, projectName) => ({
|
|
6
|
+
modulePath,
|
|
7
|
+
status: "queued",
|
|
8
|
+
passCount: 0,
|
|
9
|
+
failCount: 0,
|
|
10
|
+
skipCount: 0,
|
|
11
|
+
timeoutCount: 0,
|
|
12
|
+
durationMs: 0,
|
|
13
|
+
tests: [],
|
|
14
|
+
...projectName !== void 0 && { projectName }
|
|
15
|
+
});
|
|
16
|
+
const appendModuleIfNew = (state, modulePath, make) => {
|
|
17
|
+
if (state.modules[modulePath] !== void 0) return state;
|
|
18
|
+
return {
|
|
19
|
+
...state,
|
|
20
|
+
modules: {
|
|
21
|
+
...state.modules,
|
|
22
|
+
[modulePath]: make()
|
|
23
|
+
},
|
|
24
|
+
moduleOrder: [...state.moduleOrder, modulePath]
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
const updateModule = (state, modulePath, update) => {
|
|
28
|
+
const current = state.modules[modulePath];
|
|
29
|
+
if (current === void 0) {
|
|
30
|
+
const seeded = update(queuedModule(modulePath));
|
|
31
|
+
return {
|
|
32
|
+
...state,
|
|
33
|
+
modules: {
|
|
34
|
+
...state.modules,
|
|
35
|
+
[modulePath]: seeded
|
|
36
|
+
},
|
|
37
|
+
moduleOrder: [...state.moduleOrder, modulePath]
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
...state,
|
|
42
|
+
modules: {
|
|
43
|
+
...state.modules,
|
|
44
|
+
[modulePath]: update(current)
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
const upsertTest = (module, testName, suitePath, patch) => {
|
|
49
|
+
const idx = module.tests.findIndex((t) => t.testName === testName && samePath(t.suitePath, suitePath));
|
|
50
|
+
const next = {
|
|
51
|
+
testName,
|
|
52
|
+
suitePath,
|
|
53
|
+
...patch
|
|
54
|
+
};
|
|
55
|
+
if (idx === -1) return {
|
|
56
|
+
...module,
|
|
57
|
+
tests: [...module.tests, next]
|
|
58
|
+
};
|
|
59
|
+
const tests = module.tests.slice();
|
|
60
|
+
tests[idx] = next;
|
|
61
|
+
return {
|
|
62
|
+
...module,
|
|
63
|
+
tests
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
const samePath = (a, b) => {
|
|
67
|
+
if (a.length !== b.length) return false;
|
|
68
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
69
|
+
return true;
|
|
70
|
+
};
|
|
71
|
+
const recomputeTotals = (modules) => {
|
|
72
|
+
let passCount = 0;
|
|
73
|
+
let failCount = 0;
|
|
74
|
+
let skipCount = 0;
|
|
75
|
+
let timeoutCount = 0;
|
|
76
|
+
let durationMs = 0;
|
|
77
|
+
for (const m of Object.values(modules)) {
|
|
78
|
+
passCount += m.passCount;
|
|
79
|
+
failCount += m.failCount;
|
|
80
|
+
skipCount += m.skipCount;
|
|
81
|
+
timeoutCount += m.timeoutCount;
|
|
82
|
+
durationMs += m.durationMs;
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
passCount,
|
|
86
|
+
failCount,
|
|
87
|
+
skipCount,
|
|
88
|
+
timeoutCount,
|
|
89
|
+
durationMs
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
const upsertFailure = (failures, next) => {
|
|
93
|
+
const idx = failures.findIndex((f) => f.modulePath === next.modulePath && f.testName === next.testName && samePath(f.suitePath, next.suitePath));
|
|
94
|
+
if (idx === -1) return [...failures, next];
|
|
95
|
+
const out = failures.slice();
|
|
96
|
+
out[idx] = {
|
|
97
|
+
...failures[idx],
|
|
98
|
+
...next
|
|
99
|
+
};
|
|
100
|
+
return out;
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Apply a single event to the previous state and return the next.
|
|
104
|
+
*
|
|
105
|
+
* The function is pure: no I/O, no time, no randomness. Folding it
|
|
106
|
+
* over an event sequence yields the same state regardless of when the
|
|
107
|
+
* fold runs.
|
|
108
|
+
*
|
|
109
|
+
* @param state - the current render state
|
|
110
|
+
* @param event - the run event to apply
|
|
111
|
+
* @returns the next render state
|
|
112
|
+
* @public
|
|
113
|
+
*/
|
|
114
|
+
const reduceRenderState = (state, event) => {
|
|
115
|
+
const noop = () => state;
|
|
116
|
+
return Match.value(event).pipe(Match.tagsExhaustive({
|
|
117
|
+
RunStarted: (e) => ({
|
|
118
|
+
...initialRenderState,
|
|
119
|
+
phase: "running",
|
|
120
|
+
runId: e.runId,
|
|
121
|
+
configHash: e.configHash,
|
|
122
|
+
startedAt: e.startedAt
|
|
123
|
+
}),
|
|
124
|
+
ModuleQueued: (e) => appendModuleIfNew(state, e.modulePath, () => queuedModule(e.modulePath, e.projectName)),
|
|
125
|
+
ModuleStarted: (e) => updateModule(state, e.modulePath, (m) => ({
|
|
126
|
+
...m,
|
|
127
|
+
status: "running",
|
|
128
|
+
startedAt: e.startedAt,
|
|
129
|
+
...e.projectName !== void 0 && { projectName: e.projectName }
|
|
130
|
+
})),
|
|
131
|
+
TestStarted: (e) => updateModule(state, e.modulePath, (m) => upsertTest(m, e.testName, e.suitePath, {
|
|
132
|
+
status: "running",
|
|
133
|
+
durationMs: null
|
|
134
|
+
})),
|
|
135
|
+
TestFinished: (e) => {
|
|
136
|
+
const status = e.timedOut === true ? "timed-out" : e.status;
|
|
137
|
+
const withTest = updateModule(state, e.modulePath, (m) => upsertTest(m, e.testName, e.suitePath, {
|
|
138
|
+
status,
|
|
139
|
+
durationMs: e.durationMs,
|
|
140
|
+
...e.error !== void 0 && { error: e.error }
|
|
141
|
+
}));
|
|
142
|
+
if (e.status !== "failed") return withTest;
|
|
143
|
+
return {
|
|
144
|
+
...withTest,
|
|
145
|
+
failures: upsertFailure(withTest.failures, {
|
|
146
|
+
modulePath: e.modulePath,
|
|
147
|
+
testName: e.testName,
|
|
148
|
+
suitePath: e.suitePath,
|
|
149
|
+
...e.error !== void 0 && { error: e.error },
|
|
150
|
+
...e.timedOut === true && { timedOut: true },
|
|
151
|
+
classification: null
|
|
152
|
+
})
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
ModuleFinished: (e) => {
|
|
156
|
+
const next = updateModule(state, e.modulePath, (m) => ({
|
|
157
|
+
...m,
|
|
158
|
+
status: "finished",
|
|
159
|
+
passCount: e.passCount,
|
|
160
|
+
failCount: e.failCount,
|
|
161
|
+
skipCount: e.skipCount,
|
|
162
|
+
timeoutCount: e.timeoutCount ?? 0,
|
|
163
|
+
durationMs: e.durationMs,
|
|
164
|
+
...e.projectName !== void 0 && { projectName: e.projectName },
|
|
165
|
+
...e.tagCounts !== void 0 && { tagCounts: e.tagCounts }
|
|
166
|
+
}));
|
|
167
|
+
return {
|
|
168
|
+
...next,
|
|
169
|
+
totals: recomputeTotals(next.modules)
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
CoverageReady: (e) => ({
|
|
173
|
+
...state,
|
|
174
|
+
coverage: {
|
|
175
|
+
metrics: e.metrics,
|
|
176
|
+
thresholds: e.thresholds,
|
|
177
|
+
gaps: e.gaps,
|
|
178
|
+
violations: state.coverage?.violations ?? []
|
|
179
|
+
}
|
|
180
|
+
}),
|
|
181
|
+
ThresholdViolation: (e) => {
|
|
182
|
+
if (state.coverage === null) return state;
|
|
183
|
+
return {
|
|
184
|
+
...state,
|
|
185
|
+
coverage: {
|
|
186
|
+
...state.coverage,
|
|
187
|
+
violations: [...state.coverage.violations, {
|
|
188
|
+
metric: e.metric,
|
|
189
|
+
expected: e.expected,
|
|
190
|
+
actual: e.actual
|
|
191
|
+
}]
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
FailureClassified: (e) => ({
|
|
196
|
+
...state,
|
|
197
|
+
failures: state.failures.map((f) => f.modulePath === e.modulePath && f.testName === e.testName ? {
|
|
198
|
+
...f,
|
|
199
|
+
classification: e.classification
|
|
200
|
+
} : f)
|
|
201
|
+
}),
|
|
202
|
+
SuggestedAction: (e) => ({
|
|
203
|
+
...state,
|
|
204
|
+
suggestedActions: [...state.suggestedActions, {
|
|
205
|
+
severity: e.severity,
|
|
206
|
+
title: e.title,
|
|
207
|
+
detail: e.detail,
|
|
208
|
+
...e.targetTool !== void 0 && { targetTool: e.targetTool }
|
|
209
|
+
}]
|
|
210
|
+
}),
|
|
211
|
+
RunFinished: (e) => ({
|
|
212
|
+
...state,
|
|
213
|
+
phase: "finished",
|
|
214
|
+
finishedAt: e.finishedAt,
|
|
215
|
+
totals: {
|
|
216
|
+
passCount: e.passCount,
|
|
217
|
+
failCount: e.failCount,
|
|
218
|
+
skipCount: e.skipCount,
|
|
219
|
+
timeoutCount: e.timeoutCount ?? state.totals.timeoutCount,
|
|
220
|
+
durationMs: e.durationMs
|
|
221
|
+
}
|
|
222
|
+
}),
|
|
223
|
+
RunTimedOut: () => ({
|
|
224
|
+
...state,
|
|
225
|
+
phase: "timed-out"
|
|
226
|
+
}),
|
|
227
|
+
ModuleCollected: noop,
|
|
228
|
+
SuiteStarted: noop,
|
|
229
|
+
SuiteFinished: noop,
|
|
230
|
+
HookStarted: noop,
|
|
231
|
+
HookFinished: noop,
|
|
232
|
+
ConsoleLog: noop,
|
|
233
|
+
TestAnnotated: noop,
|
|
234
|
+
TestArtifactRecorded: noop,
|
|
235
|
+
WatcherReady: noop,
|
|
236
|
+
WatcherRerun: noop,
|
|
237
|
+
TrendComputed: (e) => ({
|
|
238
|
+
...state,
|
|
239
|
+
trend: {
|
|
240
|
+
direction: e.direction,
|
|
241
|
+
runCount: e.runCount
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
}));
|
|
245
|
+
};
|
|
246
|
+
/**
|
|
247
|
+
* Fold a sequence of events into a final `RenderState`.
|
|
248
|
+
*
|
|
249
|
+
* Convenience for tests, replays, and the agent renderer's
|
|
250
|
+
* accumulate-then-render-once path.
|
|
251
|
+
*
|
|
252
|
+
* @param events - the ordered event sequence to fold
|
|
253
|
+
* @param seed - the initial render state (defaults to `initialRenderState`)
|
|
254
|
+
* @returns the terminal render state after all events are applied
|
|
255
|
+
* @public
|
|
256
|
+
*/
|
|
257
|
+
const reduceRenderStateAll = (events, seed = initialRenderState) => events.reduce(reduceRenderState, seed);
|
|
258
|
+
|
|
259
|
+
//#endregion
|
|
260
|
+
export { reduceRenderState, reduceRenderStateAll };
|
package/render-agent.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { formatDisplayDuration } from "./format-duration.js";
|
|
2
|
+
|
|
3
|
+
//#region src/render-agent.ts
|
|
4
|
+
const DEFAULT_WIDTH = 80;
|
|
5
|
+
const DEFAULT_MAX_GAPS = 3;
|
|
6
|
+
const severityLabel = {
|
|
7
|
+
info: "info",
|
|
8
|
+
warn: "warn",
|
|
9
|
+
blocker: "blocker"
|
|
10
|
+
};
|
|
11
|
+
const formatHeader = (state) => {
|
|
12
|
+
const { passCount, failCount, skipCount, durationMs } = state.totals;
|
|
13
|
+
const parts = [`${passCount}/${passCount + failCount + skipCount} passed`];
|
|
14
|
+
if (failCount > 0) parts.push(`${failCount} failed`);
|
|
15
|
+
if (skipCount > 0) parts.push(`${skipCount} skipped`);
|
|
16
|
+
return `Tests: ${parts.join(", ")} (${formatDisplayDuration(durationMs)})`;
|
|
17
|
+
};
|
|
18
|
+
const formatModulesSection = (state) => {
|
|
19
|
+
const modules = state.moduleOrder.map((path) => state.modules[path]).filter((m) => m !== void 0);
|
|
20
|
+
if (modules.length === 0) return null;
|
|
21
|
+
if (modules.filter((m) => m.failCount > 0 || m.skipCount > 0).length === 0) {
|
|
22
|
+
const noun = modules.length === 1 ? "module" : "modules";
|
|
23
|
+
return `${modules.length} ${noun} all-passed.`;
|
|
24
|
+
}
|
|
25
|
+
const lines = ["Modules:"];
|
|
26
|
+
for (const m of modules) {
|
|
27
|
+
const parts = [];
|
|
28
|
+
if (m.passCount > 0) parts.push(`${m.passCount} passed`);
|
|
29
|
+
if (m.failCount > 0) parts.push(`${m.failCount} failed`);
|
|
30
|
+
if (m.skipCount > 0) parts.push(`${m.skipCount} skipped`);
|
|
31
|
+
const summary = parts.length > 0 ? parts.join(", ") : `status=${m.status}`;
|
|
32
|
+
lines.push(`- ${m.modulePath}: ${summary}`);
|
|
33
|
+
}
|
|
34
|
+
return lines.join("\n");
|
|
35
|
+
};
|
|
36
|
+
const truncate = (line, max) => {
|
|
37
|
+
if (line.length <= max) return line;
|
|
38
|
+
const slice = max - 1;
|
|
39
|
+
if (slice <= 0) return "…";
|
|
40
|
+
return `${line.slice(0, slice)}…`;
|
|
41
|
+
};
|
|
42
|
+
const formatFailure = (f, width, includeStack) => {
|
|
43
|
+
const suite = f.suitePath.length > 0 ? `${f.suitePath.join(" > ")} > ` : "";
|
|
44
|
+
const classification = f.classification !== null ? ` [${f.classification}]` : "";
|
|
45
|
+
const lines = [`- ${f.modulePath} > ${suite}${f.testName}${classification}`];
|
|
46
|
+
if (f.error?.message !== void 0) {
|
|
47
|
+
const firstLine = f.error.message.split("\n", 1)[0] ?? "";
|
|
48
|
+
lines.push(` ${truncate(firstLine, Math.max(20, width - 2))}`);
|
|
49
|
+
}
|
|
50
|
+
if (f.error?.diff !== void 0) for (const diffLine of f.error.diff.split("\n")) lines.push(` ${truncate(diffLine, Math.max(20, width - 2))}`);
|
|
51
|
+
if (includeStack && f.error?.stack !== void 0) for (const stackLine of f.error.stack.split("\n")) lines.push(` ${truncate(stackLine, Math.max(20, width - 2))}`);
|
|
52
|
+
return lines.join("\n");
|
|
53
|
+
};
|
|
54
|
+
const formatFailuresSection = (state, width, includeStack) => {
|
|
55
|
+
if (state.failures.length === 0) return null;
|
|
56
|
+
const lines = ["Failures:"];
|
|
57
|
+
for (const f of state.failures) lines.push(formatFailure(f, width, includeStack));
|
|
58
|
+
return lines.join("\n");
|
|
59
|
+
};
|
|
60
|
+
const formatPercent = (n) => {
|
|
61
|
+
const rounded = Math.round(n * 10) / 10;
|
|
62
|
+
return Number.isInteger(rounded) ? `${rounded}%` : `${rounded}%`;
|
|
63
|
+
};
|
|
64
|
+
const formatCoverageSection = (state, maxGaps) => {
|
|
65
|
+
const cov = state.coverage;
|
|
66
|
+
if (cov === null) return null;
|
|
67
|
+
const lines = ["Coverage:"];
|
|
68
|
+
for (const m of [
|
|
69
|
+
"lines",
|
|
70
|
+
"branches",
|
|
71
|
+
"functions",
|
|
72
|
+
"statements"
|
|
73
|
+
]) {
|
|
74
|
+
const actual = cov.metrics[m];
|
|
75
|
+
const threshold = cov.thresholds[m];
|
|
76
|
+
const thresholdNote = threshold !== void 0 ? ` (threshold ${formatPercent(threshold)})` : "";
|
|
77
|
+
lines.push(`- ${m}: ${formatPercent(actual)}${thresholdNote}`);
|
|
78
|
+
}
|
|
79
|
+
if (cov.violations.length > 0) {
|
|
80
|
+
lines.push("Violations:");
|
|
81
|
+
for (const v of cov.violations) lines.push(`- ${v.metric}: ${formatPercent(v.actual)} < ${formatPercent(v.expected)}`);
|
|
82
|
+
}
|
|
83
|
+
if (maxGaps > 0 && cov.gaps.length > 0) {
|
|
84
|
+
const topN = [...cov.gaps].sort((a, b) => b.missing.lines - a.missing.lines).slice(0, maxGaps);
|
|
85
|
+
lines.push("Gaps:");
|
|
86
|
+
for (const g of topN) {
|
|
87
|
+
const detail = g.uncoveredLines !== void 0 ? `: ${g.uncoveredLines}` : "";
|
|
88
|
+
lines.push(`- ${g.file}${detail}`);
|
|
89
|
+
}
|
|
90
|
+
const omitted = cov.gaps.length - topN.length;
|
|
91
|
+
if (omitted > 0) {
|
|
92
|
+
const noun = omitted === 1 ? "gap" : "gaps";
|
|
93
|
+
lines.push(`- (+${omitted} more ${noun})`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
};
|
|
98
|
+
const formatActionsSection = (state) => {
|
|
99
|
+
if (state.suggestedActions.length === 0) return null;
|
|
100
|
+
const lines = ["Actions:"];
|
|
101
|
+
for (const a of state.suggestedActions) lines.push(formatAction(a));
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
};
|
|
104
|
+
const formatAction = (a) => {
|
|
105
|
+
const tool = a.targetTool !== void 0 ? ` (tool: ${a.targetTool})` : "";
|
|
106
|
+
return `- ${severityLabel[a.severity]}: ${a.title}${tool}\n ${a.detail}`;
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Project the reduced `RenderState` into a single string suitable
|
|
110
|
+
* for emission once at the end of a run.
|
|
111
|
+
*
|
|
112
|
+
* The output is deterministic given a stable input — folding the same
|
|
113
|
+
* event sequence twice and rendering produces byte-identical strings.
|
|
114
|
+
*
|
|
115
|
+
* @param state - the fully-reduced render state
|
|
116
|
+
* @param options - optional output-shaping options
|
|
117
|
+
* @returns the agent-format string
|
|
118
|
+
* @public
|
|
119
|
+
*/
|
|
120
|
+
const renderAgent = (state, options = {}) => {
|
|
121
|
+
const width = options.width ?? DEFAULT_WIDTH;
|
|
122
|
+
const maxGaps = options.maxCoverageGaps ?? DEFAULT_MAX_GAPS;
|
|
123
|
+
const includeStack = options.includeStack ?? false;
|
|
124
|
+
const sections = [formatHeader(state)];
|
|
125
|
+
const failures = formatFailuresSection(state, width, includeStack);
|
|
126
|
+
if (failures !== null) sections.push(failures);
|
|
127
|
+
const modules = formatModulesSection(state);
|
|
128
|
+
if (modules !== null) sections.push(modules);
|
|
129
|
+
const coverage = formatCoverageSection(state, maxGaps);
|
|
130
|
+
if (coverage !== null) sections.push(coverage);
|
|
131
|
+
const actions = formatActionsSection(state);
|
|
132
|
+
if (actions !== null) sections.push(actions);
|
|
133
|
+
return `${sections.join("\n\n")}\n`;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
//#endregion
|
|
137
|
+
export { renderAgent };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Text } from "ink";
|
|
2
|
+
import { jsxs } from "react/jsx-runtime";
|
|
3
|
+
|
|
4
|
+
//#region src/render-ink/CountColumns.tsx
|
|
5
|
+
/**
|
|
6
|
+
* The four-outcome count columns for an aggregate row (a project or a
|
|
7
|
+
* module): pass, fail, skip, timeout. All four always render — a zero
|
|
8
|
+
* is dimmed, never omitted — so the columns line up across rows.
|
|
9
|
+
*/
|
|
10
|
+
const cell = (count, glyph, color) => count > 0 ? /* @__PURE__ */ jsxs(Text, {
|
|
11
|
+
color,
|
|
12
|
+
children: [count, glyph]
|
|
13
|
+
}) : /* @__PURE__ */ jsxs(Text, {
|
|
14
|
+
color: "gray",
|
|
15
|
+
children: [count, glyph]
|
|
16
|
+
});
|
|
17
|
+
/**
|
|
18
|
+
* Renders four colored count columns (pass ✓, fail ✗, skip ↷, timeout ⧖)
|
|
19
|
+
* for an aggregate row. Zeros are dimmed; all four columns always appear.
|
|
20
|
+
*
|
|
21
|
+
* @public
|
|
22
|
+
*/
|
|
23
|
+
const CountColumns = ({ passCount, failCount, skipCount, timeoutCount }) => /* @__PURE__ */ jsxs(Text, { children: [
|
|
24
|
+
cell(passCount, "✓", "green"),
|
|
25
|
+
" ",
|
|
26
|
+
cell(failCount, "✗", "red"),
|
|
27
|
+
" ",
|
|
28
|
+
cell(skipCount, "↷", "yellow"),
|
|
29
|
+
" ",
|
|
30
|
+
cell(timeoutCount, "⧖", "#e09a4e")
|
|
31
|
+
] });
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
34
|
+
export { CountColumns };
|