Files
awesome-copilot/extensions/java-modernization-studio/test/cockpit.test.mjs
T
Ayan Gupta 65ba0b3922 Add Java Modernization Studio canvas extension (#2156)
* Add Java Modernization Studio canvas extension

A canvas extension that drives the GitHub Copilot App Modernization for Java workflow from an interactive dashboard: environment readiness checks, repo assessment, prioritized plan/progress, validation gates (CVE scan, test generation), and one-click predefined-task runs — all grounded in the repo's real artifacts (.appmod/assessment.json, plan.md, progress.md). Includes a node:test suite (109 tests) for the grounding logic.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com>

* Secure cockpit loopback server with a per-instance token

Address Copilot review feedback on the Java Modernization Studio canvas:

- Mint a per-instance secret in createInstanceServer, embed it in the
  iframe URL, and validate it on every loopback request (/, /state,
  /events, /action). Other local processes can no longer read repo
  state or dispatch agent actions just by guessing the random port.
  Mirrors the existing diagram-viewer token pattern; the client echoes
  the token from its boot payload. The guard is a no-op when no token is
  set, so direct makeHandler unit tests are unaffected.
- Handle async request-handler rejections in createServer with a .catch
  that returns 500 and logs, instead of leaking an unhandled rejection
  that could destabilize the extension process.
- Tests: token guard 403s unauthenticated /, /state, /events and
  /action and allows valid-token requests; the end-to-end test asserts
  the tokenized URL and a tokenless 403.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix CI: add preview screenshot and clear codespell hits

Add the required assets/preview.png screenshot for the canvas-extension
validator, and resolve two codespell findings in the cockpit:
rename the planSim helper's `nd` variable to `notDone` and reword a
catalog comment to avoid the `invokable`/`invocable` flag.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Harden cockpit loopback server per Copilot review

Address the second-round Copilot review comments on the loopback server:

- broadcast(): drop an SSE client whose write() throws instead of keeping
  it in the Set, so a uncleanly-disconnected client can't cause repeated
  exceptions or leak dead entries on every subsequent broadcast.
- POST /action: treat a malformed JSON body or a missing/invalid "kind"
  as a 400 client error (with an application/json body) instead of a 200
  carrying { ok:false, error:"Unknown action: undefined" }.
- Handler catch: set Content-Type: application/json on the 500 error
  response so it is consistent with the other JSON routes.

Adds four tests covering dead-client eviction, the two 400 paths, and the
JSON-typed 500. Full suite: 115 passing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com>

* Add canvas.json gallery metadata for Java Modernization Studio

Aaron requested a canvas.json so the awesome-copilot website/gallery can
list this extension. Generated via `npm run website:data`
(writePerExtensionCanvasManifests), which derives id/name/description/
version/keywords/screenshots from package.json + the createCanvas source +
assets, and carries the author through. Output committed verbatim so a CI
regeneration produces no diff.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com>

* Add author tag to package.json

Declare the author in the package manifest (object form, matching the
author already carried in canvas.json so it includes the profile URL).
The published gallery sources author from canvas.json; this adds the
conventional npm author tag to package.json for completeness. Verified
`npm run website:data` still emits the author on the extensions record
and leaves canvas.json byte-identical.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com>

---------

Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 11:10:53 +10:00

1663 lines
74 KiB
JavaScript

// cockpit.test.mjs — Test suite for the Java Modernization Studio canvas.
// Run with: node --test test/cockpit.test.mjs
//
// Covers the testable modules (scan, catalog, prompts, renderer, server). It does
// NOT import extension.mjs, which calls joinSession() at module load and needs a
// live CLI session.
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { execFileSync } from "node:child_process";
import { EventEmitter } from "node:events";
import { scanRepo } from "../scan.mjs";
import { PREDEFINED_TASKS, catalogWithRelevance, VALIDATION_GATES } from "../catalog.mjs";
import { buildPrompt, ACTION_LABELS } from "../prompts.mjs";
import { renderHtml } from "../renderer.mjs";
import {
buildDoctorReport,
parseToolVersion,
probeOne,
runDoctor,
augmentedEnv,
discoverJavaHome,
parseJavaHomeFromRc,
TOOL_PROBES,
} from "../doctor.mjs";
import {
resolveRepoPath,
broadcast,
pushState,
dispatchAction,
makeHandler,
createInstanceServer,
} from "../server.mjs";
import {
selectNextStep,
stepKey,
isStepDone,
makeRun,
runAutopilot,
} from "../autopilot.mjs";
// ---- helpers ----------------------------------------------------------------
// Minimal provenance: a .appmod/ artifact tells the scanner this repo is actively
// running the modernization workflow, so root plan/progress/summary are trusted as
// workflow state (vs. unrelated files that merely share those names).
const PROV = { ".appmod/assessment.json": "{}" };
async function makeRepo(files = {}) {
const dir = await mkdtemp(join(tmpdir(), "appmod-test-"));
for (const [rel, content] of Object.entries(files)) {
const full = join(dir, rel);
await mkdir(dirname(full), { recursive: true });
await writeFile(full, content);
}
return dir;
}
async function withRepo(files, fn) {
const dir = await makeRepo(files);
try {
return await fn(dir);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
function git(dir, ...args) {
execFileSync("git", args, { cwd: dir, stdio: "pipe" });
}
function fakeRes() {
return {
statusCode: 200,
headers: {},
body: "",
ended: false,
setHeader(k, v) {
this.headers[String(k).toLowerCase()] = v;
},
writeHead(code, hdrs) {
this.statusCode = code;
if (hdrs) for (const k of Object.keys(hdrs)) this.headers[k.toLowerCase()] = hdrs[k];
},
write(s) {
this.body += s;
},
end(s) {
if (s !== undefined) this.body += s;
this.ended = true;
},
};
}
// Drive makeHandler with a fake POST request stream.
async function invokePost(handler, path, payloadObj) {
const req = new EventEmitter();
req.method = "POST";
req.url = path;
const res = fakeRes();
const pending = handler(req, res);
req.emit("data", JSON.stringify(payloadObj));
req.emit("end");
await pending;
return res;
}
async function invokeGet(handler, path) {
const req = new EventEmitter();
req.method = "GET";
req.url = path;
const res = fakeRes();
await handler(req, res);
return { req, res };
}
// ===========================================================================
// scan.mjs
// ===========================================================================
test("scan: nonexistent path returns ok:false", async () => {
const s = await scanRepo("/no/such/path/at/all/xyz");
assert.equal(s.ok, false);
});
test("scan: empty repo is not_started with all gates not_run", async () => {
await withRepo({}, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.ok, true);
assert.equal(s.status, "not_started");
assert.equal(s.percent, 0);
assert.equal(s.plan.exists, false);
assert.equal(s.progress.exists, false);
assert.equal(s.summary.exists, false);
for (const g of VALIDATION_GATES) assert.equal(s.gates[g.key], "not_run");
});
});
test("scan: Maven Java version from properties", async () => {
await withRepo(
{ "pom.xml": "<project><properties><java.version>8</java.version></properties></project>" },
async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.assessment.buildTool, "Maven");
assert.equal(s.assessment.javaVersion, "8");
}
);
});
test("scan: Maven compiler release strips 1.x and detects", async () => {
await withRepo(
{ "pom.xml": "<project><properties><maven.compiler.release>17</maven.compiler.release></properties></project>" },
async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.assessment.javaVersion, "17");
}
);
});
test("scan: Gradle build tool + sourceCompatibility", async () => {
await withRepo(
{ "build.gradle": "sourceCompatibility = '11'\napply plugin: 'java'" },
async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.assessment.buildTool, "Gradle");
assert.equal(s.assessment.javaVersion, "11");
}
);
});
test("scan: nested module pom is discovered", async () => {
await withRepo(
{ "service/pom.xml": "<project><properties><java.version>8</java.version></properties></project>" },
async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.assessment.buildTool, "Maven");
assert.equal(s.assessment.javaVersion, "8");
}
);
});
test("scan: Spring Boot detection", async () => {
await withRepo(
{ "pom.xml": "<project><dependencies><dependency><artifactId>spring-boot-starter-web</artifactId></dependency></dependencies></project>" },
async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.assessment.springBoot, true);
}
);
});
test("scan: Dockerfile detected at root and one level down", async () => {
await withRepo({ Dockerfile: "FROM tomcat:9" }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.assessment.hasDockerfile, true);
});
await withRepo({ "svc/Dockerfile": "FROM tomcat:9" }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.assessment.hasDockerfile, true);
});
});
test("scan: dependency relevance flags the right catalog tasks", async () => {
const cases = [
["spring-boot-starter-amqp", "rabbitmq-to-servicebus"],
["activemq-client", "activemq-to-servicebus"],
["aws-java-sdk-s3", "aws-s3-to-blob"],
["ojdbc8", "databases-to-azure"],
["spring-ldap-core", "entra-id-auth"],
];
for (const [dep, taskId] of cases) {
await withRepo(
{ "pom.xml": `<project><dependencies><dependency><artifactId>${dep}</artifactId></dependency></dependencies></project>` },
async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
const task = s.tasks.find((t) => t.id === taskId);
assert.ok(task, `task ${taskId} exists`);
assert.equal(task.relevant, true, `${dep} -> ${taskId} relevant`);
}
);
}
});
test("scan: checklist statuses (done / in_progress / pending)", async () => {
await withRepo(
{ ...PROV, "progress.md": "- [x] Done step\n- [/] Working step\n- [ ] Todo step\n" },
async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
const byTitle = Object.fromEntries(s.progress.steps.map((x) => [x.title, x.status]));
assert.equal(byTitle["Done step"], "done");
assert.equal(byTitle["Working step"], "in_progress");
assert.equal(byTitle["Todo step"], "pending");
}
);
});
test("scan: emoji status markers override", async () => {
await withRepo(
{ ...PROV, "progress.md": "- [ ] Build ✅\n- [ ] Tests ❌\n" },
async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
const byTitle = Object.fromEntries(
s.progress.steps.map((x) => [x.title.replace(/[^A-Za-z ].*/, "").trim(), x.status])
);
assert.equal(byTitle["Build"], "done");
assert.equal(byTitle["Tests"], "failed");
}
);
});
test("scan: numbered-list fallback when no checkboxes", async () => {
await withRepo({ ...PROV, "plan.md": "# Plan\n1. First thing\n2. Second thing\n" }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.plan.steps.length, 2);
assert.equal(s.plan.steps[0].title, "First thing");
});
});
test("scan: heading fallback when no checkboxes or numbers", async () => {
await withRepo({ ...PROV, "plan.md": "# Title\n## Phase one\n## Phase two\n" }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
const titles = s.plan.steps.map((x) => x.title);
assert.deepEqual(titles, ["Phase one", "Phase two"]);
});
});
test("scan: percent counts in_progress as half", async () => {
await withRepo(
{ ...PROV, "progress.md": "- [x] a\n- [/] b\n- [ ] c\n- [ ] d\n" },
async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
// (1 + 0.5) / 4 = 37.5 -> 38
assert.equal(s.percent, 38);
}
);
});
test("scan: gates derived from matching step titles", async () => {
await withRepo(
{ ...PROV, "progress.md": "- [x] Unit Tests pass\n- [/] Build app\n- [ ] CVE scan\n" },
async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.gates.tests, "done");
assert.equal(s.gates.build, "in_progress");
assert.equal(s.gates.cve, "pending"); // matched but not run
assert.equal(s.gates.consistency, "not_run"); // unmatched
}
);
});
test("scan: gates scope to an explicit Validation section, not assessment text", async () => {
// Assessment rows mention "build" and "vulnerable" (done); they must NOT
// satisfy the Build/CVE gates, which live in the Validation section (pending).
const md = [
"## Assessment",
"- [x] Inventory runtime, build tool, dependencies",
"- [x] Identify vulnerable dependencies",
"",
"## Validation gates",
"- [ ] Build — mvn clean package succeeds",
"- [ ] CVE Check — no critical CVEs",
"",
"## Next",
"- [x] something else with the word build in it",
].join("\n");
await withRepo({ ...PROV, "progress.md": md }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.gates.build, "pending");
assert.equal(s.gates.cve, "pending");
});
});
test("scan: status is completed when summary.md present", async () => {
await withRepo({ ...PROV, "summary.md": "# done" }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.status, "completed");
});
});
test("scan: status is in_progress when only plan present", async () => {
await withRepo({ ...PROV, "plan.md": "- [ ] a" }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.status, "in_progress");
});
});
test("scan: reads .appmod/assessment.json into report", async () => {
const report = {
generatedAt: "2026-01-01T00:00:00Z",
headline: "h",
findings: [{ id: "x", severity: "P0", title: "t", detail: "d", files: ["a"], action: { kind: "fix_finding" } }],
};
await withRepo({ ".appmod/assessment.json": JSON.stringify(report) }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.ok(s.report, "report should be present");
assert.equal(s.report.findings.length, 1);
assert.equal(s.report.findings[0].severity, "P0");
});
});
test("scan: report is null when assessment.json absent or malformed", async () => {
await withRepo({ "pom.xml": "<project/>" }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.report, null);
});
await withRepo({ ".appmod/assessment.json": "{ not valid json" }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.report, null, "malformed JSON should not throw, just yield null");
});
});
// ---- provenance gate (cross-repo safety) ------------------------------------
test("scan: an unrelated summary.md without provenance does NOT mark completed", async () => {
await withRepo({ "summary.md": "# My library\nUsage and changelog notes." }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.notEqual(s.status, "completed", "a stray summary.md must not flip status to completed");
assert.equal(s.status, "not_started");
assert.equal(s.summary.exists, false, "untrusted summary is treated as absent");
});
});
test("scan: an unrelated plan.md without provenance is not treated as workflow steps", async () => {
await withRepo({ "plan.md": "# Roadmap\n- [ ] ship feature x\n- [ ] write docs\n" }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.plan.steps.length, 0, "stray checkboxes must not become modernization steps");
assert.equal(s.status, "not_started");
});
});
test("scan: assessment.json provenance makes a root progress.md trusted", async () => {
await withRepo(
{ ".appmod/assessment.json": "{}", "progress.md": "## P0\n- [x] a\n- [ ] b\n" },
async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.progress.steps.length, 2);
assert.equal(s.status, "in_progress");
}
);
});
test("scan: an explicit appmod marker makes a root plan trusted without .appmod/", async () => {
await withRepo(
{ "plan.md": "<!-- appmod-cockpit -->\n## P0 Build\n- [ ] Fix build\n" },
async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.plan.steps.length, 1, "marked file is trusted");
assert.equal(s.status, "in_progress");
}
);
});
test("scan: .appmod/ namespaced docs are trusted and take precedence over root", async () => {
await withRepo(
{
".appmod/plan.md": "## P0\n- [ ] from appmod\n",
"plan.md": "## P0\n- [ ] from root\n- [ ] root two\n",
},
async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.plan.steps.length, 1, "uses .appmod/plan.md, not the root copy");
assert.equal(s.plan.steps[0].title, "from appmod");
}
);
});
test("scan: a non-directory path is reported unavailable (not scanned)", async () => {
await withRepo({ "pom.xml": "<project/>" }, async (dir) => {
const s = await scanRepo(join(dir, "pom.xml"), { includeGit: false });
assert.equal(s.ok, false, "a file path is not a repo");
});
});
test("scan: steps carry their section heading", async () => {
const md = "# Plan\n## P0 — Build\n- [x] a\n- [ ] b\n## P1 — Secrets\n- [ ] c\n";
await withRepo({ ...PROV, "progress.md": md }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
const byTitle = Object.fromEntries(s.progress.steps.map((x) => [x.title, x.section]));
assert.equal(byTitle["a"], "P0 — Build");
assert.equal(byTitle["b"], "P0 — Build");
assert.equal(byTitle["c"], "P1 — Secrets");
});
});
test("scan: ordering computes the active phase, ranks, and per-phase counts", async () => {
const md =
"# Plan\n## Assessment\n- [x] a1\n- [x] a2\n" +
"## P0 — Build consistency\n- [x] b1\n- [ ] b2\n" +
"## P1 — Secrets & identity\n- [ ] c1\n- [ ] c2\n" +
"## Validation gates\n- [ ] v1\n";
await withRepo({ ...PROV, "progress.md": md }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.ordering.activeRank, 1, "P0 is the active rank");
assert.match(s.ordering.activePhase, /P0/);
// Per-phase roll-up.
const byName = Object.fromEntries(s.ordering.phases.map((p) => [p.name, p]));
assert.equal(byName["Assessment"].done, 2);
assert.equal(byName["Assessment"].total, 2);
assert.equal(byName["P0 — Build consistency"].done, 1);
assert.equal(byName["P0 — Build consistency"].total, 2);
assert.equal(byName["P1 — Secrets & identity"].done, 0);
// Each step carries a phase rank; P1 steps sit ahead of the active P0.
const byTitle = Object.fromEntries(s.progress.steps.map((x) => [x.title, x.rank]));
assert.equal(byTitle["a1"], 0);
assert.equal(byTitle["b2"], 1);
assert.equal(byTitle["c1"], 2);
assert.ok(byTitle["c1"] > s.ordering.activeRank, "P1 step is ahead of sequence (locked)");
});
});
test("scan: ordering activeRank is null once every ranked step is done", async () => {
const md = "# Plan\n## Assessment\n- [x] a\n## P0 — Build\n- [x] b\n";
await withRepo({ ...PROV, "progress.md": md }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.ordering.activeRank, null);
assert.equal(s.ordering.activePhase, null);
});
});
test("scan: discovers custom skills from .github/skills", async () => {
await withRepo(
{
".github/skills/my-skill/SKILL.md": "name: My Skill\ndescription: Does a thing\n",
".github/skills/other/SKILL.md": "# Other Skill\n",
},
async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.skills.length, 2);
const mine = s.skills.find((x) => x.folder === "my-skill");
assert.equal(mine.name, "My Skill");
assert.equal(mine.description, "Does a thing");
}
);
});
test("scan: git branch + migration detection + dirty flag", async () => {
await withRepo({ "pom.xml": "<project/>", "README.md": "x" }, async (dir) => {
git(dir, "init", "-b", "modernize-java21");
git(dir, "config", "user.email", "t@example.com");
git(dir, "config", "user.name", "Test");
git(dir, "add", ".");
git(dir, "commit", "-m", "init", "--no-gpg-sign");
let s = await scanRepo(dir);
assert.equal(s.git.branch, "modernize-java21");
assert.equal(s.git.isMigrationBranch, true);
assert.equal(s.git.dirty, false);
await writeFile(join(dir, "new.txt"), "hi");
s = await scanRepo(dir);
assert.equal(s.git.dirty, true);
assert.equal(s.git.changedFiles, 1);
});
});
// ===========================================================================
// doctor.mjs (environment readiness)
// ===========================================================================
function fakeExec(map) {
// map: cmd -> { stdout?, stderr?, error? } (missing cmd => ENOENT)
return async (cmd) => {
const r = map[cmd];
if (!r) return { error: Object.assign(new Error("not found"), { code: "ENOENT" }), stdout: "", stderr: "" };
return { error: r.error || null, stdout: r.stdout || "", stderr: r.stderr || "" };
};
}
test("doctor: parseToolVersion handles java (stderr), node, and generic output", () => {
assert.equal(parseToolVersion("java", 'openjdk version "17.0.2" 2022-01-18'), "17.0.2");
assert.equal(parseToolVersion("node", "v22.3.0"), "22.3.0");
assert.equal(parseToolVersion("git", "git version 2.39.5"), "2.39.5");
assert.equal(parseToolVersion("x", ""), null);
});
test("doctor: probeOne reports not-found on ENOENT and found otherwise", async () => {
const exec = fakeExec({ java: { stderr: 'openjdk version "21.0.1"' } });
const found = await probeOne({ key: "java", cmd: "java", args: ["-version"] }, exec);
assert.deepEqual(found, { key: "java", found: true, version: "21.0.1" });
const missing = await probeOne({ key: "mvn", cmd: "mvn", args: ["-v"] }, exec);
assert.equal(missing.found, false);
});
test("doctor: probeOne rejects the macOS java stub (non-zero exit, no version)", async () => {
const stub = async () => ({
error: Object.assign(new Error("exit 1"), { code: 1 }),
stdout: "",
stderr: "The operation couldn't be completed. Unable to locate a Java Runtime.",
});
const r = await probeOne({ key: "java", cmd: "java", args: ["-version"] }, stub);
assert.equal(r.found, false, "stub with no real version is treated as not installed");
});
test("doctor: missing JDK + missing Maven (no wrapper) blocks readiness", () => {
const rep = buildDoctorReport({
probes: { git: { found: true, version: "2.39" } },
scan: { ok: true, assessment: { buildTool: "Maven", hasMavenWrapper: false }, git: { branch: "main" }, plan: {}, progress: {} },
});
assert.equal(rep.overall, "blocked");
const checks = rep.groups.flatMap((g) => g.checks);
const jdk = checks.find((c) => c.id === "jdk");
assert.equal(jdk.status, "fail");
assert.ok(jdk.action && jdk.action.kind === "fix_env", "JDK failure offers a fix_env action");
const build = checks.find((c) => c.id === "build");
assert.equal(build.status, "fail");
});
test("doctor: a Maven wrapper satisfies the build-tool check without mvn installed", () => {
const rep = buildDoctorReport({
probes: { java: { found: true, version: "17" }, git: { found: true } },
scan: { ok: true, assessment: { buildTool: "Maven", hasMavenWrapper: true }, git: { branch: "x" }, progress: { exists: true } },
});
const build = rep.groups.flatMap((g) => g.checks).find((c) => c.id === "build");
assert.equal(build.status, "ok");
assert.match(build.detail, /wrapper/i);
});
test("doctor: a Dockerfile without Docker is a caution (warn), not a blocker", () => {
const rep = buildDoctorReport({
probes: { java: { found: true }, mvn: { found: true }, git: { found: true } },
scan: { ok: true, assessment: { buildTool: "Maven", hasDockerfile: true }, git: { branch: "x" }, progress: { exists: true } },
});
const docker = rep.groups.flatMap((g) => g.checks).find((c) => c.id === "docker");
assert.equal(docker.status, "warn");
assert.equal(rep.overall, "caution");
});
test("doctor: no assessment yet surfaces a start_assessment action", () => {
const rep = buildDoctorReport({
probes: { java: { found: true }, mvn: { found: true }, git: { found: true } },
scan: { ok: true, assessment: { buildTool: "Maven" }, git: { branch: "x" }, plan: { exists: false }, progress: { exists: false } },
});
const assessed = rep.groups.flatMap((g) => g.checks).find((c) => c.id === "assessed");
assert.equal(assessed.status, "warn");
assert.equal(assessed.action.kind, "start_assessment");
});
test("doctor: everything present is ready", () => {
const rep = buildDoctorReport({
probes: {
java: { found: true, version: "21" }, mvn: { found: true }, git: { found: true },
docker: { found: true }, az: { found: true }, azd: { found: true }, node: { found: true },
},
scan: { ok: true, assessment: { buildTool: "Maven" }, git: { branch: "modernize", isMigrationBranch: true }, progress: { exists: true }, skills: [{ folder: "a" }] },
});
assert.equal(rep.overall, "ready");
});
test("doctor: runDoctor probes every tool via the injected exec", async () => {
const exec = fakeExec({
java: { stderr: 'openjdk version "17.0.9"' },
git: { stdout: "git version 2.40.0" },
});
const rep = await runDoctor(
{ ok: true, assessment: { buildTool: "Maven" }, git: { branch: "x" }, progress: { exists: true } },
{ exec }
);
assert.ok(rep.groups.length >= 3);
assert.equal(TOOL_PROBES.length, 8);
});
test("doctor: augmentedEnv covers Homebrew, keg JDKs, SDKMAN, and JAVA_HOME (macOS)", () => {
// GUI apps inherit a minimal PATH; the doctor must still find tools the user's
// terminal sees. Inject a directory lister so the test doesn't hit the filesystem.
const lister = (d) => (d === "/opt/homebrew/opt" ? ["openjdk@21", "openjdk", "maven", "node@20"] : []);
const env = augmentedEnv({ PATH: "/usr/bin:/bin", HOME: "/Users/x", JAVA_HOME: "/jh" }, "darwin", lister);
const parts = env.PATH.split(":");
assert.ok(parts.includes("/opt/homebrew/bin"), "Homebrew bin");
assert.ok(parts.includes("/jh/bin"), "JAVA_HOME/bin");
assert.ok(parts.includes("/Users/x/.sdkman/candidates/java/current/bin"), "SDKMAN");
assert.ok(parts.includes("/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home/bin"), "versioned keg");
assert.ok(parts.includes("/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home/bin"), "unversioned keg");
assert.ok(!parts.some((p) => /\/maven\/libexec\/openjdk/.test(p)), "non-JDK kegs are skipped");
assert.ok(env.PATH.endsWith("/usr/bin:/bin"), "original PATH preserved at the tail");
});
test("doctor: augmentedEnv is a no-op on Windows and tolerates a bare env", () => {
assert.equal(augmentedEnv({ PATH: "C:\\Windows" }, "win32", () => []).PATH, "C:\\Windows");
// No HOME/JAVA_HOME/PATH provided — must not throw and still prepend brew bins.
const env = augmentedEnv({}, "darwin", () => []);
assert.ok(env.PATH.split(":").includes("/opt/homebrew/bin"));
});
test("doctor: discoverJavaHome respects an explicit JAVA_HOME and skips rc files", async () => {
let read = false;
const readRc = async () => { read = true; return ""; };
const jh = await discoverJavaHome({ JAVA_HOME: "/explicit/jdk" }, "darwin", readRc);
assert.equal(jh, "/explicit/jdk");
assert.equal(read, false, "must not read rc files when JAVA_HOME is already set");
});
test("doctor: discoverJavaHome statically parses a literal JAVA_HOME from the rc (no shell)", async () => {
const reads = [];
const readRc = async (p) => {
reads.push(p);
if (/\.zshrc$/.test(p)) {
return 'export PATH="$HOME/bin:$PATH"\nexport JAVA_HOME="$HOME/.sdkman/candidates/java/current"\n';
}
return null;
};
const jh = await discoverJavaHome({ HOME: "/Users/x", SHELL: "/bin/zsh" }, "darwin", readRc);
assert.equal(jh, "/Users/x/.sdkman/candidates/java/current", "expands $HOME, no execution");
assert.ok(reads.some((p) => /\.zshrc$/.test(p)), "reads the zsh rc files");
});
test("doctor: discoverJavaHome returns null on Windows, no match, unresolved subst, or read error", async () => {
assert.equal(await discoverJavaHome({ HOME: "/h" }, "win32", async () => "export JAVA_HOME=/x"), null);
assert.equal(
await discoverJavaHome({ HOME: "/h", SHELL: "/bin/zsh" }, "darwin", async () => "# nothing here"),
null
);
// Command substitution can't be resolved statically -> null (falls back to PATH probing).
assert.equal(
await discoverJavaHome({ HOME: "/h", SHELL: "/bin/zsh" }, "darwin", async () =>
"export JAVA_HOME=$(/usr/libexec/java_home -v 21)"
),
null
);
assert.equal(
await discoverJavaHome({ HOME: "/h", SHELL: "/bin/zsh" }, "darwin", async () => { throw new Error("boom"); }),
null
);
});
test("doctor: parseJavaHomeFromRc handles quotes, comments, ~ and last-wins", () => {
assert.equal(parseJavaHomeFromRc("export JAVA_HOME=/opt/jdk17 # pinned", {}), "/opt/jdk17");
assert.equal(parseJavaHomeFromRc("JAVA_HOME='/opt/jdk21'", {}), "/opt/jdk21");
assert.equal(parseJavaHomeFromRc("export JAVA_HOME=~/jdks/21", { HOME: "/Users/x" }), "/Users/x/jdks/21");
assert.equal(
parseJavaHomeFromRc("export JAVA_HOME=/a\nexport JAVA_HOME=/b", {}),
"/b",
"later assignment wins"
);
assert.equal(parseJavaHomeFromRc("export NOTJAVA=/x", {}), null);
});
test("prompts: fix_env requires a tool and embeds the readiness detail", () => {
const p = buildPrompt("fix_env", { tool: "a JDK", detail: "java not found", fix: "Install Temurin" }, "/r");
assert.match(p, /a JDK/);
assert.match(p, /java not found/);
assert.match(p, /do not modify my application code/i);
assert.equal(buildPrompt("fix_env", {}, "/r"), null);
});
test("scan: detects Maven/Gradle wrappers for the doctor", async () => {
await withRepo({ "pom.xml": "<project/>", "mvnw": "#!/bin/sh\n" }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.assessment.hasMavenWrapper, true);
assert.equal(s.assessment.hasGradleWrapper, false);
});
});
test("scan: detects Windows wrapper scripts (mvnw.cmd / gradlew.bat)", async () => {
await withRepo({ "pom.xml": "<project/>", "mvnw.cmd": "@echo off\n" }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.assessment.hasMavenWrapper, true, "mvnw.cmd counts as a wrapper");
});
await withRepo({ "build.gradle": "plugins {}", "gradlew.bat": "@echo off\n" }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.assessment.hasGradleWrapper, true, "gradlew.bat counts as a wrapper");
});
});
test("scan: Dockerfile detection is case-insensitive and accepts Containerfile", async () => {
await withRepo({ "dockerfile": "FROM tomcat:9" }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.assessment.hasDockerfile, true, "lowercase dockerfile");
});
await withRepo({ "Containerfile": "FROM tomcat:9" }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.assessment.hasDockerfile, true, "Podman Containerfile");
});
});
test("scan: multi-module repo unions Java version + deps across modules (not just the first)", async () => {
await withRepo(
{
"pom.xml": "<project><packaging>pom</packaging><modules><module>api</module><module>worker</module></modules></project>",
"api/pom.xml": "<project><properties><java.version>17</java.version></properties></project>",
"worker/pom.xml":
"<project><dependencies><dependency><groupId>com.rabbitmq</groupId><artifactId>amqp-client</artifactId></dependency></dependencies></project>",
},
async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.assessment.buildTool, "Maven");
assert.equal(s.assessment.javaVersion, "17", "Java version comes from a module, not the aggregator");
assert.ok(
s.assessment.detectedKeys.includes("rabbitmq"),
"a dependency declared only in a second module is still detected"
);
}
);
});
test("scan: dependency matching no longer fires on English words containing a token", async () => {
// "important"/"constant" contain "ant"; a plain substring match used to flag Ant.
await withRepo(
{
"pom.xml":
"<project><description>An important and constant service</description></project>",
},
async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.ok(!s.assessment.detectedKeys.includes("ant"), "no false Ant detection");
}
);
// A real Ant build is still flagged, via the build tool.
await withRepo({ "build.xml": "<project default=\"build\"></project>" }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.assessment.buildTool, "Ant");
assert.ok(s.assessment.detectedKeys.includes("ant"), "genuine Ant build is detected");
});
});
test("scan: a real coordinate token is still matched with boundary-aware logic", async () => {
await withRepo(
{
"pom.xml":
"<project><dependencies><dependency><artifactId>aws-java-sdk-s3</artifactId></dependency></dependencies></project>",
},
async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.ok(s.assessment.detectedKeys.includes("awsS3"));
}
);
});
test("scan: assessment.json is normalized (severity aliases, files string, bad shapes)", async () => {
const raw = {
findings: [
{ id: "a", severity: "critical", title: "x", files: "only/one.txt" },
{ id: "b", severity: "high", title: "y" },
{ id: "c", severity: "weird", title: "z", files: ["k.txt", 5] },
"not-an-object",
{ severity: "low" },
],
strengths: "single strength",
};
await withRepo({ ".appmod/assessment.json": JSON.stringify(raw) }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
const f = s.report.findings;
assert.equal(f.length, 4, "non-object findings are dropped, valid ones kept");
assert.equal(f[0].severity, "P0", "critical -> P0");
assert.deepEqual(f[0].files, ["only/one.txt"], "string files -> array");
assert.equal(f[1].severity, "P1", "high -> P1");
assert.equal(f[2].severity, "P3", "unknown severity -> P3 (kept, not dropped)");
assert.deepEqual(f[2].files, ["k.txt"], "non-string file entries filtered out");
assert.equal(f[3].title, "Untitled finding", "missing title gets a fallback");
assert.deepEqual(s.report.strengths, ["single strength"], "string strengths -> array");
});
});
test("scan: a non-object assessment.json yields a null report (no crash)", async () => {
await withRepo({ ".appmod/assessment.json": "42" }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.report, null);
});
await withRepo({ ".appmod/assessment.json": "[1,2,3]" }, async (dir) => {
const s = await scanRepo(dir, { includeGit: false });
assert.equal(s.report, null, "a top-level array is not a valid report");
});
});
test("catalog: has 16 well-formed tasks", () => {
assert.equal(PREDEFINED_TASKS.length, 16);
for (const t of PREDEFINED_TASKS) {
assert.ok(t.id && t.name && t.category && t.summary, "task fields present: " + t.id);
assert.ok(Array.isArray(t.detect));
}
const ids = new Set(PREDEFINED_TASKS.map((t) => t.id));
assert.equal(ids.size, PREDEFINED_TASKS.length, "ids are unique");
});
test("catalog: catalogWithRelevance flags only matching detect keys", () => {
const withRel = catalogWithRelevance(["rabbitmq"]);
assert.equal(withRel.find((t) => t.id === "rabbitmq-to-servicebus").relevant, true);
assert.equal(withRel.find((t) => t.id === "aws-s3-to-blob").relevant, false);
});
// ===========================================================================
// prompts.mjs
// ===========================================================================
test("prompts: every labelled action builds a non-empty prompt", () => {
for (const kind of Object.keys(ACTION_LABELS)) {
const payload =
kind === "run_task"
? { taskId: PREDEFINED_TASKS[0].id }
: kind === "run_skill"
? { folder: "x" }
: kind === "fix_finding"
? { title: "Some finding" }
: kind === "work_step"
? { title: "Some step" }
: kind === "fix_env"
? { tool: "a JDK" }
: {};
const p = buildPrompt(kind, payload, "/repo");
assert.ok(typeof p === "string" && p.length > 0, "prompt for " + kind);
}
});
test("prompts: run_task unknown id returns null", () => {
assert.equal(buildPrompt("run_task", { taskId: "nope" }, "/repo"), null);
});
test("prompts: run_task known id embeds the task name", () => {
const p = buildPrompt("run_task", { taskId: "aws-s3-to-blob" }, "/repo");
assert.match(p, /AWS S3 to Azure Storage Blob/);
});
test("prompts: assessment embeds repo path; generate_plan embeds target", () => {
assert.match(buildPrompt("start_assessment", {}, "/my/repo"), /\/my\/repo/);
assert.match(buildPrompt("generate_plan", { targetJava: 21 }, "/r"), /Java 21/);
});
test("prompts: appmod tools referenced by hash command", () => {
assert.match(buildPrompt("run_cve", {}, "/r"), /#appmod-validate-cves-for-java/);
assert.match(buildPrompt("generate_tests", {}, "/r"), /#appmod-generate-tests-for-java/);
});
test("prompts: unknown kind returns null", () => {
assert.equal(buildPrompt("bogus", {}, "/r"), null);
});
test("prompts: fix_finding embeds the finding title/detail and needs a title", () => {
const p = buildPrompt("fix_finding", { title: "TLS off", detail: "encrypt=false", files: ["db.properties"], severity: "P1" }, "/r");
assert.match(p, /TLS off/);
assert.match(p, /encrypt=false/);
assert.match(p, /db\.properties/);
assert.equal(buildPrompt("fix_finding", {}, "/r"), null, "no title -> null");
});
test("prompts: start_assessment instructs writing structured assessment.json", () => {
const p = buildPrompt("start_assessment", {}, "/my/repo");
assert.match(p, /\.appmod\/assessment\.json/);
assert.match(p, /findings/);
assert.match(p, /P0\|P1\|P2\|P3/);
});
test("prompts: fix_finding has a label in ACTION_LABELS", () => {
assert.ok(ACTION_LABELS.fix_finding, "fix_finding should be labelled");
});
test("prompts: work_step embeds the step title + phase and requires a title", () => {
const p = buildPrompt("work_step", { title: "Add ingress TLS", section: "P1 — Secrets & identity" }, "/r");
assert.match(p, /Add ingress TLS/);
assert.match(p, /P1 — Secrets & identity/);
assert.match(p, /progress\.md/);
assert.equal(buildPrompt("work_step", {}, "/r"), null, "no title -> null");
});
// ===========================================================================
// renderer.mjs
// ===========================================================================
test("renderer: returns full HTML doc with app root and boot blob", () => {
const html = renderHtml({ instanceId: "abc", initialTab: "plan" });
assert.match(html, /^<!doctype html>/);
assert.match(html, /id="app"/);
assert.match(html, /Java Modernization Studio/);
assert.match(html, /"initialTab":"plan"/);
assert.match(html, /"instanceId":"abc"/);
});
test("renderer: defaults initialTab to overview", () => {
const html = renderHtml({ instanceId: "x" });
assert.match(html, /"initialTab":"overview"/);
});
test("renderer: button hover is contrast-safe (no inverted emphasis background)", () => {
const html = renderHtml({ instanceId: "css" });
const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
// Regression: a `:hover` must never swap a dark-text button onto the dark
// `--background-color-emphasis` surface (that made hovered text unreadable).
assert.ok(!/:hover[^}]*background-color-emphasis/.test(css), "no emphasis background on hover");
// The default (non-primary) button keeps a readable surface on hover.
assert.match(css, /button\.btn:not\(\.primary\):not\(:disabled\):hover/);
// Keyboard users get a visible focus ring.
assert.match(css, /button\.btn:focus-visible/);
});
test("renderer: boot blob escapes < so it cannot break out of <script>", () => {
const html = renderHtml({ instanceId: "</script><script>bad", initialTab: "overview" });
// The dangerous literal must not appear as a real closing tag + injected script.
assert.ok(!html.includes("</script><script>bad"));
assert.match(html, /\\u003c\/script>/);
});
test("renderer: includes an Assessment tab and the guided journey/findings UI", () => {
const html = renderHtml({ instanceId: "z", initialTab: "assessment" });
assert.match(html, /data-tab="assessment"/);
assert.match(html, /assessmentTab/);
assert.match(html, /journeyHtml/);
assert.match(html, /function heroNext/);
assert.match(html, /findingCard/);
assert.match(html, /goto:assessment/);
assert.match(html, /"initialTab":"assessment"/);
});
test("renderer: ships CSS for stepper, hero, and severity finding cards", () => {
const html = renderHtml({ instanceId: "z" });
assert.match(html, /\.stepper/);
assert.match(html, /\.hero/);
assert.match(html, /\.finding\.sev-P0/);
});
test("renderer: Plan tab is actionable (work_step buttons, grouping, continue-here)", () => {
const html = renderHtml({ instanceId: "z", initialTab: "plan" });
assert.match(html, /function planTab/);
assert.match(html, /work_step/);
assert.match(html, /stepRowAction/);
assert.match(html, /Continue here/);
assert.match(html, /Work on this/);
});
test("renderer: Plan tab ships the ordering guardrails (approach guide + locks + override)", () => {
const html = renderHtml({ instanceId: "z", initialTab: "plan" });
assert.match(html, /Recommended approach/);
assert.match(html, /function approachGuide/);
// Lock-with-override: locked rows show a reason and a de-emphasized "Do anyway".
assert.match(html, /Do anyway/);
assert.match(html, /function shortPhase/);
assert.match(html, /s\.ordering|ordering/);
});
test("renderer: Assessment-tab findings honor the same phase locks", () => {
const html = renderHtml({ instanceId: "z", initialTab: "assessment" });
assert.match(html, /function isFindingLocked/);
assert.match(html, /function sevRank/);
assert.match(html, /issues first/);
});
test("renderer: Readiness tab + nav are wired (doctor checks, re-check, fix actions)", () => {
const html = renderHtml({ instanceId: "z", initialTab: "readiness" });
assert.match(html, /function doctorTab/);
assert.match(html, /data-tab="readiness"/);
assert.match(html, /Environment readiness/);
assert.match(html, /recheck_env/);
assert.match(html, /function checkIcon/);
assert.match(html, /Open Readiness/); // overview nudge when blocked
});
// Execute the serialized client against a realistic state and return the #app
// HTML for a given tab. Runs the script with `new Function` (global scope, NOT
// this module's lexical scope) so a stray reference to an undeclared variable
// surfaces as a real ReferenceError instead of silently resolving to a leaked
// local — that scope leak is exactly what hid a "dead Plan tab" regression.
async function renderClientTab(state, initialTab) {
const html = renderHtml({ instanceId: "exec", initialTab });
const script = html.split("<script>")[1].split("</script>")[0];
const slots = {};
const make = (id) => ({
_html: "", id,
set innerHTML(v) { this._html = v; },
get innerHTML() { return this._html; },
addEventListener() {}, querySelectorAll() { return []; }, querySelector() { return null; },
classList: { add() {}, remove() {}, toggle() {} }, setAttribute() {}, style: {}, dataset: {}, textContent: "",
});
const win = { __APPMOD__: { instanceId: "exec", initialTab }, addEventListener() {}, location: {}, scrollTo() {} };
const ES = class { constructor() {} addEventListener() {} close() {} };
const doc = {
getElementById: (id) => { if (!slots[id]) slots[id] = make(id); return slots[id]; },
querySelectorAll: () => [],
querySelector: (s) => { if (typeof s === "string" && s[0] === "#") { const id = s.slice(1); if (!slots[id]) slots[id] = make(id); return slots[id]; } return null; },
addEventListener() {}, createElement: () => make("x"), body: make("body"),
};
const fetchShim = async () => ({ ok: true, json: async () => state });
// Inject our shims as the only free identifiers the client uses, so the body
// runs against them rather than this module's lexical scope.
const fn = new Function("window", "document", "EventSource", "fetch", "setTimeout", "clearTimeout", "console", script);
fn(win, doc, ES, fetchShim, setTimeout, clearTimeout, console);
// The client renders once synchronously (Loading), then loadState() fetches
// and re-renders async — let those microtasks settle before reading #app.
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
return { app: () => (slots.app ? slots.app._html : "") };
}
// A realistic snapshot with a multi-phase plan, an assessment report, gates, an
// environment report, and no autopilot run — exercises the common render paths.
function sampleState(extra = {}) {
const steps = [
{ title: "Fix the build", section: "P0 Build", status: "in_progress", rank: 1 },
{ title: "Patch CVEs", section: "P1 Security", status: "pending", rank: 2 },
{ title: "Upgrade Java", section: "P2 Runtime", status: "pending", rank: 3 },
];
return {
ok: true, repoPath: "/demo/app", scannedAt: new Date().toISOString(),
status: "in_progress", percent: 10,
assessment: { buildTool: "Maven", javaVersion: "8", springBoot: true, hasDockerfile: true, detectedKeys: [] },
tasks: [], skills: [],
git: { branch: "modernize/java21", isMigrationBranch: true, dirty: true, changedFiles: 3 },
plan: { exists: true, steps }, progress: { exists: true, steps },
summary: { exists: false, markdown: "" },
report: {
generatedAt: new Date().toISOString(), headline: "Java 8 Spring app", summary: "Outdated runtime and deps.",
stack: { buildTool: "Maven", java: "8", framework: "Spring Boot", database: "MSSQL", container: "Docker" },
findings: [
{ id: "java8", severity: "P0", title: "Java 8 is EOL", detail: "Upgrade the runtime.", files: ["pom.xml"], action: { kind: "generate_plan", payload: { targetJava: 21 }, label: "Plan upgrade" } },
{ id: "cve", severity: "P1", title: "Vulnerable dependency", detail: "Bump it.", files: [], action: { kind: "fix_finding", payload: {}, label: "Fix" } },
],
strengths: ["Has a Dockerfile"],
},
ordering: { activeRank: 1, activePhase: "P0 Build", phases: [{ name: "P0 Build", rank: 1, done: 0, total: 1 }] },
gates: { build: "not_run", tests: "not_run", cve: "not_run", consistency: "not_run", completeness: "not_run" },
doctor: { overall: "ready", generatedAt: new Date().toISOString(), groups: [
{ id: "build", name: "Build & run", checks: [{ id: "jdk", label: "JDK", status: "ok", detail: "Java 21" }] },
] },
autopilot: null,
...extra,
};
}
test("renderer: every tab renders without throwing against a realistic state", async () => {
const tabs = ["overview", "readiness", "assessment", "plan", "validation", "tasks", "summary"];
for (const tab of tabs) {
const r = await renderClientTab(sampleState(), tab);
const out = r.app();
assert.ok(out && out.length > 40, "tab '" + tab + "' produced no body");
assert.ok(!/hit an error/.test(out), "tab '" + tab + "' fell back to the error banner");
}
});
test("renderer: Plan tab actually renders its checklist when executed (regression)", async () => {
const r = await renderClientTab(sampleState(), "plan");
const out = r.app();
assert.match(out, /Plan &amp; Progress/);
assert.match(out, /Fix the build/);
assert.match(out, /data-kind="work_step"/);
assert.match(out, /Autopilot/);
assert.ok(!/hit an error/.test(out));
});
test("renderer: a running autopilot shows the live strip and freezes manual actions", async () => {
const state = sampleState({
autopilot: { running: true, scope: "phase", status: "running", current: { title: "Fix the build", section: "P0 Build" }, completed: [{ title: "Earlier", section: "P0 Build", done: true }], maxSteps: 25 },
});
const r = await renderClientTab(state, "plan");
const out = r.app();
assert.match(out, /Autopilot running/);
assert.match(out, /autopilot_stop/);
assert.match(out, /Earlier/); // step log
assert.match(out, /data-kind="work_step"[^>]*disabled/); // manual buttons frozen
});
// Drive the live client: capture its click handler + EventSource so a test can
// fire a real button click and simulate an SSE (re)connect, then read #app.
// fetch routes GET /state -> current snapshot, POST /action -> a scripted result.
function driveClient(initialState, opts = {}) {
const initialTab = opts.initialTab || "readiness";
const html = renderHtml({ instanceId: "drive", initialTab });
const script = html.split("<script>")[1].split("</script>")[0];
const slots = {};
const make = (id) => ({
_html: "", id,
set innerHTML(v) { this._html = v; }, get innerHTML() { return this._html; },
addEventListener() {}, querySelectorAll() { return []; }, querySelector() { return null; },
classList: { add() {}, remove() {}, toggle() {} }, setAttribute() {}, style: {}, dataset: {}, textContent: "",
});
let clickHandler = null;
const es = { onopen: null, onmessage: null, onerror: null, addEventListener() {}, close() {} };
const win = { __APPMOD__: { instanceId: "drive", initialTab }, addEventListener() {}, location: {}, scrollTo() {} };
const ES = function () { return es; }; // `new ES()` yields our singleton
const doc = {
getElementById: (id) => { if (!slots[id]) slots[id] = make(id); return slots[id]; },
querySelectorAll: () => [],
querySelector: (s) => { if (typeof s === "string" && s[0] === "#") { const id = s.slice(1); if (!slots[id]) slots[id] = make(id); return slots[id]; } return null; },
addEventListener: (type, fn) => { if (type === "click") clickHandler = fn; },
createElement: () => make("x"), body: make("body"),
};
let getState = initialState;
let getStateCalls = 0;
const fetchShim = async (url, init) => {
if (init && init.method === "POST") return { ok: true, json: async () => (opts.actionResponse || { ok: true, message: "ok" }) };
getStateCalls++;
return { ok: true, json: async () => getState };
};
new Function("window", "document", "EventSource", "fetch", "setTimeout", "clearTimeout", "console", script)(win, doc, ES, fetchShim, setTimeout, clearTimeout, console);
const settle = async () => { await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0)); };
return {
async ready() { await settle(); return this; },
app: () => (slots.app ? slots.app._html : ""),
getStateCalls: () => getStateCalls,
setGetState(s) { getState = s; },
async clickKind(kind, payload) {
const btnEl = { disabled: false, dataset: { kind, payload: JSON.stringify(payload || {}) } };
const ev = { target: { closest: (sel) => (sel.indexOf("nav.tabs") === 0 ? null : btnEl) } };
clickHandler(ev);
await settle();
},
async reconnect() { if (es.onopen) es.onopen(); await settle(); },
};
}
test("renderer: recheck_env applies the inline result and never strands the spinner", async () => {
// Backend returns a fresh, now-ready snapshot inline (res.state) — the client
// must apply it directly, not fall back to the agent-style 'pending' banner
// that only clears on an SSE broadcast (which can be lost on a provider restart).
const blocked = sampleState({ doctor: { overall: "blocked", generatedAt: "t", groups: [{ id: "build", name: "Build", checks: [{ id: "jdk", label: "JDK", status: "fail", detail: "java not found" }] }] } });
const ready = sampleState({ doctor: { overall: "ready", generatedAt: "t2", groups: [{ id: "build", name: "Build", checks: [{ id: "jdk", label: "JDK", status: "ok", detail: "Java 21" }] }] } });
const c = await driveClient(blocked, { initialTab: "readiness", actionResponse: { ok: true, message: "Re-checked environment", state: ready } }).ready();
assert.match(c.app(), /Not ready yet/); // starts blocked
await c.clickKind("recheck_env", {});
assert.match(c.app(), /Your environment is ready/); // inline state applied
assert.ok(!/Watch the chat/.test(c.app()), "must not show the agent 'pending' spinner banner");
assert.equal(c.getStateCalls(), 1, "recheck must not need an extra /state fetch (uses inline state)");
});
test("renderer: an SSE reconnect resyncs state (recovers a missed broadcast)", async () => {
const blocked = sampleState({ doctor: { overall: "blocked", generatedAt: "t", groups: [{ id: "build", name: "Build", checks: [{ id: "jdk", label: "JDK", status: "fail", detail: "x" }] }] } });
const ready = sampleState({ doctor: { overall: "ready", generatedAt: "t2", groups: [{ id: "build", name: "Build", checks: [{ id: "jdk", label: "JDK", status: "ok", detail: "Java 21" }] }] } });
const c = await driveClient(blocked, { initialTab: "readiness" }).ready();
const before = c.getStateCalls();
assert.match(c.app(), /Not ready yet/);
// Provider finished the recheck after a restart that dropped the broadcast;
// backend state is now ready. The stream reconnects -> client must re-fetch.
c.setGetState(ready);
await c.reconnect();
assert.equal(c.getStateCalls(), before + 1, "onopen must re-fetch /state");
assert.match(c.app(), /Your environment is ready/);
});
// ===========================================================================
// server.mjs
// ===========================================================================
test("server: resolveRepoPath precedence input > session > last, else null", () => {
assert.equal(
resolveRepoPath({ input: { repoPath: "/in" }, session: { workingDirectory: "/s" } }, "/last"),
"/in"
);
assert.equal(resolveRepoPath({ session: { workingDirectory: "/s" } }, "/last"), "/s");
assert.equal(resolveRepoPath({}, "/last"), "/last");
// No process.cwd() fallback — unresolved means null (UI shows "repo not available").
assert.equal(resolveRepoPath({}, null), null);
assert.equal(resolveRepoPath({}, undefined), null);
});
test("server: broadcast writes SSE frame to every client", () => {
const chunks = [];
const rec = { sseClients: new Set([{ write: (s) => chunks.push(s) }]) };
broadcast(rec, { type: "state", state: { ok: true } });
assert.equal(chunks.length, 1);
assert.match(chunks[0], /^data: /);
assert.match(chunks[0], /"type":"state"/);
});
test("server: pushState scans repo and broadcasts snapshot", async () => {
await withRepo({ "pom.xml": "<project/>" }, async (dir) => {
const chunks = [];
const rec = { repoPath: dir, sseClients: new Set([{ write: (s) => chunks.push(s) }]) };
const state = await pushState(rec);
assert.equal(state.ok, true);
assert.equal(chunks.length, 1);
assert.match(chunks[0], /"buildTool":"Maven"/);
});
});
test("server: dispatch refresh re-scans without sending a prompt", async () => {
await withRepo({ "pom.xml": "<project/>" }, async (dir) => {
let sent = 0;
const rec = { repoPath: dir, sseClients: new Set() };
const res = await dispatchAction(rec, "refresh", {}, { sendPrompt: async () => sent++ });
assert.equal(res.ok, true);
assert.equal(sent, 0);
});
});
test("server: dispatch agent action forwards crafted prompt to sendPrompt", async () => {
const rec = { repoPath: "/repo", sseClients: new Set() };
const sentPrompts = [];
const res = await dispatchAction(rec, "run_cve", {}, { sendPrompt: async (p) => sentPrompts.push(p) });
assert.equal(res.ok, true);
assert.equal(res.label, "CVE scan");
assert.equal(sentPrompts.length, 1);
assert.match(sentPrompts[0], /#appmod-validate-cves-for-java/);
});
test("server: dispatch unknown action returns error, no send", async () => {
const rec = { repoPath: "/repo", sseClients: new Set() };
let sent = 0;
const res = await dispatchAction(rec, "bogus", {}, { sendPrompt: async () => sent++ });
assert.equal(res.ok, false);
assert.match(res.error, /Unknown action/);
assert.equal(sent, 0);
});
test("server: dispatch without sendPrompt reports session not ready", async () => {
const rec = { repoPath: "/repo", sseClients: new Set() };
const res = await dispatchAction(rec, "run_cve", {}, {});
assert.equal(res.ok, false);
assert.match(res.error, /not ready/);
});
test("server: handler GET / serves HTML", async () => {
const rec = { repoPath: "/repo", sseClients: new Set() };
const handler = makeHandler(rec, { instanceId: "i", initialTab: "overview", sendPrompt: async () => {} });
const { res } = await invokeGet(handler, "/");
assert.match(res.headers["content-type"], /text\/html/);
assert.match(res.body, /Java Modernization Studio/);
});
test("server: handler GET /state serves scanned JSON", async () => {
await withRepo({ "pom.xml": "<project/>" }, async (dir) => {
const rec = { repoPath: dir, sseClients: new Set() };
const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async () => {} });
const { res } = await invokeGet(handler, "/state");
const json = JSON.parse(res.body);
assert.equal(json.ok, true);
assert.equal(json.repoPath, dir);
});
});
test("server: handler GET /events registers and cleans up SSE client", async () => {
const rec = { repoPath: "/repo", sseClients: new Set() };
const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async () => {} });
const { req } = await invokeGet(handler, "/events");
assert.equal(rec.sseClients.size, 1);
req.emit("close");
assert.equal(rec.sseClients.size, 0);
});
test("server: handler POST /action runs dispatch", async () => {
const rec = { repoPath: "/repo", sseClients: new Set() };
const sent = [];
const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async (p) => sent.push(p) });
const res = await invokePost(handler, "/action", { kind: "run_cve" });
const json = JSON.parse(res.body);
assert.equal(json.ok, true);
assert.equal(sent.length, 1);
});
test("server: token guard forbids requests without the per-instance token", async () => {
const rec = { repoPath: "/repo", sseClients: new Set(), token: "s3cret" };
const sent = [];
const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async (p) => sent.push(p) });
for (const path of ["/", "/state", "/events"]) {
const { res } = await invokeGet(handler, path);
assert.equal(res.statusCode, 403, path + " without a token must be forbidden");
}
const post = await invokePost(handler, "/action", { kind: "run_cve" });
assert.equal(post.statusCode, 403, "/action without a token must be forbidden");
assert.equal(sent.length, 0, "no action is dispatched for an unauthenticated request");
assert.equal(rec.sseClients.size, 0, "no SSE client is registered for an unauthenticated request");
});
test("server: token guard allows requests carrying the correct token", async () => {
const rec = { repoPath: "/repo", sseClients: new Set(), token: "s3cret" };
const sent = [];
const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async (p) => sent.push(p) });
const { req } = await invokeGet(handler, "/events?token=s3cret");
assert.equal(rec.sseClients.size, 1, "a valid token registers the SSE client");
req.emit("close");
const post = await invokePost(handler, "/action?token=s3cret", { kind: "run_cve" });
const json = JSON.parse(post.body);
assert.equal(json.ok, true);
assert.equal(sent.length, 1);
});
test("server: POST /action rejects an oversized request body (413)", async () => {
const rec = { repoPath: "/repo", sseClients: new Set() };
const sent = [];
const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async (p) => sent.push(p) });
const req = new EventEmitter();
req.method = "POST";
req.url = "/action";
const res = fakeRes();
const pending = handler(req, res);
req.emit("data", "x".repeat(300 * 1024)); // > 256 KB cap
req.emit("end");
await pending;
assert.equal(res.statusCode, 413);
const json = JSON.parse(res.body);
assert.equal(json.ok, false);
assert.match(json.error, /too large/i);
assert.equal(sent.length, 0, "no action is dispatched for a rejected body");
});
test("server: handler unknown route 404s", async () => {
const rec = { repoPath: "/repo", sseClients: new Set() };
const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async () => {} });
const { res } = await invokeGet(handler, "/nope");
assert.equal(res.statusCode, 404);
});
test("server: broadcast drops a client whose write fails (no leak, no repeat throw)", () => {
const live = [];
const dead = {
write() {
throw new Error("EPIPE: client gone");
},
};
const rec = { sseClients: new Set([dead, { write: (s) => live.push(s) }]) };
broadcast(rec, { type: "state", state: { ok: true } });
assert.ok(!rec.sseClients.has(dead), "a client whose write throws is removed from the Set");
assert.equal(rec.sseClients.size, 1, "the healthy client is retained");
assert.equal(live.length, 1, "the healthy client still receives the frame");
// A later broadcast must not keep throwing now that the dead client is gone.
assert.doesNotThrow(() => broadcast(rec, { type: "state", state: { ok: true } }));
assert.equal(live.length, 2);
});
test("server: POST /action rejects malformed JSON with 400 and dispatches nothing", async () => {
const rec = { repoPath: "/repo", sseClients: new Set() };
let sent = 0;
const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async () => sent++ });
const req = new EventEmitter();
req.method = "POST";
req.url = "/action";
const res = fakeRes();
const pending = handler(req, res);
req.emit("data", "{ not valid json ");
req.emit("end");
await pending;
assert.equal(res.statusCode, 400);
assert.match(res.headers["content-type"], /application\/json/);
const json = JSON.parse(res.body);
assert.equal(json.ok, false);
assert.match(json.error, /invalid JSON/i);
assert.equal(sent, 0, "a malformed body dispatches no action");
});
test("server: POST /action rejects a missing/invalid kind with 400", async () => {
const rec = { repoPath: "/repo", sseClients: new Set() };
let sent = 0;
const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async () => sent++ });
const res = await invokePost(handler, "/action", { payload: {} }); // no kind
assert.equal(res.statusCode, 400);
assert.match(res.headers["content-type"], /application\/json/);
const json = JSON.parse(res.body);
assert.equal(json.ok, false);
assert.match(json.error, /kind/i);
assert.equal(sent, 0, "a kindless request dispatches no action");
});
test("server: a handler exception returns 500 with a JSON content-type", async () => {
// A throwing repoPath getter makes buildState (GET /state) reject, exercising
// the handler's outer catch — which must still label the error body as JSON.
const rec = {
sseClients: new Set(),
get repoPath() {
throw new Error("boom");
},
};
const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async () => {} });
const { res } = await invokeGet(handler, "/state");
assert.equal(res.statusCode, 500);
assert.match(res.headers["content-type"], /application\/json/);
const json = JSON.parse(res.body);
assert.equal(json.ok, false);
});
test("server: recheck_env runs the doctor, caches it, and broadcasts (no agent prompt)", async () => {
await withRepo({ "pom.xml": "<project/>" }, async (dir) => {
let runs = 0;
const chunks = [];
const rec = {
repoPath: dir,
sseClients: new Set([{ write: (s) => chunks.push(s) }]),
doctor: null,
runDoctor: async () => ({ overall: "blocked", groups: [], runs: ++runs }),
};
let sent = 0;
const res = await dispatchAction(rec, "recheck_env", {}, { sendPrompt: async () => sent++ });
assert.equal(res.ok, true);
assert.equal(sent, 0, "recheck_env does not message the agent");
assert.equal(rec.doctor.overall, "blocked");
assert.equal(runs, 1);
assert.match(chunks[0], /"overall":"blocked"/);
});
});
test("server: createInstanceServer binds a loopback socket end-to-end", async () => {
await withRepo({ "pom.xml": "<project/>" }, async (dir) => {
const sent = [];
const rec = await createInstanceServer({
instanceId: "live",
repoPath: dir,
initialTab: "overview",
sendPrompt: async (p) => sent.push(p),
runDoctor: async () => ({ overall: "ready", groups: [] }),
});
try {
assert.match(rec.url, /^http:\/\/127\.0\.0\.1:\d+\/\?token=[a-f0-9]+$/);
const u = new URL(rec.url);
const base = u.origin;
const q = "?token=" + u.searchParams.get("token");
const home = await fetch(rec.url);
assert.equal(home.status, 200);
assert.match(await home.text(), /Java Modernization Studio/);
const state = await (await fetch(base + "/state" + q)).json();
assert.equal(state.ok, true);
assert.equal(state.assessment.buildTool, "Maven");
assert.equal(state.doctor.overall, "ready", "doctor report is attached to state");
const action = await fetch(base + "/action" + q, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "generate_tests" }),
});
const aj = await action.json();
assert.equal(aj.ok, true);
assert.equal(sent.length, 1);
// A request that omits the per-instance token must be refused, even though
// it reached the right loopback port.
const unauth = await fetch(base + "/state");
assert.equal(unauth.status, 403, "missing token is rejected at the socket");
} finally {
await new Promise((r) => rec.server.close(() => r()));
}
});
});
// ---- autopilot --------------------------------------------------------------
// Build an evolving plan model: snapshot() reflects current statuses and a fresh
// activeRank; markNextDone() simulates the agent checking a step off.
function planSim(steps) {
const model = steps.map((s) => ({ section: null, rank: null, status: "pending", ...s }));
const activeRank = () => {
const notDone = model.filter((s) => s.status !== "done" && s.rank != null);
return notDone.length ? Math.min(...notDone.map((s) => s.rank)) : null;
};
return {
model,
snapshot: async () => ({
ok: true,
progress: { steps: model.map((s) => ({ ...s })) },
plan: { steps: model.map((s) => ({ ...s })) },
ordering: { activeRank: activeRank() },
}),
markNextDone() {
const notDone = model.find((s) => s.status !== "done");
if (notDone) notDone.status = "done";
},
};
}
test("autopilot: selectNextStep follows phase order then falls back", () => {
const s = {
progress: { steps: [
{ title: "A", status: "done", rank: 1 },
{ title: "B", status: "pending", rank: 1 },
{ title: "C", status: "pending", rank: 2 },
] },
ordering: { activeRank: 1 },
};
assert.equal(selectNextStep(s).title, "B", "picks the active-phase pending step");
const s2 = { progress: { steps: [{ title: "X", status: "done", rank: 1 }] }, ordering: { activeRank: null } };
assert.equal(selectNextStep(s2), null, "null when nothing is pending");
assert.equal(selectNextStep({}), null, "null when there are no steps");
});
test("autopilot: stepKey is stable and isStepDone reads status", () => {
const step = { title: "Upgrade", section: "P0 Build" };
assert.equal(stepKey(step), "P0 Build::Upgrade");
const state = { progress: { steps: [{ title: "Upgrade", section: "P0 Build", status: "done" }] } };
assert.equal(isStepDone(state, step), true);
assert.equal(isStepDone({ progress: { steps: [] } }, step), false);
});
test("autopilot: auto_step prompt is autonomous and never commits", () => {
assert.equal(buildPrompt("auto_step", {}, "/repo"), null, "no title -> no prompt");
const p = buildPrompt("auto_step", { title: "Bump Spring", section: "P1 Security" }, "/repo");
assert.match(p, /AUTOPILOT/);
assert.match(p, /Bump Spring/);
assert.match(p, /Do NOT commit/);
assert.match(p, /one step/i);
});
test("autopilot: runs every step to completion (happy path)", async () => {
const sim = planSim([
{ title: "A", section: "P0 Build", rank: 1 },
{ title: "B", section: "P0 Build", rank: 1 },
]);
const run = makeRun({ scope: "all", maxSteps: 10, startRank: 1 });
const prompts = [];
await runAutopilot(run, {
snapshot: sim.snapshot,
runTurn: async (p) => { prompts.push(p); sim.markNextDone(); },
buildStepPrompt: (step) => step.title,
onProgress: () => {},
});
assert.equal(run.status, "completed");
assert.equal(run.completed.length, 2);
assert.ok(run.completed.every((c) => c.done), "each step ended up checked off");
assert.deepEqual(prompts, ["A", "B"]);
assert.equal(run.running, false);
});
test("autopilot: phase scope stops at the phase boundary", async () => {
const sim = planSim([
{ title: "A", section: "P0 Build", rank: 1 },
{ title: "B", section: "P1 Security", rank: 2 },
]);
const run = makeRun({ scope: "phase", maxSteps: 10, startRank: 1 });
await runAutopilot(run, {
snapshot: sim.snapshot,
runTurn: async () => sim.markNextDone(),
buildStepPrompt: (step) => step.title,
onProgress: () => {},
});
assert.equal(run.status, "phase_done");
assert.equal(run.completed.length, 1, "only the P0 step ran; it paused before P1");
assert.equal(run.completed[0].title, "A");
});
test("autopilot: stops (stuck) when a step does not get checked off", async () => {
const sim = planSim([{ title: "Tricky", section: "P0 Build", rank: 1 }]);
const run = makeRun({ scope: "all", maxSteps: 10, startRank: 1 });
await runAutopilot(run, {
snapshot: sim.snapshot,
runTurn: async () => { /* agent fails to complete the step */ },
buildStepPrompt: (step) => step.title,
onProgress: () => {},
});
assert.equal(run.status, "stuck");
assert.equal(run.stuck, "Tricky");
assert.equal(run.completed.length, 1);
assert.equal(run.completed[0].done, false);
});
test("autopilot: honors cancellation between steps", async () => {
const sim = planSim([
{ title: "A", section: "P0 Build", rank: 1 },
{ title: "B", section: "P0 Build", rank: 1 },
]);
const run = makeRun({ scope: "all", maxSteps: 10, startRank: 1 });
await runAutopilot(run, {
snapshot: sim.snapshot,
runTurn: async () => sim.markNextDone(),
buildStepPrompt: (step) => step.title,
onProgress: (r) => { if (r.current) r.cancelled = true; },
});
assert.equal(run.status, "cancelled");
assert.equal(run.completed.length, 1, "the in-flight step finished, then it stopped");
});
test("server: autopilot_start refuses without a runTurn driver", async () => {
const rec = { repoPath: "/nope", sseClients: new Set(), doctor: null, autopilot: null };
const res = await dispatchAction(rec, "autopilot_start", {}, { sendPrompt: async () => {}, log: () => {} });
assert.equal(res.ok, false);
assert.match(res.error, /drive/i);
assert.equal(rec.autopilot, null);
});
test("server: autopilot_start is blocked when the environment is not ready", async () => {
await withRepo({ ...PROV, "pom.xml": "<project/>", "progress.md": "## P0 Build\n- [ ] Fix build\n" }, async (dir) => {
const rec = {
repoPath: dir,
sseClients: new Set(),
doctor: null,
autopilot: null,
runDoctor: async () => ({ overall: "blocked", groups: [] }),
};
const res = await dispatchAction(rec, "autopilot_start", {}, { runTurn: async () => {}, log: () => {} });
assert.equal(res.ok, false);
assert.match(res.error, /environment/i);
assert.equal(rec.autopilot, null, "no run starts while blocked");
});
});
test("server: autopilot_start drives the loop and broadcasts progress", async () => {
await withRepo({ ...PROV, "pom.xml": "<project/>", "progress.md": "## P0 Build\n- [ ] Fix the build\n" }, async (dir) => {
const chunks = [];
const rec = {
repoPath: dir,
sseClients: new Set([{ write: (s) => chunks.push(s) }]),
doctor: null,
autopilot: null,
runDoctor: async () => ({ overall: "ready", groups: [] }),
};
let turns = 0;
const res = await dispatchAction(rec, "autopilot_start", { scope: "phase" }, { runTurn: async () => { turns++; }, log: () => {} });
assert.equal(res.ok, true);
assert.ok(rec.autopilot, "a run record is attached");
await rec.autopilotPromise; // let the loop settle
assert.equal(rec.autopilot.running, false);
assert.ok(turns >= 1, "the agent was driven at least once");
// The step never gets checked off (runTurn is a stub), so it pauses as stuck.
assert.equal(rec.autopilot.status, "stuck");
assert.ok(chunks.some((c) => /autopilot|"type":"state"/.test(c)), "progress was broadcast");
});
});
test("server: autopilot_stop and autopilot_dismiss behave by run state", async () => {
const rec = { repoPath: "/x", sseClients: new Set(), doctor: null, autopilot: null };
const stopIdle = await dispatchAction(rec, "autopilot_stop", {}, {});
assert.equal(stopIdle.ok, false, "nothing to stop when idle");
rec.autopilot = { running: true, cancelled: false, completed: [] };
const stopRunning = await dispatchAction(rec, "autopilot_stop", {}, {});
assert.equal(stopRunning.ok, true);
assert.equal(rec.autopilot.cancelled, true, "stop flips the cancel flag");
rec.autopilot = { running: false, status: "completed", completed: [] };
await withRepo({ "pom.xml": "<project/>" }, async (dir) => {
rec.repoPath = dir;
const res = await dispatchAction(rec, "autopilot_dismiss", {}, {});
assert.equal(res.ok, true);
assert.equal(rec.autopilot, null, "dismiss clears a finished run");
});
});
test("renderer: ships the Autopilot controls (start/stop, card, live strip)", () => {
const html = renderHtml({ instanceId: "x" });
assert.match(html, /autopilot_start/);
assert.match(html, /autopilot_stop/);
assert.match(html, /Autopilot/);
assert.match(html, /autopilotStrip/);
});