返回 CodeWhale
sprite-task.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / sprite-task.test.mjs
1 // Sprite Task hold for one turn: 5 min expiry refreshed every 60 s, released
2 // at turn end, capped so a crashed holder leaves only a short tail (S0 Q7).
3 import { test, after } from "node:test";
4 import assert from "node:assert/strict";
5 import { spawn } from "node:child_process";
6 import fs from "node:fs";
7 import http from "node:http";
8 import os from "node:os";
9 import path from "node:path";
10 import { once } from "node:events";
11 import { createTaskHold, expireSeconds, spriteApi } from "../src/sprite-task.mjs";
12 // These transports are Unix sockets inside the Linux Sprite; Windows cannot bind the path.
13 const UNIX_SOCKETS = { skip: process.platform === "win32" && "Unix-socket transport (Sprite/Linux only)" };
14
15 const ROOT = path.resolve(import.meta.dirname, "..");
16 const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-task-"));
17 after(() => fs.rmSync(dir, { recursive: true, force: true }));
18
19 /** A fake /.sprite/api.sock: POST/PUT register, DELETE removes, GET lists. */
20 function fakeApi(sock, { putStatus = null } = {}) {
21 const tasks = new Map();
22 const log = [];
23 const server = http.createServer((req, res) => {
24 let body = "";
25 req.on("data", (c) => { body += c; });
26 req.on("end", () => {
27 log.push(`${req.method} ${req.url} host=${req.headers.host}`);
28 const send = (status, obj) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(obj ? JSON.stringify(obj) : ""); };
29 const name = decodeURIComponent(req.url.split("/")[3] ?? "");
30 if (req.method === "GET") return send(200, { tasks: [...tasks.values()] });
31 if (req.method === "POST" || req.method === "PUT") {
32 if (req.method === "PUT" && putStatus) return send(putStatus, null);
33 const p = JSON.parse(body);
34 const task = { name: p.name, expire: p.expire, expires_at: "2026-09-22T20:05:00Z" };
35 tasks.set(p.name, task);
36 return send(200, task);
37 }
38 if (req.method === "DELETE") { tasks.delete(name); return send(204, null); }
39 send(405, null);
40 });
41 });
42 return new Promise((resolve) => server.listen(sock, () => resolve({ server, tasks, log })));
43 }
44
45 test("expiry is capped at 5 minutes", () => {
46 assert.equal(expireSeconds("5m"), 300);
47 assert.equal(expireSeconds("90s"), 90);
48 assert.throws(() => expireSeconds("1h"));
49 assert.throws(() => expireSeconds("6m"));
50 assert.throws(() => expireSeconds("10s"));
51 assert.throws(() => createTaskHold({ name: "Bad Name" }));
52 assert.throws(() => createTaskHold({ name: "turn-1", expire: "60s", refreshMs: 60_000 }), /shorter/);
53 });
54
55 test("acquire registers, refresh re-registers, release deletes", UNIX_SOCKETS, async (t) => {
56 const sock = path.join(dir, "api1.sock");
57 const api = await fakeApi(sock);
58 t.after(() => api.server.close());
59 const events = [];
60 const hold = createTaskHold({ name: "turn-abc", refreshMs: 30, socket: sock, onEvent: (e) => events.push(e.event) });
61 await hold.acquire();
62 assert.equal(api.tasks.get("turn-abc").expire, "300s");
63 await new Promise((r) => setTimeout(r, 80));
64 await hold.release();
65 assert.equal(api.tasks.size, 0);
66 assert.ok(events.includes("refreshed"));
67 assert.equal(events[0], "acquired");
68 assert.equal(events.at(-1), "released");
69 assert.ok(api.log.every((l) => l.endsWith("host=sprite")));
70 assert.ok(api.log.some((l) => l.startsWith("PUT /v1/tasks/turn-abc")));
71 });
72
73 test("refresh falls back to POST when PUT is not offered", UNIX_SOCKETS, async (t) => {
74 const sock = path.join(dir, "api2.sock");
75 const api = await fakeApi(sock, { putStatus: 405 });
76 t.after(() => api.server.close());
77 const events = [];
78 const hold = createTaskHold({ name: "turn-x", refreshMs: 30, socket: sock, onEvent: (e) => events.push(e.event) });
79 await hold.acquire();
80 await new Promise((r) => setTimeout(r, 70));
81 await hold.release();
82 assert.ok(events.includes("refreshed"));
83 assert.ok(!events.includes("refresh_failed"));
84 });
85
86 test("turn-hold CLI holds for the turn and releases on stdin EOF (parent gone)", UNIX_SOCKETS, async (t) => {
87 const sock = path.join(dir, "api3.sock");
88 const api = await fakeApi(sock);
89 t.after(() => api.server.close());
90 const child = spawn(process.execPath, [path.join(ROOT, "mcp/turn-hold.mjs"), "--name", "turn-cli", "--socket", sock], { stdio: ["pipe", "pipe", "inherit"] });
91 let out = "";
92 child.stdout.on("data", (c) => { out += c; });
93 for (let i = 0; i < 50 && !out.includes("acquired"); i++) await new Promise((r) => setTimeout(r, 20));
94 assert.ok(api.tasks.has("turn-cli"), "held while the turn runs");
95 child.stdin.end();
96 const [code] = await once(child, "exit");
97 assert.equal(code, 0);
98 assert.equal(api.tasks.size, 0, "released at turn end");
99 assert.match(out, /"event":"released"/);
100 const listed = await spriteApi("GET", "/v1/tasks", null, { socket: sock });
101 assert.deepEqual(listed.body.tasks, []);
102 });
103
104 test("turn-hold CLI refuses a long expiry and reports an unreachable socket", async () => {
105 const run = (args) => new Promise((resolve) => {
106 const c = spawn(process.execPath, [path.join(ROOT, "mcp/turn-hold.mjs"), ...args], { stdio: ["ignore", "pipe", "ignore"] });
107 let out = ""; c.stdout.on("data", (d) => { out += d; });
108 c.on("exit", (code) => resolve({ code, out }));
109 });
110 let r = await run(["--name", "turn-1", "--expire", "1h", "--socket", path.join(dir, "none.sock")]);
111 assert.equal(r.code, 2);
112 assert.match(r.out, /"event":"refused"/);
113 r = await run(["--name", "turn-1", "--socket", path.join(dir, "none.sock")]);
114 assert.equal(r.code, 1);
115 assert.match(r.out, /"event":"acquire_failed"/);
116 });
117
117 lines Plain Text