@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.8.5",
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":"a3c85408868d913b2ab265f910ab062916c1c394895cec71cae0f79fc99f5a6c"}
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
- return finalize(context, { status: "owner_lost", startedAt, endedAt: new Date().toISOString(), exitCode: null, signal: null, reason: `workload_spawn_failed:${error instanceof Error ? error.message : String(error)}` }, call);
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
- return finalize(context, { status: "owner_lost", startedAt, endedAt: new Date().toISOString(), exitCode: null, signal: null, reason: `workload_spawn_failed:${result.error.message}`, output: capture.output, outputCharacters: capture.characters }, call);
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
- return finalize(context, { status, startedAt, endedAt: new Date().toISOString(), exitCode: result.exitCode, signal: result.signal, output: capture.output, outputCharacters: capture.characters }, call);
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
+ }