From 943e6afa17336f3ff1efa47a15f14149b4524949 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:38:00 -0300 Subject: [PATCH 1/4] feat(web): Add the shared local server, request security and ui --- src/cli/commands/review.ts | 72 +++++-------- src/cli/commands/ui.ts | 61 +++++++++++ src/cli/index.ts | 93 +++++++++------- src/web/home-page.ts | 54 ++++++++++ src/web/security.ts | 112 +++++++++++++++++++ src/web/server.ts | 202 +++++++++++++++++++++++++++++++++++ tests/review-command.test.ts | 41 ++++++- tests/web-cli.test.ts | 84 +++++++++++++++ tests/web-pages.test.ts | 14 +++ tests/web-security.test.ts | 169 +++++++++++++++++++++++++++++ tests/web-server.test.ts | 153 ++++++++++++++++++++++++++ 11 files changed, 969 insertions(+), 86 deletions(-) create mode 100644 src/cli/commands/ui.ts create mode 100644 src/web/home-page.ts create mode 100644 src/web/security.ts create mode 100644 src/web/server.ts create mode 100644 tests/web-cli.test.ts create mode 100644 tests/web-pages.test.ts create mode 100644 tests/web-security.test.ts create mode 100644 tests/web-server.test.ts diff --git a/src/cli/commands/review.ts b/src/cli/commands/review.ts index 14f071c..1414bc2 100644 --- a/src/cli/commands/review.ts +++ b/src/cli/commands/review.ts @@ -1,39 +1,16 @@ import http from "node:http"; -import { spawn } from "node:child_process"; -import { InvalidArgumentError, type Command } from "commander"; +import type { Command } from "commander"; import { REVIEW_PAGE } from "../../web/review-page.js"; import { getLocalReview } from "../../git/review.js"; +import { DEFAULT_WEB_PORT, openBrowser, parseWebPort, startWebServer, type WebRoute } from "../../web/server.js"; -export const DEFAULT_REVIEW_PORT = 3100; +export const DEFAULT_REVIEW_PORT = DEFAULT_WEB_PORT; export function parseReviewPort(raw: string | undefined): number { - if (raw === undefined) return DEFAULT_REVIEW_PORT; - if (!/^\d+$/.test(raw)) { - throw new InvalidArgumentError("--port must be a positive integer"); - } - const port = Number(raw); - if (!Number.isInteger(port) || port <= 0 || port > 65535) { - throw new InvalidArgumentError("--port must be a positive integer"); - } - return port; + return parseWebPort(raw); } -export function openBrowser(url: string): boolean { - const opener = - process.platform === "darwin" - ? "open" - : process.platform === "win32" - ? "cmd" - : "xdg-open"; - const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; - try { - const child = spawn(opener, args, { detached: true, stdio: "ignore" }); - child.unref(); - return true; - } catch { - return false; - } -} +export { openBrowser }; export interface ReviewDeps { /** Repo root for /api/review. Defaults to process.cwd() at request time. */ @@ -41,6 +18,10 @@ export interface ReviewDeps { loadReview?: (root: string, ref: string, file?: string) => Promise; } +export interface ReviewCommandDependencies extends ReviewDeps { + startServer?: typeof startWebServer; +} + function sendJson(res: http.ServerResponse, code: number, value: unknown): void { res.writeHead(code, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify(value)); @@ -91,7 +72,16 @@ export interface ReviewCommandOptions { open?: boolean; } -export function registerReviewCommand(program: Command): void { +export function createReviewRoutes(reviewDeps?: ReviewDeps): WebRoute[] { + const handler = createReviewHandler(reviewDeps); + return [ + { path: "/", handler, kind: "page" }, + { path: "/review", handler, kind: "page", label: "Review" }, + { path: "/api/review", handler, kind: "api" }, + ]; +} + +export function registerReviewCommand(program: Command, dependencies: ReviewCommandDependencies = {}): void { program .command("review") .description("Open a local review of the current git changes") @@ -107,29 +97,17 @@ export function registerReviewCommand(program: Command): void { return; } - const server = http.createServer(createReviewHandler()); try { - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(port, "127.0.0.1", () => resolve()); + await (dependencies.startServer ?? startWebServer)({ + routes: createReviewRoutes(dependencies), + port, + initialPath: "/", + title: "CodeDeck review", + open: opts.open, }); } catch (error) { console.error(`Failed to listen on 127.0.0.1:${port}: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; - return; - } - - const url = `http://127.0.0.1:${port}/`; - console.log(`CodeDeck review on ${url}`); - if (opts.open !== false && !openBrowser(url)) { - console.log(`Could not open a browser, visit ${url} manually.`); } - - const shutdown = () => { - server.close(() => process.exit(0)); - setTimeout(() => process.exit(0), 1000).unref?.(); - }; - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); }); } diff --git a/src/cli/commands/ui.ts b/src/cli/commands/ui.ts new file mode 100644 index 0000000..b53f382 --- /dev/null +++ b/src/cli/commands/ui.ts @@ -0,0 +1,61 @@ +import type { Command } from "commander"; +import { createReviewRoutes } from "./review.js"; +import { renderHomePage } from "../../web/home-page.js"; +import { DEFAULT_WEB_PORT, parseWebPort, startWebServer, type WebRoute } from "../../web/server.js"; + +export interface UiCommandOptions { + port?: string; + open?: boolean; +} + +export interface UiCommandDependencies { + startServer?: typeof startWebServer; +} + +export function createUiRoutes(): WebRoute[] { + const reviewRoutes = createReviewRoutes(); + const pages = reviewRoutes.flatMap((route) => + route.kind === "page" && route.label ? [{ label: route.label, path: route.path }] : [], + ); + const reviewRouteTable = reviewRoutes.filter((route) => route.path !== "/"); + const home: WebRoute = { + path: "/", + kind: "page", + handler: (_request, response) => { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end(renderHomePage(pages)); + }, + }; + return [home, ...reviewRouteTable]; +} + +export function registerUiCommand(program: Command, dependencies: UiCommandDependencies = {}): void { + program + .command("ui") + .description("Open the local CodeDeck console") + .option("--port ", "port to listen on (default: 3100)", String(DEFAULT_WEB_PORT)) + .option("--no-open", "serve the console without opening a browser") + .action(async (opts: UiCommandOptions) => { + let port: number; + try { + port = parseWebPort(opts.port); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + return; + } + + try { + await (dependencies.startServer ?? startWebServer)({ + routes: createUiRoutes(), + port, + initialPath: "/", + title: "CodeDeck UI", + open: opts.open, + }); + } catch (error) { + console.error(`Failed to listen on 127.0.0.1:${port}: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } + }); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 820a345..7311051 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import { Command } from "commander"; -import { readFileSync } from "node:fs"; +import { readFileSync, realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; import path from "node:path"; @@ -22,6 +22,7 @@ import { registerProfileCommand } from "./commands/profile.js"; import { registerSetupCommand } from "./commands/setup.js"; import { registerUsageCommand } from "./commands/usage.js"; import { registerReviewCommand } from "./commands/review.js"; +import { registerUiCommand } from "./commands/ui.js"; import { getCliInvocation, getCliName } from "./cli-name.js"; function getVersion(): string { @@ -34,21 +35,22 @@ function getVersion(): string { } } -const program = new Command(); +export function createCliProgram(): Command { + const program = new Command(); -// CODEDECK_CLI_NAME renames the tool (for example a `codedeck-dev` -// alias), so the help below shows that name instead of `npx codedeck`. -const cliName = getCliName(); -const cli = getCliInvocation(); + // CODEDECK_CLI_NAME renames the tool (for example a `codedeck-dev` + // alias), so the help below shows that name instead of `npx codedeck`. + const cliName = getCliName(); + const cli = getCliInvocation(); -program - .name(cliName) - .description("CodeDeck — local runtime for coding agents\nManage Claude, Codex, OpenCode and OMP through a single session interface") - .version(getVersion()) - .helpOption("-h, --help", "display help for command") - .showHelpAfterError("(add --help for details)") - .showSuggestionAfterError(true) - .addHelpText("after", ` + program + .name(cliName) + .description("CodeDeck — local runtime for coding agents\nManage Claude, Codex, OpenCode and OMP through a single session interface") + .version(getVersion()) + .helpOption("-h, --help", "display help for command") + .showHelpAfterError("(add --help for details)") + .showSuggestionAfterError(true) + .addHelpText("after", ` Examples: $ ${cli} run "implement authentication" --agent claude --worktree $ ${cli} run "fix the tests" --agent codex --bg @@ -78,29 +80,44 @@ Run '${cli} --help' for command-specific options. Docs: https://github.com/4ndreello/run-agent `); -registerRunCommand(program); -registerPsCommand(program); -registerClaimsCommand(program); -registerShowCommand(program); -registerRenameCommand(program); -registerLogsCommand(program); -registerWaitCommand(program); -registerSendCommand(program); -registerStopCommand(program); -registerDoneCommand(program); -registerDiffCommand(program); -registerDoctorCommand(program); -registerModelsCommand(program); -registerOpenCommand(program); -registerProfileCommand(program); -registerSetupCommand(program); -registerUsageCommand(program); -registerReviewCommand(program); + registerRunCommand(program); + registerPsCommand(program); + registerClaimsCommand(program); + registerShowCommand(program); + registerRenameCommand(program); + registerLogsCommand(program); + registerWaitCommand(program); + registerSendCommand(program); + registerStopCommand(program); + registerDoneCommand(program); + registerDiffCommand(program); + registerDoctorCommand(program); + registerModelsCommand(program); + registerOpenCommand(program); + registerProfileCommand(program); + registerSetupCommand(program); + registerUsageCommand(program); + registerReviewCommand(program); + registerUiCommand(program); -// Make `codedeck help` behave like `codedeck --help` -program.command("help", { hidden: true }).action(() => program.outputHelp()); + // Make `codedeck help` behave like `codedeck --help`. + program.command("help", { hidden: true }).action(() => program.outputHelp()); + return program; +} -program.parseAsync(process.argv).catch((err) => { - console.error(err instanceof Error ? err.message : String(err)); - process.exit(1); -}); +function isCliEntryPoint(): boolean { + const entryPath = process.argv[1]; + if (!entryPath) return false; + try { + return realpathSync(entryPath) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return path.resolve(entryPath) === path.resolve(fileURLToPath(import.meta.url)); + } +} + +if (isCliEntryPoint()) { + createCliProgram().parseAsync(process.argv).catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + }); +} diff --git a/src/web/home-page.ts b/src/web/home-page.ts new file mode 100644 index 0000000..db3a0ee --- /dev/null +++ b/src/web/home-page.ts @@ -0,0 +1,54 @@ +export interface HomePageRoute { + label: string; + path: string; +} + +export function renderHomePage(routes: readonly HomePageRoute[]): string { + const links = routes + .map((route) => `
  • ${escapeHtml(route.label)}
  • `) + .join("\n"); + + return ` + + + + +CodeDeck + + + + +
    +

    CodeDeck

    +

    Choose a local console page.

    +
      ${links}
    +
    + +`; +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (character) => { + switch (character) { + case "&": return "&"; + case "<": return "<"; + case ">": return ">"; + case '"': return """; + case "'": return "'"; + default: return character; + } + }); +} diff --git a/src/web/security.ts b/src/web/security.ts new file mode 100644 index 0000000..fc8529f --- /dev/null +++ b/src/web/security.ts @@ -0,0 +1,112 @@ +import { randomBytes } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; + +export interface WebSecurity { + port: number; + token: string; + cookieName: string; +} + +export interface WebRoutePolicy { + htmlPage?: boolean; +} + +export const WEB_FORBIDDEN_MESSAGE = "forbidden"; + +export function createWebSecurity(port: number): WebSecurity { + const token = randomBytes(32).toString("hex"); + return { + port, + token, + cookieName: `codedeck_ui_token_${port}`, + }; +} + +export function isAllowedWebHost(host: string | undefined, port: number): boolean { + if (!host) return false; + const normalized = host.toLowerCase(); + return normalized === `127.0.0.1:${port}` || normalized === `localhost:${port}`; +} + +export function getTokenUrl(url: string, token: string): string { + const tokenUrl = new URL(url); + tokenUrl.searchParams.set("t", token); + return tokenUrl.toString(); +} + +export function checkWebRequest( + request: IncomingMessage, + response: ServerResponse, + security: WebSecurity, + policy: WebRoutePolicy = {}, +): boolean { + if (!isAllowedWebHost(request.headers.host, security.port)) { + reject(response); + return false; + } + + if (request.method === "POST" && !hasValidActionCredentials(request, security)) { + reject(response); + return false; + } + + if (policy.htmlPage) { + response.setHeader("Content-Security-Policy", "frame-ancestors 'none'"); + if (request.method === "GET" && redirectWithSessionCookie(request, response, security)) return false; + } + + return true; +} + +function hasValidActionCredentials(request: IncomingMessage, security: WebSecurity): boolean { + const cookie = request.headers.cookie + ?.split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(`${security.cookieName}=`)); + if (cookie?.slice(security.cookieName.length + 1) !== security.token) return false; + + const origin = request.headers.origin; + const host = request.headers.host; + if (!origin || !host) return false; + + try { + const parsedOrigin = new URL(origin); + return ( + parsedOrigin.protocol === "http:" && + parsedOrigin.host.toLowerCase() === host.toLowerCase() && + parsedOrigin.port === String(security.port) + ); + } catch { + return false; + } +} + +function redirectWithSessionCookie( + request: IncomingMessage, + response: ServerResponse, + security: WebSecurity, +): boolean { + let url: URL; + try { + url = new URL(request.url || "/", `http://127.0.0.1:${security.port}`); + } catch { + return false; + } + + const tokens = url.searchParams.getAll("t"); + if (tokens.length !== 1 || tokens[0] !== security.token) return false; + + url.searchParams.delete("t"); + response.setHeader( + "Set-Cookie", + `${security.cookieName}=${security.token}; Path=/; HttpOnly; SameSite=Strict`, + ); + response.writeHead(303, { Location: `${url.pathname}${url.search}` }); + response.end(); + return true; +} + +function reject(response: ServerResponse): void { + response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }); + response.end(WEB_FORBIDDEN_MESSAGE); +} diff --git a/src/web/server.ts b/src/web/server.ts new file mode 100644 index 0000000..d507c3f --- /dev/null +++ b/src/web/server.ts @@ -0,0 +1,202 @@ +import http, { type RequestListener, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { spawn } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { InvalidArgumentError } from "commander"; +import { checkWebRequest, createWebSecurity, getTokenUrl, isAllowedWebHost, type WebSecurity } from "./security.js"; + +export const DEFAULT_WEB_PORT = 3100; + +export interface WebRoute { + path: string; + handler: RequestListener; + kind: "page" | "api"; + label?: string; +} + +export interface WebServerOptions { + routes: readonly WebRoute[]; + port?: number; + initialPath: string; + title?: string; + open?: boolean; + openBrowser?: (url: string) => boolean | Promise; + log?: (message: string) => void; + serverFactory?: (handler: RequestListener) => Server; + signalTarget?: EventEmitter; + closeServer?: () => Promise | void; + exit?: (code: number) => void; +} + +export interface CreateWebServerOptions { + routes: readonly WebRoute[]; + getSecurity: () => WebSecurity | undefined; + serverFactory?: (handler: RequestListener) => Server; +} + +export interface WebServerHandle { + server: Server; + address: AddressInfo; + port: number; + baseUrl: string; + initialUrl: string; + security: WebSecurity; + close(): Promise; +} + +export function parseWebPort(raw: string | undefined): number { + if (raw === undefined) return DEFAULT_WEB_PORT; + if (!/^\d+$/.test(raw)) throw new InvalidArgumentError("--port must be a positive integer"); + const port = Number(raw); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new InvalidArgumentError("--port must be a positive integer"); + } + return port; +} + +export function openBrowser(url: string): Promise { + const opener = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "cmd" + : "xdg-open"; + const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; + return new Promise((resolve) => { + try { + const child = spawn(opener, args, { detached: true, stdio: "ignore" }); + child.once("spawn", () => { + child.unref(); + resolve(true); + }); + child.once("error", () => resolve(false)); + } catch { + resolve(false); + } + }); +} + +export function createWebServer(options: CreateWebServerOptions): Server { + const serverFactory = options.serverFactory ?? ((handler) => http.createServer({ requireHostHeader: false }, handler)); + return serverFactory((request, response) => { + const security = options.getSecurity(); + if (!security) { + response.writeHead(503, { "content-type": "text/plain; charset=utf-8" }); + response.end("server starting"); + return; + } + dispatchRequest(options.routes, security, request, response); + }); +} + +export async function startWebServer(options: WebServerOptions): Promise { + const requestedPort = options.port ?? DEFAULT_WEB_PORT; + let security: WebSecurity | undefined; + const server = createWebServer({ + routes: options.routes, + getSecurity: () => security, + serverFactory: options.serverFactory, + }); + + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("listening", onListening); + reject(error); + }; + const onListening = () => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(requestedPort, "127.0.0.1"); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + await new Promise((resolve) => server.close(() => resolve())); + throw new Error("Web server did not return a TCP address"); + } + + security = createWebSecurity(address.port); + const baseUrl = `http://127.0.0.1:${address.port}`; + const pageUrl = new URL(options.initialPath, baseUrl).toString(); + const initialUrl = getTokenUrl(pageUrl, security.token); + const log = options.log ?? ((message: string) => console.log(message)); + + if (options.open === false) { + log(`${options.title ?? "CodeDeck"} on ${initialUrl}`); + } else if (!(await (options.openBrowser ?? openBrowser)(initialUrl))) { + log(`Could not open a browser, visit ${initialUrl} manually.`); + } else { + log(`${options.title ?? "CodeDeck"} on ${initialUrl}`); + } + + const signalTarget = options.signalTarget ?? process; + const exit = options.exit ?? ((code: number) => process.exit(code)); + let signalClose: Promise | undefined; + let actualClose: Promise | undefined; + let shutdown = () => {}; + const close = (): Promise => { + signalTarget.off("SIGINT", shutdown); + signalTarget.off("SIGTERM", shutdown); + if (!actualClose) { + actualClose = new Promise((resolve) => server.close(() => resolve())); + } + return actualClose; + }; + shutdown = () => { + signalTarget.off("SIGINT", shutdown); + signalTarget.off("SIGTERM", shutdown); + if (!signalClose) { + signalClose = options.closeServer + ? Promise.resolve(options.closeServer()) + : close(); + void signalClose.then(() => exit(0), () => exit(0)); + } + }; + signalTarget.on("SIGINT", shutdown); + signalTarget.on("SIGTERM", shutdown); + + return { + server, + address, + port: address.port, + baseUrl, + initialUrl, + security, + close, + }; +} + +function dispatchRequest( + routes: readonly WebRoute[], + security: WebSecurity, + request: http.IncomingMessage, + response: http.ServerResponse, +): void { + if (!isAllowedWebHost(request.headers.host, security.port)) { + response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }); + response.end("forbidden"); + return; + } + + let pathname: string; + try { + pathname = new URL(request.url || "/", `http://127.0.0.1:${security.port}`).pathname; + } catch { + response.writeHead(400, { "content-type": "text/plain; charset=utf-8" }); + response.end("bad request"); + return; + } + + const route = routes.find((candidate) => candidate.path === pathname); + if (!checkWebRequest(request, response, security, { htmlPage: route?.kind === "page" })) return; + if (!route) { + response.writeHead(404, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify({ error: "not found" })); + return; + } + + route.handler(request, response); +} diff --git a/tests/review-command.test.ts b/tests/review-command.test.ts index eda5363..d15014c 100644 --- a/tests/review-command.test.ts +++ b/tests/review-command.test.ts @@ -1,6 +1,10 @@ import { Command } from "commander"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { parseReviewPort, registerReviewCommand } from "../src/cli/commands/review.js"; +import { startWebServer, type WebServerHandle } from "../src/web/server.js"; +import { EventEmitter } from "node:events"; + +afterEach(() => vi.restoreAllMocks()); describe("parseReviewPort", () => { it("defaults to 3100", () => { @@ -26,4 +30,39 @@ describe("registerReviewCommand", () => { expect(program.commands.map((command) => command.name())).toEqual(["review"]); expect(program.commands[0].description()).toContain("local review"); }); + + it("starts the shared server with the review aliases and API route", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const program = new Command(); + let started: WebServerHandle | undefined; + registerReviewCommand(program, { + startServer: async (options) => { + started = await startWebServer({ + ...options, + port: 0, + signalTarget: new EventEmitter(), + exit: () => {}, + }); + return started; + }, + loadReview: async (_root, ref, file) => ({ ref, file }), + }); + + await program.parseAsync(["node", "codedeck", "review", "--no-open"], { from: "node" }); + + expect(started?.initialUrl).toContain("?t="); + expect(log.mock.calls.flat().join(" ")).toContain(started?.initialUrl); + const root = await fetch(`${started?.baseUrl}/`); + const alias = await fetch(`${started?.baseUrl}/review`); + expect(root.status).toBe(200); + expect(alias.status).toBe(200); + expect(await root.text()).toContain("Review local"); + expect(await alias.text()).toContain("Review local"); + + const api = await fetch(`${started?.baseUrl}/api/review?file=src/web/server.ts`); + expect(api.status).toBe(200); + expect(await api.json()).toEqual({ ref: "HEAD", file: "src/web/server.ts" }); + + await started?.close(); + }); }); diff --git a/tests/web-cli.test.ts b/tests/web-cli.test.ts new file mode 100644 index 0000000..8aced0b --- /dev/null +++ b/tests/web-cli.test.ts @@ -0,0 +1,84 @@ +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Command } from "commander"; +import { createCliProgram } from "../src/cli/index.js"; +import { registerUiCommand } from "../src/cli/commands/ui.js"; +import { startWebServer, type WebServerHandle } from "../src/web/server.js"; + +const handles: WebServerHandle[] = []; +const originalExitCode = process.exitCode; + +afterEach(async () => { + await Promise.all(handles.splice(0).map((handle) => handle.close())); + process.exitCode = originalExitCode; + vi.restoreAllMocks(); +}); + +describe("ui CLI command", () => { + it("appears in root help and serves only its registered home and review pages", async () => { + const root = createCliProgram(); + expect(root.helpInformation()).toContain("ui"); + + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + let started: WebServerHandle | undefined; + const program = new Command(); + registerUiCommand(program, { + startServer: async (options) => { + started = await startWebServer({ + ...options, + port: 0, + signalTarget: new EventEmitter(), + exit: vi.fn(), + }); + handles.push(started); + return started; + }, + }); + await program.parseAsync(["node", "codedeck", "ui", "--no-open"], { from: "node" }); + + expect(started).toBeDefined(); + expect(started?.initialUrl).toContain("?t="); + expect(log.mock.calls.flat().join(" ")).toContain(started?.initialUrl); + const rootResponse = await fetch(`${started?.baseUrl}/`); + const rootHtml = await rootResponse.text(); + expect(rootResponse.status).toBe(200); + expect(rootHtml).toContain('href="/review"'); + expect(rootHtml).not.toContain('href="/setup"'); + expect(rootHtml).not.toContain('href="/usage"'); + + const reviewResponse = await fetch(`${started?.baseUrl}/review`); + expect(reviewResponse.status).toBe(200); + expect(reviewResponse.headers.get("content-security-policy")).toBe("frame-ancestors 'none'"); + expect(await reviewResponse.text()).toContain("Review local"); + }); + + it("rejects an invalid port without starting a server", async () => { + const startServer = vi.fn(); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const program = new Command(); + registerUiCommand(program, { startServer }); + + await program.parseAsync(["node", "codedeck", "ui", "--port", "0"], { from: "node" }); + + expect(startServer).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith("--port must be a positive integer"); + expect(process.exitCode).toBe(1); + }); + + it("reports a listen failure without printing a started URL", async () => { + const startServer = vi.fn(async () => { + throw new Error("EADDRINUSE"); + }); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const program = new Command(); + registerUiCommand(program, { startServer }); + + await program.parseAsync(["node", "codedeck", "ui", "--no-open"], { from: "node" }); + + expect(startServer).toHaveBeenCalledOnce(); + expect(error).toHaveBeenCalledWith("Failed to listen on 127.0.0.1:3100: EADDRINUSE"); + expect(log).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/tests/web-pages.test.ts b/tests/web-pages.test.ts new file mode 100644 index 0000000..bf7c982 --- /dev/null +++ b/tests/web-pages.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { renderHomePage } from "../src/web/home-page.js"; + +describe("renderHomePage", () => { + it("links only the pages registered by the active route table", () => { + const html = renderHomePage([{ label: "Review", path: "/review" }]); + + expect(html).toContain('href="/review"'); + expect(html).toContain("Review"); + expect(html).not.toContain('href="/usage"'); + expect(html).not.toContain('href="/setup"'); + expect(html).toContain("