返回 CodeWhale
harmony-route-lifecycle.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / harmony-route-lifecycle.test.mjs
1 import { test } from "node:test";
2 import assert from "node:assert/strict";
3 import fs from "node:fs";
4 import os from "node:os";
5 import path from "node:path";
6 import { setTimeout as delay } from "node:timers/promises";
7 import { create } from "../src/backends/harmonyos.mjs";
8 import { withSignal } from "../src/exec.mjs";
9
10 const ok = { code: 0, stdout: "", stderr: "" };
11
12 async function until(predicate, message) {
13 const deadline = Date.now() + 2_000;
14 while (!predicate() && Date.now() < deadline) await delay(10);
15 assert.ok(predicate(), message);
16 }
17
18 function fixture(t, { blockAt = 2, blockStage = "capture", ignoreAbort = false } = {}) {
19 const root = fs.mkdtempSync(path.join(os.tmpdir(), "cu-harmony-lifecycle-"));
20 const originalPath = process.env.PATH;
21 const originalRecordingsDir = process.env.CODEWHALE_CU_RECORDINGS_DIR;
22 const muxMarker = path.join(root, "unexpected-mux");
23 // Only have("ffmpeg") should inspect this executable. No encoder or device
24 // program runs; invoking it makes the fixture fail during cleanup.
25 const ffmpeg = path.join(root, "ffmpeg");
26 fs.writeFileSync(ffmpeg, `#!${process.execPath}\nrequire('node:fs').writeFileSync(${JSON.stringify(muxMarker)}, 'invoked'); process.exit(97);\n`, { mode: 0o700 });
27 process.env.PATH = `${root}${path.delimiter}${originalPath ?? ""}`;
28 process.env.CODEWHALE_CU_RECORDINGS_DIR = path.join(root, "recordings");
29
30 const frameDirs = new Set();
31 const blocked = new Set();
32 const captures = [];
33 const pulls = [];
34 let aborts = 0;
35 function pause(signal) {
36 return new Promise((resolve, reject) => {
37 const finish = (error) => {
38 signal.removeEventListener("abort", abort);
39 blocked.delete(release);
40 if (error) reject(error); else resolve();
41 };
42 const release = () => finish();
43 const abort = () => {
44 aborts++;
45 if (!ignoreAbort) finish(Object.assign(new Error("fixture hdc aborted"), { code: "cancelled" }));
46 };
47 blocked.add(release);
48 signal.addEventListener("abort", abort, { once: true });
49 if (signal.aborted) abort();
50 });
51 }
52 const backend = create({ exec: {
53 async shell(args, { signal } = {}) {
54 assert.ok(signal instanceof AbortSignal, "capture and cleanup use the recording owner's signal");
55 if (args[0] === "rm") return signal.aborted ? { ...ok, code: -1 } : ok;
56 assert.equal(args[0], "snapshot_display", "the fake never launches a real device command");
57 assert.equal(signal.aborted, false, "no new capture starts after its owner stops");
58 captures.push({ remote: args[2], signal });
59 if (blockStage === "capture" && captures.length >= blockAt) await pause(signal);
60 return ok;
61 },
62 async pullFile(remote, local, { signal } = {}) {
63 pulls.push({ remote, local, signal });
64 frameDirs.add(path.dirname(local));
65 fs.writeFileSync(local, "partial fixture frame");
66 if (blockStage === "pull" && pulls.length >= blockAt) await pause(signal);
67 fs.writeFileSync(local, `completed fixture frame: ${remote}`);
68 return local;
69 },
70 } });
71 t.after(async () => {
72 for (const release of [...blocked]) release();
73 const closed = await backend.closeSession();
74 if (closed?.framesDir) frameDirs.add(closed.framesDir);
75 if (originalPath === undefined) delete process.env.PATH; else process.env.PATH = originalPath;
76 if (originalRecordingsDir === undefined) delete process.env.CODEWHALE_CU_RECORDINGS_DIR;
77 else process.env.CODEWHALE_CU_RECORDINGS_DIR = originalRecordingsDir;
78 const muxed = fs.existsSync(muxMarker);
79 for (const dir of frameDirs) fs.rmSync(dir, { recursive: true, force: true });
80 fs.rmSync(root, { recursive: true, force: true });
81 assert.equal(muxed, false, "route/session close must not invoke ffmpeg");
82 });
83 return {
84 backend, captures, pulls, frameDirs,
85 get blocked() { return blocked.size; },
86 get aborts() { return aborts; },
87 release() { for (const resume of [...blocked]) resume(); },
88 };
89 }
90
91 test("Harmony route close aborts a pending capture, retains frames, and stops its timer", { skip: process.platform === "win32" }, async t => {
92 const f = fixture(t);
93 const recording = await f.backend.recordingStart({ intervalMs: 150 });
94 await until(() => f.blocked === 1, "the second capture must be in flight");
95 await delay(350);
96 assert.equal(f.captures.length, 2, "several timer periods cannot overlap a pending frame");
97 const saved = fs.readFileSync(f.pulls[0].local);
98 const closed = await f.backend.closeSession();
99 assert.equal(f.aborts, 1);
100 assert.equal(closed.id, recording.id);
101 assert.equal(closed.frames, 1);
102 assert.equal(closed.framesDir, path.dirname(f.pulls[0].local));
103 assert.deepEqual(fs.readFileSync(f.pulls[0].local), saved, "closing must preserve the completed frame bytes");
104 assert.equal((await f.backend.recordingStatus({ id: recording.id })).running, false);
105 await delay(350);
106 assert.equal(f.captures.length, 2, "no timer capture occurs after close resolves");
107 assert.equal(await f.backend.closeSession(), null, "closing an already quiesced owner is harmless");
108 });
109
110 test("Harmony close during its initial frame preserves partial output and prevents a late timer", { skip: process.platform === "win32" }, async t => {
111 const f = fixture(t, { blockAt: 1, blockStage: "pull" });
112 const startup = f.backend.recordingStart({ intervalMs: 150 });
113 const rejected = assert.rejects(startup, /closed during startup/);
114 await until(() => f.blocked === 1, "startup must reach the pending pull");
115 const partial = f.pulls[0].local;
116 await f.backend.closeSession();
117 await rejected;
118 assert.equal(f.aborts, 1);
119 assert.equal(fs.readFileSync(partial, "utf8"), "partial fixture frame");
120 await delay(350);
121 assert.equal(f.captures.length, 1, "startup must not install a timer after its close");
122 assert.deepEqual((await f.backend.recordingList()).running, []);
123 });
124
125 test("Harmony recording startup cancellation propagates to its owned frame", { skip: process.platform === "win32" }, async t => {
126 const f = fixture(t, { blockAt: 1, blockStage: "pull" });
127 const controller = new AbortController();
128 const startup = withSignal(controller.signal, () => f.backend.recordingStart({ intervalMs: 150 }));
129 const rejected = assert.rejects(startup, error => error.code === "cancelled");
130 await until(() => f.blocked === 1, "startup must own a frame before cancellation");
131 controller.abort();
132 await rejected;
133 assert.equal(f.aborts, 1);
134 assert.equal(fs.readFileSync(f.pulls[0].local, "utf8"), "partial fixture frame");
135 await delay(350);
136 assert.equal(f.captures.length, 1);
137 assert.deepEqual((await f.backend.recordingList()).running, []);
138 });
139
140 test("Harmony failed quiesce retains recorder ownership until a later cleanup succeeds", { skip: process.platform === "win32" }, async t => {
141 const f = fixture(t, { blockStage: "pull", ignoreAbort: true });
142 const recording = await f.backend.recordingStart({ intervalMs: 150 });
143 await until(() => f.blocked === 1, "a stubborn frame must be in flight");
144 const began = Date.now();
145 await assert.rejects(f.backend.closeSession(), /did not stop within 2 seconds/);
146 assert.ok(Date.now() - began < 3_000, "failed cleanup must remain inside the MCP shutdown budget");
147 assert.equal(f.aborts, 1);
148 await assert.rejects(f.backend.recordingStart(), /already running/, "a new recorder cannot replace an unquiesced owner");
149 assert.equal(fs.readFileSync(f.pulls[1].local, "utf8"), "partial fixture frame");
150 assert.equal(f.captures.length, 2, "timers stay stopped even after cleanup times out");
151 f.release();
152 const closed = await f.backend.closeSession();
153 assert.equal(closed.id, recording.id, "retry still owns the original recorder");
154 assert.equal(closed.framesDir, path.dirname(f.pulls[0].local));
155 assert.equal(closed.frames, 2);
156 assert.equal(fs.readdirSync(closed.framesDir).length, 2);
157 await delay(350);
158 assert.equal(f.captures.length, 2);
159 assert.equal((await f.backend.recordingStatus({ id: recording.id })).running, false);
160 });
161
161 lines Plain Text