Files
2026-07-13 12:22:59 +08:00

160 lines
5.8 KiB
TypeScript

/**
* Launch renderer contract: one merged status header per op carrying the op,
* target name, and daemon state — replacing the old stacked "pending header +
* bare `✓ Launch` + raw text" render — plus per-op body rules (logs strip the
* LLM-facing `[name: state; cursor=N]` suffix, list caps collapsed rows).
*/
import { describe, expect, it } from "bun:test";
import type { DaemonSnapshot } from "@oh-my-pi/pi-coding-agent/launch/protocol";
import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { type LaunchToolDetails, launchToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/launch";
import { toolRenderers } from "@oh-my-pi/pi-coding-agent/tools/renderers";
import { sanitizeText } from "@oh-my-pi/pi-utils";
async function theme() {
const t = await getThemeByName("dark");
expect(t).toBeDefined();
return t!;
}
const lines = (component: { render: (w: number) => readonly string[] }, width = 200) =>
sanitizeText(component.render(width).join("\n")).split("\n");
const daemon = (overrides: Partial<DaemonSnapshot>): DaemonSnapshot => ({
name: "web",
id: "d-1",
state: "running",
pid: 51234,
createdAt: 0,
startedAt: Date.now() - 22_600,
restartCount: 0,
outputBytes: 0,
persist: false,
detached: false,
...overrides,
});
describe("launchToolRenderer", () => {
it("is registered with merged call/result so the pending header is replaced, not stacked", () => {
expect(Object.is(toolRenderers.launch.renderResult, launchToolRenderer.renderResult)).toBe(true);
expect(toolRenderers.launch.mergeCallAndResult).toBe(true);
});
it("folds a stop result into one header with op, name, and exit state", async () => {
const uiTheme = await theme();
const rendered = lines(
launchToolRenderer.renderResult(
{
content: [{ type: "text", text: "Stopped pyc-profile-run: exited exit=0 uptime=22.6s restarts=0" }],
details: {
op: "stop",
daemon: daemon({ name: "pyc-profile-run", state: "exited", exitedAt: Date.now(), exitCode: 0 }),
} satisfies LaunchToolDetails,
},
{ expanded: false, isPartial: false },
uiTheme,
{ op: "stop", name: "pyc-profile-run" },
),
);
expect(rendered).toHaveLength(1);
expect(rendered[0]).toContain("Launch stop");
expect(rendered[0]).toContain("pyc-profile-run");
expect(rendered[0]).toContain("exited");
expect(rendered[0]).toContain("exit 0");
});
it("renders log lines without the trailing cursor-status suffix, surfacing it as header meta", async () => {
const uiTheme = await theme();
const rendered = lines(
launchToolRenderer.renderResult(
{
content: [{ type: "text", text: "line one\nline two\n[web: running; cursor=2210]" }],
details: { op: "logs", cursor: 2210, timedOut: false, state: "running" } satisfies LaunchToolDetails,
},
{ expanded: false, isPartial: false },
uiTheme,
{ op: "logs", name: "web" },
),
);
expect(rendered[0]).toContain("Launch logs");
expect(rendered[0]).toContain("cursor 2210");
expect(rendered).toContain("line one");
expect(rendered).toContain("line two");
expect(rendered.some(line => line.includes("[web: running"))).toBe(false);
});
it("caps a collapsed list to the preview item limit with a more-items row", async () => {
const uiTheme = await theme();
const daemons = Array.from({ length: 11 }, (_, i) => daemon({ name: `svc-${i}`, id: `d-${i}` }));
const rendered = lines(
launchToolRenderer.renderResult(
{
content: [{ type: "text", text: "" }],
details: { op: "list", daemons } satisfies LaunchToolDetails,
},
{ expanded: false, isPartial: false },
uiTheme,
{ op: "list" },
),
);
expect(rendered[0]).toContain("11 processes");
expect(rendered.some(line => line.includes("svc-0"))).toBe(true);
expect(rendered.some(line => line.includes("svc-10"))).toBe(false);
expect(rendered.some(line => line.includes("3 more processes"))).toBe(true);
});
it("marks a failed start with the daemon's exit reason even though the result is not an error", async () => {
const uiTheme = await theme();
const rendered = lines(
launchToolRenderer.renderResult(
{
content: [{ type: "text", text: "Failed to launch web: failed exit=127" }],
details: {
op: "start",
daemon: daemon({
state: "failed",
exitedAt: Date.now(),
exitCode: 127,
exitReason: "spawn bun ENOENT",
}),
} satisfies LaunchToolDetails,
},
{ expanded: false, isPartial: false },
uiTheme,
{ op: "start", name: "web", application: "bun", args: ["run", "dev"] },
),
);
expect(rendered[0]).toContain("Launch start");
expect(rendered[0]).toContain("failed");
expect(rendered.some(line => line.includes("spawn bun ENOENT"))).toBe(true);
});
it("names the unmet readiness condition instead of a contradictory Ready + timed-out pair", async () => {
const uiTheme = await theme();
const rendered = lines(
launchToolRenderer.renderResult(
{
content: [{ type: "text", text: "" }],
details: {
op: "start",
timedOut: true,
daemon: daemon({
state: "starting",
readyMatch: "Local: http://localhost:3100",
readyPending: ["port"],
}),
} satisfies LaunchToolDetails,
},
{ expanded: false, isPartial: false },
uiTheme,
{ op: "start", name: "web", application: "bunx", args: ["vite"], ready: { log: "Local:", port: 3100 } },
),
);
expect(rendered[0]).toContain("waiting on port");
expect(rendered.some(line => line.includes("log matched: Local: http://localhost:3100"))).toBe(true);
expect(rendered.some(line => line.includes("port 3100 on 127.0.0.1 never accepted connections"))).toBe(true);
// The old render labeled the log match a bare "ready:" while also saying readiness timed out.
expect(rendered.some(line => line.includes("ready: Local:"))).toBe(false);
});
});