@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 +5 -1
- package/src/commands.mjs +2 -0
- package/src/pw/daemon.mjs +53 -4
- package/src/pw/run.mjs +15 -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/src/botbuddy-release-repair.json +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botbuddy/cli",
|
|
3
|
-
"version": "1.8.
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"schema_version":1,"source_version":"1.8.3","source_identity":"a3c85408868d913b2ab265f910ab062916c1c394895cec71cae0f79fc99f5a6c"}
|