mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +00:00
feat(cli): give every preview lifecycle op one JSON document (#3309)
`--status`, `--stop`, `--list` and `--kill-all` emit a schema-versioned envelope with an `ok` discriminant under `--json`, from one writer and one failure-payload builder. Human output is unchanged; the JSON path is additive. The value is in the failure paths. An agent that gets a bare error line on stderr and an empty stdout cannot tell a crash from a "not running", so every failure is a document too — including a missing project, which under `--json` resolves through the throwing resolver rather than the human-shaped nudge.
This commit is contained in:
@@ -3,7 +3,13 @@ import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { handlePreviewKillAll, handlePreviewList, studioLandingSearch } from "./preview.js";
|
||||
import { runCommand } from "citty";
|
||||
import {
|
||||
default as previewCommand,
|
||||
handlePreviewKillAll,
|
||||
handlePreviewList,
|
||||
studioLandingSearch,
|
||||
} from "./preview.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -67,9 +73,9 @@ describe("preview --kill-all", () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const warn = vi.spyOn(clack.log, "warn").mockImplementation(() => {});
|
||||
|
||||
await handlePreviewKillAll(3002, {
|
||||
await handlePreviewKillAll(3002, false, {
|
||||
listManaged: async () => [session(41402, "/tmp/unprovable"), session(41403, "/tmp/healthy")],
|
||||
stopManaged: async (projectDir) => {
|
||||
stopManaged: async (projectDir: string) => {
|
||||
if (projectDir === "/tmp/unprovable") throw new Error("ownership failed");
|
||||
return true;
|
||||
},
|
||||
@@ -85,7 +91,7 @@ describe("preview --kill-all", () => {
|
||||
it("reports nothing to kill when no preview is running", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await handlePreviewKillAll(3002, {
|
||||
await handlePreviewKillAll(3002, false, {
|
||||
listManaged: async () => [],
|
||||
killScanned: async () => ({ killed: 0, unverified: [] }),
|
||||
});
|
||||
@@ -99,7 +105,7 @@ describe("preview --list", () => {
|
||||
it("prefers the managed record over the same server's own self-report", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await handlePreviewList(3002, {
|
||||
await handlePreviewList(3002, false, {
|
||||
listManaged: async () => [
|
||||
{ pid: 99, port: 3002, projectDir: resolve("/tmp/demo"), logPath: "/tmp/demo.log" },
|
||||
],
|
||||
@@ -120,3 +126,93 @@ describe("preview --list", () => {
|
||||
log.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("preview lifecycle JSON failures", () => {
|
||||
it.each([
|
||||
[
|
||||
"list",
|
||||
() =>
|
||||
handlePreviewList(3002, true, {
|
||||
scan: async () => {
|
||||
throw new Error("list probe failed");
|
||||
},
|
||||
listManaged: async () => [],
|
||||
}),
|
||||
"preview-list-failed",
|
||||
],
|
||||
[
|
||||
"kill-all",
|
||||
() =>
|
||||
handlePreviewKillAll(3002, true, {
|
||||
listManaged: async () => [],
|
||||
killScanned: async () => {
|
||||
throw new Error("scan failed");
|
||||
},
|
||||
}),
|
||||
"preview-kill-all-failed",
|
||||
],
|
||||
] as const)("wraps %s failures in one JSON document", async (operation, run, code) => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await run();
|
||||
|
||||
expect(log).toHaveBeenCalledOnce();
|
||||
const [line] = log.mock.calls[0] as [string];
|
||||
expect(JSON.parse(line)).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
ok: false,
|
||||
error: { code },
|
||||
});
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps stopping after a record whose ownership cannot be proven", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const session = (port: number, projectDir: string) => ({
|
||||
pid: 4321,
|
||||
port,
|
||||
projectDir,
|
||||
logPath: `${projectDir}.log`,
|
||||
});
|
||||
|
||||
await handlePreviewKillAll(3002, true, {
|
||||
listManaged: async () => [session(41402, "/tmp/unprovable"), session(41403, "/tmp/healthy")],
|
||||
stopManaged: async (projectDir) => {
|
||||
if (projectDir === "/tmp/unprovable") throw new Error("ownership failed");
|
||||
return true;
|
||||
},
|
||||
killScanned: async () => ({ killed: 0, unverified: [] }),
|
||||
});
|
||||
|
||||
const [line] = log.mock.calls[0] as [string];
|
||||
// The second record must still be stopped AND the first must be reported:
|
||||
// propagating the first failure left every later preview running, unlisted.
|
||||
expect(JSON.parse(line)).toMatchObject({
|
||||
operation: "kill-all",
|
||||
ok: true,
|
||||
result: { state: "killed-all", stopped: 1, failed: ["/tmp/unprovable: ownership failed"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps stop failures in one JSON document", async () => {
|
||||
const missing = join(tmpdir(), `hf-preview-missing-${process.pid}-${Date.now()}`);
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await runCommand(previewCommand, {
|
||||
rawArgs: [missing, "--stop", "--json"],
|
||||
});
|
||||
|
||||
expect(log).toHaveBeenCalledOnce();
|
||||
const [line] = log.mock.calls[0] as [string];
|
||||
expect(JSON.parse(line)).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
operation: "stop",
|
||||
ok: false,
|
||||
error: { code: "preview-stop-failed" },
|
||||
});
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ import {
|
||||
type FindPortResult,
|
||||
} from "../server/portUtils.js";
|
||||
import { killOrphanedProcesses, killProcessTree } from "../utils/orphanCleanup.js";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
import { resolveProject, resolveProjectOrThrow } from "../utils/project.js";
|
||||
import { resolveAutoProxy } from "../utils/projectConfig.js";
|
||||
import { studioProxyEnv } from "../utils/studioProxyEnv.js";
|
||||
import {
|
||||
@@ -70,6 +70,13 @@ import {
|
||||
startBackgroundPreview,
|
||||
stopBackgroundPreview,
|
||||
} from "./previewLifecycle.js";
|
||||
import {
|
||||
lifecycleFailurePayload,
|
||||
lifecyclePayload,
|
||||
writeLifecycleJson,
|
||||
type PreviewLifecycleOperation,
|
||||
type PreviewLifecycleSession,
|
||||
} from "./previewLifecycleOutput.js";
|
||||
import { resolveLocalBrowserGpuMode, type BrowserGpuMode } from "../browser/gpuPolicy.js";
|
||||
|
||||
interface BrowserLaunchOptions {
|
||||
@@ -215,38 +222,83 @@ export default defineCommand({
|
||||
const preferredContextPort = hasExplicitPreviewPort(process.argv) ? startPort : undefined;
|
||||
|
||||
if (args.status || args.stop) {
|
||||
const project = resolveProject(args.dir);
|
||||
if (args.stop) {
|
||||
const stopped = await stopBackgroundPreview(project.dir, startPort);
|
||||
try {
|
||||
// Under --json a missing project is a lifecycle failure document, not a
|
||||
// human-shaped nudge, so the throwing resolver is the right one there.
|
||||
const project = args.json ? resolveProjectOrThrow(args.dir) : resolveProject(args.dir);
|
||||
if (args.stop) {
|
||||
const stopped = await stopBackgroundPreview(project.dir, startPort);
|
||||
if (args.json) {
|
||||
writeLifecycleJson(
|
||||
lifecyclePayload(
|
||||
"stop",
|
||||
stopped
|
||||
? { state: "stopped", projectDir: project.dir }
|
||||
: { state: "not-running", projectDir: project.dir },
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
stopped
|
||||
? `\n ${c.success("Stopped background preview")} ${c.dim(project.dir)}\n`
|
||||
: `\n ${c.dim("No background preview is running for")} ${project.dir}\n`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const status = await readBackgroundPreviewStatus(project.dir, startPort);
|
||||
if (!status) {
|
||||
if (args.json) {
|
||||
writeLifecycleJson(
|
||||
lifecyclePayload("status", { state: "not-running", projectDir: project.dir }),
|
||||
);
|
||||
} else {
|
||||
console.log(`\n ${c.dim("No background preview is running for")} ${project.dir}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (args.json) {
|
||||
writeLifecycleJson(
|
||||
lifecyclePayload(
|
||||
"status",
|
||||
previewLifecycleSession({
|
||||
state: "running",
|
||||
mode: "background",
|
||||
projectName: project.name,
|
||||
projectDir: project.dir,
|
||||
port: status.port,
|
||||
pid: status.pid,
|
||||
logPath: status.logPath,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(`\n ${c.success("Background preview running")}`);
|
||||
console.log(
|
||||
stopped
|
||||
? `\n ${c.success("Stopped background preview")} ${c.dim(project.dir)}\n`
|
||||
: `\n ${c.dim("No background preview is running for")} ${project.dir}\n`,
|
||||
` ${c.accent(`http://localhost:${status.port}`)} ${c.dim(`(PID ${status.pid})`)}`,
|
||||
);
|
||||
console.log(` ${c.dim(status.logPath)}\n`);
|
||||
} catch (error) {
|
||||
reportPreviewFailure(
|
||||
Boolean(args.json),
|
||||
args.stop ? "stop" : "status",
|
||||
args.stop ? "preview-stop-failed" : "preview-status-failed",
|
||||
errorMessage(error),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const status = await readBackgroundPreviewStatus(project.dir, startPort);
|
||||
if (!status) {
|
||||
console.log(`\n ${c.dim("No background preview is running for")} ${project.dir}\n`);
|
||||
return;
|
||||
}
|
||||
console.log(`\n ${c.success("Background preview running")}`);
|
||||
console.log(
|
||||
` ${c.accent(`http://localhost:${status.port}`)} ${c.dim(`(PID ${status.pid})`)}`,
|
||||
);
|
||||
console.log(` ${c.dim(status.logPath)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
// --list: scan and display active servers
|
||||
if (args.list) {
|
||||
await handlePreviewList(startPort);
|
||||
await handlePreviewList(startPort, Boolean(args.json));
|
||||
return;
|
||||
}
|
||||
|
||||
// --kill-all: kill all active servers
|
||||
if (args["kill-all"]) {
|
||||
await handlePreviewKillAll(startPort);
|
||||
await handlePreviewKillAll(startPort, Boolean(args.json));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -421,6 +473,44 @@ export default defineCommand({
|
||||
},
|
||||
});
|
||||
|
||||
function previewLifecycleSession(options: {
|
||||
state: PreviewLifecycleSession["state"];
|
||||
mode: PreviewLifecycleSession["mode"];
|
||||
projectName: string;
|
||||
projectDir: string;
|
||||
port: number;
|
||||
pid: number | null;
|
||||
host?: string;
|
||||
logPath?: string;
|
||||
}): PreviewLifecycleSession {
|
||||
const host = options.host ?? "127.0.0.1";
|
||||
const serverUrl = previewBaseUrl(options.port, host);
|
||||
return {
|
||||
state: options.state,
|
||||
mode: options.mode,
|
||||
projectName: options.projectName,
|
||||
projectDir: options.projectDir,
|
||||
host,
|
||||
port: options.port,
|
||||
pid: options.pid,
|
||||
serverUrl,
|
||||
studioUrl: studioDeepLink(serverUrl, options.projectName, options.projectDir),
|
||||
ready: true,
|
||||
...(options.logPath ? { logPath: options.logPath } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function reportPreviewFailure(
|
||||
json: boolean,
|
||||
operation: PreviewLifecycleOperation,
|
||||
code: string,
|
||||
message: string,
|
||||
): void {
|
||||
if (json) writeLifecycleJson(lifecycleFailurePayload(operation, code, message));
|
||||
else clack.log.error(message);
|
||||
setCommandExitCode(1);
|
||||
}
|
||||
|
||||
interface PreviewActionDependencies {
|
||||
scan?: typeof scanActiveServers;
|
||||
listManaged?: typeof listBackgroundPreviewStatuses;
|
||||
@@ -435,38 +525,64 @@ interface PreviewActionDependencies {
|
||||
*/
|
||||
export async function handlePreviewList(
|
||||
startPort: number,
|
||||
json: boolean,
|
||||
dependencies: PreviewActionDependencies = {},
|
||||
): Promise<void> {
|
||||
const [scannedServers, managedSessions] = await Promise.all([
|
||||
(dependencies.scan ?? scanActiveServers)(startPort),
|
||||
(dependencies.listManaged ?? listBackgroundPreviewStatuses)(),
|
||||
]);
|
||||
const managedKeys = new Set(
|
||||
managedSessions.map((session) => `${resolve(session.projectDir)}\0${session.port}`),
|
||||
);
|
||||
const servers = [
|
||||
...managedSessions.map((session) => ({
|
||||
port: session.port,
|
||||
projectName: basename(session.projectDir),
|
||||
projectDir: session.projectDir,
|
||||
pid: String(session.pid),
|
||||
})),
|
||||
...scannedServers.filter(
|
||||
(server) => !managedKeys.has(`${resolve(server.projectDir)}\0${server.port}`),
|
||||
),
|
||||
];
|
||||
if (servers.length === 0) {
|
||||
console.log("\n No active preview servers found.\n");
|
||||
return;
|
||||
}
|
||||
console.log(`\n ${c.bold("Active preview servers:")}\n`);
|
||||
for (const server of servers) {
|
||||
const pid = server.pid ? c.dim(` (PID ${server.pid})`) : "";
|
||||
console.log(
|
||||
` ${c.accent(`Port ${server.port}`)} ${server.projectName} ${c.dim(server.projectDir)}${pid}`,
|
||||
try {
|
||||
const [scannedServers, managedSessions] = await Promise.all([
|
||||
(dependencies.scan ?? scanActiveServers)(startPort),
|
||||
(dependencies.listManaged ?? listBackgroundPreviewStatuses)(),
|
||||
]);
|
||||
const managedKeys = new Set(
|
||||
managedSessions.map((session) => `${resolve(session.projectDir)}\0${session.port}`),
|
||||
);
|
||||
const servers = [
|
||||
...managedSessions.map((session) => ({
|
||||
port: session.port,
|
||||
host: "127.0.0.1",
|
||||
projectName: basename(session.projectDir),
|
||||
projectDir: session.projectDir,
|
||||
version: "managed",
|
||||
pid: String(session.pid),
|
||||
})),
|
||||
...scannedServers.filter(
|
||||
(server) => !managedKeys.has(`${resolve(server.projectDir)}\0${server.port}`),
|
||||
),
|
||||
];
|
||||
if (json) {
|
||||
writeLifecycleJson(
|
||||
lifecyclePayload("list", {
|
||||
state: "listed",
|
||||
sessions: servers.map((server) =>
|
||||
previewLifecycleSession({
|
||||
state: "running",
|
||||
mode: server.version === "managed" ? "background" : "unknown",
|
||||
projectName: server.projectName,
|
||||
projectDir: server.projectDir,
|
||||
port: server.port,
|
||||
pid: server.pid ? Number(server.pid) : null,
|
||||
host: server.host,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (servers.length === 0) {
|
||||
console.log("\n No active preview servers found.\n");
|
||||
return;
|
||||
}
|
||||
console.log(`\n ${c.bold("Active preview servers:")}\n`);
|
||||
for (const server of servers) {
|
||||
const pid = server.pid ? c.dim(` (PID ${server.pid})`) : "";
|
||||
console.log(
|
||||
` ${c.accent(`Port ${server.port}`)} ${server.projectName} ${c.dim(server.projectDir)}${pid}`,
|
||||
);
|
||||
}
|
||||
console.log(`\n ${servers.length} server${servers.length === 1 ? "" : "s"} running.\n`);
|
||||
} catch (error) {
|
||||
reportPreviewFailure(json, "list", "preview-list-failed", errorMessage(error));
|
||||
}
|
||||
console.log(`\n ${servers.length} server${servers.length === 1 ? "" : "s"} running.\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -479,42 +595,66 @@ export async function handlePreviewList(
|
||||
*/
|
||||
export async function handlePreviewKillAll(
|
||||
startPort: number,
|
||||
json: boolean,
|
||||
dependencies: PreviewActionDependencies = {},
|
||||
): Promise<void> {
|
||||
const managedSessions = await (dependencies.listManaged ?? listBackgroundPreviewStatuses)();
|
||||
let killed = 0;
|
||||
const failures: string[] = [];
|
||||
for (const session of managedSessions) {
|
||||
try {
|
||||
if (
|
||||
await (dependencies.stopManaged ?? stopBackgroundPreview)(session.projectDir, session.port)
|
||||
) {
|
||||
killed++;
|
||||
try {
|
||||
const managedSessions = await (dependencies.listManaged ?? listBackgroundPreviewStatuses)();
|
||||
let killed = 0;
|
||||
// One unprovable record must not abandon the servers after it. A stop pass
|
||||
// collects per-record failures and keeps going; propagating the first one
|
||||
// left every later preview running AND unreported.
|
||||
const failures: string[] = [];
|
||||
for (const session of managedSessions) {
|
||||
try {
|
||||
if (
|
||||
await (dependencies.stopManaged ?? stopBackgroundPreview)(
|
||||
session.projectDir,
|
||||
session.port,
|
||||
)
|
||||
) {
|
||||
killed++;
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push(`${session.projectDir}: ${errorMessage(error)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push(`${session.projectDir}: ${error instanceof Error ? error.message : error}`);
|
||||
}
|
||||
const swept = await (dependencies.killScanned ?? killActiveServers)(startPort);
|
||||
killed += swept.killed;
|
||||
// Ports whose owner the OS could not confirm are skipped rather than
|
||||
// signalled; an agent reading this envelope needs to see that too, not just
|
||||
// a lower count.
|
||||
const unverified = swept.unverified;
|
||||
if (json) {
|
||||
writeLifecycleJson(
|
||||
lifecyclePayload("kill-all", {
|
||||
state: "killed-all",
|
||||
stopped: killed,
|
||||
...(failures.length > 0 ? { failed: failures } : {}),
|
||||
...(unverified.length > 0 ? { unverifiedPorts: unverified } : {}),
|
||||
}),
|
||||
);
|
||||
} else if (failures.length > 0 || unverified.length > 0) {
|
||||
console.log(`\n Killed ${killed} preview server${killed === 1 ? "" : "s"}.`);
|
||||
for (const failure of failures) clack.log.warn(`Could not stop ${failure}`);
|
||||
if (unverified.length > 0) {
|
||||
const plural = unverified.length === 1 ? "" : "s";
|
||||
clack.log.warn(
|
||||
`Left ${unverified.length} server${plural} alone (port${plural} ` +
|
||||
`${unverified.join(", ")}): the OS could not confirm which process owns the ` +
|
||||
`socket, and the server's own claim is not proof. Install lsof, or stop it ` +
|
||||
`with its own preview --stop.`,
|
||||
);
|
||||
}
|
||||
console.log();
|
||||
} else if (killed === 0) {
|
||||
console.log("\n No active preview servers to kill.\n");
|
||||
} else {
|
||||
console.log(`\n Killed ${killed} preview server${killed === 1 ? "" : "s"}.\n`);
|
||||
}
|
||||
} catch (error) {
|
||||
reportPreviewFailure(json, "kill-all", "preview-kill-all-failed", errorMessage(error));
|
||||
}
|
||||
const swept = await (dependencies.killScanned ?? killActiveServers)(startPort);
|
||||
killed += swept.killed;
|
||||
if (killed > 0) {
|
||||
console.log(`\n Killed ${killed} preview server${killed === 1 ? "" : "s"}.`);
|
||||
} else if (failures.length === 0 && swept.unverified.length === 0) {
|
||||
console.log("\n No active preview servers to kill.");
|
||||
}
|
||||
for (const failure of failures) clack.log.warn(`Could not stop ${failure}`);
|
||||
if (swept.unverified.length > 0) {
|
||||
// Fail-closed, said out loud: a security control that degrades silently is
|
||||
// one nobody knows to fix.
|
||||
const ports = swept.unverified.join(", ");
|
||||
const plural = swept.unverified.length === 1 ? "" : "s";
|
||||
clack.log.warn(
|
||||
`Left ${swept.unverified.length} server${plural} alone (port${plural} ${ports}): the OS ` +
|
||||
`could not confirm which process owns the socket, and the server's own claim is not ` +
|
||||
`proof. Install lsof, or stop it with its own preview --stop.`,
|
||||
);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
// `host` is the loopback the server actually bound (Vite binds `[::1]`, embedded
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
lifecycleFailurePayload,
|
||||
lifecyclePayload,
|
||||
writeLifecycleJson,
|
||||
type PreviewLifecycleSession,
|
||||
} from "./previewLifecycleOutput.js";
|
||||
|
||||
const session: PreviewLifecycleSession = {
|
||||
state: "started",
|
||||
mode: "background",
|
||||
projectName: "demo",
|
||||
projectDir: "/tmp/demo",
|
||||
host: "127.0.0.1",
|
||||
port: 3002,
|
||||
pid: 42,
|
||||
serverUrl: "http://127.0.0.1:3002",
|
||||
studioUrl: "http://127.0.0.1:3002/#project/demo",
|
||||
ready: true,
|
||||
logPath: "/tmp/demo.log",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("preview lifecycle JSON", () => {
|
||||
it("versions a managed start result", () => {
|
||||
expect(lifecyclePayload("start", session)).toEqual({
|
||||
schemaVersion: 1,
|
||||
operation: "start",
|
||||
ok: true,
|
||||
result: session,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["status", { state: "not-running", projectDir: "/tmp/demo" }],
|
||||
["stop", { state: "stopped", projectDir: "/tmp/demo" }],
|
||||
["list", { state: "listed", sessions: [session] }],
|
||||
["kill-all", { state: "killed-all", stopped: 1 }],
|
||||
] as const)("versions the %s result", (operation, result) => {
|
||||
expect(lifecyclePayload(operation, result)).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
ok: true,
|
||||
result,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes exactly one parseable JSON document", () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
writeLifecycleJson(lifecyclePayload("start", { ...session, pid: null, state: "reused" }));
|
||||
|
||||
expect(log).toHaveBeenCalledOnce();
|
||||
const [line] = log.mock.calls[0] as [string];
|
||||
expect(JSON.parse(line)).toEqual({
|
||||
schemaVersion: 1,
|
||||
operation: "start",
|
||||
ok: true,
|
||||
result: { ...session, pid: null, state: "reused" },
|
||||
});
|
||||
});
|
||||
|
||||
it("versions lifecycle failures with a stable code", () => {
|
||||
expect(
|
||||
lifecycleFailurePayload(
|
||||
"start",
|
||||
"conflicting-lifecycle-flags",
|
||||
"--background and --foreground cannot be used together",
|
||||
),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
operation: "start",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "conflicting-lifecycle-flags",
|
||||
message: "--background and --foreground cannot be used together",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
export type PreviewLifecycleOperation = "start" | "status" | "stop" | "list" | "kill-all";
|
||||
|
||||
export type PreviewLifecycleState = "started" | "reused" | "running";
|
||||
|
||||
export interface PreviewLifecycleSession {
|
||||
state: PreviewLifecycleState;
|
||||
mode: "background" | "foreground" | "unknown";
|
||||
projectName: string;
|
||||
projectDir: string;
|
||||
host: string;
|
||||
port: number;
|
||||
pid: number | null;
|
||||
serverUrl: string;
|
||||
studioUrl: string;
|
||||
ready: boolean;
|
||||
logPath?: string;
|
||||
}
|
||||
|
||||
export type PreviewLifecycleResult =
|
||||
| PreviewLifecycleSession
|
||||
| { state: "not-running"; projectDir?: string }
|
||||
| { state: "stopped"; projectDir: string }
|
||||
| { state: "listed"; sessions: readonly PreviewLifecycleSession[] }
|
||||
/**
|
||||
* `failed` carries the records a stop pass could not prove ownership of;
|
||||
* `unverifiedPorts` the ones skipped because the OS would not confirm which
|
||||
* process owns the socket. Both are omissions from `stopped`, so an agent
|
||||
* that only reads the count would otherwise see a silent shortfall.
|
||||
*/
|
||||
| {
|
||||
state: "killed-all";
|
||||
stopped: number;
|
||||
failed?: readonly string[];
|
||||
unverifiedPorts?: readonly number[];
|
||||
};
|
||||
|
||||
export interface PreviewLifecyclePayload {
|
||||
schemaVersion: 1;
|
||||
operation: PreviewLifecycleOperation;
|
||||
ok: true;
|
||||
result: PreviewLifecycleResult;
|
||||
}
|
||||
|
||||
export interface PreviewLifecycleFailurePayload {
|
||||
schemaVersion: 1;
|
||||
operation: PreviewLifecycleOperation;
|
||||
ok: false;
|
||||
error: { code: string; message: string };
|
||||
}
|
||||
|
||||
export function lifecyclePayload(
|
||||
operation: PreviewLifecycleOperation,
|
||||
result: PreviewLifecycleResult,
|
||||
): PreviewLifecyclePayload {
|
||||
return { schemaVersion: 1, operation, ok: true, result };
|
||||
}
|
||||
|
||||
export function lifecycleFailurePayload(
|
||||
operation: PreviewLifecycleOperation,
|
||||
code: string,
|
||||
message: string,
|
||||
): PreviewLifecycleFailurePayload {
|
||||
return { schemaVersion: 1, operation, ok: false, error: { code, message } };
|
||||
}
|
||||
|
||||
export function writeLifecycleJson(
|
||||
payload: PreviewLifecyclePayload | PreviewLifecycleFailurePayload,
|
||||
): void {
|
||||
console.log(JSON.stringify(payload));
|
||||
}
|
||||
Reference in New Issue
Block a user