@redwoodjs/agent-ci 0.1.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 +110 -0
- package/README.md +79 -0
- package/dist/cli.js +628 -0
- package/dist/config.js +63 -0
- package/dist/docker/container-config.js +178 -0
- package/dist/docker/container-config.test.js +156 -0
- package/dist/docker/service-containers.js +205 -0
- package/dist/docker/service-containers.test.js +236 -0
- package/dist/docker/shutdown.js +120 -0
- package/dist/docker/shutdown.test.js +148 -0
- package/dist/output/agent-mode.js +7 -0
- package/dist/output/agent-mode.test.js +36 -0
- package/dist/output/cleanup.js +218 -0
- package/dist/output/cleanup.test.js +241 -0
- package/dist/output/concurrency.js +57 -0
- package/dist/output/concurrency.test.js +88 -0
- package/dist/output/debug.js +36 -0
- package/dist/output/logger.js +57 -0
- package/dist/output/logger.test.js +82 -0
- package/dist/output/reporter.js +67 -0
- package/dist/output/run-state.js +126 -0
- package/dist/output/run-state.test.js +169 -0
- package/dist/output/state-renderer.js +149 -0
- package/dist/output/state-renderer.test.js +488 -0
- package/dist/output/tree-renderer.js +52 -0
- package/dist/output/tree-renderer.test.js +105 -0
- package/dist/output/working-directory.js +20 -0
- package/dist/runner/directory-setup.js +98 -0
- package/dist/runner/directory-setup.test.js +31 -0
- package/dist/runner/git-shim.js +92 -0
- package/dist/runner/git-shim.test.js +57 -0
- package/dist/runner/local-job.js +691 -0
- package/dist/runner/metadata.js +90 -0
- package/dist/runner/metadata.test.js +127 -0
- package/dist/runner/result-builder.js +119 -0
- package/dist/runner/result-builder.test.js +177 -0
- package/dist/runner/step-wrapper.js +82 -0
- package/dist/runner/step-wrapper.test.js +77 -0
- package/dist/runner/sync.js +80 -0
- package/dist/runner/workspace.js +66 -0
- package/dist/types.js +1 -0
- package/dist/workflow/job-scheduler.js +62 -0
- package/dist/workflow/job-scheduler.test.js +130 -0
- package/dist/workflow/workflow-parser.js +556 -0
- package/dist/workflow/workflow-parser.test.js +642 -0
- package/package.json +39 -0
- package/shim.sh +11 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { renderRunState } from "./state-renderer.js";
|
|
3
|
+
// Freeze time so spinner frames and elapsed times are deterministic.
|
|
4
|
+
// Date.now() → 0 → Math.floor(0/80) % 10 → frame index 0 → "⠋"
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
vi.useFakeTimers();
|
|
7
|
+
vi.setSystemTime(0);
|
|
8
|
+
});
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.useRealTimers();
|
|
11
|
+
});
|
|
12
|
+
function makeState(overrides = {}) {
|
|
13
|
+
return {
|
|
14
|
+
runId: "test-run",
|
|
15
|
+
status: "running",
|
|
16
|
+
startedAt: "1970-01-01T00:00:00.000Z",
|
|
17
|
+
workflows: [],
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
describe("renderRunState", () => {
|
|
22
|
+
describe("single workflow, single job", () => {
|
|
23
|
+
it("renders boot spinner before timeline appears", () => {
|
|
24
|
+
const state = makeState({
|
|
25
|
+
workflows: [
|
|
26
|
+
{
|
|
27
|
+
id: "ci.yml",
|
|
28
|
+
path: "/repo/.github/workflows/ci.yml",
|
|
29
|
+
status: "running",
|
|
30
|
+
jobs: [
|
|
31
|
+
{
|
|
32
|
+
id: "test",
|
|
33
|
+
runnerId: "agent-ci-5",
|
|
34
|
+
status: "booting",
|
|
35
|
+
startedAt: "1970-01-01T00:00:00.000Z",
|
|
36
|
+
steps: [],
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
const output = renderRunState(state);
|
|
43
|
+
expect(output).toContain("ci.yml");
|
|
44
|
+
expect(output).toContain("⠋");
|
|
45
|
+
expect(output).toContain("Starting runner agent-ci-5 (0s)");
|
|
46
|
+
});
|
|
47
|
+
it("renders starting-runner node alongside steps once running", () => {
|
|
48
|
+
const state = makeState({
|
|
49
|
+
workflows: [
|
|
50
|
+
{
|
|
51
|
+
id: "ci.yml",
|
|
52
|
+
path: "/repo/.github/workflows/ci.yml",
|
|
53
|
+
status: "running",
|
|
54
|
+
jobs: [
|
|
55
|
+
{
|
|
56
|
+
id: "test",
|
|
57
|
+
runnerId: "agent-ci-5",
|
|
58
|
+
status: "running",
|
|
59
|
+
startedAt: "1970-01-01T00:00:00.000Z",
|
|
60
|
+
bootDurationMs: 2300,
|
|
61
|
+
steps: [
|
|
62
|
+
{ name: "Set up job", index: 1, status: "completed", durationMs: 1000 },
|
|
63
|
+
{
|
|
64
|
+
name: "Run pnpm check",
|
|
65
|
+
index: 2,
|
|
66
|
+
status: "running",
|
|
67
|
+
startedAt: "1970-01-01T00:00:00.000Z",
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
const output = renderRunState(state);
|
|
76
|
+
expect(output).toContain("ci.yml");
|
|
77
|
+
expect(output).toContain("Starting runner agent-ci-5 (2.3s)");
|
|
78
|
+
expect(output).toContain("test");
|
|
79
|
+
expect(output).toContain("✓ 1. Set up job (1s)");
|
|
80
|
+
expect(output).toContain("⠋ 2. Run pnpm check (0s...)");
|
|
81
|
+
});
|
|
82
|
+
it("renders completed steps with tick icons", () => {
|
|
83
|
+
const state = makeState({
|
|
84
|
+
workflows: [
|
|
85
|
+
{
|
|
86
|
+
id: "ci.yml",
|
|
87
|
+
path: "/repo/.github/workflows/ci.yml",
|
|
88
|
+
status: "completed",
|
|
89
|
+
jobs: [
|
|
90
|
+
{
|
|
91
|
+
id: "test",
|
|
92
|
+
runnerId: "agent-ci-5",
|
|
93
|
+
status: "completed",
|
|
94
|
+
bootDurationMs: 2000,
|
|
95
|
+
steps: [
|
|
96
|
+
{ name: "Set up job", index: 1, status: "completed", durationMs: 1000 },
|
|
97
|
+
{ name: "Run tests", index: 2, status: "completed", durationMs: 10000 },
|
|
98
|
+
{ name: "Complete job", index: 3, status: "completed", durationMs: 200 },
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
const output = renderRunState(state);
|
|
106
|
+
expect(output).toContain("✓ 1. Set up job (1s)");
|
|
107
|
+
expect(output).toContain("✓ 2. Run tests (10s)");
|
|
108
|
+
expect(output).toContain("✓ 3. Complete job (0s)");
|
|
109
|
+
});
|
|
110
|
+
it("renders a failed step with ✗ icon", () => {
|
|
111
|
+
const state = makeState({
|
|
112
|
+
workflows: [
|
|
113
|
+
{
|
|
114
|
+
id: "ci.yml",
|
|
115
|
+
path: "/repo/.github/workflows/ci.yml",
|
|
116
|
+
status: "failed",
|
|
117
|
+
jobs: [
|
|
118
|
+
{
|
|
119
|
+
id: "test",
|
|
120
|
+
runnerId: "agent-ci-5",
|
|
121
|
+
status: "failed",
|
|
122
|
+
failedStep: "Run tests",
|
|
123
|
+
bootDurationMs: 1000,
|
|
124
|
+
steps: [
|
|
125
|
+
{ name: "Set up job", index: 1, status: "completed", durationMs: 500 },
|
|
126
|
+
{ name: "Run tests", index: 2, status: "failed", durationMs: 5000 },
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
const output = renderRunState(state);
|
|
134
|
+
expect(output).toContain("✓ 1. Set up job (1s)");
|
|
135
|
+
expect(output).toContain("✗ 2. Run tests (5s)");
|
|
136
|
+
});
|
|
137
|
+
it("renders a skipped step with ⊘ icon", () => {
|
|
138
|
+
const state = makeState({
|
|
139
|
+
workflows: [
|
|
140
|
+
{
|
|
141
|
+
id: "ci.yml",
|
|
142
|
+
path: "/repo/.github/workflows/ci.yml",
|
|
143
|
+
status: "completed",
|
|
144
|
+
jobs: [
|
|
145
|
+
{
|
|
146
|
+
id: "test",
|
|
147
|
+
runnerId: "agent-ci-5",
|
|
148
|
+
status: "completed",
|
|
149
|
+
bootDurationMs: 1000,
|
|
150
|
+
steps: [
|
|
151
|
+
{ name: "Run tests", index: 1, status: "skipped" },
|
|
152
|
+
{ name: "Complete job", index: 2, status: "completed", durationMs: 100 },
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
});
|
|
159
|
+
const output = renderRunState(state);
|
|
160
|
+
expect(output).toContain("⊘ 1. Run tests");
|
|
161
|
+
});
|
|
162
|
+
it("renders a pending step with ○ icon", () => {
|
|
163
|
+
const state = makeState({
|
|
164
|
+
workflows: [
|
|
165
|
+
{
|
|
166
|
+
id: "ci.yml",
|
|
167
|
+
path: "/repo/.github/workflows/ci.yml",
|
|
168
|
+
status: "running",
|
|
169
|
+
jobs: [
|
|
170
|
+
{
|
|
171
|
+
id: "test",
|
|
172
|
+
runnerId: "agent-ci-5",
|
|
173
|
+
status: "running",
|
|
174
|
+
bootDurationMs: 1000,
|
|
175
|
+
steps: [
|
|
176
|
+
{ name: "Set up job", index: 1, status: "completed", durationMs: 500 },
|
|
177
|
+
{ name: "Run tests", index: 2, status: "pending" },
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
});
|
|
184
|
+
const output = renderRunState(state);
|
|
185
|
+
expect(output).toContain("○ 2. Run tests");
|
|
186
|
+
});
|
|
187
|
+
it("renders paused step with frozen timer and retry hints", () => {
|
|
188
|
+
const state = makeState({
|
|
189
|
+
workflows: [
|
|
190
|
+
{
|
|
191
|
+
id: "ci.yml",
|
|
192
|
+
path: "/repo/.github/workflows/ci.yml",
|
|
193
|
+
status: "running",
|
|
194
|
+
jobs: [
|
|
195
|
+
{
|
|
196
|
+
id: "test",
|
|
197
|
+
runnerId: "agent-ci-5",
|
|
198
|
+
status: "paused",
|
|
199
|
+
bootDurationMs: 1000,
|
|
200
|
+
pausedAtStep: "Run tests",
|
|
201
|
+
pausedAtMs: "1970-01-01T00:00:05.000Z", // 5s after epoch
|
|
202
|
+
attempt: 1,
|
|
203
|
+
lastOutputLines: ["Error: assertion failed"],
|
|
204
|
+
steps: [
|
|
205
|
+
{ name: "Set up job", index: 1, status: "completed", durationMs: 500 },
|
|
206
|
+
{
|
|
207
|
+
name: "Run tests",
|
|
208
|
+
index: 2,
|
|
209
|
+
status: "paused",
|
|
210
|
+
startedAt: "1970-01-01T00:00:03.000Z", // 3s after epoch
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
});
|
|
218
|
+
const output = renderRunState(state);
|
|
219
|
+
// Paused step icon
|
|
220
|
+
expect(output).toContain("⏸ 2. Run tests (2s)"); // 5s - 3s = 2s frozen
|
|
221
|
+
// Retry attempt indicator
|
|
222
|
+
expect(output).toContain("Step failed attempt #1");
|
|
223
|
+
// Trailing retry/abort hints (single-job mode)
|
|
224
|
+
expect(output).toContain("↻ To retry:");
|
|
225
|
+
expect(output).toContain("agent-ci retry --runner agent-ci-5");
|
|
226
|
+
expect(output).toContain("■ To abort:");
|
|
227
|
+
expect(output).toContain("agent-ci abort --runner agent-ci-5");
|
|
228
|
+
// Last output lines
|
|
229
|
+
expect(output).toContain("Last output:");
|
|
230
|
+
expect(output).toContain("Error: assertion failed");
|
|
231
|
+
});
|
|
232
|
+
it("renders retrying step with 'retrying' label", () => {
|
|
233
|
+
const state = makeState({
|
|
234
|
+
workflows: [
|
|
235
|
+
{
|
|
236
|
+
id: "ci.yml",
|
|
237
|
+
path: "/repo/.github/workflows/ci.yml",
|
|
238
|
+
status: "running",
|
|
239
|
+
jobs: [
|
|
240
|
+
{
|
|
241
|
+
id: "test",
|
|
242
|
+
runnerId: "agent-ci-5",
|
|
243
|
+
status: "running",
|
|
244
|
+
bootDurationMs: 1000,
|
|
245
|
+
pausedAtStep: "Run tests", // was paused on this step
|
|
246
|
+
attempt: 1, // has been retried
|
|
247
|
+
steps: [
|
|
248
|
+
{ name: "Set up job", index: 1, status: "completed", durationMs: 500 },
|
|
249
|
+
{
|
|
250
|
+
name: "Run tests",
|
|
251
|
+
index: 2,
|
|
252
|
+
status: "running",
|
|
253
|
+
startedAt: "1970-01-01T00:00:00.000Z",
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
});
|
|
261
|
+
const output = renderRunState(state);
|
|
262
|
+
expect(output).toContain("retrying");
|
|
263
|
+
expect(output).toContain("Run tests");
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
describe("multi-job workflow", () => {
|
|
267
|
+
it("collapses completed jobs to a single summary line", () => {
|
|
268
|
+
const state = makeState({
|
|
269
|
+
workflows: [
|
|
270
|
+
{
|
|
271
|
+
id: "ci.yml",
|
|
272
|
+
path: "/repo/.github/workflows/ci.yml",
|
|
273
|
+
status: "running",
|
|
274
|
+
jobs: [
|
|
275
|
+
{
|
|
276
|
+
id: "lint",
|
|
277
|
+
runnerId: "agent-ci-5-j1",
|
|
278
|
+
status: "completed",
|
|
279
|
+
durationMs: 5000,
|
|
280
|
+
steps: [],
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
id: "test",
|
|
284
|
+
runnerId: "agent-ci-5-j2",
|
|
285
|
+
status: "running",
|
|
286
|
+
bootDurationMs: 1000,
|
|
287
|
+
steps: [
|
|
288
|
+
{
|
|
289
|
+
name: "Run tests",
|
|
290
|
+
index: 1,
|
|
291
|
+
status: "running",
|
|
292
|
+
startedAt: "1970-01-01T00:00:00.000Z",
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
});
|
|
300
|
+
const output = renderRunState(state);
|
|
301
|
+
// Completed job collapsed (includes runner name)
|
|
302
|
+
expect(output).toContain("✓ lint");
|
|
303
|
+
expect(output).toContain("agent-ci-5-j1");
|
|
304
|
+
// Running job shows steps
|
|
305
|
+
expect(output).toContain("test");
|
|
306
|
+
expect(output).toContain("agent-ci-5-j2");
|
|
307
|
+
expect(output).toContain("⠋ 1. Run tests (0s...)");
|
|
308
|
+
// Does NOT show "Starting runner" for the running job in multi-job mode
|
|
309
|
+
expect(output).not.toContain("Starting runner agent-ci-5-j2 (");
|
|
310
|
+
});
|
|
311
|
+
it("shows ✗ icon for failed completed job", () => {
|
|
312
|
+
const state = makeState({
|
|
313
|
+
workflows: [
|
|
314
|
+
{
|
|
315
|
+
id: "ci.yml",
|
|
316
|
+
path: "/repo/.github/workflows/ci.yml",
|
|
317
|
+
status: "failed",
|
|
318
|
+
jobs: [
|
|
319
|
+
{
|
|
320
|
+
id: "lint",
|
|
321
|
+
runnerId: "agent-ci-5-j1",
|
|
322
|
+
status: "failed",
|
|
323
|
+
failedStep: "Run lint",
|
|
324
|
+
durationMs: 3000,
|
|
325
|
+
steps: [],
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
id: "test",
|
|
329
|
+
runnerId: "agent-ci-5-j2",
|
|
330
|
+
status: "completed",
|
|
331
|
+
durationMs: 5000,
|
|
332
|
+
steps: [],
|
|
333
|
+
},
|
|
334
|
+
],
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
});
|
|
338
|
+
const output = renderRunState(state);
|
|
339
|
+
expect(output).toContain("✗ lint");
|
|
340
|
+
expect(output).toContain("agent-ci-5-j1");
|
|
341
|
+
});
|
|
342
|
+
it("shows retry hint as child node in multi-job paused mode", () => {
|
|
343
|
+
const state = makeState({
|
|
344
|
+
workflows: [
|
|
345
|
+
{
|
|
346
|
+
id: "ci.yml",
|
|
347
|
+
path: "/repo/.github/workflows/ci.yml",
|
|
348
|
+
status: "running",
|
|
349
|
+
jobs: [
|
|
350
|
+
{
|
|
351
|
+
id: "lint",
|
|
352
|
+
runnerId: "agent-ci-5-j1",
|
|
353
|
+
status: "completed",
|
|
354
|
+
durationMs: 5000,
|
|
355
|
+
steps: [],
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
id: "test",
|
|
359
|
+
runnerId: "agent-ci-5-j2",
|
|
360
|
+
status: "paused",
|
|
361
|
+
pausedAtStep: "Run tests",
|
|
362
|
+
pausedAtMs: "1970-01-01T00:00:05.000Z",
|
|
363
|
+
attempt: 1,
|
|
364
|
+
bootDurationMs: 1000,
|
|
365
|
+
steps: [
|
|
366
|
+
{
|
|
367
|
+
name: "Run tests",
|
|
368
|
+
index: 1,
|
|
369
|
+
status: "paused",
|
|
370
|
+
startedAt: "1970-01-01T00:00:03.000Z",
|
|
371
|
+
},
|
|
372
|
+
],
|
|
373
|
+
},
|
|
374
|
+
],
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
});
|
|
378
|
+
const output = renderRunState(state);
|
|
379
|
+
// Retry hint is a child node (not trailing output like single-job mode)
|
|
380
|
+
expect(output).toContain("↻ retry: agent-ci retry --runner agent-ci-5-j2");
|
|
381
|
+
// No trailing "To retry:" / "To abort:" lines in multi-job mode
|
|
382
|
+
expect(output).not.toContain("↻ To retry:");
|
|
383
|
+
expect(output).not.toContain("■ To abort:");
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
describe("multi-workflow (--all mode)", () => {
|
|
387
|
+
it("renders multiple workflow roots", () => {
|
|
388
|
+
const state = makeState({
|
|
389
|
+
workflows: [
|
|
390
|
+
{
|
|
391
|
+
id: "ci.yml",
|
|
392
|
+
path: "/repo/.github/workflows/ci.yml",
|
|
393
|
+
status: "completed",
|
|
394
|
+
jobs: [
|
|
395
|
+
{
|
|
396
|
+
id: "test",
|
|
397
|
+
runnerId: "agent-ci-5-j1",
|
|
398
|
+
status: "completed",
|
|
399
|
+
durationMs: 15000,
|
|
400
|
+
steps: [],
|
|
401
|
+
},
|
|
402
|
+
],
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
id: "deploy.yml",
|
|
406
|
+
path: "/repo/.github/workflows/deploy.yml",
|
|
407
|
+
status: "running",
|
|
408
|
+
jobs: [
|
|
409
|
+
{
|
|
410
|
+
id: "deploy",
|
|
411
|
+
runnerId: "agent-ci-5-j2",
|
|
412
|
+
status: "booting",
|
|
413
|
+
startedAt: "1970-01-01T00:00:00.000Z",
|
|
414
|
+
steps: [],
|
|
415
|
+
},
|
|
416
|
+
],
|
|
417
|
+
},
|
|
418
|
+
],
|
|
419
|
+
});
|
|
420
|
+
const output = renderRunState(state);
|
|
421
|
+
expect(output).toContain("ci.yml");
|
|
422
|
+
expect(output).toContain("deploy.yml");
|
|
423
|
+
expect(output).toContain("✓ test");
|
|
424
|
+
expect(output).toContain("agent-ci-5-j1");
|
|
425
|
+
expect(output).toContain("⠋ Starting runner agent-ci-5-j2 (0s)");
|
|
426
|
+
});
|
|
427
|
+
it("groups multiple jobs under their respective workflow", () => {
|
|
428
|
+
const state = makeState({
|
|
429
|
+
workflows: [
|
|
430
|
+
{
|
|
431
|
+
id: "ci.yml",
|
|
432
|
+
path: "/repo/.github/workflows/ci.yml",
|
|
433
|
+
status: "completed",
|
|
434
|
+
jobs: [
|
|
435
|
+
{
|
|
436
|
+
id: "lint",
|
|
437
|
+
runnerId: "agent-ci-5-j1",
|
|
438
|
+
status: "completed",
|
|
439
|
+
durationMs: 5000,
|
|
440
|
+
steps: [],
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
id: "test",
|
|
444
|
+
runnerId: "agent-ci-5-j2",
|
|
445
|
+
status: "completed",
|
|
446
|
+
durationMs: 10000,
|
|
447
|
+
steps: [],
|
|
448
|
+
},
|
|
449
|
+
],
|
|
450
|
+
},
|
|
451
|
+
],
|
|
452
|
+
});
|
|
453
|
+
const output = renderRunState(state);
|
|
454
|
+
// ci.yml appears exactly once as root
|
|
455
|
+
expect(output.split("ci.yml").length).toBe(2); // 1 occurrence → 2 parts
|
|
456
|
+
expect(output).toContain("✓ lint");
|
|
457
|
+
expect(output).toContain("agent-ci-5-j1");
|
|
458
|
+
expect(output).toContain("✓ test");
|
|
459
|
+
expect(output).toContain("agent-ci-5-j2");
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
describe("boot spinner in booting phase", () => {
|
|
463
|
+
it("shows elapsed boot time in seconds", () => {
|
|
464
|
+
// Boot started 7 seconds ago in wall clock time
|
|
465
|
+
vi.setSystemTime(7000);
|
|
466
|
+
const state = makeState({
|
|
467
|
+
workflows: [
|
|
468
|
+
{
|
|
469
|
+
id: "ci.yml",
|
|
470
|
+
path: "/repo/.github/workflows/ci.yml",
|
|
471
|
+
status: "running",
|
|
472
|
+
jobs: [
|
|
473
|
+
{
|
|
474
|
+
id: "test",
|
|
475
|
+
runnerId: "agent-ci-5",
|
|
476
|
+
status: "booting",
|
|
477
|
+
startedAt: "1970-01-01T00:00:00.000Z", // epoch
|
|
478
|
+
steps: [],
|
|
479
|
+
},
|
|
480
|
+
],
|
|
481
|
+
},
|
|
482
|
+
],
|
|
483
|
+
});
|
|
484
|
+
const output = renderRunState(state);
|
|
485
|
+
expect(output).toContain("Starting runner agent-ci-5 (7s)");
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// ─── Tree Renderer ────────────────────────────────────────────────────────────
|
|
2
|
+
// Renders a tree structure using Unicode box-drawing characters.
|
|
3
|
+
//
|
|
4
|
+
// Example output:
|
|
5
|
+
// [*] tests.yml
|
|
6
|
+
// └── [job] test
|
|
7
|
+
// └── [run] agent-ci-5
|
|
8
|
+
// ├── [+] Set up job (1s)
|
|
9
|
+
// ├── [>] Run pnpm check (12s...)
|
|
10
|
+
// └── [ ] Pending...
|
|
11
|
+
/**
|
|
12
|
+
* Render a tree of nodes into a string with box-drawing characters.
|
|
13
|
+
*
|
|
14
|
+
* @param nodes One or more root-level nodes to render.
|
|
15
|
+
* @param prefix Internal — the leading whitespace/connector for the current depth.
|
|
16
|
+
* @param isRoot Internal — whether we're rendering top-level roots (no connectors).
|
|
17
|
+
*/
|
|
18
|
+
export function renderTree(nodes, prefix = "", isRoot = true) {
|
|
19
|
+
const lines = [];
|
|
20
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
21
|
+
const node = nodes[i];
|
|
22
|
+
const isLast = i === nodes.length - 1;
|
|
23
|
+
if (isRoot) {
|
|
24
|
+
// Root level — no connector
|
|
25
|
+
lines.push(node.label);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
const connector = isLast ? "└── " : "├── ";
|
|
29
|
+
lines.push(prefix + connector + node.label);
|
|
30
|
+
}
|
|
31
|
+
if (node.children && node.children.length > 0) {
|
|
32
|
+
let childPrefix;
|
|
33
|
+
if (isRoot) {
|
|
34
|
+
// Children of root nodes start at the base indentation
|
|
35
|
+
childPrefix = "";
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
childPrefix = isLast ? prefix + " " : prefix + "│ ";
|
|
39
|
+
}
|
|
40
|
+
lines.push(renderTree(node.children, childPrefix, false));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const result = lines.filter((l) => l.length > 0).join("\n");
|
|
44
|
+
// Add 1-space left padding only at the top-level call
|
|
45
|
+
if (isRoot) {
|
|
46
|
+
return result
|
|
47
|
+
.split("\n")
|
|
48
|
+
.map((l) => ` ${l}`)
|
|
49
|
+
.join("\n");
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderTree } from "./tree-renderer.js";
|
|
3
|
+
describe("renderTree", () => {
|
|
4
|
+
it("renders a single root node", () => {
|
|
5
|
+
const nodes = [{ label: "[*] tests.yml" }];
|
|
6
|
+
expect(renderTree(nodes)).toBe(" [*] tests.yml");
|
|
7
|
+
});
|
|
8
|
+
it("renders a linear chain", () => {
|
|
9
|
+
const tree = [
|
|
10
|
+
{
|
|
11
|
+
label: "[*] tests.yml",
|
|
12
|
+
children: [
|
|
13
|
+
{
|
|
14
|
+
label: "[job] test",
|
|
15
|
+
children: [{ label: "[run] agent-ci-5" }],
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
const expected = [" [*] tests.yml", " └── [job] test", " └── [run] agent-ci-5"].join("\n");
|
|
21
|
+
expect(renderTree(tree)).toBe(expected);
|
|
22
|
+
});
|
|
23
|
+
it("renders multiple siblings with correct connectors", () => {
|
|
24
|
+
const tree = [
|
|
25
|
+
{
|
|
26
|
+
label: "[*] tests.yml",
|
|
27
|
+
children: [
|
|
28
|
+
{
|
|
29
|
+
label: "[job] test",
|
|
30
|
+
children: [
|
|
31
|
+
{
|
|
32
|
+
label: "[run] agent-ci-5",
|
|
33
|
+
children: [
|
|
34
|
+
{ label: "[+] Set up job (1s)" },
|
|
35
|
+
{ label: "[+] actions/checkout@v4 (2s)" },
|
|
36
|
+
{ label: "[>] Run pnpm check (12s...)" },
|
|
37
|
+
{ label: "[ ] Pending..." },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
const expected = [
|
|
46
|
+
" [*] tests.yml",
|
|
47
|
+
" └── [job] test",
|
|
48
|
+
" └── [run] agent-ci-5",
|
|
49
|
+
" ├── [+] Set up job (1s)",
|
|
50
|
+
" ├── [+] actions/checkout@v4 (2s)",
|
|
51
|
+
" ├── [>] Run pnpm check (12s...)",
|
|
52
|
+
" └── [ ] Pending...",
|
|
53
|
+
].join("\n");
|
|
54
|
+
expect(renderTree(tree)).toBe(expected);
|
|
55
|
+
});
|
|
56
|
+
it("renders multiple root nodes", () => {
|
|
57
|
+
const tree = [{ label: "[*] a.yml" }, { label: "[*] b.yml" }];
|
|
58
|
+
const expected = [" [*] a.yml", " [*] b.yml"].join("\n");
|
|
59
|
+
expect(renderTree(tree)).toBe(expected);
|
|
60
|
+
});
|
|
61
|
+
it("renders deep nesting with mixed siblings", () => {
|
|
62
|
+
const tree = [
|
|
63
|
+
{
|
|
64
|
+
label: "[*] tests.yml",
|
|
65
|
+
children: [
|
|
66
|
+
{
|
|
67
|
+
label: "[job] test",
|
|
68
|
+
children: [
|
|
69
|
+
{
|
|
70
|
+
label: "[run] agent-ci-5",
|
|
71
|
+
children: [
|
|
72
|
+
{ label: "[+] Set up job (1s)" },
|
|
73
|
+
{
|
|
74
|
+
label: "[>] Run pnpm check (12s...)",
|
|
75
|
+
children: [{ label: "[output] Checking 142 files..." }],
|
|
76
|
+
},
|
|
77
|
+
{ label: "[ ] Pending..." },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
const expected = [
|
|
86
|
+
" [*] tests.yml",
|
|
87
|
+
" └── [job] test",
|
|
88
|
+
" └── [run] agent-ci-5",
|
|
89
|
+
" ├── [+] Set up job (1s)",
|
|
90
|
+
" ├── [>] Run pnpm check (12s...)",
|
|
91
|
+
" │ └── [output] Checking 142 files...",
|
|
92
|
+
" └── [ ] Pending...",
|
|
93
|
+
].join("\n");
|
|
94
|
+
expect(renderTree(tree)).toBe(expected);
|
|
95
|
+
});
|
|
96
|
+
it("handles nodes with empty children arrays", () => {
|
|
97
|
+
const tree = [
|
|
98
|
+
{
|
|
99
|
+
label: "root",
|
|
100
|
+
children: [],
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
expect(renderTree(tree)).toBe(" root");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
// Pinned to the monorepo root (project root), not the cli package
|
|
6
|
+
export const PROJECT_ROOT = path.resolve(fileURLToPath(import.meta.url), "..", "..", "..");
|
|
7
|
+
// When running inside a container with Docker-outside-of-Docker (shared socket),
|
|
8
|
+
// /tmp is NOT visible to the Docker host. Use a project-relative directory
|
|
9
|
+
// so bind mounts resolve correctly on the host.
|
|
10
|
+
const isInsideDocker = fs.existsSync("/.dockerenv");
|
|
11
|
+
export const DEFAULT_WORKING_DIR = isInsideDocker
|
|
12
|
+
? path.join(PROJECT_ROOT, ".agent-ci")
|
|
13
|
+
: path.join(os.tmpdir(), "agent-ci", path.basename(PROJECT_ROOT));
|
|
14
|
+
let workingDirectory = DEFAULT_WORKING_DIR;
|
|
15
|
+
export function setWorkingDirectory(dir) {
|
|
16
|
+
workingDirectory = dir;
|
|
17
|
+
}
|
|
18
|
+
export function getWorkingDirectory() {
|
|
19
|
+
return workingDirectory;
|
|
20
|
+
}
|