@botbuddy/cli 1.8.5 → 1.8.7

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.7",
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/",
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/pw/daemon.mjs CHANGED
@@ -1,13 +1,62 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import net from "node:net";
3
3
  import { createRequire } from "node:module";
4
- import { existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
6
  import { homedir } from "node:os";
7
+ import { normaliseLane } from "./args.mjs";
7
8
  export const daemonDir = (env = process.env) => env.BB_PW_DAEMON_DIR || join(homedir(), ".botbuddy", "pw-daemon");
8
9
  export function resolveCliBin(env = process.env) { if (env.BB_PW_CLI_BIN) return { cmd: "node", args: [env.BB_PW_CLI_BIN] }; const require = createRequire(import.meta.url); const pkg = require.resolve("@playwright/cli/package.json"), meta = JSON.parse(readFileSync(pkg, "utf8")), bin = typeof meta.bin === "string" ? meta.bin : meta.bin["playwright-cli"] || Object.values(meta.bin)[0]; return { cmd: "node", args: [join(dirname(pkg), bin)] }; }
9
10
  export function spawnExec(plan, env = process.env) { const { cmd, args } = resolveCliBin(env); return new Promise((resolve) => { const child = spawn(cmd, [...args, ...plan.execArgv], { stdio: "inherit", env: { ...env, PWTEST_DAEMON_SESSION_DIR: daemonDir(env) } }); child.on("exit", (code) => resolve(code ?? 1)); child.on("error", () => resolve(127)); }); }
10
- export function readSession(session, env = process.env) { const root = daemonDir(env); if (!existsSync(root)) return null; for (const item of readdirSync(root)) { try { return JSON.parse(readFileSync(join(root, item, `${session}.session`), "utf8")); } catch {} } return null; }
11
- export function sendToDaemon(socketPath, positional, { connect = net.createConnection, cwd = process.cwd(), timeoutMs = 30000 } = {}) { return new Promise((resolve) => { let done = false, buffer = "", socket; const finish = (value) => { if (!done) { done = true; socket?.destroy(); resolve(value); } }; socket = connect(socketPath, () => socket.write(JSON.stringify({ id: 1, method: "run", params: { args: { _: positional }, cwd } }) + "\n")); socket.on("data", (data) => { buffer += String(data); const newline = buffer.indexOf("\n"); if (newline < 0) return; try { const reply = JSON.parse(buffer.slice(0, newline)), text = reply.result?.text ?? ""; finish(reply.error || /^### Error\b/m.test(text) ? { ok: false, error: reply.error?.message ?? reply.error ?? text } : { ok: true, text }); } catch { finish({ ok: false, error: "bb-pw: malformed daemon reply" }); } }); socket.on("error", (error) => finish({ ok: false, error: error.message })); setTimeout(() => finish({ ok: false, error: "bb-pw: daemon socket timeout" }), timeoutMs).unref(); }); }
11
+ // BOT-1522: playwright-cli keeps one sub-dir per invocation context under the
12
+ // daemon dir, and only ever cleans up its OWN sub-dir's `<session>.session`. A
13
+ // lane opened from another worktree whose daemon has since exited leaves a file
14
+ // pointing at a socket that no longer exists — and it can sort before today's
15
+ // live one. Selection is therefore by liveness, never by readdir order, and the
16
+ // caller gets a remediation naming the lane and the command, never a raw syscall.
17
+ export const laneOf = (session) => normaliseLane(session ?? "");
18
+ // Any connect-phase failure means the daemon is not there to answer; name the
19
+ // shape in prose so the syscall code never reaches the operator (AC-3/AC-6).
20
+ const CONNECT_FAILURE = { ENOENT: "its socket is gone", ECONNREFUSED: "its socket refused the connection", ENOTSOCK: "its socket refused the connection", ECONNRESET: "the connection dropped", EPIPE: "the connection dropped" };
21
+ const connectFailure = (error) => (error?.code ? CONNECT_FAILURE[error.code] ?? "the connection failed" : null);
22
+ export function notRunningMessage(session, ignored = [], detail = null) {
23
+ const why = ignored.length ? `ignored stale session file(s): ${ignored.map((entry) => `${entry.file} [${entry.reason}]`).join(", ")}` : detail ?? "no session file found";
24
+ return `bb-pw: ${session} daemon is not running (${why}). Run \`bb-pw ${laneOf(session) || "<lane>"} open <url>\` first, then retry.`;
25
+ }
26
+ // Every `<root>/<sub>/<session>.session`, newest first. A file whose socket path
27
+ // is ABSENT on disk is provably dead (the daemon's tmp dir is gone) and is
28
+ // pruned; one whose socket EXISTS is kept — the daemon may be starting — and
29
+ // liveness is decided by connecting (resolveSession), not by the file. Corrupt
30
+ // JSON is left alone: it is not provably dead. Only this session's file is read,
31
+ // so another lane's live daemon in the same sub-dir is never touched.
32
+ export function scanSessions(session, env = process.env, { prune = true } = {}) {
33
+ const root = daemonDir(env), candidates = [], ignored = [];
34
+ if (!existsSync(root)) return { candidates, ignored };
35
+ for (const item of readdirSync(root)) {
36
+ const file = join(root, item, `${session}.session`);
37
+ let data; try { data = JSON.parse(readFileSync(file, "utf8")); } catch { continue; }
38
+ if (!data?.socketPath) { ignored.push({ file, reason: "no socket path" }); continue; }
39
+ if (!existsSync(data.socketPath)) { ignored.push({ file, reason: "socket missing" }); if (prune) { try { unlinkSync(file); } catch {} } continue; }
40
+ let mtimeMs = 0; try { mtimeMs = statSync(file).mtimeMs; } catch {}
41
+ candidates.push({ file, data, mtimeMs });
42
+ }
43
+ // Newest first; a deterministic path tiebreak so an mtime tie never falls back to readdir order.
44
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs || b.file.localeCompare(a.file));
45
+ return { candidates, ignored };
46
+ }
47
+ // Synchronous, back-compatible, READ-ONLY reader (reap.mjs): the newest session
48
+ // whose socket path exists on disk. Pruning belongs to the socket path (socketRun).
49
+ export function readSession(session, env = process.env) { return scanSessions(session, env, { prune: false }).candidates[0]?.data ?? null; }
50
+ export function sendToDaemon(socketPath, positional, { connect = net.createConnection, cwd = process.cwd(), timeoutMs = 30000, session = null } = {}) { return new Promise((resolve) => { let done = false, buffer = "", socket; const finish = (value) => { if (!done) { done = true; socket?.destroy(); resolve(value); } }; socket = connect(socketPath, () => socket.write(JSON.stringify({ id: 1, method: "run", params: { args: { _: positional }, cwd } }) + "\n")); socket.on("data", (data) => { buffer += String(data); const newline = buffer.indexOf("\n"); if (newline < 0) return; try { const reply = JSON.parse(buffer.slice(0, newline)), text = reply.result?.text ?? ""; finish(reply.error || /^### Error\b/m.test(text) ? { ok: false, error: reply.error?.message ?? reply.error ?? text } : { ok: true, text }); } catch { finish({ ok: false, error: "bb-pw: malformed daemon reply" }); } }); socket.on("error", (error) => { const why = session ? connectFailure(error) : null; finish({ ok: false, error: why ? notRunningMessage(session, [], why) : error.message }); }); setTimeout(() => finish({ ok: false, error: "bb-pw: daemon socket timeout" }), timeoutMs).unref(); }); }
12
51
  export function socketAlive(socketPath, { connect = net.createConnection, timeoutMs = 1000 } = {}) { return new Promise((resolve) => { if (!socketPath) return resolve(false); let done = false, socket; const finish = (value) => { if (!done) { done = true; socket?.destroy(); resolve(value); } }; socket = connect(socketPath, () => finish(true)); socket.on("error", () => finish(false)); setTimeout(() => finish(false), timeoutMs).unref(); }); }
13
- export async function socketRun(plan, env = process.env) { const session = readSession(plan.session, env); return session?.socketPath ? sendToDaemon(session.socketPath, plan.socketArgs) : { ok: false, error: `bb-pw: no open daemon for ${plan.session}; open it first` }; }
52
+ // Authoritative, async: the newest candidate whose socket actually ANSWERS.
53
+ export async function resolveSession(session, env = process.env, { alive = socketAlive } = {}) {
54
+ const { candidates, ignored } = scanSessions(session, env);
55
+ for (const candidate of candidates) { if (await alive(candidate.data.socketPath)) return { session: candidate.data, ignored }; ignored.push({ file: candidate.file, reason: "did not answer" }); }
56
+ return { session: null, ignored };
57
+ }
58
+ export async function socketRun(plan, env = process.env, { alive, send = sendToDaemon } = {}) {
59
+ const { session, ignored } = await resolveSession(plan.session, env, { alive });
60
+ if (!session) return { ok: false, error: notRunningMessage(plan.session, ignored) };
61
+ return send(session.socketPath, plan.socketArgs, { session: plan.session });
62
+ }
package/src/pw/run.mjs CHANGED
@@ -7,6 +7,7 @@ import { canonicalizeHostString } from "./host.mjs";
7
7
  import { resolveAgentProfile } from "../wait-profile.mjs";
8
8
  import { readProfileIdentity } from "../agent-credential-store.mjs";
9
9
  import { loadConfig, getConfig } from "../config.mjs";
10
+ import { VERSION } from "../version.mjs";
10
11
  // BOT-1488: canonicalize the raw hostname the SAME way acquire_resources does
11
12
  // server-side, so the lane name bb-pw builds/matches/prints is the one the lock
12
13
  // kernel actually stored ("jonos-mbp:8", not "Jonos-MBP.localdomain:8").
@@ -68,8 +69,22 @@ async function gate({ env, host, lane, deps }) {
68
69
  return { allowed: false, message: `bb-pw: could not verify lane lock ${laneName} (${error.message}). Set BB_PW_NO_LOCK=1 for local-only work.` };
69
70
  }
70
71
  }
72
+ // BOT-1522: a stale global install (1.6.1 on the reporting host) replayed the
73
+ // pre-BOT-1488 refusal and was indistinguishable from a code regression. Every
74
+ // non-zero exit now names the bundled version, and --version exists at all, so
75
+ // an agent can tell "old binary" from "real bug" without reading the source.
76
+ const versionLine = () => `bb-pw (@botbuddy/cli v${VERSION})`;
71
77
  export async function runPw(argv, deps = {}) {
78
+ const stderr = deps.stderr ?? process.stderr;
79
+ const code = await runPwInner(argv, deps);
80
+ // Neutral: a refusal may be correct policy, not staleness — the line only lets
81
+ // the reader compare this binary against cli/package.json in their checkout.
82
+ if (code !== 0) stderr.write(`${versionLine()} — if your checkout's cli/package.json is newer, this global is stale: npm i -g @botbuddy/cli@latest, or run node cli/bin/bb-pw.mjs from the repo.\n`);
83
+ return code;
84
+ }
85
+ async function runPwInner(argv, deps = {}) {
72
86
  const env = deps.env ?? process.env, stdout = deps.stdout ?? process.stdout, stderr = deps.stderr ?? process.stderr; let args = [...argv]; if (["--help", "-h"].includes(args[0])) { help(stdout); return 0; }
87
+ if (["--version", "-v"].includes(args[0])) { stdout.write(`${versionLine()}\n`); return 0; }
73
88
  while (args[0] === "--profile" || args[0] === "--session-id") { const flag = args[0]; if (!args[1]) { stderr.write(`bb-pw: ${flag} needs a value\n`); return 2; } deps = flag === "--profile" ? { ...deps, profile: args[1] } : { ...deps, sessionId: args[1] }; args = args.slice(2); }
74
89
  let plan; try { plan = planInvocation(args, env); } catch (error) { stderr.write(`${error.message}\n`); return 2; }
75
90
  const { spawnExec, socketRun } = deps.daemon ?? await import("./daemon.mjs");
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
+ }
@@ -1 +0,0 @@
1
- {"schema_version":1,"source_version":"1.8.3","source_identity":"a3c85408868d913b2ab265f910ab062916c1c394895cec71cae0f79fc99f5a6c"}