feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @file Desktop smoke test.
|
||||
*
|
||||
* Boots the compiled main process under Electron, then probes the embedded
|
||||
* dashboard server's /api/health endpoint. This is intentionally minimal:
|
||||
* it does not exercise the BrowserWindow (which requires a display) so it
|
||||
* runs on headless CI without xvfb. The window itself is covered by manual
|
||||
* QA in the PR description.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import http from "node:http";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DESKTOP_ROOT = path.resolve(__dirname, "..");
|
||||
const MAIN_JS = path.join(DESKTOP_ROOT, "out", "main.js");
|
||||
// Resolve the actual Electron executable (electron.exe on Windows, the binary
|
||||
// under Electron.app on macOS). The `.bin/electron` shim is extension-less and
|
||||
// cannot be spawned without a shell on Windows; `require("electron")` returns
|
||||
// the real binary path on every platform.
|
||||
const ELECTRON_BIN = createRequire(import.meta.url)("electron");
|
||||
|
||||
const HEALTH_TIMEOUT_MS = 60_000;
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
|
||||
/** Resolve when GET /api/health on any of these ports answers ok. */
|
||||
async function waitForHealth(ports, deadline) {
|
||||
while (Date.now() < deadline) {
|
||||
for (const port of ports) {
|
||||
const ok = await probeHealth(port);
|
||||
if (ok) return port;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||||
}
|
||||
throw new Error(`No port answered /api/health within ${HEALTH_TIMEOUT_MS}ms (tried ${ports})`);
|
||||
}
|
||||
|
||||
function probeHealth(port) {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get({ host: "127.0.0.1", port, path: "/api/health", timeout: 1500 }, (res) => {
|
||||
let buf = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => (buf += chunk));
|
||||
res.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(buf)?.status === "ok");
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on("error", () => resolve(false));
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let electronProc;
|
||||
// Pick a unique high port for each test run so we never accidentally probe an
|
||||
// unrelated server (e.g. the user's own `npm start` on 4820). The env var
|
||||
// `CCAM_DESKTOP_BIND_PORT` tells the desktop process to bind exactly this port,
|
||||
// skipping the "adopt an existing healthy server" code path.
|
||||
const TEST_PORT = 50000 + Math.floor(Math.random() * 5000);
|
||||
|
||||
// On POSIX, spawn the Electron parent as a process-group leader so we can
|
||||
// signal the whole tree (helpers, embedded server) with one kill(-pid).
|
||||
// Without this, SIGTERM only hits the parent and leaves helpers alive,
|
||||
// keeping the stdio pipes open and hanging `node --test` indefinitely.
|
||||
const IS_POSIX = process.platform !== "win32";
|
||||
|
||||
/** Kill the Electron process tree and resolve when it's actually gone. */
|
||||
async function killElectronTree(proc, { timeoutMs = 5_000 } = {}) {
|
||||
if (!proc || proc.exitCode !== null || proc.signalCode !== null) return;
|
||||
proc.killedByTest = true;
|
||||
|
||||
const signalGroup = (sig) => {
|
||||
try {
|
||||
if (IS_POSIX && proc.pid) process.kill(-proc.pid, sig);
|
||||
else proc.kill(sig);
|
||||
} catch {
|
||||
/* group may already be gone */
|
||||
}
|
||||
};
|
||||
|
||||
const exited = once(proc, "exit");
|
||||
signalGroup("SIGTERM");
|
||||
|
||||
const timer = new Promise((resolve) => setTimeout(resolve, timeoutMs, "timeout"));
|
||||
const winner = await Promise.race([exited.then(() => "exit"), timer]);
|
||||
if (winner === "timeout") {
|
||||
signalGroup("SIGKILL");
|
||||
await Promise.race([exited, new Promise((r) => setTimeout(r, 2_000))]);
|
||||
}
|
||||
}
|
||||
|
||||
describe("desktop smoke", () => {
|
||||
before(async () => {
|
||||
electronProc = spawn(ELECTRON_BIN, [MAIN_JS], {
|
||||
cwd: DESKTOP_ROOT,
|
||||
detached: IS_POSIX,
|
||||
env: {
|
||||
...process.env,
|
||||
// Suppress the BrowserWindow on the test runner; we only care that
|
||||
// the server boots cleanly.
|
||||
ELECTRON_DISABLE_GPU: "1",
|
||||
ELECTRON_ENABLE_LOGGING: "1",
|
||||
CCAM_DESKTOP_VERBOSE: "1",
|
||||
CCAM_DESKTOP_BIND_PORT: String(TEST_PORT),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
electronProc.stdout.on("data", (b) => process.stdout.write(`[electron] ${b}`));
|
||||
electronProc.stderr.on("data", (b) => process.stderr.write(`[electron] ${b}`));
|
||||
|
||||
electronProc.on("exit", (code, signal) => {
|
||||
if (!electronProc.killedByTest) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`electron exited unexpectedly: code=${code} signal=${signal}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await killElectronTree(electronProc);
|
||||
});
|
||||
|
||||
it("brings up the embedded server and serves /api/health on the bound port", async () => {
|
||||
const deadline = Date.now() + HEALTH_TIMEOUT_MS;
|
||||
const port = await waitForHealth([TEST_PORT], deadline);
|
||||
assert.equal(
|
||||
port,
|
||||
TEST_PORT,
|
||||
`desktop process should have bound CCAM_DESKTOP_BIND_PORT=${TEST_PORT}`
|
||||
);
|
||||
assert.ok(
|
||||
electronProc && !electronProc.killed && electronProc.exitCode === null,
|
||||
"electron process should still be alive when /api/health answers"
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user