| 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 |