@botbuddy/cli 1.8.5 → 1.8.6
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/package.json +5 -1
- package/src/botbuddy-release-repair.json +1 -1
- package/src/commands.mjs +2 -0
- package/src/run.mjs +90 -6
- package/src/test/playwright-reporter.mjs +99 -0
- package/src/test-lane-events.mjs +104 -0
- package/src/test-lane.mjs +275 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botbuddy/cli",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.6",
|
|
4
4
|
"description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
"pw": "./bin/pw.mjs",
|
|
9
9
|
"bb-pw": "./bin/bb-pw.mjs"
|
|
10
10
|
},
|
|
11
|
+
"exports": {
|
|
12
|
+
"./playwright-reporter": "./src/test/playwright-reporter.mjs",
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
11
15
|
"files": [
|
|
12
16
|
"bin/",
|
|
13
17
|
"src/",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"schema_version":1,"source_version":"1.8.3","source_identity":"
|
|
1
|
+
{"schema_version":1,"source_version":"1.8.3","source_identity":"8304ea6fbec329d54d6361cb6cbf5a6789331b0851d09caf151f0663ade18ec2"}
|
package/src/commands.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { CallUsageError, discoveryUrlFor, formatDiscovery, parseCallArgs } from
|
|
|
7
7
|
import { cmdStack } from "./stack.mjs";
|
|
8
8
|
import { cmdDocker } from "./docker-hygiene.mjs";
|
|
9
9
|
import { cmdRun } from "./run.mjs";
|
|
10
|
+
import { cmdTest } from "./test-lane.mjs";
|
|
10
11
|
import { runWait } from "./wait.mjs";
|
|
11
12
|
import { green, red, cyan, dim, bold, die } from "./utils.mjs";
|
|
12
13
|
import { VERSION } from "./version.mjs";
|
|
@@ -32,6 +33,7 @@ export async function run(argv) {
|
|
|
32
33
|
case "stack": return cmdStack(args);
|
|
33
34
|
case "docker": return cmdDocker(args);
|
|
34
35
|
case "run": return cmdRun(args);
|
|
36
|
+
case "test": return cmdTest(args);
|
|
35
37
|
case "wait": return runWait(args);
|
|
36
38
|
case "pw": return runPw(args);
|
|
37
39
|
case "profile": return cmdProfile(args);
|
package/src/run.mjs
CHANGED
|
@@ -13,12 +13,16 @@ import { homedir } from "os";
|
|
|
13
13
|
import { spawn } from "child_process";
|
|
14
14
|
import { fileURLToPath } from "url";
|
|
15
15
|
import { callToolJson } from "./api.mjs";
|
|
16
|
+
import { parseLaneEvents, laneCaseCounts, flushLaneCases, buildLaneSummary, laneSummaryFilename } from "./test-lane-events.mjs";
|
|
16
17
|
|
|
17
18
|
export const RUN_SCHEMA_VERSION = 1;
|
|
18
19
|
export const EXIT = Object.freeze({ OK: 0, INVALID: 4, BACKEND: 5, INTERNAL: 7 });
|
|
19
20
|
export const DEFAULT_TIMEOUT_SECONDS = 8 * 60 * 60;
|
|
20
21
|
export const TIMEOUT_GRACE_MS = 5_000;
|
|
21
22
|
const MAX_CAPTURE_BYTES = 8_192;
|
|
23
|
+
// BOT-1510: how often the worker flushes lane case verdicts to the backend.
|
|
24
|
+
// Well under AC-3's 5 s so a begin/verdict is visible within the window.
|
|
25
|
+
const LANE_FLUSH_INTERVAL_MS = 2_500;
|
|
22
26
|
|
|
23
27
|
export function parseRunArgs(argv) {
|
|
24
28
|
const opts = { sessionId: null, environment: null, category: "validation", kind: "other", expectedDuration: 0, timeout: DEFAULT_TIMEOUT_SECONDS, rerunReason: null, json: false };
|
|
@@ -91,7 +95,7 @@ function workerInvocation(context) {
|
|
|
91
95
|
return [process.execPath, [bin, "run", "--worker", context]];
|
|
92
96
|
}
|
|
93
97
|
|
|
94
|
-
export async function launchRun(argv, { cwd = process.cwd(), spawnImpl = spawn, call = callToolJson } = {}) {
|
|
98
|
+
export async function launchRun(argv, { cwd = process.cwd(), spawnImpl = spawn, call = callToolJson, childEnv = null, testRun = null } = {}) {
|
|
95
99
|
const { opts, command, errors } = parseRunArgs(argv);
|
|
96
100
|
if (errors.length) return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", errors, exit_code: EXIT.INVALID } };
|
|
97
101
|
const runId = randomUUID();
|
|
@@ -106,7 +110,7 @@ export async function launchRun(argv, { cwd = process.cwd(), spawnImpl = spawn,
|
|
|
106
110
|
}
|
|
107
111
|
const context = contextPath(runId);
|
|
108
112
|
const receipt = receiptPath(runId);
|
|
109
|
-
await writeJson(context, { schema_version: RUN_SCHEMA_VERSION, context_path: context, run_id: runId, run_credential: registration.data.run_credential, receipt_upload: registration.data.receipt_upload, command, cwd, timeout_seconds: opts.timeout, receipt_path: receipt, launched_at: new Date().toISOString() });
|
|
113
|
+
await writeJson(context, { schema_version: RUN_SCHEMA_VERSION, context_path: context, run_id: runId, run_credential: registration.data.run_credential, receipt_upload: registration.data.receipt_upload, command, cwd, timeout_seconds: opts.timeout, receipt_path: receipt, launched_at: new Date().toISOString(), ...(childEnv && Object.keys(childEnv).length ? { child_env: childEnv } : {}), ...(testRun ? { test_run: testRun } : {}) });
|
|
110
114
|
const [file, args] = workerInvocation(context);
|
|
111
115
|
try {
|
|
112
116
|
const worker = spawnImpl(file, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
@@ -124,9 +128,12 @@ export async function runWorker(contextFile, { spawnImpl = spawn, call = callToo
|
|
|
124
128
|
let timedOut = false;
|
|
125
129
|
let child;
|
|
126
130
|
try {
|
|
127
|
-
child = spawnImpl(context.command[0], context.command.slice(1), { cwd: context.cwd, detached: true, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
131
|
+
child = spawnImpl(context.command[0], context.command.slice(1), { cwd: context.cwd, detached: true, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, ...(context.child_env ? { env: { ...process.env, ...context.child_env } } : {}) });
|
|
128
132
|
} catch (error) {
|
|
129
|
-
|
|
133
|
+
const endedAt = new Date().toISOString();
|
|
134
|
+
const receipt = await finalize(context, { status: "owner_lost", startedAt, endedAt, exitCode: null, signal: null, reason: `workload_spawn_failed:${error instanceof Error ? error.message : String(error)}` }, call);
|
|
135
|
+
if (context.test_run) await completeTestRun(context, { status: "owner_lost", exitCode: null, signal: null, startedAt, endedAt, call });
|
|
136
|
+
return receipt;
|
|
130
137
|
}
|
|
131
138
|
// A failed spawn can report through the child `error` event on the next turn.
|
|
132
139
|
// Subscribe before any filesystem I/O so a busy parallel test run (or a real
|
|
@@ -140,6 +147,9 @@ export async function runWorker(contextFile, { spawnImpl = spawn, call = callToo
|
|
|
140
147
|
child.stdout?.on("data", (chunk) => capture.append(chunk));
|
|
141
148
|
child.stderr?.on("data", (chunk) => capture.append(chunk));
|
|
142
149
|
await writeJson(context.context_path, { ...context, worker_pid: process.pid, workload_pid: child.pid });
|
|
150
|
+
// BOT-1510: for a test lane, tail the reporter's NDJSON and flush per-case
|
|
151
|
+
// verdicts while the child runs. The test process never touches the network.
|
|
152
|
+
const lane = context.test_run ? startLaneFlusher(context, call) : null;
|
|
143
153
|
let killTimer;
|
|
144
154
|
const timer = setTimeout(() => {
|
|
145
155
|
timedOut = true;
|
|
@@ -149,11 +159,85 @@ export async function runWorker(contextFile, { spawnImpl = spawn, call = callToo
|
|
|
149
159
|
const result = await completion;
|
|
150
160
|
clearTimeout(timer);
|
|
151
161
|
clearTimeout(killTimer);
|
|
162
|
+
if (lane) await lane.stop();
|
|
163
|
+
const endedAt = new Date().toISOString();
|
|
152
164
|
if (result.error) {
|
|
153
|
-
|
|
165
|
+
const receipt = await finalize(context, { status: "owner_lost", startedAt, endedAt, exitCode: null, signal: null, reason: `workload_spawn_failed:${result.error.message}`, output: capture.output, outputCharacters: capture.characters }, call);
|
|
166
|
+
if (context.test_run) await completeTestRun(context, { status: "owner_lost", exitCode: null, signal: null, startedAt, endedAt, call });
|
|
167
|
+
return receipt;
|
|
154
168
|
}
|
|
155
169
|
const status = terminalStatus({ ...result, timedOut });
|
|
156
|
-
|
|
170
|
+
const receipt = await finalize(context, { status, startedAt, endedAt, exitCode: result.exitCode, signal: result.signal, output: capture.output, outputCharacters: capture.characters }, call);
|
|
171
|
+
// AC-4/AC-5: complete the run (final flush → update_test_run status:completed →
|
|
172
|
+
// emit_test_run_signal fires → the armed `test-run:` wait wakes).
|
|
173
|
+
if (context.test_run) await completeTestRun(context, { status, exitCode: result.exitCode, signal: result.signal, startedAt, endedAt, call });
|
|
174
|
+
return receipt;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Periodic lane flusher: reduce the NDJSON events file to the current case state
|
|
178
|
+
// and upsert it via sync_test_run_cases. Idempotent (keyed by case_key), so a
|
|
179
|
+
// re-flush of an unchanged list is a no-op server-side. Never throws.
|
|
180
|
+
function startLaneFlusher(context, call) {
|
|
181
|
+
const tr = context.test_run;
|
|
182
|
+
let stopped = false;
|
|
183
|
+
let chain = Promise.resolve();
|
|
184
|
+
const tick = () => {
|
|
185
|
+
chain = chain.then(async () => {
|
|
186
|
+
if (stopped) return;
|
|
187
|
+
let text = "";
|
|
188
|
+
try { text = await readFile(tr.events_path, "utf8"); } catch { return; }
|
|
189
|
+
const { cases } = parseLaneEvents(text, { warn: () => {} });
|
|
190
|
+
if (cases.size) await flushLaneCases({ call, testRunId: tr.test_run_id, cases });
|
|
191
|
+
}).catch(() => { /* a flush failure retries on the next tick; never blocks the child */ });
|
|
192
|
+
};
|
|
193
|
+
const timer = setInterval(tick, LANE_FLUSH_INTERVAL_MS);
|
|
194
|
+
timer.unref?.();
|
|
195
|
+
return { async stop() { stopped = true; clearInterval(timer); await chain; } };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Terminal: one last flush, write the .botbuddy/test-runs summary, then retry
|
|
199
|
+
// update_test_run{completed} until accepted (like update_command_run).
|
|
200
|
+
async function completeTestRun(context, { status, exitCode, signal, startedAt, endedAt, call }) {
|
|
201
|
+
const tr = context.test_run;
|
|
202
|
+
let text = "";
|
|
203
|
+
try { text = await readFile(tr.events_path, "utf8"); } catch { /* no events (0-test lane, or offline reporter) */ }
|
|
204
|
+
const { cases } = parseLaneEvents(text, { warn: () => {} });
|
|
205
|
+
if (cases.size) { try { await flushLaneCases({ call, testRunId: tr.test_run_id, cases }); } catch { /* update below still records terminal state */ } }
|
|
206
|
+
const counts = laneCaseCounts(cases);
|
|
207
|
+
const cancelled = status !== "success" && status !== "failure";
|
|
208
|
+
const durationMs = Math.max(0, Date.parse(endedAt) - Date.parse(startedAt));
|
|
209
|
+
const summary = buildLaneSummary({
|
|
210
|
+
lane: tr.lane, runner: tr.runner, testRunId: tr.test_run_id,
|
|
211
|
+
startedAt, finishedAt: endedAt, durationMs, exitCode, signal,
|
|
212
|
+
command: context.command, cwd: context.cwd, gitSha: tr.git_sha ?? null, branch: tr.branch ?? null,
|
|
213
|
+
counts, cancelled, lefthookEnv: laneLefthookEnv(),
|
|
214
|
+
});
|
|
215
|
+
try {
|
|
216
|
+
const summaryPath = join(context.cwd, ".botbuddy", "test-runs", laneSummaryFilename(endedAt, tr.lane));
|
|
217
|
+
await mkdir(dirname(summaryPath), { recursive: true });
|
|
218
|
+
await writeFile(summaryPath, JSON.stringify(summary, null, 2) + "\n");
|
|
219
|
+
} catch { /* the summary is best-effort; the test_runs row is authoritative */ }
|
|
220
|
+
await retryUpdateTestRun({
|
|
221
|
+
test_run_id: tr.test_run_id, status: "completed",
|
|
222
|
+
metadata: { lane: tr.lane, runner: tr.runner, exit_code: exitCode ?? null, duration_ms: durationMs, cwd: context.cwd, command: context.command, command_run_id: context.run_id, cancelled, ...counts },
|
|
223
|
+
}, call);
|
|
224
|
+
return summary;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function retryUpdateTestRun(update, call, { sleep = (ms) => new Promise((r) => setTimeout(r, ms)), maxAttempts = 6 } = {}) {
|
|
228
|
+
let delay = 1_000;
|
|
229
|
+
for (let attempt = 1; ; attempt++) {
|
|
230
|
+
let res;
|
|
231
|
+
try { res = await call("update_test_run", update); } catch (error) { res = { ok: false, error: error instanceof Error ? error.message : String(error) }; }
|
|
232
|
+
if (res?.ok && !res.isError) return res;
|
|
233
|
+
if (attempt >= maxAttempts) return res; // detached worker gives up rather than hanging forever
|
|
234
|
+
await sleep(delay);
|
|
235
|
+
delay = Math.min(delay * 2, 30_000);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function laneLefthookEnv(env = process.env) {
|
|
240
|
+
return { LEFTHOOK: env.LEFTHOOK ?? null, LEFTHOOK_EXCLUDE: env.LEFTHOOK_EXCLUDE ?? null, LEFTHOOK_INCLUDE: env.LEFTHOOK_INCLUDE ?? null };
|
|
157
241
|
}
|
|
158
242
|
|
|
159
243
|
function boundedCapture() {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// BOT-1510 — Playwright reporter for `botbuddy test <lane>` (runner=playwright).
|
|
2
|
+
//
|
|
3
|
+
// Wired via `@botbuddy/cli/playwright-reporter` behind an env-conditional line in
|
|
4
|
+
// the host's playwright.config.ts:
|
|
5
|
+
// reporter: process.env.BOTBUDDY_TEST_RUN_ID
|
|
6
|
+
// ? [["list"], ["@botbuddy/cli/playwright-reporter"]]
|
|
7
|
+
// : [["list"], ["html", { open: "never" }]]
|
|
8
|
+
//
|
|
9
|
+
// It does ONE thing: append NDJSON begin/verdict events to the file named by
|
|
10
|
+
// $BOTBUDDY_TEST_RUN_EVENTS. It NEVER opens a socket — the detached `botbuddy
|
|
11
|
+
// run` worker tails this file and is the only process that talks to the backend.
|
|
12
|
+
// No env → a silent no-op, so a normal `pnpm test:e2e` is unaffected.
|
|
13
|
+
|
|
14
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
15
|
+
import { dirname } from "node:path";
|
|
16
|
+
|
|
17
|
+
const MAX_FAIL_REASON_BYTES = 2048;
|
|
18
|
+
|
|
19
|
+
export function verdictFor(status) {
|
|
20
|
+
switch (status) {
|
|
21
|
+
case "passed": return "pass";
|
|
22
|
+
case "skipped": return "skip";
|
|
23
|
+
case "failed":
|
|
24
|
+
case "timedOut":
|
|
25
|
+
case "interrupted": return "fail";
|
|
26
|
+
default: return "fail";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function projectName(test) {
|
|
31
|
+
try {
|
|
32
|
+
const p = test?.parent?.project?.();
|
|
33
|
+
if (p?.name) return p.name;
|
|
34
|
+
} catch { /* older Playwright shapes — fall through */ }
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Stable per-case identity: "<file>::<titlePath joined ' › '>::<project>".
|
|
39
|
+
export function caseKey(test) {
|
|
40
|
+
const file = test?.location?.file ?? "";
|
|
41
|
+
const path = (typeof test?.titlePath === "function" ? test.titlePath() : []).filter(Boolean).join(" › ");
|
|
42
|
+
return `${file}::${path}::${projectName(test)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function truncateUtf8(input, maxBytes) {
|
|
46
|
+
const bytes = new TextEncoder().encode(input);
|
|
47
|
+
if (bytes.length <= maxBytes) return input;
|
|
48
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(bytes.slice(0, maxBytes)).replace(/�+$/, "");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default class BotBuddyPlaywrightReporter {
|
|
52
|
+
constructor(_options = {}, { env = process.env } = {}) {
|
|
53
|
+
this._path = env.BOTBUDDY_TEST_RUN_EVENTS || null;
|
|
54
|
+
this._order = new Map();
|
|
55
|
+
this._dirEnsured = false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_append(event) {
|
|
59
|
+
if (!this._path) return; // no telemetry target — a normal local run
|
|
60
|
+
try {
|
|
61
|
+
if (!this._dirEnsured) { mkdirSync(dirname(this._path), { recursive: true }); this._dirEnsured = true; }
|
|
62
|
+
appendFileSync(this._path, JSON.stringify(event) + "\n");
|
|
63
|
+
} catch (err) {
|
|
64
|
+
// Telemetry must never fail the test run.
|
|
65
|
+
process.stderr.write(`[botbuddy-reporter] could not append event: ${err?.message ?? err}\n`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
onBegin(_config, suite) {
|
|
70
|
+
if (!this._path) return;
|
|
71
|
+
const tests = typeof suite?.allTests === "function" ? suite.allTests() : [];
|
|
72
|
+
tests.forEach((t, i) => {
|
|
73
|
+
const key = caseKey(t);
|
|
74
|
+
this._order.set(key, i);
|
|
75
|
+
const title = (typeof t?.titlePath === "function" ? t.titlePath() : []).filter(Boolean).join(" › ") || key;
|
|
76
|
+
this._append({ type: "begin", key, title, sort_order: i });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
onTestEnd(test, result) {
|
|
81
|
+
if (!this._path) return;
|
|
82
|
+
const key = caseKey(test);
|
|
83
|
+
const sort_order = this._order.get(key) ?? this._order.size;
|
|
84
|
+
const firstError = Array.isArray(result?.errors) ? result.errors.find((e) => e?.message)?.message : result?.error?.message;
|
|
85
|
+
this._append({
|
|
86
|
+
type: "verdict",
|
|
87
|
+
key,
|
|
88
|
+
sort_order,
|
|
89
|
+
verdict: verdictFor(result?.status),
|
|
90
|
+
fail_reason: firstError ? truncateUtf8(String(firstError), MAX_FAIL_REASON_BYTES) : null,
|
|
91
|
+
duration_ms: Number.isFinite(result?.duration) ? Math.round(result.duration) : null,
|
|
92
|
+
retry: Number.isInteger(result?.retry) ? result.retry : 0,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Playwright requires this hook to exist; nothing to flush — the worker owns
|
|
97
|
+
// the terminal update.
|
|
98
|
+
onEnd() {}
|
|
99
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// BOT-1510 — the detached worker's view of a lane's NDJSON telemetry.
|
|
2
|
+
//
|
|
3
|
+
// The Playwright reporter (or the generic runner) appends begin/verdict events
|
|
4
|
+
// to an NDJSON file; the worker tails it and flushes the reduced per-case state
|
|
5
|
+
// to `sync_test_run_cases`. These functions are the pure, unit-tested core:
|
|
6
|
+
// parse (corrupt lines skipped, never a crash), reduce to a case map (last
|
|
7
|
+
// verdict wins), chunk to the 500-case cap, count, and build the terminal
|
|
8
|
+
// `.botbuddy/test-runs/<iso>-<lane>.json` summary.
|
|
9
|
+
|
|
10
|
+
export const SYNC_BATCH = 500;
|
|
11
|
+
|
|
12
|
+
// Parse NDJSON lane events into an ordered case map. Corrupt/partial lines are
|
|
13
|
+
// skipped with a stderr warning (AC-11) and counted in `malformed`.
|
|
14
|
+
export function parseLaneEvents(text, { warn = (m) => process.stderr.write(m) } = {}) {
|
|
15
|
+
const cases = new Map();
|
|
16
|
+
let malformed = 0;
|
|
17
|
+
for (const raw of String(text ?? "").split("\n")) {
|
|
18
|
+
const line = raw.trim();
|
|
19
|
+
if (!line) continue;
|
|
20
|
+
let ev;
|
|
21
|
+
try { ev = JSON.parse(line); } catch { malformed += 1; warn("[botbuddy] skipping corrupt lane event line\n"); continue; }
|
|
22
|
+
if (!ev || typeof ev.key !== "string" || (ev.type !== "begin" && ev.type !== "verdict")) { malformed += 1; continue; }
|
|
23
|
+
const c = cases.get(ev.key) ?? { key: ev.key, title: ev.key, sort_order: cases.size, verdict: "pending", fail_reason: null, duration_ms: null };
|
|
24
|
+
if (typeof ev.title === "string" && ev.title) c.title = ev.title;
|
|
25
|
+
if (Number.isInteger(ev.sort_order)) c.sort_order = ev.sort_order;
|
|
26
|
+
if (ev.type === "verdict") {
|
|
27
|
+
if (ev.verdict) c.verdict = ev.verdict; // last write wins (retries)
|
|
28
|
+
c.fail_reason = typeof ev.fail_reason === "string" ? ev.fail_reason : null;
|
|
29
|
+
if (Number.isFinite(ev.duration_ms)) c.duration_ms = ev.duration_ms;
|
|
30
|
+
}
|
|
31
|
+
cases.set(ev.key, c);
|
|
32
|
+
}
|
|
33
|
+
return { cases, malformed };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Case map → the sync_test_run_cases payload, ordered by sort_order.
|
|
37
|
+
export function laneCasesToSync(cases) {
|
|
38
|
+
return [...cases.values()]
|
|
39
|
+
.sort((a, b) => a.sort_order - b.sort_order)
|
|
40
|
+
.map((c) => ({
|
|
41
|
+
key: c.key, title: c.title, sort_order: c.sort_order, verdict: c.verdict,
|
|
42
|
+
...(c.fail_reason != null ? { fail_reason: c.fail_reason } : {}),
|
|
43
|
+
...(c.duration_ms != null ? { duration_ms: c.duration_ms } : {}),
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function chunkCases(arr, size = SYNC_BATCH) {
|
|
48
|
+
const out = [];
|
|
49
|
+
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function laneCaseCounts(cases) {
|
|
54
|
+
let passed = 0, failed = 0, skipped = 0, pending = 0;
|
|
55
|
+
for (const c of cases.values()) {
|
|
56
|
+
if (c.verdict === "pass") passed += 1;
|
|
57
|
+
else if (c.verdict === "fail") failed += 1;
|
|
58
|
+
else if (c.verdict === "skip") skipped += 1;
|
|
59
|
+
else pending += 1;
|
|
60
|
+
}
|
|
61
|
+
return { case_count: cases.size, passed, failed, skipped, pending };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Flush the current case state in ≤500-case batches. Never throws — a failed
|
|
65
|
+
// batch is reported so the caller can retry on the next tick without ever
|
|
66
|
+
// blocking or killing the child.
|
|
67
|
+
export async function flushLaneCases({ call, testRunId, cases }) {
|
|
68
|
+
const chunks = chunkCases(laneCasesToSync(cases));
|
|
69
|
+
let sent = 0;
|
|
70
|
+
let ok = true;
|
|
71
|
+
for (const chunk of chunks) {
|
|
72
|
+
let res;
|
|
73
|
+
try { res = await call("sync_test_run_cases", { test_run_id: testRunId, cases: chunk }); }
|
|
74
|
+
catch (err) { res = { ok: false, error: err instanceof Error ? err.message : String(err) }; }
|
|
75
|
+
if (res?.ok && !res.isError) sent += chunk.length; else ok = false;
|
|
76
|
+
}
|
|
77
|
+
return { chunks: chunks.length, sent, ok };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The `.botbuddy/test-runs/<iso>-<lane>.json` summary: the log-test-run.mjs
|
|
81
|
+
// shape (BOT-461) plus test_run_id, so BOT-1387/BOT-608 consumers stay
|
|
82
|
+
// compatible while gaining the run linkage.
|
|
83
|
+
export function buildLaneSummary({
|
|
84
|
+
lane, runner, testRunId, startedAt, finishedAt, durationMs, exitCode, signal,
|
|
85
|
+
command, cwd, gitSha = null, branch = null, counts = null, cancelled = false, lefthookEnv = null,
|
|
86
|
+
}) {
|
|
87
|
+
return {
|
|
88
|
+
runner, source: "cli-lane",
|
|
89
|
+
command: Array.isArray(command) ? command.join(" ") : String(command ?? ""),
|
|
90
|
+
started_at: startedAt, finished_at: finishedAt, duration_ms: durationMs,
|
|
91
|
+
exit_code: exitCode ?? null, signal: signal ?? null,
|
|
92
|
+
verdict: exitCode === 0 ? "pass" : "fail",
|
|
93
|
+
git_sha: gitSha, branch, spawn_error: null,
|
|
94
|
+
meta: { lane, cwd, cancelled, ...(counts ?? {}) },
|
|
95
|
+
lefthook_env: lefthookEnv,
|
|
96
|
+
test_run_id: testRunId,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// `.botbuddy/test-runs/<iso>-<lane>.json` — the same ISO-safe basename shape as
|
|
101
|
+
// scripts/log-test-run.mjs, but keyed by lane (not runner) per AC-4.
|
|
102
|
+
export function laneSummaryFilename(finishedAtIso, lane) {
|
|
103
|
+
return `${finishedAtIso.replace(/[:.]/g, "-")}-${lane}.json`;
|
|
104
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// BOT-1510 — `botbuddy test <lane>`: run a repo-configured local test lane AS a
|
|
2
|
+
// BotBuddy test run.
|
|
3
|
+
//
|
|
4
|
+
// The launcher creates a `test_runs` row for the lane, promotes it to `active`
|
|
5
|
+
// (attaching the ticket in a SECOND call so a human's ticket+env draft run is
|
|
6
|
+
// never hijacked by create_test_run's upsert), then hands the lane to the
|
|
7
|
+
// existing durable `botbuddy run` worker with the test-run id in the child's
|
|
8
|
+
// env. It prints exactly one JSON line and exits 0 — like `botbuddy run` — while
|
|
9
|
+
// the lane keeps executing detached. A worker (later phase) tails the reporter's
|
|
10
|
+
// NDJSON and flushes per-case verdicts through `sync_test_run_cases`.
|
|
11
|
+
//
|
|
12
|
+
// Backend down? The lane still runs; the JSON line says telemetry:"unavailable",
|
|
13
|
+
// wait:null, and the child exit code is preserved.
|
|
14
|
+
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { readFile } from "node:fs/promises";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { execFileSync } from "node:child_process";
|
|
21
|
+
|
|
22
|
+
import { EXIT, launchRun, receiptPath, DEFAULT_TIMEOUT_SECONDS } from "./run.mjs";
|
|
23
|
+
import { callToolJson } from "./api.mjs";
|
|
24
|
+
|
|
25
|
+
// EXIT.{OK,INVALID,BACKEND,INTERNAL} plus a --wait timeout code (AC-9).
|
|
26
|
+
export const EXIT_TEST = Object.freeze({ ...EXIT, TIMEOUT: 2 });
|
|
27
|
+
export const WAITS_URL = "https://app.bot-buddy.ai/waits";
|
|
28
|
+
const KNOWN_RUNNERS = new Set(["playwright", "generic"]);
|
|
29
|
+
|
|
30
|
+
export function parseTestArgs(argv, { env = process.env } = {}) {
|
|
31
|
+
const opts = {
|
|
32
|
+
sessionId: env.BOTBUDDY_SESSION_ID ?? null,
|
|
33
|
+
environment: "local",
|
|
34
|
+
ticket: null, pr: null, repo: null,
|
|
35
|
+
wait: false, json: false,
|
|
36
|
+
};
|
|
37
|
+
const errors = [];
|
|
38
|
+
const separator = argv.indexOf("--");
|
|
39
|
+
const flags = separator === -1 ? argv : argv.slice(0, separator);
|
|
40
|
+
const extra = separator === -1 ? [] : argv.slice(separator + 1);
|
|
41
|
+
let subcommand = null;
|
|
42
|
+
let lane = null;
|
|
43
|
+
const value = (name, i) => { if (flags[i + 1] == null) { errors.push(`${name} needs a value`); return null; } return flags[i + 1]; };
|
|
44
|
+
for (let i = 0; i < flags.length; i++) {
|
|
45
|
+
const flag = flags[i];
|
|
46
|
+
switch (flag) {
|
|
47
|
+
case "--session-id": opts.sessionId = value(flag, i); i++; break;
|
|
48
|
+
case "--environment": opts.environment = value(flag, i); i++; break;
|
|
49
|
+
case "--ticket": opts.ticket = value(flag, i); i++; break;
|
|
50
|
+
case "--pr": { const raw = value(flag, i); i++; opts.pr = raw == null ? null : Number(raw); if (raw != null && !Number.isInteger(opts.pr)) errors.push("--pr must be an integer"); break; }
|
|
51
|
+
case "--repo": opts.repo = value(flag, i); i++; break;
|
|
52
|
+
case "--wait": opts.wait = true; break;
|
|
53
|
+
case "--json": opts.json = true; break;
|
|
54
|
+
default:
|
|
55
|
+
if (flag.startsWith("-")) { errors.push(`unknown option: ${flag}`); break; }
|
|
56
|
+
if (lane === null && subcommand === null) {
|
|
57
|
+
if (flag === "list" || flag === "help") subcommand = flag;
|
|
58
|
+
else lane = flag;
|
|
59
|
+
} else errors.push(`unexpected argument: ${flag}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!subcommand && !lane) errors.push("a lane is required (e.g. `botbuddy test e2e`); run `botbuddy test list`");
|
|
63
|
+
return { subcommand, lane, opts, extra, errors };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function findGitRoot(cwd) {
|
|
67
|
+
let dir = cwd;
|
|
68
|
+
for (;;) {
|
|
69
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
70
|
+
const parent = dirname(dir);
|
|
71
|
+
if (parent === dir) return null;
|
|
72
|
+
dir = parent;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// `.botbuddy/lanes.json` at the git root — no defaults (gate 9): a missing file
|
|
77
|
+
// or an unknown lane is a hard, named failure, never a guessed command.
|
|
78
|
+
export function resolveLane(laneName, { cwd = process.cwd(), readFileImpl = readFileSync } = {}) {
|
|
79
|
+
const root = findGitRoot(cwd) ?? cwd;
|
|
80
|
+
const path = join(root, ".botbuddy", "lanes.json");
|
|
81
|
+
if (!existsSync(path)) return { ok: false, error: "missing", path, root };
|
|
82
|
+
let config;
|
|
83
|
+
try { config = JSON.parse(readFileImpl(path, "utf8")); }
|
|
84
|
+
catch (e) { return { ok: false, error: "invalid", path, root, detail: e instanceof Error ? e.message : String(e) }; }
|
|
85
|
+
const lanes = config?.lanes && typeof config.lanes === "object" ? config.lanes : {};
|
|
86
|
+
const available = Object.keys(lanes);
|
|
87
|
+
if (!laneName || !lanes[laneName]) return { ok: false, error: "unknown", path, root, available };
|
|
88
|
+
const lane = lanes[laneName];
|
|
89
|
+
if (!Array.isArray(lane.command) || lane.command.length === 0) return { ok: false, error: "invalid", path, root, detail: `lane "${laneName}" has no command` };
|
|
90
|
+
if (!KNOWN_RUNNERS.has(lane.runner)) return { ok: false, error: "invalid", path, root, detail: `lane "${laneName}" runner must be playwright|generic` };
|
|
91
|
+
return { ok: true, appSlug: config.app_slug ?? null, lane, path, root };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Best-effort git/gh context. Every field falls back to null — never fabricated
|
|
95
|
+
// (gate 9). CLI overrides (--ticket/--pr/--repo) win over the derived values.
|
|
96
|
+
export function defaultGitInfo({ cwd = process.cwd(), ticket = null, pr = null, repo = null } = {}) {
|
|
97
|
+
const git = (args) => { try { return execFileSync("git", args, { cwd, encoding: "utf8" }).trim() || null; } catch { return null; } };
|
|
98
|
+
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
99
|
+
const sha = git(["rev-parse", "HEAD"]);
|
|
100
|
+
let ticketId = ticket;
|
|
101
|
+
if (!ticketId && branch) {
|
|
102
|
+
const m = branch.match(/(?:^|\/)((?:bot|ent)-\d+)/i);
|
|
103
|
+
if (m) ticketId = m[1].toUpperCase();
|
|
104
|
+
}
|
|
105
|
+
const ticketUrl = ticketId?.startsWith("BOT-") ? `https://linear.app/botbuddy/issue/${ticketId}` : null;
|
|
106
|
+
let prNumber = pr;
|
|
107
|
+
let prUrl = null;
|
|
108
|
+
if (prNumber == null) {
|
|
109
|
+
try {
|
|
110
|
+
const raw = execFileSync("gh", ["pr", "view", "--json", "number,url"], { cwd, encoding: "utf8" });
|
|
111
|
+
const parsed = JSON.parse(raw);
|
|
112
|
+
prNumber = Number.isInteger(parsed.number) ? parsed.number : null;
|
|
113
|
+
prUrl = parsed.url ?? null;
|
|
114
|
+
} catch { /* no PR / gh unavailable — null, never fabricated */ }
|
|
115
|
+
}
|
|
116
|
+
return { branch, sha, prNumber, prUrl, repo, ticket: ticketId, ticketUrl };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The production lane launcher: run the lane through the durable `botbuddy run`
|
|
120
|
+
// worker, carrying the telemetry env into its detached child.
|
|
121
|
+
async function defaultLaunchLane({ command, sessionId, environment, expectedDurationSeconds, childEnv, testRun, cwd, call }) {
|
|
122
|
+
const argv = ["--session-id", sessionId, "--environment", environment, "--category", "validation", "--kind", "full_suite", "--expected-duration", String(expectedDurationSeconds ?? 0), "--", ...command];
|
|
123
|
+
const result = await launchRun(argv, { cwd, call, childEnv, testRun });
|
|
124
|
+
return { exitCode: result.exitCode, runId: result.receipt?.run_id ?? null };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function waitCommand(testRunId, sessionId) {
|
|
128
|
+
return `botbuddy wait 'test-run:id=${testRunId}' --session-id ${sessionId} --heartbeat`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function launchTestLane(argv, {
|
|
132
|
+
cwd = process.cwd(),
|
|
133
|
+
env = process.env,
|
|
134
|
+
call = callToolJson,
|
|
135
|
+
launchLane = defaultLaunchLane,
|
|
136
|
+
gitInfo = defaultGitInfo,
|
|
137
|
+
eventsDir = join(homedir(), ".botbuddy", "test-lanes"),
|
|
138
|
+
} = {}) {
|
|
139
|
+
const { lane: laneName, opts, extra, errors } = parseTestArgs(argv, { env });
|
|
140
|
+
if (errors.length) {
|
|
141
|
+
for (const e of errors) process.stderr.write(`botbuddy test: ${e}\n`);
|
|
142
|
+
return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", errors }) };
|
|
143
|
+
}
|
|
144
|
+
if (!opts.sessionId) {
|
|
145
|
+
process.stderr.write("botbuddy test: --session-id is required (or set $BOTBUDDY_SESSION_ID)\n");
|
|
146
|
+
return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", errors: ["--session-id is required"] }) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const resolved = resolveLane(laneName, { cwd });
|
|
150
|
+
if (!resolved.ok) {
|
|
151
|
+
if (resolved.error === "missing") process.stderr.write(`botbuddy test: no lane config — create ${resolved.path}\n`);
|
|
152
|
+
else if (resolved.error === "unknown") process.stderr.write(`botbuddy test: unknown lane "${laneName}". Available: ${resolved.available.join(", ") || "(none)"}\n`);
|
|
153
|
+
else process.stderr.write(`botbuddy test: invalid lane config (${resolved.detail}) at ${resolved.path}\n`);
|
|
154
|
+
return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", error: resolved.error, path: resolved.path }) };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const git = gitInfo({ cwd, ticket: opts.ticket, pr: opts.pr, repo: opts.repo });
|
|
158
|
+
const sha7 = (git.sha ?? "").slice(0, 7) || "nosha";
|
|
159
|
+
const laneRunId = randomUUID();
|
|
160
|
+
const eventsPath = join(eventsDir, `${laneRunId}.ndjson`);
|
|
161
|
+
const title = `${laneName} · ${git.branch ?? "detached"} · ${sha7}`;
|
|
162
|
+
|
|
163
|
+
// Step 1: create WITHOUT ticket_id (create_test_run upserts on ticket_id+env —
|
|
164
|
+
// passing it would hijack a human's draft/active run for the same ticket).
|
|
165
|
+
const createArgs = {
|
|
166
|
+
app_slug: resolved.appSlug, environment: opts.environment, title,
|
|
167
|
+
commit_sha: git.sha ?? undefined, branch_name: git.branch ?? undefined,
|
|
168
|
+
pr_number: git.prNumber ?? undefined, pr_url: git.prUrl ?? undefined,
|
|
169
|
+
source_kind: "cli-lane", source_id: laneRunId,
|
|
170
|
+
};
|
|
171
|
+
const created = await call("create_test_run", createArgs);
|
|
172
|
+
const testRunId = created?.ok && !created.isError ? created.data?.id ?? null : null;
|
|
173
|
+
|
|
174
|
+
if (testRunId) {
|
|
175
|
+
// Step 2: attach the ticket + promote to active in one update.
|
|
176
|
+
await call("update_test_run", {
|
|
177
|
+
test_run_id: testRunId,
|
|
178
|
+
ticket_id: git.ticket ?? undefined,
|
|
179
|
+
ticket_url: git.ticketUrl ?? undefined,
|
|
180
|
+
status: "active",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Step 3: launch the lane under the durable worker, telemetry env in the child.
|
|
185
|
+
const childEnv = { BOTBUDDY_LANE: laneName };
|
|
186
|
+
if (testRunId) { childEnv.BOTBUDDY_TEST_RUN_ID = testRunId; childEnv.BOTBUDDY_TEST_RUN_EVENTS = eventsPath; }
|
|
187
|
+
const testRun = testRunId
|
|
188
|
+
? { test_run_id: testRunId, events_path: eventsPath, lane: laneName, runner: resolved.lane.runner, git_sha: git.sha ?? null, branch: git.branch ?? null }
|
|
189
|
+
: null;
|
|
190
|
+
const launch = await launchLane({
|
|
191
|
+
command: [...resolved.lane.command, ...extra],
|
|
192
|
+
sessionId: opts.sessionId, environment: opts.environment,
|
|
193
|
+
expectedDurationSeconds: resolved.lane.expected_duration_seconds ?? 0,
|
|
194
|
+
childEnv, testRun, cwd, call, testRunId, lane: laneName, runner: resolved.lane.runner,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const commandRunId = launch.runId;
|
|
198
|
+
const line = testRunId
|
|
199
|
+
? {
|
|
200
|
+
outcome: "launched", test_run_id: testRunId, command_run_id: commandRunId, lane: laneName,
|
|
201
|
+
wait: waitCommand(testRunId, opts.sessionId),
|
|
202
|
+
receipt_path: commandRunId ? receiptPath(commandRunId) : null, waits_url: WAITS_URL,
|
|
203
|
+
}
|
|
204
|
+
: {
|
|
205
|
+
outcome: "launched", test_run_id: null, command_run_id: commandRunId, lane: laneName,
|
|
206
|
+
telemetry: "unavailable", wait: null,
|
|
207
|
+
receipt_path: commandRunId ? receiptPath(commandRunId) : null, waits_url: WAITS_URL,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
return { exitCode: launch.exitCode, line: JSON.stringify(line), testRunId, commandRunId, opts };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// AC-9 (--wait foreground convenience): block until the run reaches a terminal
|
|
214
|
+
// status, then surface the LANE's exit code (from the command-run receipt the
|
|
215
|
+
// worker wrote, falling back to the run's metadata). A timeout returns exit 2
|
|
216
|
+
// and never touches the still-running lane. We poll get_test_run rather than the
|
|
217
|
+
// SSE wait engine because runWait() hard-exits and cannot yield the lane code.
|
|
218
|
+
const TERMINAL_RUN_STATUSES = new Set(["completed", "archived", "superseded"]);
|
|
219
|
+
export async function waitForLane({
|
|
220
|
+
testRunId, commandRunId, timeoutSec = DEFAULT_TIMEOUT_SECONDS,
|
|
221
|
+
call = callToolJson, readReceipt = (p) => readFile(p, "utf8").then(JSON.parse),
|
|
222
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, pollMs = 3_000,
|
|
223
|
+
} = {}) {
|
|
224
|
+
const deadline = now() + timeoutSec * 1_000;
|
|
225
|
+
for (;;) {
|
|
226
|
+
let status = null;
|
|
227
|
+
let metaExit;
|
|
228
|
+
try {
|
|
229
|
+
const res = await call("get_test_run", { test_run_id: testRunId });
|
|
230
|
+
if (res?.ok && !res.isError) { status = res.data?.status ?? null; metaExit = res.data?.metadata?.exit_code; }
|
|
231
|
+
} catch { /* transient — retry until the deadline */ }
|
|
232
|
+
if (status && TERMINAL_RUN_STATUSES.has(status)) {
|
|
233
|
+
let exitCode = Number.isInteger(metaExit) ? metaExit : 0;
|
|
234
|
+
if (commandRunId) { try { const r = await readReceipt(receiptPath(commandRunId)); if (Number.isInteger(r?.exit_code)) exitCode = r.exit_code; } catch { /* metadata fallback */ } }
|
|
235
|
+
return { outcome: "completed", exitCode };
|
|
236
|
+
}
|
|
237
|
+
if (now() >= deadline) return { outcome: "timeout", exitCode: EXIT_TEST.TIMEOUT };
|
|
238
|
+
await sleep(pollMs);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function laneList(cwd) {
|
|
243
|
+
const resolved = resolveLane(null, { cwd });
|
|
244
|
+
if (resolved.error === "missing") { process.stderr.write(`botbuddy test: no lane config — create ${resolved.path}\n`); return EXIT_TEST.INVALID; }
|
|
245
|
+
const lanes = resolved.available ?? [];
|
|
246
|
+
process.stdout.write(JSON.stringify({ app_slug: resolved.appSlug ?? null, lanes }) + "\n");
|
|
247
|
+
return EXIT_TEST.OK;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function testHelp() {
|
|
251
|
+
console.log(`botbuddy test <lane> [--session-id <uuid>] [--environment local] [--ticket <KEY>] [--pr <n>] [--repo <owner/repo>] [--wait] [--json] [-- <extra args>]
|
|
252
|
+
botbuddy test list List the lanes configured in .botbuddy/lanes.json
|
|
253
|
+
botbuddy test help Show this help
|
|
254
|
+
|
|
255
|
+
Runs a repo-configured local test lane as a BotBuddy test run, prints a
|
|
256
|
+
ready-to-paste \`botbuddy wait 'test-run:id=…'\` line, and shows the run on
|
|
257
|
+
${WAITS_URL} while it executes detached.`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function cmdTest(args, { cwd = process.cwd() } = {}) {
|
|
261
|
+
const { subcommand } = parseTestArgs(args);
|
|
262
|
+
if (subcommand === "help" || args[0] === "--help" || args[0] === "-h") { testHelp(); return; }
|
|
263
|
+
if (subcommand === "list") { process.exitCode = laneList(cwd); return; }
|
|
264
|
+
const result = await launchTestLane(args, { cwd });
|
|
265
|
+
process.stdout.write(result.line + "\n");
|
|
266
|
+
// --wait (AC-9): after launch, block until the run completes and exit with the
|
|
267
|
+
// lane's exit code; a timeout exits 2 without killing the lane. Only meaningful
|
|
268
|
+
// when telemetry is live (there is a run to wait on).
|
|
269
|
+
if (result.testRunId && result.opts?.wait) {
|
|
270
|
+
const wait = await waitForLane({ testRunId: result.testRunId, commandRunId: result.commandRunId });
|
|
271
|
+
process.exitCode = wait.exitCode;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
process.exitCode = result.exitCode;
|
|
275
|
+
}
|