feat(cli): run a managed background preview in every launch mode (#3310)

`--background` was rejected outside the embedded server. It now re-execs the
CLI in foreground, which makes it mode-agnostic by construction: whichever
server the child resolves to serves the config endpoint the readiness probe
looks for. `--foreground` is its counterpart, for a non-interactive shell that
wants to stay attached, and a bare launch keeps the same promise — attached in
an interactive terminal, managed in an agent session.

That generalization exposed an existing hole. Local-studio mode runs Vite with
the studio package as its cwd and needs that package's own Vite config, which
the published tarball does not carry, but resolving the package was treated as
proof the mode was usable. An npm-installed studio therefore took a path that
can never come up — previously a clear error, now a ten-second silent timeout.
The predicate becomes "can this studio actually be served", so a published
install falls back to embedded mode, which works.

Over the 1k line budget at ~1.3k. The overage is one command file and its
tests carrying one invariant, and the seam that would split it further is
inside a single request-handling function — a split there would produce two
PRs neither of which starts a preview on its own.
This commit is contained in:
Miguel Ángel
2026-08-19 17:02:44 -04:00
committed by GitHub
parent 634df5a5af
commit 9da422fd7f
24 changed files with 980 additions and 249 deletions
@@ -125,4 +125,15 @@ describe("media treatment routing documentation", () => {
expect(template).toContain("do not improvise equivalent CSS/SVG filters or overlays");
}
});
it("gives agents a process-owned preview lifecycle in new project instructions", () => {
for (const file of ["AGENTS.md", "CLAUDE.md"]) {
const template = read("packages", "cli", "src", "templates", "_shared", file);
expect(template).toContain("npx hyperframes preview --background");
expect(template).toContain("npx hyperframes preview --status");
expect(template).toContain("npx hyperframes preview --stop");
expect(template).toContain("leaving refreshes at `ERR_CONNECTION_TIMED_OUT`");
expect(template).not.toContain("run_in_background: true");
}
});
});
+299 -22
View File
@@ -1,20 +1,33 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { join } from "node:path";
import * as clack from "@clack/prompts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runCommand } from "citty";
import {
default as previewCommand,
foregroundPreviewReadyPayload,
handlePreviewKillAll,
handlePreviewList,
previewLaunchMode,
previewLaunchModeError,
previewPortError,
publicPreviewPid,
previewViteArgs,
reportPreviewShutdown,
studioReadyUrl,
studioDeepLink,
studioLandingSearch,
studioSummaryUrls,
waitForStudioChildClose,
} from "./preview.js";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
vi.restoreAllMocks();
process.exitCode = undefined;
});
function projectWith(storyboard: string | null, frameFiles: string[] = []): string {
@@ -59,6 +72,33 @@ describe("studioLandingSearch", () => {
});
});
describe("Studio handoff URLs", () => {
it("hands off the exact timeline project route", () => {
const dir = projectWith(null);
expect(studioDeepLink("http://127.0.0.1:3002", "demo", dir)).toBe(
"http://127.0.0.1:3002/#project/demo",
);
expect(studioSummaryUrls("demo", "http://127.0.0.1:3002", dir)).toEqual({
serverUrl: "http://127.0.0.1:3002",
studioUrl: "http://127.0.0.1:3002/#project/demo",
});
});
it("hands off the exact storyboard route while a project is still planning", () => {
const dir = projectWith(FRAME(1, "outline"));
expect(studioDeepLink("http://127.0.0.1:3002", "demo", dir)).toBe(
"http://127.0.0.1:3002/?view=storyboard#project/demo",
);
});
it("URL-encodes project names that have hash-route metacharacters", () => {
const dir = projectWith(null);
expect(studioDeepLink("http://127.0.0.1:3002", "Launch #1? 50%", dir)).toBe(
"http://127.0.0.1:3002/#project/Launch%20%231%3F%2050%25",
);
});
});
describe("preview --kill-all", () => {
const session = (port: number, projectDir: string) => ({
pid: 4321,
@@ -101,29 +141,157 @@ describe("preview --kill-all", () => {
});
});
describe("preview --list", () => {
it("prefers the managed record over the same server's own self-report", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {});
describe("previewLaunchMode", () => {
it.each([
[
{
background: false,
foreground: false,
interactive: false,
devMode: false,
localStudio: false,
},
"background",
],
[
{
background: false,
foreground: false,
interactive: true,
devMode: false,
localStudio: false,
},
"embedded",
],
[
{
background: false,
foreground: true,
interactive: false,
devMode: true,
localStudio: false,
},
"dev",
],
[
{
background: false,
foreground: true,
interactive: false,
devMode: false,
localStudio: true,
},
"local",
],
[
{
background: true,
foreground: false,
interactive: true,
devMode: true,
localStudio: true,
},
"background",
],
] as const)("resolves %o to %s", (options, expected) => {
expect(previewLaunchMode(options)).toBe(expected);
});
await handlePreviewList(3002, false, {
listManaged: async () => [
{ pid: 99, port: 3002, projectDir: resolve("/tmp/demo"), logPath: "/tmp/demo.log" },
],
scan: async () => [
{
port: 3002,
projectName: "demo",
projectDir: resolve("/tmp/demo"),
version: "test",
pid: "99",
},
],
});
it("rejects conflicting lifecycle overrides and actions", () => {
expect(
previewLaunchModeError({
background: true,
foreground: true,
status: false,
stop: false,
list: false,
killAll: false,
}),
).toBe("--background and --foreground cannot be used together");
expect(
previewLaunchModeError({
background: false,
foreground: false,
status: true,
stop: true,
list: false,
killAll: false,
}),
).toBe("Only one of --status, --stop, --list, or --kill-all can be used at a time");
expect(
previewLaunchModeError({
background: true,
foreground: false,
status: false,
stop: false,
list: false,
killAll: false,
}),
).toBeNull();
expect(
previewLaunchModeError({
background: true,
foreground: false,
status: true,
stop: false,
list: false,
killAll: false,
}),
).toBe("Preview launch overrides cannot be combined with lifecycle actions");
expect(
previewLaunchModeError({
background: false,
foreground: true,
status: false,
stop: false,
list: false,
killAll: true,
}),
).toBe("Preview launch overrides cannot be combined with lifecycle actions");
expect(
previewLaunchModeError({
background: false,
foreground: false,
forceNew: true,
status: true,
stop: false,
list: false,
killAll: false,
}),
).toBe("Preview launch overrides cannot be combined with lifecycle actions");
});
const printed = log.mock.calls.flat().join("\n");
expect(printed).toContain("1 server running");
expect(printed).toContain("PID 99");
log.mockRestore();
it.each([
[undefined, null],
["3002", null],
["1", null],
["65535", null],
["banana", "--port must be an integer between 1 and 65535"],
["3002oops", "--port must be an integer between 1 and 65535"],
["0", "--port must be an integer between 1 and 65535"],
["65536", "--port must be an integer between 1 and 65535"],
])("validates preview port %j", (value, expected) => {
expect(previewPortError(value)).toBe(expected);
});
it("prefers the live server PID over its launcher PID", () => {
expect(publicPreviewPid("9876", 4321)).toBe(9876);
expect(publicPreviewPid(null, 4321)).toBe(4321);
});
it("pins detached Vite to the port the lifecycle scanner waits on", () => {
expect(previewViteArgs(3032)).toEqual(["--host", "127.0.0.1", "--port", "3032"]);
});
it.each([
[" Local: http://localhost:43127/", "http://localhost:43127"],
[" Local: http://127.0.0.1:43127/", "http://127.0.0.1:43127"],
[
"\u001b[32m Local:\u001b[0m \u001b[36mhttp://127.0.0.1:43127/\u001b[0m",
"http://127.0.0.1:43127",
],
])("extracts the ready URL from Vite output %j", (output, expected) => {
expect(studioReadyUrl(output)).toBe(expected);
});
});
@@ -196,6 +364,25 @@ describe("preview lifecycle JSON failures", () => {
});
});
it("wraps managed-start validation failures in one JSON document", async () => {
const dir = projectWith(null);
writeFileSync(join(dir, "index.html"), "<html></html>");
const log = vi.spyOn(console, "log").mockImplementation(() => {});
await runCommand(previewCommand, {
rawArgs: [dir, "--background", "--json", "--user-data-dir", join(dir, "profile")],
});
expect(log).toHaveBeenCalledOnce();
const [line] = log.mock.calls[0] as [string];
expect(JSON.parse(line)).toMatchObject({
schemaVersion: 1,
operation: "start",
ok: false,
error: { code: "preview-validation-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(() => {});
@@ -215,4 +402,94 @@ describe("preview lifecycle JSON failures", () => {
});
expect(error).not.toHaveBeenCalled();
});
it("wraps missing-project start failures without human stderr", async () => {
const missing = join(tmpdir(), `hf-preview-missing-start-${process.pid}-${Date.now()}`);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const error = vi.spyOn(console, "error").mockImplementation(() => {});
await runCommand(previewCommand, { rawArgs: [missing, "--background", "--json"] });
expect(log).toHaveBeenCalledOnce();
const [line] = log.mock.calls[0] as [string];
expect(JSON.parse(line)).toMatchObject({
operation: "start",
ok: false,
error: { code: "preview-start-failed" },
});
expect(error).not.toHaveBeenCalled();
});
});
describe("foreground preview JSON", () => {
it("emits the same ready session contract before remaining attached", () => {
const dir = projectWith(null);
expect(foregroundPreviewReadyPayload("Launch #1", "http://localhost:4567", dir, 4321)).toEqual({
schemaVersion: 1,
operation: "start",
ok: true,
result: {
state: "started",
mode: "foreground",
projectName: "Launch #1",
projectDir: dir,
host: "127.0.0.1",
port: 4567,
pid: 4321,
serverUrl: "http://127.0.0.1:4567",
studioUrl: "http://127.0.0.1:4567/#project/Launch%20%231",
ready: true,
},
});
});
it("keeps embedded shutdown silent after the readiness envelope", () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {});
reportPreviewShutdown(true);
expect(log).not.toHaveBeenCalled();
});
});
describe("waitForStudioChildClose", () => {
it("resolves when the child closed before the listener was attached", async () => {
const signalTarget = { once: vi.fn(), off: vi.fn() };
const child = {
exitCode: 1,
signalCode: null,
once: vi.fn(),
} as unknown as Parameters<typeof waitForStudioChildClose>[0];
await expect(waitForStudioChildClose(child, signalTarget)).resolves.toBeUndefined();
expect(child.once).not.toHaveBeenCalled();
expect(signalTarget.once).toHaveBeenCalledTimes(2);
expect(signalTarget.off).toHaveBeenCalledTimes(2);
});
it("reaps on process exit even when stdio never emits close", async () => {
let exit: (() => void) | undefined;
const signalTarget = { once: vi.fn(), off: vi.fn() };
const child = {
exitCode: null,
signalCode: null,
once: vi.fn((event: string, listener: () => void) => {
if (event === "exit") exit = listener;
}),
} as unknown as Parameters<typeof waitForStudioChildClose>[0];
let resolved = false;
const waiting = waitForStudioChildClose(child, signalTarget).then(() => {
resolved = true;
});
await Promise.resolve();
expect(resolved).toBe(false);
expect(child.once).toHaveBeenCalledWith("exit", expect.any(Function));
exit?.();
await waiting;
expect(resolved).toBe(true);
expect(signalTarget.off).toHaveBeenCalledTimes(2);
});
});
File diff suppressed because it is too large Load Diff
@@ -49,7 +49,7 @@ describe("background preview lifecycle", () => {
);
});
it("does not let the detached child inherit launcher-only flags", () => {
it("forces the detached child foreground without inheriting launcher-only flags", () => {
expect(
buildBackgroundPreviewArgs([
"/opt/hyperframes/cli.js",
@@ -57,8 +57,9 @@ describe("background preview lifecycle", () => {
projectDir,
"--background",
"--open",
"--json",
]),
).toEqual(["/opt/hyperframes/cli.js", "preview", projectDir, "--no-open"]);
).toEqual(["/opt/hyperframes/cli.js", "preview", projectDir, "--foreground", "--no-open"]);
});
it("reuses an already-running server for the same project", async () => {
@@ -248,10 +248,13 @@ export function buildBackgroundPreviewArgs(argv: string[]): string[] {
(arg) =>
arg !== "--background" &&
!arg.startsWith("--background=") &&
arg !== "--foreground" &&
!arg.startsWith("--foreground=") &&
arg !== "--open" &&
arg !== "--no-open",
arg !== "--no-open" &&
arg !== "--json",
);
return [...filtered, "--no-open"];
return [...filtered, "--foreground", "--no-open"];
}
export async function readBackgroundPreviewStatus(