@alchemy.run/test 0.0.0 → 2.0.0-beta.86
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 +201 -0
- package/bin/alchemy-test.ts +2 -0
- package/lib/Alchemy.d.ts +77 -0
- package/lib/Alchemy.d.ts.map +1 -0
- package/lib/Alchemy.js +195 -0
- package/lib/Alchemy.js.map +1 -0
- package/lib/Api.d.ts +135 -0
- package/lib/Api.d.ts.map +1 -0
- package/lib/Api.js +245 -0
- package/lib/Api.js.map +1 -0
- package/lib/Bun.d.ts +66 -0
- package/lib/Bun.d.ts.map +1 -0
- package/lib/Bun.js +142 -0
- package/lib/Bun.js.map +1 -0
- package/lib/Capture.d.ts +10 -0
- package/lib/Capture.d.ts.map +1 -0
- package/lib/Capture.js +68 -0
- package/lib/Capture.js.map +1 -0
- package/lib/Core.d.ts +301 -0
- package/lib/Core.d.ts.map +1 -0
- package/lib/Core.js +442 -0
- package/lib/Core.js.map +1 -0
- package/lib/DevCli.d.ts +47 -0
- package/lib/DevCli.d.ts.map +1 -0
- package/lib/DevCli.js +181 -0
- package/lib/DevCli.js.map +1 -0
- package/lib/Expect.d.ts +84 -0
- package/lib/Expect.d.ts.map +1 -0
- package/lib/Expect.js +518 -0
- package/lib/Expect.js.map +1 -0
- package/lib/FileLog.d.ts +27 -0
- package/lib/FileLog.d.ts.map +1 -0
- package/lib/FileLog.js +136 -0
- package/lib/FileLog.js.map +1 -0
- package/lib/Http.d.ts +109 -0
- package/lib/Http.d.ts.map +1 -0
- package/lib/Http.js +123 -0
- package/lib/Http.js.map +1 -0
- package/lib/Model.d.ts +111 -0
- package/lib/Model.d.ts.map +1 -0
- package/lib/Model.js +59 -0
- package/lib/Model.js.map +1 -0
- package/lib/PlainReporter.d.ts +18 -0
- package/lib/PlainReporter.d.ts.map +1 -0
- package/lib/PlainReporter.js +274 -0
- package/lib/PlainReporter.js.map +1 -0
- package/lib/Plan.d.ts +19 -0
- package/lib/Plan.d.ts.map +1 -0
- package/lib/Plan.js +32 -0
- package/lib/Plan.js.map +1 -0
- package/lib/Registry.d.ts +34 -0
- package/lib/Registry.d.ts.map +1 -0
- package/lib/Registry.js +80 -0
- package/lib/Registry.js.map +1 -0
- package/lib/Reporter.d.ts +160 -0
- package/lib/Reporter.d.ts.map +1 -0
- package/lib/Reporter.js +9 -0
- package/lib/Reporter.js.map +1 -0
- package/lib/Runner.d.ts +51 -0
- package/lib/Runner.d.ts.map +1 -0
- package/lib/Runner.js +1075 -0
- package/lib/Runner.js.map +1 -0
- package/lib/StrayOutput.d.ts +8 -0
- package/lib/StrayOutput.d.ts.map +1 -0
- package/lib/StrayOutput.js +259 -0
- package/lib/StrayOutput.js.map +1 -0
- package/lib/Tags.d.ts +17 -0
- package/lib/Tags.d.ts.map +1 -0
- package/lib/Tags.js +84 -0
- package/lib/Tags.js.map +1 -0
- package/lib/TestState.d.ts +12 -0
- package/lib/TestState.d.ts.map +1 -0
- package/lib/TestState.js +15 -0
- package/lib/TestState.js.map +1 -0
- package/lib/Tui.d.ts +4 -0
- package/lib/Tui.d.ts.map +1 -0
- package/lib/Tui.js +1137 -0
- package/lib/Tui.js.map +1 -0
- package/lib/Vitest.d.ts +58 -0
- package/lib/Vitest.d.ts.map +1 -0
- package/lib/Vitest.js +131 -0
- package/lib/Vitest.js.map +1 -0
- package/lib/WorktreeStage.d.ts +17 -0
- package/lib/WorktreeStage.d.ts.map +1 -0
- package/lib/WorktreeStage.js +48 -0
- package/lib/WorktreeStage.js.map +1 -0
- package/lib/cli.d.ts +2 -0
- package/lib/cli.d.ts.map +1 -0
- package/lib/cli.js +222 -0
- package/lib/cli.js.map +1 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +12 -0
- package/lib/index.js.map +1 -0
- package/package.json +73 -3
- package/src/Alchemy.ts +337 -0
- package/src/Api.ts +522 -0
- package/src/Bun.ts +254 -0
- package/src/Capture.ts +72 -0
- package/src/Core.ts +906 -0
- package/src/DevCli.ts +229 -0
- package/src/Expect.ts +763 -0
- package/src/FileLog.ts +158 -0
- package/src/Http.ts +201 -0
- package/src/Model.ts +178 -0
- package/src/PlainReporter.ts +338 -0
- package/src/Plan.ts +45 -0
- package/src/Registry.ts +101 -0
- package/src/Reporter.ts +171 -0
- package/src/Runner.ts +1361 -0
- package/src/StrayOutput.ts +283 -0
- package/src/Tags.ts +100 -0
- package/src/TestState.ts +35 -0
- package/src/Tui.ts +1272 -0
- package/src/Vitest.ts +238 -0
- package/src/WorktreeStage.ts +45 -0
- package/src/cli.ts +330 -0
- package/src/index.ts +58 -0
- package/README.md +0 -9
package/lib/Tui.js
ADDED
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive opentui reporter — a k9s-style live view of the run.
|
|
3
|
+
*
|
|
4
|
+
* Layout:
|
|
5
|
+
* header — run stats (pass/fail/running/queued, elapsed, import progress)
|
|
6
|
+
* list — files with their tests nested inside (expand with enter / l)
|
|
7
|
+
* detail — Enter on a test opens its error + captured output
|
|
8
|
+
* footer — key hints / filter input / status toggles
|
|
9
|
+
*
|
|
10
|
+
* Keys (vim-style):
|
|
11
|
+
* j/k or arrows move one line space / ^d ^u / ^f ^b page / half page
|
|
12
|
+
* gg / G top / bottom / live type-to-filter
|
|
13
|
+
* enter open test / toggle h / l collapse / expand file
|
|
14
|
+
* e / c expand / collapse all visible files
|
|
15
|
+
* esc back / clear filter p f n s toggle pass/fail/pending/skipped
|
|
16
|
+
* r retry test / file R retry all failed tests
|
|
17
|
+
* x kill running test
|
|
18
|
+
* y copy error+output q quit
|
|
19
|
+
*
|
|
20
|
+
* Mouse: the wheel scrolls the viewable content without moving the selected
|
|
21
|
+
* line; clicking a row selects it. Drag-to-select text works in the detail
|
|
22
|
+
* pane (enter) and copies to the clipboard on release (OSC52 + pbcopy);
|
|
23
|
+
* list rows are not text-selectable — they're a recycled pool, and an
|
|
24
|
+
* anchored selection highlight would leave residue as rows repaint (use `y`
|
|
25
|
+
* to copy a row's details instead).
|
|
26
|
+
*
|
|
27
|
+
* Colors follow the terminal theme: default foreground/background
|
|
28
|
+
* everywhere, ANSI palette colors only for status glyphs, and a subtle
|
|
29
|
+
* theme-aware background for the selected row (neovim-style).
|
|
30
|
+
*
|
|
31
|
+
* Performance: the list is a fixed pool of Text rows (one per terminal
|
|
32
|
+
* line) painted from the visible window only — updates are O(window), never
|
|
33
|
+
* O(total tests).
|
|
34
|
+
*/
|
|
35
|
+
import { BoxRenderable, bg, CliRenderEvents, createCliRenderer, dim, green, red, ScrollBoxRenderable, strikethrough, StyledText, stringToStyledText, TextRenderable, yellow, } from "@opentui/core";
|
|
36
|
+
import * as Effect from "effect/Effect";
|
|
37
|
+
import * as Layer from "effect/Layer";
|
|
38
|
+
import { Reporter, } from "./Reporter.js";
|
|
39
|
+
import { captureStrayOutput } from "./StrayOutput.js";
|
|
40
|
+
const groupOf = (status) => {
|
|
41
|
+
switch (status) {
|
|
42
|
+
case "pass":
|
|
43
|
+
return "pass";
|
|
44
|
+
case "fail":
|
|
45
|
+
return "fail";
|
|
46
|
+
case "queued":
|
|
47
|
+
case "running":
|
|
48
|
+
return "pending";
|
|
49
|
+
case "skip":
|
|
50
|
+
case "todo":
|
|
51
|
+
return "skipped";
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const GLYPH = {
|
|
55
|
+
queued: "·",
|
|
56
|
+
running: "◐",
|
|
57
|
+
pass: "✓",
|
|
58
|
+
fail: "✗",
|
|
59
|
+
skip: "↓",
|
|
60
|
+
todo: "○",
|
|
61
|
+
};
|
|
62
|
+
/** ANSI palette chunk for a status glyph — follows the terminal theme. */
|
|
63
|
+
const statusChunk = (status, text) => {
|
|
64
|
+
switch (status) {
|
|
65
|
+
case "pass":
|
|
66
|
+
return green(text);
|
|
67
|
+
case "fail":
|
|
68
|
+
return red(text);
|
|
69
|
+
case "running":
|
|
70
|
+
case "todo":
|
|
71
|
+
// todo is warning-colored: unimplemented coverage must not blend in.
|
|
72
|
+
return yellow(text);
|
|
73
|
+
case "queued":
|
|
74
|
+
case "skip":
|
|
75
|
+
return dim(text);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const formatDuration = (ms) => ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`;
|
|
79
|
+
// Captured output is replayed verbatim (ANSI is stripped at capture time).
|
|
80
|
+
const formatLogs = (logs) => logs.map((log) => log.message).join("\n");
|
|
81
|
+
const makeCounts = () => ({
|
|
82
|
+
queued: 0,
|
|
83
|
+
running: 0,
|
|
84
|
+
pass: 0,
|
|
85
|
+
fail: 0,
|
|
86
|
+
skip: 0,
|
|
87
|
+
todo: 0,
|
|
88
|
+
});
|
|
89
|
+
const entryLabel = (entry) => `${entry.meta.titlePath.join(" > ")}` +
|
|
90
|
+
(entry.result !== undefined && entry.status !== "queued" && entry.status !== "running"
|
|
91
|
+
? ` (${formatDuration(entry.result.durationMs)})`
|
|
92
|
+
: "");
|
|
93
|
+
const fileSummary = (node) => {
|
|
94
|
+
const c = node.counts;
|
|
95
|
+
const parts = [
|
|
96
|
+
...(c.fail > 0 ? [`${c.fail} failed`] : []),
|
|
97
|
+
...(c.running > 0 ? [`${c.running} running`] : []),
|
|
98
|
+
`${c.pass}/${node.tests.length - c.skip - c.todo} passed`,
|
|
99
|
+
...(c.skip > 0 ? [`${c.skip} skipped`] : []),
|
|
100
|
+
...(c.todo > 0 ? [`${c.todo} todo`] : []),
|
|
101
|
+
];
|
|
102
|
+
return parts.join(" · ");
|
|
103
|
+
};
|
|
104
|
+
const visibleFileStatus = (node, tests) => {
|
|
105
|
+
if (tests.some((test) => test.status === "fail"))
|
|
106
|
+
return "fail";
|
|
107
|
+
if (node.hook !== undefined &&
|
|
108
|
+
tests.some((test) => test.status === "running" || test.status === "queued")) {
|
|
109
|
+
return "running";
|
|
110
|
+
}
|
|
111
|
+
if (tests.some((test) => test.status === "running"))
|
|
112
|
+
return "running";
|
|
113
|
+
if (tests.some((test) => test.status === "queued"))
|
|
114
|
+
return "queued";
|
|
115
|
+
if (tests.some((test) => test.status === "pass"))
|
|
116
|
+
return "pass";
|
|
117
|
+
if (tests.some((test) => test.status === "todo"))
|
|
118
|
+
return "todo";
|
|
119
|
+
return "skip";
|
|
120
|
+
};
|
|
121
|
+
/** Why a file's tests are still gray — surfaced on the file row. */
|
|
122
|
+
const filePhase = (node) => {
|
|
123
|
+
if (node.hook !== undefined) {
|
|
124
|
+
return `${node.hook} running (${formatDuration(Date.now() - node.hookStartedAt)})…`;
|
|
125
|
+
}
|
|
126
|
+
if (!node.started && node.counts.queued > 0) {
|
|
127
|
+
return "waiting for a worker slot…";
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
};
|
|
131
|
+
class TuiState {
|
|
132
|
+
byId = new Map();
|
|
133
|
+
files = new Map();
|
|
134
|
+
fileOrder = [];
|
|
135
|
+
fileLogs = new Map();
|
|
136
|
+
/** Independent status groups; only failed and pending show by default. */
|
|
137
|
+
show = {
|
|
138
|
+
pass: false,
|
|
139
|
+
fail: true,
|
|
140
|
+
pending: true,
|
|
141
|
+
skipped: false,
|
|
142
|
+
};
|
|
143
|
+
/** Active filter query ("" = none). Applied live on every keystroke. */
|
|
144
|
+
filter = "";
|
|
145
|
+
/** True while the footer is capturing filter keystrokes. */
|
|
146
|
+
filterInput = false;
|
|
147
|
+
detailOpen = false;
|
|
148
|
+
selectedIndex = 0;
|
|
149
|
+
topIndex = 0;
|
|
150
|
+
/**
|
|
151
|
+
* When true the viewport tracks the selection (keyboard navigation).
|
|
152
|
+
* Mouse-wheel scrolling detaches it; the next keyboard move re-attaches.
|
|
153
|
+
*/
|
|
154
|
+
viewportFollows = true;
|
|
155
|
+
summary;
|
|
156
|
+
controller;
|
|
157
|
+
startedAt = Date.now();
|
|
158
|
+
collectTotal = 0;
|
|
159
|
+
collectDone = 0;
|
|
160
|
+
dirty = true;
|
|
161
|
+
counts = makeCounts();
|
|
162
|
+
fileNode(file) {
|
|
163
|
+
let node = this.files.get(file);
|
|
164
|
+
if (node === undefined) {
|
|
165
|
+
node = {
|
|
166
|
+
file,
|
|
167
|
+
tests: [],
|
|
168
|
+
counts: makeCounts(),
|
|
169
|
+
expanded: undefined,
|
|
170
|
+
started: false,
|
|
171
|
+
hook: undefined,
|
|
172
|
+
hookStartedAt: 0,
|
|
173
|
+
};
|
|
174
|
+
this.files.set(file, node);
|
|
175
|
+
this.fileOrder.push(node);
|
|
176
|
+
}
|
|
177
|
+
return node;
|
|
178
|
+
}
|
|
179
|
+
upsert(meta, status, result) {
|
|
180
|
+
let entry = this.byId.get(meta.id);
|
|
181
|
+
if (entry === undefined) {
|
|
182
|
+
const file = this.fileNode(meta.file);
|
|
183
|
+
entry = { meta, file, status, label: "" };
|
|
184
|
+
this.byId.set(meta.id, entry);
|
|
185
|
+
file.tests.push(entry);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
this.counts[entry.status]--;
|
|
189
|
+
entry.file.counts[entry.status]--;
|
|
190
|
+
}
|
|
191
|
+
this.counts[status]++;
|
|
192
|
+
entry.file.counts[status]++;
|
|
193
|
+
entry.status = status;
|
|
194
|
+
if (status === "running") {
|
|
195
|
+
// (Re)started — retries included: drop the stale result so the row
|
|
196
|
+
// shows a fresh live timer instead of the previous run's duration.
|
|
197
|
+
entry.result = undefined;
|
|
198
|
+
entry.startedAt = Date.now();
|
|
199
|
+
}
|
|
200
|
+
if (result !== undefined)
|
|
201
|
+
entry.result = result;
|
|
202
|
+
entry.label = entryLabel(entry);
|
|
203
|
+
this.dirty = true;
|
|
204
|
+
}
|
|
205
|
+
allGroupsShown() {
|
|
206
|
+
return this.show.pass && this.show.fail && this.show.pending && this.show.skipped;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Toggle a status group on/off, independently of the others (pressing `p`
|
|
210
|
+
* hides passing tests and NOTHING else). Hiding every group is allowed —
|
|
211
|
+
* the list is simply empty and the footer's struck-through toggle bar
|
|
212
|
+
* shows why.
|
|
213
|
+
*/
|
|
214
|
+
toggleGroup(group) {
|
|
215
|
+
this.show[group] = !this.show[group];
|
|
216
|
+
this.dirty = true;
|
|
217
|
+
}
|
|
218
|
+
matches(entry) {
|
|
219
|
+
if (!this.show[groupOf(entry.status)])
|
|
220
|
+
return false;
|
|
221
|
+
if (this.filter === "")
|
|
222
|
+
return true;
|
|
223
|
+
const haystack = `${entry.meta.file} ${entry.meta.titlePath.join(" ")}`.toLowerCase();
|
|
224
|
+
// Every whitespace-separated word must match somewhere (AND semantics).
|
|
225
|
+
return this.filter
|
|
226
|
+
.toLowerCase()
|
|
227
|
+
.split(/\s+/)
|
|
228
|
+
.filter((w) => w !== "")
|
|
229
|
+
.every((word) => haystack.includes(word));
|
|
230
|
+
}
|
|
231
|
+
isExpanded(node) {
|
|
232
|
+
// Expansion is ALWAYS a deliberate choice (enter / l) — failures don't
|
|
233
|
+
// auto-expand their file (the red glyph + count already flag it), and
|
|
234
|
+
// filtering narrows WHICH files are listed without expanding them.
|
|
235
|
+
return node.expanded === true;
|
|
236
|
+
}
|
|
237
|
+
/** Flattened visible tree: file rows with expanded tests nested inside. */
|
|
238
|
+
visible() {
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const node of this.fileOrder) {
|
|
241
|
+
const tests = node.tests.filter((t) => this.matches(t));
|
|
242
|
+
if (tests.length === 0)
|
|
243
|
+
continue;
|
|
244
|
+
const expanded = this.isExpanded(node);
|
|
245
|
+
out.push({ kind: "file", node, status: visibleFileStatus(node, tests) });
|
|
246
|
+
if (expanded) {
|
|
247
|
+
for (const entry of tests)
|
|
248
|
+
out.push({ kind: "test", entry });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
visibleTestCount() {
|
|
254
|
+
let count = 0;
|
|
255
|
+
for (const node of this.visible()) {
|
|
256
|
+
if (node.kind === "test")
|
|
257
|
+
count++;
|
|
258
|
+
}
|
|
259
|
+
return count;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Clipboard
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
/** OSC52 first (works over ssh in modern terminals), then a system tool. */
|
|
266
|
+
const copyToClipboard = (renderer, text) => {
|
|
267
|
+
let copied = false;
|
|
268
|
+
try {
|
|
269
|
+
copied = renderer.copyToClipboardOSC52(text);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
copied = false;
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
const cmd = process.platform === "darwin"
|
|
276
|
+
? ["pbcopy"]
|
|
277
|
+
: process.platform === "win32"
|
|
278
|
+
? ["clip"]
|
|
279
|
+
: ["xclip", "-selection", "clipboard"];
|
|
280
|
+
const proc = Bun.spawn(cmd, {
|
|
281
|
+
stdin: "pipe",
|
|
282
|
+
stdout: "ignore",
|
|
283
|
+
stderr: "ignore",
|
|
284
|
+
});
|
|
285
|
+
proc.stdin.write(text);
|
|
286
|
+
proc.stdin.end();
|
|
287
|
+
copied = true;
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// no system clipboard tool — OSC52 result stands
|
|
291
|
+
}
|
|
292
|
+
return copied;
|
|
293
|
+
};
|
|
294
|
+
const detailContent = (state, entry) => {
|
|
295
|
+
const { meta, result } = entry;
|
|
296
|
+
const running = entry.status === "running";
|
|
297
|
+
const lines = [
|
|
298
|
+
`${meta.file} > ${meta.titlePath.join(" > ")}`,
|
|
299
|
+
`status: ${entry.status}${running && entry.startedAt !== undefined
|
|
300
|
+
? ` elapsed: ${formatDuration(Date.now() - entry.startedAt)}`
|
|
301
|
+
: ""}${!running && result !== undefined
|
|
302
|
+
? ` duration: ${formatDuration(result.durationMs)} retries: ${result.retries}`
|
|
303
|
+
: ""}`,
|
|
304
|
+
"",
|
|
305
|
+
];
|
|
306
|
+
if (!running && result?.error !== undefined) {
|
|
307
|
+
lines.push("── error ──", result.error, "");
|
|
308
|
+
}
|
|
309
|
+
// Finished tests show their result logs; running tests tail the LIVE
|
|
310
|
+
// buffer the runner appends to.
|
|
311
|
+
const logs = running ? entry.liveLogs : result?.logs;
|
|
312
|
+
if (logs !== undefined && logs.length > 0) {
|
|
313
|
+
lines.push("── captured output ──", formatLogs(logs), "");
|
|
314
|
+
}
|
|
315
|
+
const hookLogs = state.fileLogs.get(meta.file);
|
|
316
|
+
if (hookLogs !== undefined && hookLogs.length > 0) {
|
|
317
|
+
lines.push("── file hooks (deploy/destroy) ──", formatLogs(hookLogs));
|
|
318
|
+
}
|
|
319
|
+
return lines.join("\n");
|
|
320
|
+
};
|
|
321
|
+
const FOOTER_HINTS = "j/k · enter open · / filter · h/l fold · e/c all · r retry · R retry failed · x kill · y copy · q quit";
|
|
322
|
+
const makeTui = async (logFile) => {
|
|
323
|
+
const state = new TuiState();
|
|
324
|
+
/** True once the renderer is torn down — no further writes are allowed. */
|
|
325
|
+
let disposed = false;
|
|
326
|
+
// Ctrl+C is handled by US (exitOnCtrlC: false): opentui's built-in handler
|
|
327
|
+
// destroys the renderer while our flush timer may still be queued, which
|
|
328
|
+
// crashed with "TextBuffer is destroyed". We dispose first, then exit.
|
|
329
|
+
const hadWindow = "window" in globalThis;
|
|
330
|
+
const renderer = await createCliRenderer({
|
|
331
|
+
exitOnCtrlC: false,
|
|
332
|
+
// Console capture is owned by StrayOutput's global patch (diverted into
|
|
333
|
+
// the run log) — don't let opentui's overlay re-patch it afterwards.
|
|
334
|
+
consoleMode: "disabled",
|
|
335
|
+
});
|
|
336
|
+
// opentui's CliRenderer constructor does `global.window = {}` to hang
|
|
337
|
+
// `requestAnimationFrame` on it and never reads it back. The tests share
|
|
338
|
+
// this process, and browser-detecting code (emscripten loaders such as
|
|
339
|
+
// pglite under @prisma/dev) treats `typeof window === "object"` as "in a
|
|
340
|
+
// browser" and then dereferences `window.location`. Restore the plain-bun
|
|
341
|
+
// globals a non-TUI run has.
|
|
342
|
+
if (!hadWindow)
|
|
343
|
+
delete globalThis.window;
|
|
344
|
+
const selectionBackground = () => renderer.themeMode === "light" ? "#d0d0d0" : "#303030";
|
|
345
|
+
// Divert stray JS-level stdout/stderr writes into the run log while the
|
|
346
|
+
// TUI owns the screen: anything that bypasses the per-test Effect Console
|
|
347
|
+
// (third-party bridges writing to the streams directly, e.g. miniflare
|
|
348
|
+
// forwarding workerd's console) would corrupt the alternate screen.
|
|
349
|
+
// Installed AFTER the renderer is created — opentui captures the real
|
|
350
|
+
// stream methods at construction and renders through its native library,
|
|
351
|
+
// so the diversion cannot affect the TUI itself.
|
|
352
|
+
const restoreStreams = captureStrayOutput(logFile);
|
|
353
|
+
// No explicit colors on chrome — match the terminal's own theme.
|
|
354
|
+
const header = new TextRenderable(renderer, {
|
|
355
|
+
id: "header",
|
|
356
|
+
content: "alchemy-test — collecting…",
|
|
357
|
+
height: 1,
|
|
358
|
+
width: "100%",
|
|
359
|
+
});
|
|
360
|
+
const list = new BoxRenderable(renderer, {
|
|
361
|
+
id: "list",
|
|
362
|
+
width: "100%",
|
|
363
|
+
flexGrow: 1,
|
|
364
|
+
flexDirection: "column",
|
|
365
|
+
});
|
|
366
|
+
let rows = [];
|
|
367
|
+
const rebuildRowPool = () => {
|
|
368
|
+
for (const row of rows)
|
|
369
|
+
list.remove(row.text);
|
|
370
|
+
rows = [];
|
|
371
|
+
const count = Math.max(renderer.terminalHeight - 2, 1);
|
|
372
|
+
for (let i = 0; i < count; i++) {
|
|
373
|
+
const index = i;
|
|
374
|
+
const text = new TextRenderable(renderer, {
|
|
375
|
+
id: `list-row-${i}`,
|
|
376
|
+
width: "100%",
|
|
377
|
+
height: 1,
|
|
378
|
+
content: "",
|
|
379
|
+
// NOT selectable: rows are a recycled pool, and opentui anchors
|
|
380
|
+
// text-selection highlights on the renderable — a drag would leave
|
|
381
|
+
// highlight residue behind as row content repaints under it. Copy
|
|
382
|
+
// from the list with `y`; free-form text selection lives in the
|
|
383
|
+
// detail pane (enter), whose content is stable.
|
|
384
|
+
selectable: false,
|
|
385
|
+
// Wheel scrolls the viewable content without moving the selection;
|
|
386
|
+
// a click selects the row under the cursor.
|
|
387
|
+
onMouse: (event) => {
|
|
388
|
+
if (event.type === "scroll" && event.scroll !== undefined) {
|
|
389
|
+
scrollViewport(event.scroll.direction === "up" ? -3 : 3);
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
onMouseDown: () => selectRow(index),
|
|
393
|
+
});
|
|
394
|
+
rows.push({ text, key: "" });
|
|
395
|
+
list.add(text);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
const detail = new ScrollBoxRenderable(renderer, {
|
|
399
|
+
id: "detail",
|
|
400
|
+
width: "100%",
|
|
401
|
+
flexGrow: 1,
|
|
402
|
+
visible: false,
|
|
403
|
+
// Tail semantics: pinned to the bottom while content grows (a running
|
|
404
|
+
// test's live output); scrolling up detaches, scrolling back re-pins.
|
|
405
|
+
stickyScroll: true,
|
|
406
|
+
stickyStart: "bottom",
|
|
407
|
+
});
|
|
408
|
+
const detailText = new TextRenderable(renderer, {
|
|
409
|
+
id: "detail-text",
|
|
410
|
+
content: "",
|
|
411
|
+
selectable: true,
|
|
412
|
+
});
|
|
413
|
+
detail.add(detailText);
|
|
414
|
+
/** Content of the open detail pane (for `y` copy). */
|
|
415
|
+
let detailRaw = "";
|
|
416
|
+
/** Entry shown in the detail pane, refreshed live while it's running. */
|
|
417
|
+
let detailEntry;
|
|
418
|
+
const footer = new TextRenderable(renderer, {
|
|
419
|
+
id: "footer",
|
|
420
|
+
content: FOOTER_HINTS,
|
|
421
|
+
height: 1,
|
|
422
|
+
width: "100%",
|
|
423
|
+
});
|
|
424
|
+
const root = new BoxRenderable(renderer, {
|
|
425
|
+
id: "root",
|
|
426
|
+
width: "100%",
|
|
427
|
+
height: "100%",
|
|
428
|
+
flexDirection: "column",
|
|
429
|
+
});
|
|
430
|
+
root.add(header);
|
|
431
|
+
root.add(list);
|
|
432
|
+
root.add(detail);
|
|
433
|
+
root.add(footer);
|
|
434
|
+
renderer.root.add(root);
|
|
435
|
+
rebuildRowPool();
|
|
436
|
+
renderer.on(CliRenderEvents.RESIZE, () => {
|
|
437
|
+
if (disposed)
|
|
438
|
+
return;
|
|
439
|
+
rebuildRowPool();
|
|
440
|
+
renderList();
|
|
441
|
+
});
|
|
442
|
+
/**
|
|
443
|
+
* Drop any active text selection. The list is a recycled pool of row
|
|
444
|
+
* renderables, so a lingering selection highlight would stick to screen
|
|
445
|
+
* positions while the content underneath changes (ghost highlights after
|
|
446
|
+
* a click, glyphs staying inverted through repaints).
|
|
447
|
+
*/
|
|
448
|
+
const clearTextSelection = () => {
|
|
449
|
+
if (!disposed && renderer.hasSelection)
|
|
450
|
+
renderer.clearSelection();
|
|
451
|
+
};
|
|
452
|
+
// Drag-selected text is copied automatically on release (the terminal's
|
|
453
|
+
// own CMD+C can't see the TUI's selection).
|
|
454
|
+
renderer.on(CliRenderEvents.SELECTION, (selection) => {
|
|
455
|
+
if (disposed || selection == null)
|
|
456
|
+
return;
|
|
457
|
+
if (selection.isDragging)
|
|
458
|
+
return;
|
|
459
|
+
const text = selection.getSelectedText();
|
|
460
|
+
// A plain click anchors a zero-width selection — nothing to copy.
|
|
461
|
+
if (text.trim().length > 1 && copyToClipboard(renderer, text)) {
|
|
462
|
+
flash("selection copied ✓");
|
|
463
|
+
}
|
|
464
|
+
// Clear either way: the copy already happened, and a selection left
|
|
465
|
+
// behind turns into ghost highlighting as the row pool repaints under
|
|
466
|
+
// it. Deferred — this event is emitted from inside finishSelection,
|
|
467
|
+
// which still touches the selection after the emit.
|
|
468
|
+
setTimeout(clearTextSelection, 0);
|
|
469
|
+
});
|
|
470
|
+
/**
|
|
471
|
+
* Full-width run of spaces appended to single-line chrome (header/footer):
|
|
472
|
+
* with transparent backgrounds, a shorter repaint would otherwise leave
|
|
473
|
+
* the previous content's tail cells on screen. Overflow is clipped.
|
|
474
|
+
*/
|
|
475
|
+
const padLine = () => " ".repeat(renderer.terminalWidth);
|
|
476
|
+
/** Transient footer message (e.g. "copied"); reverts on the next tick. */
|
|
477
|
+
let flashUntil = 0;
|
|
478
|
+
const flash = (message) => {
|
|
479
|
+
flashUntil = Date.now() + 1500;
|
|
480
|
+
footer.content = stringToStyledText(` ${message}${padLine()}`);
|
|
481
|
+
};
|
|
482
|
+
const updateFooter = () => {
|
|
483
|
+
if (Date.now() < flashUntil)
|
|
484
|
+
return;
|
|
485
|
+
if (state.filterInput) {
|
|
486
|
+
footer.content = stringToStyledText(` /${state.filter}█ ${state.visibleTestCount()} matches (enter close · esc clear)${padLine()}`);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
// Always-visible toggle bar: each group shows its hotkey and current
|
|
490
|
+
// state — bright when shown, dim + struck-through when hidden.
|
|
491
|
+
const toggles = [
|
|
492
|
+
["pass", "p:pass"],
|
|
493
|
+
["fail", "f:fail"],
|
|
494
|
+
["pending", "n:pending"],
|
|
495
|
+
["skipped", "s:skipped"],
|
|
496
|
+
].flatMap(([group, label], index) => [
|
|
497
|
+
...(index > 0 ? [dim(" ")] : []),
|
|
498
|
+
state.show[group] ? stringToStyledText(label).chunks[0] : strikethrough(dim(label)),
|
|
499
|
+
]);
|
|
500
|
+
// Toggle states lead (before the hints) so they're never clipped on
|
|
501
|
+
// narrow terminals — the footer truncates at the terminal width.
|
|
502
|
+
const filter = state.filter !== "" ? ` │ filter: ${state.filter}` : "";
|
|
503
|
+
footer.content = new StyledText([
|
|
504
|
+
dim(" show: "),
|
|
505
|
+
...toggles,
|
|
506
|
+
dim(`${filter} │ ${FOOTER_HINTS}${padLine()}`),
|
|
507
|
+
]);
|
|
508
|
+
};
|
|
509
|
+
const updateHeader = () => {
|
|
510
|
+
const counts = state.counts;
|
|
511
|
+
const elapsed = formatDuration(state.summary?.durationMs ?? Date.now() - state.startedAt);
|
|
512
|
+
const done = state.summary !== undefined;
|
|
513
|
+
const collecting = !done && state.collectDone < state.collectTotal
|
|
514
|
+
? ` │ importing ${state.collectDone}/${state.collectTotal} files`
|
|
515
|
+
: "";
|
|
516
|
+
header.content = new StyledText([
|
|
517
|
+
dim(" alchemy-test "),
|
|
518
|
+
green(`${GLYPH.pass} ${counts.pass}`),
|
|
519
|
+
dim(" "),
|
|
520
|
+
red(`${GLYPH.fail} ${counts.fail}`),
|
|
521
|
+
dim(" "),
|
|
522
|
+
yellow(`${GLYPH.running} ${counts.running}`),
|
|
523
|
+
dim(` ${GLYPH.queued} ${counts.queued}`),
|
|
524
|
+
...(counts.skip > 0 ? [dim(` ${GLYPH.skip} ${counts.skip}`)] : []),
|
|
525
|
+
dim(` │ ${elapsed}${collecting}`),
|
|
526
|
+
...(state.summary?.plan
|
|
527
|
+
? [
|
|
528
|
+
dim(` │ ${state.summary.plan.found} found, ${state.summary.plan.excluded} excluded by plan`),
|
|
529
|
+
]
|
|
530
|
+
: []),
|
|
531
|
+
...(done ? [dim(" │ DONE — press q to quit")] : []),
|
|
532
|
+
dim(padLine()),
|
|
533
|
+
]);
|
|
534
|
+
};
|
|
535
|
+
/**
|
|
536
|
+
* Paint the visible window into the row pool. O(window): a row's content
|
|
537
|
+
* is only written when its cache key changed since the last paint.
|
|
538
|
+
*/
|
|
539
|
+
const renderList = () => {
|
|
540
|
+
if (state.detailOpen || disposed)
|
|
541
|
+
return;
|
|
542
|
+
const nodes = state.visible();
|
|
543
|
+
const max = Math.max(nodes.length - 1, 0);
|
|
544
|
+
state.selectedIndex = Math.min(state.selectedIndex, max);
|
|
545
|
+
if (state.viewportFollows) {
|
|
546
|
+
if (state.selectedIndex < state.topIndex) {
|
|
547
|
+
state.topIndex = state.selectedIndex;
|
|
548
|
+
}
|
|
549
|
+
else if (state.selectedIndex >= state.topIndex + rows.length) {
|
|
550
|
+
state.topIndex = state.selectedIndex - rows.length + 1;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
state.topIndex = Math.max(0, Math.min(state.topIndex, Math.max(nodes.length - rows.length, 0)));
|
|
554
|
+
const width = renderer.terminalWidth;
|
|
555
|
+
for (let i = 0; i < rows.length; i++) {
|
|
556
|
+
const row = rows[i];
|
|
557
|
+
const node = nodes[state.topIndex + i];
|
|
558
|
+
const isSelected = node !== undefined && state.topIndex + i === state.selectedIndex;
|
|
559
|
+
if (node === undefined) {
|
|
560
|
+
// Empty content emits no cells, so it does not erase whatever this
|
|
561
|
+
// recycled screen row painted previously. Repaint the entire width.
|
|
562
|
+
const emptyKey = `empty|${width}`;
|
|
563
|
+
if (row.key !== emptyKey) {
|
|
564
|
+
row.key = emptyKey;
|
|
565
|
+
row.text.content = " ".repeat(width);
|
|
566
|
+
}
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
let head;
|
|
570
|
+
let status;
|
|
571
|
+
let rest;
|
|
572
|
+
if (node.kind === "file") {
|
|
573
|
+
const expanded = state.isExpanded(node.node);
|
|
574
|
+
status = node.status;
|
|
575
|
+
head = ` ${expanded ? "▾" : "▸"} ${GLYPH[status]} `;
|
|
576
|
+
const phase = filePhase(node.node);
|
|
577
|
+
rest =
|
|
578
|
+
`${node.node.file} ${fileSummary(node.node)}` +
|
|
579
|
+
(phase !== undefined ? ` — ${phase}` : "");
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
status = node.entry.status;
|
|
583
|
+
head = ` ${GLYPH[status]} `;
|
|
584
|
+
rest = node.entry.label;
|
|
585
|
+
// Live elapsed timer while running (resets on retry).
|
|
586
|
+
if (status === "running" && node.entry.startedAt !== undefined) {
|
|
587
|
+
rest += ` (${formatDuration(Date.now() - node.entry.startedAt)}…)`;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
// Clip to the terminal width AND pad to it — with transparent
|
|
591
|
+
// backgrounds a shorter repaint would otherwise leave the previous
|
|
592
|
+
// content's tail cells on screen.
|
|
593
|
+
const restWidth = Math.max(width - head.length, 0);
|
|
594
|
+
rest = rest.slice(0, restWidth).padEnd(restWidth, " ");
|
|
595
|
+
const key = `${isSelected ? "S" : "."}|${status}|${head}|${rest}`;
|
|
596
|
+
if (row.key === key)
|
|
597
|
+
continue;
|
|
598
|
+
row.key = key;
|
|
599
|
+
const chunks = [statusChunk(status, head)];
|
|
600
|
+
if (rest !== "") {
|
|
601
|
+
// Only the glyph is colored; the label uses the terminal's default
|
|
602
|
+
// foreground (skipped rows are dimmed, todos are warning-yellow).
|
|
603
|
+
if (status === "skip") {
|
|
604
|
+
chunks.push(dim(rest));
|
|
605
|
+
}
|
|
606
|
+
else if (status === "todo") {
|
|
607
|
+
chunks.push(yellow(rest));
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
chunks.push(...stringToStyledText(rest).chunks);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
// Use an explicit background instead of terminal inverse video.
|
|
614
|
+
// OpenTUI's cell diff can leave inverse attributes behind when the
|
|
615
|
+
// selection moves upward; a concrete background has symmetric paint
|
|
616
|
+
// and clear operations in both navigation directions.
|
|
617
|
+
row.text.content = new StyledText(isSelected ? chunks.map(bg(selectionBackground())) : chunks);
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
const moveSelection = (delta) => {
|
|
621
|
+
const nodes = state.visible();
|
|
622
|
+
if (nodes.length === 0)
|
|
623
|
+
return;
|
|
624
|
+
state.viewportFollows = true;
|
|
625
|
+
state.selectedIndex = Math.max(0, Math.min(state.selectedIndex + delta, nodes.length - 1));
|
|
626
|
+
renderList();
|
|
627
|
+
};
|
|
628
|
+
/** Scroll the viewable content without moving the selection (wheel). */
|
|
629
|
+
const scrollViewport = (delta) => {
|
|
630
|
+
const nodes = state.visible();
|
|
631
|
+
if (nodes.length === 0)
|
|
632
|
+
return;
|
|
633
|
+
state.viewportFollows = false;
|
|
634
|
+
state.topIndex = Math.max(0, Math.min(state.topIndex + delta, Math.max(nodes.length - rows.length, 0)));
|
|
635
|
+
renderList();
|
|
636
|
+
};
|
|
637
|
+
/** Click on a visible row selects it (without scrolling). */
|
|
638
|
+
const selectRow = (rowIndex) => {
|
|
639
|
+
const nodes = state.visible();
|
|
640
|
+
const index = state.topIndex + rowIndex;
|
|
641
|
+
if (index >= nodes.length)
|
|
642
|
+
return;
|
|
643
|
+
state.selectedIndex = index;
|
|
644
|
+
renderList();
|
|
645
|
+
};
|
|
646
|
+
const refresh = () => {
|
|
647
|
+
if (disposed)
|
|
648
|
+
return;
|
|
649
|
+
updateHeader();
|
|
650
|
+
updateFooter();
|
|
651
|
+
// Keep `dirty` set while the detail pane hides the list, so the pending
|
|
652
|
+
// repaint happens when the list becomes visible again.
|
|
653
|
+
if (state.dirty && !state.detailOpen) {
|
|
654
|
+
state.dirty = false;
|
|
655
|
+
renderList();
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
const selectedNode = () => state.visible()[state.selectedIndex];
|
|
659
|
+
const setExpanded = (node, expanded) => {
|
|
660
|
+
node.expanded = expanded;
|
|
661
|
+
state.dirty = true;
|
|
662
|
+
renderList();
|
|
663
|
+
};
|
|
664
|
+
const setAllVisibleExpanded = (expanded) => {
|
|
665
|
+
const selected = selectedNode();
|
|
666
|
+
const visibleFiles = new Set(state.visible().map((node) => (node.kind === "file" ? node.node : node.entry.file)));
|
|
667
|
+
for (const file of visibleFiles)
|
|
668
|
+
file.expanded = expanded;
|
|
669
|
+
const nodes = state.visible();
|
|
670
|
+
const selectedFile = selected?.kind === "file" ? selected.node : selected?.entry.file;
|
|
671
|
+
const selectedIndex = nodes.findIndex((node) => expanded && selected?.kind === "test"
|
|
672
|
+
? node.kind === "test" && node.entry === selected.entry
|
|
673
|
+
: node.kind === "file" && node.node === selectedFile);
|
|
674
|
+
state.selectedIndex = Math.max(selectedIndex, 0);
|
|
675
|
+
state.dirty = true;
|
|
676
|
+
renderList();
|
|
677
|
+
};
|
|
678
|
+
const previewContent = (entry) => `${detailContent(state, entry)}\nOutput previews are bounded. Full log: ${logFile}\n`;
|
|
679
|
+
/** Re-render the open detail pane (no-op when its content is unchanged). */
|
|
680
|
+
const refreshDetail = () => {
|
|
681
|
+
if (detailEntry === undefined)
|
|
682
|
+
return;
|
|
683
|
+
const raw = previewContent(detailEntry);
|
|
684
|
+
if (raw === detailRaw)
|
|
685
|
+
return;
|
|
686
|
+
detailRaw = raw;
|
|
687
|
+
detailText.content = stringToStyledText(raw);
|
|
688
|
+
};
|
|
689
|
+
const openDetail = (entry) => {
|
|
690
|
+
detailEntry = entry;
|
|
691
|
+
detailRaw = previewContent(entry);
|
|
692
|
+
detailText.content = stringToStyledText(detailRaw);
|
|
693
|
+
state.detailOpen = true;
|
|
694
|
+
list.visible = false;
|
|
695
|
+
detail.visible = true;
|
|
696
|
+
detail.focus();
|
|
697
|
+
// Running test → tail the output (sticky keeps it pinned as it grows);
|
|
698
|
+
// finished test → start at the top where the error is.
|
|
699
|
+
detail.scrollTo(entry.status === "running" ? 1_000_000_000 : 0);
|
|
700
|
+
};
|
|
701
|
+
const closeDetail = () => {
|
|
702
|
+
state.detailOpen = false;
|
|
703
|
+
detailEntry = undefined;
|
|
704
|
+
detail.visible = false;
|
|
705
|
+
list.visible = true;
|
|
706
|
+
renderList();
|
|
707
|
+
refresh();
|
|
708
|
+
};
|
|
709
|
+
const copySelection = () => {
|
|
710
|
+
let text;
|
|
711
|
+
if (state.detailOpen) {
|
|
712
|
+
text = detailRaw;
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
const node = selectedNode();
|
|
716
|
+
if (node?.kind === "test") {
|
|
717
|
+
text = detailContent(state, node.entry);
|
|
718
|
+
}
|
|
719
|
+
else if (node?.kind === "file") {
|
|
720
|
+
// Copy every failure of the file plus its hook logs.
|
|
721
|
+
text = node.node.tests
|
|
722
|
+
.filter((t) => t.status === "fail")
|
|
723
|
+
.map((t) => detailContent(state, t))
|
|
724
|
+
.join("\n\n");
|
|
725
|
+
if (text === "" && node.node.tests.length > 0) {
|
|
726
|
+
text = detailContent(state, node.node.tests[0]);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
if (text === undefined || text === "")
|
|
731
|
+
return;
|
|
732
|
+
flash(copyToClipboard(renderer, text) ? "copied to clipboard ✓" : "copy failed");
|
|
733
|
+
};
|
|
734
|
+
const retrySelection = () => {
|
|
735
|
+
const controller = state.controller;
|
|
736
|
+
if (controller === undefined)
|
|
737
|
+
return;
|
|
738
|
+
const node = state.detailOpen ? undefined : selectedNode();
|
|
739
|
+
if (node === undefined)
|
|
740
|
+
return;
|
|
741
|
+
if (node.kind === "test") {
|
|
742
|
+
if (node.entry.status === "running" || node.entry.status === "queued") {
|
|
743
|
+
flash("still running — x to kill");
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
controller.retryTest(node.entry.meta.id);
|
|
747
|
+
flash(`retrying ${node.entry.meta.name}`);
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
controller.retryFile(node.node.file);
|
|
751
|
+
flash(`retrying ${node.node.file}`);
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
const retryFailures = () => {
|
|
755
|
+
const controller = state.controller;
|
|
756
|
+
if (controller === undefined)
|
|
757
|
+
return;
|
|
758
|
+
const failed = [...state.byId.values()].filter((entry) => entry.status === "fail");
|
|
759
|
+
for (const entry of failed)
|
|
760
|
+
controller.retryTest(entry.meta.id);
|
|
761
|
+
flash(failed.length === 0
|
|
762
|
+
? "no failed tests to retry"
|
|
763
|
+
: `retrying ${failed.length} failed test${failed.length === 1 ? "" : "s"}`);
|
|
764
|
+
};
|
|
765
|
+
const killSelection = () => {
|
|
766
|
+
const controller = state.controller;
|
|
767
|
+
if (controller === undefined)
|
|
768
|
+
return;
|
|
769
|
+
const node = state.detailOpen ? undefined : selectedNode();
|
|
770
|
+
if (node === undefined)
|
|
771
|
+
return;
|
|
772
|
+
if (node.kind === "test") {
|
|
773
|
+
if (node.entry.status !== "running") {
|
|
774
|
+
flash("not running — nothing to kill");
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
controller.killTest(node.entry.meta.id);
|
|
778
|
+
flash(`killing ${node.entry.meta.name}`);
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
for (const entry of node.node.tests) {
|
|
782
|
+
if (entry.status === "running")
|
|
783
|
+
controller.killTest(entry.meta.id);
|
|
784
|
+
}
|
|
785
|
+
flash(`killing running tests in ${node.node.file}`);
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
let resolveQuit;
|
|
789
|
+
const quit = new Promise((resolve) => {
|
|
790
|
+
resolveQuit = resolve;
|
|
791
|
+
});
|
|
792
|
+
const onFilterKey = (key) => {
|
|
793
|
+
switch (key.name) {
|
|
794
|
+
case "escape":
|
|
795
|
+
state.filter = "";
|
|
796
|
+
state.filterInput = false;
|
|
797
|
+
break;
|
|
798
|
+
case "return":
|
|
799
|
+
case "enter":
|
|
800
|
+
state.filterInput = false;
|
|
801
|
+
break;
|
|
802
|
+
case "backspace":
|
|
803
|
+
state.filter = state.filter.slice(0, -1);
|
|
804
|
+
break;
|
|
805
|
+
default: {
|
|
806
|
+
// Printable, single-character inputs extend the query; the list
|
|
807
|
+
// re-filters on every keystroke.
|
|
808
|
+
const seq = key.sequence ?? "";
|
|
809
|
+
if (seq.length === 1 && !key.ctrl && !key.meta && seq >= " ") {
|
|
810
|
+
state.filter += seq;
|
|
811
|
+
}
|
|
812
|
+
else {
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
state.selectedIndex = 0;
|
|
818
|
+
state.topIndex = 0;
|
|
819
|
+
state.dirty = true;
|
|
820
|
+
refresh();
|
|
821
|
+
};
|
|
822
|
+
// Vim `gg` prefix: true after a bare `g`, cleared by any other key.
|
|
823
|
+
let pendingG = false;
|
|
824
|
+
renderer.keyInput.on("keypress", (key) => {
|
|
825
|
+
// Ctrl+C: tear the TUI down BEFORE exiting so no queued timer callback
|
|
826
|
+
// touches a destroyed renderer ("TextBuffer is destroyed").
|
|
827
|
+
if (key.ctrl && key.name === "c") {
|
|
828
|
+
dispose();
|
|
829
|
+
process.exit(130);
|
|
830
|
+
}
|
|
831
|
+
if (disposed)
|
|
832
|
+
return;
|
|
833
|
+
// Keyboard input takes over from the mouse: drop any live text
|
|
834
|
+
// selection so its highlight can't linger over rows that repaint.
|
|
835
|
+
clearTextSelection();
|
|
836
|
+
if (state.filterInput && !state.detailOpen) {
|
|
837
|
+
onFilterKey(key);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
switch (key.name) {
|
|
841
|
+
case "q":
|
|
842
|
+
resolveQuit();
|
|
843
|
+
return;
|
|
844
|
+
case "escape":
|
|
845
|
+
if (state.detailOpen) {
|
|
846
|
+
closeDetail();
|
|
847
|
+
}
|
|
848
|
+
else if (state.filter !== "") {
|
|
849
|
+
state.filter = "";
|
|
850
|
+
state.dirty = true;
|
|
851
|
+
refresh();
|
|
852
|
+
}
|
|
853
|
+
return;
|
|
854
|
+
case "y":
|
|
855
|
+
copySelection();
|
|
856
|
+
return;
|
|
857
|
+
case "r":
|
|
858
|
+
if (key.shift) {
|
|
859
|
+
retryFailures();
|
|
860
|
+
}
|
|
861
|
+
else {
|
|
862
|
+
retrySelection();
|
|
863
|
+
}
|
|
864
|
+
return;
|
|
865
|
+
case "x":
|
|
866
|
+
killSelection();
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
if (state.detailOpen)
|
|
870
|
+
return; // ScrollBox handles its own keys
|
|
871
|
+
// gg — jump to top (vim)
|
|
872
|
+
if (key.name === "g" && !key.shift && !key.ctrl) {
|
|
873
|
+
if (pendingG) {
|
|
874
|
+
pendingG = false;
|
|
875
|
+
moveSelection(-Number.MAX_SAFE_INTEGER);
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
pendingG = true;
|
|
879
|
+
}
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
pendingG = false;
|
|
883
|
+
// vim paging: ctrl-d/u half page, ctrl-f/b full page
|
|
884
|
+
if (key.ctrl) {
|
|
885
|
+
switch (key.name) {
|
|
886
|
+
case "d":
|
|
887
|
+
moveSelection(Math.max(Math.floor(rows.length / 2), 1));
|
|
888
|
+
return;
|
|
889
|
+
case "u":
|
|
890
|
+
moveSelection(-Math.max(Math.floor(rows.length / 2), 1));
|
|
891
|
+
return;
|
|
892
|
+
case "f":
|
|
893
|
+
moveSelection(rows.length);
|
|
894
|
+
return;
|
|
895
|
+
case "b":
|
|
896
|
+
moveSelection(-rows.length);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
// G — jump to bottom (vim)
|
|
902
|
+
if (key.name === "g" && key.shift) {
|
|
903
|
+
moveSelection(Number.MAX_SAFE_INTEGER);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
switch (key.name) {
|
|
907
|
+
case "/":
|
|
908
|
+
state.filterInput = true;
|
|
909
|
+
refresh();
|
|
910
|
+
return;
|
|
911
|
+
case "space":
|
|
912
|
+
case " ":
|
|
913
|
+
// space pages forward (less/vim style)
|
|
914
|
+
moveSelection(rows.length);
|
|
915
|
+
return;
|
|
916
|
+
case "return":
|
|
917
|
+
case "enter": {
|
|
918
|
+
const node = selectedNode();
|
|
919
|
+
if (node === undefined)
|
|
920
|
+
return;
|
|
921
|
+
if (node.kind === "file") {
|
|
922
|
+
setExpanded(node.node, !state.isExpanded(node.node));
|
|
923
|
+
}
|
|
924
|
+
else {
|
|
925
|
+
openDetail(node.entry);
|
|
926
|
+
}
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
case "left":
|
|
930
|
+
case "h": {
|
|
931
|
+
const node = selectedNode();
|
|
932
|
+
if (node === undefined)
|
|
933
|
+
return;
|
|
934
|
+
// On a test row, collapse the parent file and land on it.
|
|
935
|
+
const target = node.kind === "file" ? node.node : node.entry.file;
|
|
936
|
+
setExpanded(target, false);
|
|
937
|
+
const index = state.visible().findIndex((n) => n.kind === "file" && n.node === target);
|
|
938
|
+
if (index >= 0) {
|
|
939
|
+
state.selectedIndex = index;
|
|
940
|
+
renderList();
|
|
941
|
+
}
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
case "right":
|
|
945
|
+
case "l": {
|
|
946
|
+
const node = selectedNode();
|
|
947
|
+
if (node?.kind === "file")
|
|
948
|
+
setExpanded(node.node, true);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
case "e":
|
|
952
|
+
setAllVisibleExpanded(true);
|
|
953
|
+
return;
|
|
954
|
+
case "c":
|
|
955
|
+
setAllVisibleExpanded(false);
|
|
956
|
+
return;
|
|
957
|
+
case "up":
|
|
958
|
+
case "k":
|
|
959
|
+
moveSelection(-1);
|
|
960
|
+
return;
|
|
961
|
+
case "down":
|
|
962
|
+
case "j":
|
|
963
|
+
moveSelection(1);
|
|
964
|
+
return;
|
|
965
|
+
case "pageup":
|
|
966
|
+
moveSelection(-rows.length);
|
|
967
|
+
return;
|
|
968
|
+
case "pagedown":
|
|
969
|
+
moveSelection(rows.length);
|
|
970
|
+
return;
|
|
971
|
+
case "home":
|
|
972
|
+
moveSelection(-Number.MAX_SAFE_INTEGER);
|
|
973
|
+
return;
|
|
974
|
+
case "end":
|
|
975
|
+
moveSelection(Number.MAX_SAFE_INTEGER);
|
|
976
|
+
return;
|
|
977
|
+
// Independent status-group visibility toggles: each key flips ONLY its
|
|
978
|
+
// own group. Hiding all four leaves the list empty (no auto-reset).
|
|
979
|
+
case "p":
|
|
980
|
+
state.toggleGroup("pass");
|
|
981
|
+
break;
|
|
982
|
+
case "f":
|
|
983
|
+
state.toggleGroup("fail");
|
|
984
|
+
break;
|
|
985
|
+
case "n":
|
|
986
|
+
state.toggleGroup("pending");
|
|
987
|
+
break;
|
|
988
|
+
case "s":
|
|
989
|
+
state.toggleGroup("skipped");
|
|
990
|
+
break;
|
|
991
|
+
default:
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
state.selectedIndex = 0;
|
|
995
|
+
state.topIndex = 0;
|
|
996
|
+
refresh();
|
|
997
|
+
});
|
|
998
|
+
// Single flush loop: repaints the visible window at most 10x/s and ONLY
|
|
999
|
+
// when test data changed; otherwise it just ticks the elapsed-time header
|
|
1000
|
+
// while the run is live.
|
|
1001
|
+
const interval = setInterval(() => {
|
|
1002
|
+
if (disposed)
|
|
1003
|
+
return;
|
|
1004
|
+
// Tail the open detail pane while its test is running.
|
|
1005
|
+
if (state.detailOpen)
|
|
1006
|
+
refreshDetail();
|
|
1007
|
+
// Live elapsed timers: hook phases on file rows and running test rows.
|
|
1008
|
+
if (state.counts.running > 0)
|
|
1009
|
+
state.dirty = true;
|
|
1010
|
+
for (const node of state.fileOrder) {
|
|
1011
|
+
if (node.hook !== undefined)
|
|
1012
|
+
state.dirty = true;
|
|
1013
|
+
}
|
|
1014
|
+
if (state.dirty) {
|
|
1015
|
+
refresh();
|
|
1016
|
+
}
|
|
1017
|
+
else if (state.summary === undefined || Date.now() < flashUntil + 100) {
|
|
1018
|
+
updateHeader();
|
|
1019
|
+
updateFooter();
|
|
1020
|
+
}
|
|
1021
|
+
}, 100);
|
|
1022
|
+
const dispose = () => {
|
|
1023
|
+
if (disposed)
|
|
1024
|
+
return;
|
|
1025
|
+
disposed = true;
|
|
1026
|
+
clearInterval(interval);
|
|
1027
|
+
restoreStreams();
|
|
1028
|
+
try {
|
|
1029
|
+
renderer.destroy();
|
|
1030
|
+
}
|
|
1031
|
+
catch {
|
|
1032
|
+
// Never let teardown mask the run's real outcome.
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
return {
|
|
1036
|
+
renderer,
|
|
1037
|
+
state,
|
|
1038
|
+
refresh: () => {
|
|
1039
|
+
if (!disposed)
|
|
1040
|
+
refresh();
|
|
1041
|
+
},
|
|
1042
|
+
quit,
|
|
1043
|
+
dispose,
|
|
1044
|
+
};
|
|
1045
|
+
};
|
|
1046
|
+
// ---------------------------------------------------------------------------
|
|
1047
|
+
// Events
|
|
1048
|
+
// ---------------------------------------------------------------------------
|
|
1049
|
+
const onEvent = (tui, event) => {
|
|
1050
|
+
const { state } = tui;
|
|
1051
|
+
switch (event._tag) {
|
|
1052
|
+
case "CollectStart":
|
|
1053
|
+
state.collectTotal = event.files.length;
|
|
1054
|
+
break;
|
|
1055
|
+
case "FileCollected":
|
|
1056
|
+
state.collectDone++;
|
|
1057
|
+
break;
|
|
1058
|
+
case "RunStart":
|
|
1059
|
+
state.startedAt = Date.now();
|
|
1060
|
+
for (const meta of event.tests)
|
|
1061
|
+
state.upsert(meta, "queued");
|
|
1062
|
+
break;
|
|
1063
|
+
case "FileStart":
|
|
1064
|
+
state.fileNode(event.file).started = true;
|
|
1065
|
+
// Live hook-log buffer — grows in place as deploy/destroy run.
|
|
1066
|
+
if (event.logs !== undefined) {
|
|
1067
|
+
state.fileLogs.set(event.file, event.logs);
|
|
1068
|
+
}
|
|
1069
|
+
break;
|
|
1070
|
+
case "HookStart": {
|
|
1071
|
+
const node = state.fileNode(event.file);
|
|
1072
|
+
node.hook = event.hook;
|
|
1073
|
+
node.hookStartedAt = Date.now();
|
|
1074
|
+
break;
|
|
1075
|
+
}
|
|
1076
|
+
case "HookEnd":
|
|
1077
|
+
state.fileNode(event.file).hook = undefined;
|
|
1078
|
+
break;
|
|
1079
|
+
case "TestStart": {
|
|
1080
|
+
state.upsert(event.test, "running");
|
|
1081
|
+
const entry = state.byId.get(event.test.id);
|
|
1082
|
+
if (entry !== undefined)
|
|
1083
|
+
entry.liveLogs = event.logs;
|
|
1084
|
+
break;
|
|
1085
|
+
}
|
|
1086
|
+
case "TestLocked":
|
|
1087
|
+
break;
|
|
1088
|
+
case "TestEnd":
|
|
1089
|
+
state.upsert(event.test, event.result.status, event.result);
|
|
1090
|
+
break;
|
|
1091
|
+
case "FileEnd": {
|
|
1092
|
+
const node = state.fileNode(event.file);
|
|
1093
|
+
node.hook = undefined;
|
|
1094
|
+
state.fileLogs.set(event.file, event.logs);
|
|
1095
|
+
if (event.error !== undefined) {
|
|
1096
|
+
// Surface import/hook failures as a synthetic failed entry.
|
|
1097
|
+
state.upsert({
|
|
1098
|
+
id: `${event.file} > [file]`,
|
|
1099
|
+
file: event.file,
|
|
1100
|
+
titlePath: ["[file]"],
|
|
1101
|
+
name: "[file]",
|
|
1102
|
+
tags: [],
|
|
1103
|
+
optInTags: [],
|
|
1104
|
+
}, "fail", {
|
|
1105
|
+
status: "fail",
|
|
1106
|
+
durationMs: 0,
|
|
1107
|
+
error: event.error,
|
|
1108
|
+
logs: event.logs,
|
|
1109
|
+
retries: 0,
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
break;
|
|
1113
|
+
}
|
|
1114
|
+
case "RunEnd":
|
|
1115
|
+
state.summary = event.summary;
|
|
1116
|
+
// Final state should render immediately, not on the next tick.
|
|
1117
|
+
state.dirty = true;
|
|
1118
|
+
tui.refresh();
|
|
1119
|
+
return;
|
|
1120
|
+
default:
|
|
1121
|
+
break;
|
|
1122
|
+
}
|
|
1123
|
+
// Everything else just marks the state dirty; the flush interval batches
|
|
1124
|
+
// repaints (a burst of TestEnd events must not repaint per event).
|
|
1125
|
+
state.dirty = true;
|
|
1126
|
+
};
|
|
1127
|
+
export const TuiReporter = (logFile) => Layer.effect(Reporter)(Effect.gen(function* () {
|
|
1128
|
+
const tui = yield* Effect.acquireRelease(Effect.promise(() => makeTui(logFile)), (t) => Effect.sync(() => t.dispose()));
|
|
1129
|
+
return {
|
|
1130
|
+
emit: (event) => Effect.sync(() => onEvent(tui, event)),
|
|
1131
|
+
waitForExit: () => Effect.promise(() => tui.quit),
|
|
1132
|
+
attachController: (controller) => Effect.sync(() => {
|
|
1133
|
+
tui.state.controller = controller;
|
|
1134
|
+
}),
|
|
1135
|
+
};
|
|
1136
|
+
}));
|
|
1137
|
+
//# sourceMappingURL=Tui.js.map
|