@botbuddy/cli 1.5.0 → 1.5.1

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/src/run.test.mjs DELETED
@@ -1,173 +0,0 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
3
- import { mkdtempSync, readFileSync, renameSync, unlinkSync } from "node:fs";
4
- import { join } from "node:path";
5
- import { tmpdir } from "node:os";
6
-
7
- import { EventEmitter } from "node:events";
8
- import { EXIT, parseRunArgs, terminalStatus, commandHash, contextPath, launchRun, retryTerminalUpdate, runWorker, cancelRun } from "./run.mjs";
9
-
10
- test("run: rejects an unowned workload before it can start", () => {
11
- const parsed = parseRunArgs(["--environment", "local", "--", "node", "-e", "process.exit(0)"]);
12
- assert.ok(parsed.errors.some((e) => e.includes("session-id")));
13
- assert.equal(parsed.command[0], "node");
14
- });
15
-
16
- test("run: accepts and forwards a structured rerun reason", async () => {
17
- const parsed = parseRunArgs(["--session-id", "session-1", "--environment", "local", "--rerun-reason", '{"code":"source_changed","detail":"retry after fix"}', "--", "node", "-e", "process.exit(0)"]);
18
- assert.deepEqual(parsed.opts.rerunReason, { code: "source_changed", detail: "retry after fix" });
19
-
20
- const calls = [];
21
- const result = await launchRun(["--session-id", "session-1", "--environment", "local", "--rerun-reason", '{"code":"source_changed","detail":"retry after fix"}', "--", "node", "-e", "process.exit(0)"], {
22
- call: async (name, args) => {
23
- calls.push({ name, args });
24
- return { ok: true, data: { accepted: true, run_credential: "x".repeat(64), receipt_upload: "receipt://tenant/session/run" } };
25
- },
26
- spawnImpl: () => Object.assign(new EventEmitter(), { unref() {} }),
27
- });
28
- assert.equal(result.exitCode, EXIT.OK);
29
- assert.deepEqual(calls[0].args.rerun_reason, { code: "source_changed", detail: "retry after fix" });
30
- unlinkSync(contextPath(result.receipt.run_id));
31
- });
32
-
33
- test("run: rejects an invalid rerun reason before registering a run", () => {
34
- const parsed = parseRunArgs(["--session-id", "session-1", "--environment", "local", "--rerun-reason", "not-json", "--", "node", "-e", "process.exit(0)"]);
35
- assert.ok(parsed.errors.some((error) => error.includes("rerun-reason")));
36
- });
37
-
38
- test("run: derives complete terminal outcomes", () => {
39
- assert.equal(terminalStatus({ exitCode: 0, signal: null }), "success");
40
- assert.equal(terminalStatus({ exitCode: 1, signal: null }), "failure");
41
- assert.equal(terminalStatus({ exitCode: null, signal: "SIGTERM" }), "cancelled");
42
- assert.equal(terminalStatus({ exitCode: null, signal: "SIGTERM", timedOut: true }), "timed_out");
43
- assert.equal(terminalStatus({ exitCode: null, signal: null, ownerLost: true }), "owner_lost");
44
- });
45
-
46
- test("run: the detached worker records one receipt after its launcher is gone", async () => {
47
- const dir = mkdtempSync(join(tmpdir(), "bb-run-"));
48
- const receiptPath = join(dir, "receipt.json");
49
- const contextPath = join(dir, "context.json");
50
- const updates = [];
51
- const context = {
52
- context_path: contextPath, run_id: "run-survives-launcher", run_credential: "x".repeat(64), receipt_upload: "receipt://botbuddy/session/run-survives-launcher",
53
- command: [process.execPath, "-e", "setTimeout(() => process.exit(0), 30)"], cwd: dir, timeout_seconds: 2, receipt_path: receiptPath,
54
- };
55
- await import("node:fs/promises").then(({ writeFile }) => writeFile(contextPath, JSON.stringify(context)));
56
- await runWorker(contextPath, { call: async (name, args) => { updates.push({ name, args }); return { ok: true, data: {} }; } });
57
- const receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
58
- assert.equal(receipt.status, "success");
59
- assert.equal(updates.length, 1);
60
- assert.equal(updates[0].name, "update_command_run");
61
- assert.equal(updates[0].args.status, "succeeded");
62
- assert.equal(updates[0].args.run_id, context.run_id);
63
- });
64
-
65
- test("run: terminal callbacks retain and retry their sealed context until accepted", async () => {
66
- const dir = mkdtempSync(join(tmpdir(), "bb-run-retry-"));
67
- const context = { context_path: join(dir, "context.json") };
68
- const update = { run_id: "retry-run", run_credential: "x".repeat(64), status: "succeeded" };
69
- const attempts = [];
70
- await retryTerminalUpdate(context, update, async () => {
71
- attempts.push(true);
72
- return attempts.length === 1 ? { ok: false, error: "offline" } : { ok: true, data: { accepted: true } };
73
- }, { sleep: async () => {} });
74
- assert.equal(attempts.length, 2);
75
- const retained = JSON.parse(readFileSync(context.context_path, "utf8"));
76
- assert.equal(retained.terminal_update.attempts, 1);
77
- });
78
-
79
- test("run: bounds captured output while preserving its total size", async () => {
80
- const dir = mkdtempSync(join(tmpdir(), "bb-run-output-"));
81
- const receiptPath = join(dir, "receipt.json");
82
- const contextPath = join(dir, "context.json");
83
- const context = {
84
- context_path: contextPath, run_id: "bounded-output", run_credential: "x".repeat(64), receipt_upload: "receipt://botbuddy/session/bounded-output",
85
- command: [process.execPath, "-e", "process.stdout.write('x'.repeat(50000))"], cwd: dir, timeout_seconds: 2, receipt_path: receiptPath,
86
- };
87
- await import("node:fs/promises").then(({ writeFile }) => writeFile(contextPath, JSON.stringify(context)));
88
- await runWorker(contextPath, { call: async () => ({ ok: true, data: { accepted: true } }) });
89
- const receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
90
- assert.equal(receipt.output_characters, 50000);
91
- assert.ok(receipt.bounded_log.length <= 8192);
92
- });
93
-
94
- test("run: escalates a timed-out SIGTERM-resistant process group to SIGKILL", async () => {
95
- const dir = mkdtempSync(join(tmpdir(), "bb-run-timeout-"));
96
- const receiptPath = join(dir, "receipt.json");
97
- const contextPath = join(dir, "context.json");
98
- const context = {
99
- context_path: contextPath, run_id: "timeout-escalation", run_credential: "x".repeat(64), receipt_upload: "receipt://botbuddy/session/timeout-escalation",
100
- command: [process.execPath, "-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1_000)"], cwd: dir, timeout_seconds: 0.03, timeout_grace_ms: 20, receipt_path: receiptPath,
101
- };
102
- await import("node:fs/promises").then(({ writeFile }) => writeFile(contextPath, JSON.stringify(context)));
103
- await runWorker(contextPath, { call: async () => ({ ok: true, data: { accepted: true } }) });
104
- const receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
105
- assert.equal(receipt.status, "timed_out");
106
- });
107
-
108
- test("run: asynchronous workload spawn errors become terminal owner-loss receipts", async () => {
109
- const dir = mkdtempSync(join(tmpdir(), "bb-run-spawn-error-"));
110
- const receiptPath = join(dir, "receipt.json");
111
- const contextPath = join(dir, "context.json");
112
- const context = {
113
- context_path: contextPath, run_id: "async-spawn-error", run_credential: "x".repeat(64), receipt_upload: "receipt://botbuddy/session/async-spawn-error",
114
- command: ["missing"], cwd: dir, timeout_seconds: 2, receipt_path: receiptPath,
115
- };
116
- await import("node:fs/promises").then(({ writeFile }) => writeFile(contextPath, JSON.stringify(context)));
117
- const child = Object.assign(new EventEmitter(), { pid: 123, stdout: null, stderr: null });
118
- // Emit the spawn error only AFTER runWorker has synchronously attached its
119
- // 'error' listener (waitForChild runs before the first await). A fixed timer
120
- // could otherwise fire while runWorker is still in its initial `await
121
- // readFile`, making 'error' an unhandled event that throws under load.
122
- await runWorker(contextPath, {
123
- spawnImpl: () => { queueMicrotask(() => child.emit("error", new Error("ENOENT"))); return child; },
124
- call: async () => ({ ok: true, data: { accepted: true } }),
125
- });
126
- const receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
127
- assert.equal(receipt.status, "owner_lost");
128
- assert.match(receipt.reason, /ENOENT/);
129
- });
130
-
131
- test("run: command hashes are stable and command-sensitive", () => {
132
- assert.equal(commandHash(["pnpm", "test"]), commandHash(["pnpm", "test"]));
133
- assert.notEqual(commandHash(["pnpm", "test"]), commandHash(["pnpm", "test:e2e"]));
134
- assert.equal(EXIT.BACKEND, 5);
135
- });
136
-
137
- test("run: cancellation terminates the detached workload process group and records one cancelled receipt", async () => {
138
- const dir = mkdtempSync(join(tmpdir(), "bb-run-cancel-"));
139
- const runId = `cancel-${Date.now()}`;
140
- const receiptPath = join(dir, "receipt.json");
141
- const sourceContext = join(dir, "context.json");
142
- const updates = [];
143
- const canonical = join(process.env.HOME ?? "", ".botbuddy", "command-runs", `${runId}.context.json`);
144
- const context = {
145
- context_path: canonical, run_id: runId, run_credential: "x".repeat(64), receipt_upload: `receipt://botbuddy/session/${runId}`,
146
- command: [process.execPath, "-e", "setTimeout(() => process.exit(0), 10_000)"], cwd: dir, timeout_seconds: 20, receipt_path: receiptPath,
147
- };
148
- await import("node:fs/promises").then(({ writeFile }) => writeFile(sourceContext, JSON.stringify(context)));
149
- // cancelRun intentionally uses the canonical per-user runner directory; link
150
- // this deterministic fixture into it without giving the test a live server.
151
- await import("node:fs/promises").then(({ mkdir }) => mkdir(join(process.env.HOME ?? "", ".botbuddy", "command-runs"), { recursive: true }));
152
- renameSync(sourceContext, canonical);
153
- const worker = runWorker(canonical, { call: async (name, args) => { updates.push({ name, args }); return { ok: true, data: {} }; } });
154
- for (let attempt = 0; attempt < 20; attempt++) {
155
- // The worker rewrites this file to stamp workload_pid; tolerate a transient
156
- // read (an unfinished write, though writeJson is atomic) by retrying.
157
- let current;
158
- try {
159
- current = JSON.parse(readFileSync(canonical, "utf8"));
160
- } catch {
161
- await new Promise((resolve) => setTimeout(resolve, 25));
162
- continue;
163
- }
164
- if (Number.isInteger(current.workload_pid)) break;
165
- await new Promise((resolve) => setTimeout(resolve, 25));
166
- }
167
- const cancelled = await cancelRun(runId, { call: async () => ({ ok: true, data: {} }) });
168
- assert.equal(cancelled.receipt.outcome, "cancellation_requested", JSON.stringify(cancelled.receipt));
169
- await worker;
170
- const receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
171
- assert.equal(receipt.status, "cancelled");
172
- assert.equal(updates.filter((u) => u.name === "update_command_run").length, 1);
173
- });