import { describe, expect, test } from "bun:test";import { spawnSync } from "node:child_process";import { createServer } from "node:net";import { chmodSync, existsSync, lstatSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync,} from "node:fs";import { homedir, platform, tmpdir } from "node:os";import { join, sep } from "node:path";import { cleanupIsolatedTestHome, createIsolatedTestHome, FX_BIN, HAS_API_KEY, REPO_ROOT, runFx,} from "../evals/eval-helpers";import { FAKE_GATEWAY_MODEL, fakeGatewayFinalText, startFakeGateway,} from "./tmux-helpers"; const TIMEOUT = 15_000;const NO_GATEWAY_AUTH = { AI_GATEWAY_API_KEY: undefined, VERCEL_OIDC_TOKEN: undefined,};const MISSING_AUTH_MESSAGE = "Fx needs access to Vercel AI Gateway. Run fx login to sign in, fx setup to use an API key, or set AI_GATEWAY_API_KEY."; const KEYCHAIN_SERVICE = "FX_AI_GATEWAY_API_KEY"; function maxLineWidth(text: string): number { return Math.max(...text.split(/\r?\n/).map((line) => Bun.stringWidth(line)));} function sourceVersion(): string { const source = readFileSync(join(REPO_ROOT, "src/main.zig"), "utf8"); const match = source.match(/pub const version = "([^"]+)";/); if (!match) throw new Error("src/main.zig version declaration not found"); return match[1];} function doctorSessionDiagnosticsLimit(): number { const source = readFileSync( join(REPO_ROOT, "src/core/cli/doctor_runtime.zig"), "utf8", ); const match = source.match(/const default_session_diagnostics_limit: usize = (\d+);/); if (!match) throw new Error("doctor session diagnostics limit not found"); return Number(match[1]);} const SEEDED_GATEWAY_TOKEN = "seeded-access-token"; function writeSeededFxAuth( home: string, teamId?: string, issuer = "https://vercel.com", expiresAtMs = Date.now() + 60 * 60 * 1000,): void { const fxDir = join(home, ".fx"); mkdirSync(fxDir, { recursive: true, mode: 0o700 }); chmodSync(fxDir, 0o700); const authPath = join(fxDir, "auth.json"); const auth: Record<string, string | number> = { version: 1, issuer, client_id: "test-client", access_token: SEEDED_GATEWAY_TOKEN, refresh_token: "seeded-refresh-token", expires_at_ms: expiresAtMs, scope: "openid", token_type: "Bearer", }; if (teamId) { auth.team_id = teamId; auth.team_slug = "vercel-labs"; } writeFileSync(authPath, JSON.stringify(auth) + "\n", { mode: 0o600 }); chmodSync(authPath, 0o600);} function startRequestCatcher() { const requests: Array<{ method: string; path: string }> = []; const server = Bun.serve({ hostname: "0.0.0.0", port: 0, fetch(request) { const url = new URL(request.url); requests.push({ method: request.method, path: url.pathname }); return Response.json({ revoked: true }); }, }); return { issuerUrl: `http://127.0.0.1:${server.port}`, endpoint: `http://localhost.:${server.port}/oauth/revoke`, requests, stop() { server.stop(true); }, };} function startLogoutIssuer( revokeStatuses: number[], authPath?: string, revocationEndpoint?: string | null,) { const providerDetail = `provider rejected ${SEEDED_GATEWAY_TOKEN} and seeded-refresh-token`; const requests: Array<{ method: string; path: string; tokenTypeHint?: string; validForm?: boolean; localSessionPresent?: boolean; }> = []; let revokeAttempt = 0; const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { const url = new URL(request.url); const issuerUrl = `http://127.0.0.1:${server.port}`; if (url.pathname === "/.well-known/openid-configuration") { requests.push({ method: request.method, path: url.pathname }); return Response.json({ issuer: issuerUrl, device_authorization_endpoint: `${issuerUrl}/oauth/device`, token_endpoint: `${issuerUrl}/oauth/token`, ...(revocationEndpoint === null ? {} : { revocation_endpoint: revocationEndpoint ?? `${issuerUrl}/oauth/revoke`, }), }); } if (url.pathname === "/oauth/revoke") { const form = await request.formData(); const tokenTypeHint = form.get("token_type_hint"); const expectedToken = tokenTypeHint === "refresh_token" ? "seeded-refresh-token" : tokenTypeHint === "access_token" ? SEEDED_GATEWAY_TOKEN : null; const validForm = form.get("client_id") === "test-client" && expectedToken !== null && form.get("token") === expectedToken; requests.push({ method: request.method, path: url.pathname, tokenTypeHint: typeof tokenTypeHint === "string" ? tokenTypeHint : "missing", validForm, ...(authPath ? { localSessionPresent: existsSync(authPath) } : {}), }); const configuredStatus = revokeStatuses[revokeAttempt] ?? 200; revokeAttempt += 1; const revokeStatus = validForm ? configuredStatus : 400; return Response.json( revokeStatus >= 400 ? { error: providerDetail } : { revoked: true }, { status: revokeStatus }, ); } return new Response("not found", { status: 404 }); }, }); return { issuerUrl: `http://127.0.0.1:${server.port}`, providerDetail, requests, stop() { server.stop(true); }, };} function snapshotTree(root: string): string[] { const entries: string[] = []; const visit = (path: string, relative: string): void => { const info = lstatSync(path); entries.push( `${relative}|${info.isDirectory() ? "dir" : "file"}|${info.mode & 0o777}|${info.size}`, ); if (!info.isDirectory()) return; for (const name of readdirSync(path).sort()) { visit(join(path, name), relative ? join(relative, name) : name); } }; visit(root, ""); return entries;} function writeLegacySession( home: string, workspaceRoot: string, sessionId: string, opts: { createdAtMs?: number; updatedAtMs?: number; historyLen?: number; } = {},): void { const sessionDir = join(home, ".fx", "sessions", sessionId); mkdirSync(sessionDir, { recursive: true, mode: 0o700 }); chmodSync(join(home, ".fx"), 0o700); chmodSync(join(home, ".fx", "sessions"), 0o700); chmodSync(sessionDir, 0o700); const historyLen = opts.historyLen ?? 0; writeFileSync( join(sessionDir, "session.json"), JSON.stringify({ schema_version: 2, id: sessionId, created_at_ms: opts.createdAtMs ?? 1, updated_at_ms: opts.updatedAtMs ?? 2, workspace_root: workspaceRoot, conversation_language: "en", history_len: historyLen, history: historyLen > 0 ? [{ role: "user", content: "saved" }] : [], total_input_tokens: 0, total_output_tokens: 0, }) + "\n", { mode: 0o600 }, );} describe("cli: help", () => { test( "fx help exits 0 and renders the complete navigation page", async () => { const r = await runFx(["help"]); expect(r.code).toBe(0); expect(r.stderr).toBe(""); expect(r.stdout).not.toContain("\x1b["); expect(r.stdout).toStartWith( `𝒇x v${sourceVersion()}\nFast, native coding agent for the terminal.\n`, ); expect(r.stdout).toContain("Commands:\n"); expect(r.stdout).toContain("Run one noninteractive request"); expect(r.stdout).toContain("credits|balance"); expect(r.stdout).toContain("Flags:\n"); expect(r.stdout).toContain("--context-limit <spec>"); expect(r.stdout).toContain("Set name=bytes|off; repeatable"); expect(r.stdout).toContain("--add-dir <path>"); expect(r.stdout).toContain("-c, --continue"); expect(r.stdout).toContain("-r"); expect(r.stdout).toContain("Open the saved-session picker"); expect(r.stdout).not.toContain("-c, -r, --continue"); expect(r.stdout).toContain("--resume [last|<id>]"); expect(r.stdout).toContain("--resume-last"); expect(r.stdout).toContain("-v, --version"); expect(r.stdout).not.toContain("Must appear before the command"); expect(r.stdout).toContain("Examples:\n"); expect(r.stdout).toContain("https://fx.sh/docs"); expect(r.stdout).toContain("run `/feedback` inside 𝒇x"); expect(r.stdout).not.toContain(" Work "); expect(r.stdout).not.toContain("\n\n\nRun `fx <command> --help`"); }, TIMEOUT, ); test( "fx --help exits 0", async () => { const r = await runFx(["--help"]); expect(r.code).toBe(0); expect(r.stdout).toContain("ask"); }, TIMEOUT, ); test( "fx -h exits 0", async () => { const r = await runFx(["-h"]); expect(r.code).toBe(0); expect(r.stdout).toContain("ask"); }, TIMEOUT, ); test( "fx ask help renders documented options through both aliases", async () => { const env = { ...NO_GATEWAY_AUTH, FX_DISABLE_KEYCHAIN: "1", }; const expected = `fx ask Run one noninteractive request Usage: fx ask [--auto|--yolo] [--image PATH] [--json] [--no-save] [--no-color] [--resume <last|id>|--resume-id <id>] [--continue-recovery] [--] <prompt> Options: --auto Automatically review unresolved permission requests --yolo Disable permission checks and command sandboxing --image PATH Attach an image file; repeat for multiple images --json Emit machine-readable JSON instead of text --no-save Do not save the session; incompatible with --resume and --resume-id --no-color Render TTY output without colors or hyperlinks --resume <last|id> Continue the last session or a session by id --resume-id <id> Continue a session by exact id --continue-recovery Resume the paused model response in the selected session -- Treat every following argument as prompt text The prompt may be passed as arguments or piped on stdin when no prompt args are given.TTY stdout uses the Minimal transcript presentation; redirected stdout emits raw assistant Markdown.Operational progress and diagnostics are written to stderr. JSON output keeps raw Markdown in \`output\`.`; for (const alias of ["--help", "-h"]) { const result = await runFx(["ask", alias], { env }); expect(result.code).toBe(0); expect(result.stderr).toBe(""); expect(result.stdout).toBe(expected); } }, TIMEOUT, ); test( "fx acp help documents accepted options", async () => { for (const alias of ["--help", "-h"]) { const r = await runFx(["acp", alias]); expect(r.code).toBe(0); expect(r.stderr).toBe(""); expect(r.stdout).toContain( "Usage:\n fx acp [--model <id>] [--log-file <path>]", ); expect(r.stdout).toContain("--model <id>"); expect(r.stdout).toContain("--log-file <path>"); } }, TIMEOUT, ); test( "fx replay help describes golden output", async () => { const r = await runFx(["replay", "--help"]); expect(r.code).toBe(0); expect(r.stderr).toBe(""); expect(r.stdout).toContain("--golden <path>"); expect(r.stdout).toContain("Write the final rendered grid to a file"); expect(r.stdout).not.toContain("Compare output against a golden file"); }, TIMEOUT, ); test( "fx acp rejects unknown options and missing option values", async () => { for (const args of [["--bogus"], ["--model"], ["--log-file"]]) { const result = await runFx(["acp", ...args]); expect(result.code).toBe(1); expect(result.stdout).toBe(""); expect(result.stderr).toBe( "usage: fx acp [--model <id>] [--log-file <path>]\n", ); } }, TIMEOUT, ); for (const alias of ["help", "--help", "-h"]) { test( `fx ${alias} respects COLUMNS=60`, async () => { const r = await runFx([alias], { env: { COLUMNS: "60" } }); expect(r.code).toBe(0); expect(r.stderr).toBe(""); expect(r.stdout).toContain("Commands:"); expect(r.stdout).toContain("ask"); expect(r.stdout).toContain("setup"); expect(r.stdout).toContain("status"); expect(r.stdout).toContain("doctor"); expect(maxLineWidth(r.stdout)).toBeLessThanOrEqual(60); }, TIMEOUT, ); } for (const alias of ["help", "--help", "-h"]) { test( `fx ${alias} --record rejects the interactive-only modifier`, async () => { const r = await runFx([alias, "--record"]); expect(r.code).not.toBe(0); expect(r.stderr).toContain( "usage: fx --record is only supported for interactive startup", ); }, TIMEOUT, ); }}); describe("cli: version", () => { for (const alias of ["--version", "-v"]) { test( `fx ${alias} prints the source version`, async () => { const r = await runFx([alias]); expect(r.code).toBe(0); expect(r.stdout).toBe(`${sourceVersion()}\n`); expect(r.stderr).toBe(""); }, TIMEOUT, ); }}); describe("cli: status", () => { test( "status and doctor expose the MCP profile error that blocks ask startup", async () => { const root = mkdtempSync(join(tmpdir(), "fx-e2e-mcp-config-diagnostic-")); const home = join(root, "home"); const workspace = join(root, "workspace"); const fxDir = join(home, ".fx"); mkdirSync(fxDir, { recursive: true, mode: 0o700 }); mkdirSync(workspace); writeFileSync(join(fxDir, "mcp.json"), "{invalid json", { mode: 0o600 }); const gateway = startFakeGateway([]); try { const env = { HOME: realpathSync(home), AI_GATEWAY_API_KEY: "mcp-config-diagnostic-key", VERCEL_OIDC_TOKEN: undefined, FX_DISABLE_KEYCHAIN: "1", FX_AUTO_UPGRADE: "0", FX_MODEL: FAKE_GATEWAY_MODEL, FX_E2E_GATEWAY_CHAT_URL: gateway.chatUrl, }; const cwd = realpathSync(workspace); const before = snapshotTree(home); const statusText = await runFx(["status"], { cwd, env }); const statusJsonResult = await runFx(["status", "--json"], { cwd, env }); const doctorText = await runFx(["doctor"], { cwd, env }); const doctorJsonResult = await runFx(["doctor", "--json"], { cwd, env }); const ask = await runFx( ["ask", "--json", "--no-save", "Do nothing."], { cwd, env }, ); for (const result of [statusText, statusJsonResult, doctorText, doctorJsonResult]) { expect(result.code).toBe(0); expect(result.stderr).toBe(""); } expect(statusText.stdout).toContain( "[status] mcp_config_error=McpConfigInvalidJson\n", ); expect(JSON.parse(statusJsonResult.stdout)).toMatchObject({ kind: "status", mcp_config_error: "McpConfigInvalidJson", }); expect(doctorText.stdout).toContain( "[fail] mcp_config: failed to load ~/.fx/mcp.json: McpConfigInvalidJson\n", ); const doctorJson = JSON.parse(doctorJsonResult.stdout); expect(doctorJson.fail_count).toBe(1); expect( doctorJson.checks.filter( (check: { name: string }) => check.name === "mcp_config", ), ).toEqual([ { name: "mcp_config", status: "fail", detail: "failed to load ~/.fx/mcp.json: McpConfigInvalidJson", }, ]); expect(ask.code).toBe(1); expect(ask.stderr).toBe(""); expect(JSON.parse(ask.stdout)).toMatchObject({ exit_code: 1, error: "McpConfigInvalidJson", });Lines 1–500 of 4616. Load more
import { describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { createServer } from "node:net"; import { chmodSync, existsSync, lstatSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync, } from "node:fs"; import { homedir, platform, tmpdir } from "node:os"; import { join, sep } from "node:path"; import { cleanupIsolatedTestHome, createIsolatedTestHome, FX_BIN, HAS_API_KEY, REPO_ROOT, runFx, } from "../evals/eval-helpers"; import { FAKE_GATEWAY_MODEL, fakeGatewayFinalText, startFakeGateway, } from "./tmux-helpers"; const TIMEOUT = 15_000; const NO_GATEWAY_AUTH = { AI_GATEWAY_API_KEY: undefined, VERCEL_OIDC_TOKEN: undefined, }; const MISSING_AUTH_MESSAGE = "Fx needs access to Vercel AI Gateway. Run fx login to sign in, fx setup to use an API key, or set AI_GATEWAY_API_KEY."; const KEYCHAIN_SERVICE = "FX_AI_GATEWAY_API_KEY"; function maxLineWidth(text: string): number { return Math.max(...text.split(/\r?\n/).map((line) => Bun.stringWidth(line))); } function sourceVersion(): string { const source = readFileSync(join(REPO_ROOT, "src/main.zig"), "utf8"); const match = source.match(/pub const version = "([^"]+)";/); if (!match) throw new Error("src/main.zig version declaration not found"); return match[1]; } function doctorSessionDiagnosticsLimit(): number { const source = readFileSync( join(REPO_ROOT, "src/core/cli/doctor_runtime.zig"), "utf8", ); const match = source.match(/const default_session_diagnostics_limit: usize = (\d+);/); if (!match) throw new Error("doctor session diagnostics limit not found"); return Number(match[1]); } const SEEDED_GATEWAY_TOKEN = "seeded-access-token"; function writeSeededFxAuth( home: string, teamId?: string, issuer = "https://vercel.com", expiresAtMs = Date.now() + 60 * 60 * 1000, ): void { const fxDir = join(home, ".fx"); mkdirSync(fxDir, { recursive: true, mode: 0o700 }); chmodSync(fxDir, 0o700); const authPath = join(fxDir, "auth.json"); const auth: Record<string, string | number> = { version: 1, issuer, client_id: "test-client", access_token: SEEDED_GATEWAY_TOKEN, refresh_token: "seeded-refresh-token", expires_at_ms: expiresAtMs, scope: "openid", token_type: "Bearer", }; if (teamId) { auth.team_id = teamId; auth.team_slug = "vercel-labs"; } writeFileSync(authPath, JSON.stringify(auth) + "\n", { mode: 0o600 }); chmodSync(authPath, 0o600); } function startRequestCatcher() { const requests: Array<{ method: string; path: string }> = []; const server = Bun.serve({ hostname: "0.0.0.0", port: 0, fetch(request) { const url = new URL(request.url); requests.push({ method: request.method, path: url.pathname }); return Response.json({ revoked: true }); }, }); return { issuerUrl: `http://127.0.0.1:${server.port}`, endpoint: `http://localhost.:${server.port}/oauth/revoke`, requests, stop() { server.stop(true); }, }; } function startLogoutIssuer( revokeStatuses: number[], authPath?: string, revocationEndpoint?: string | null, ) { const providerDetail = `provider rejected ${SEEDED_GATEWAY_TOKEN} and seeded-refresh-token`; const requests: Array<{ method: string; path: string; tokenTypeHint?: string; validForm?: boolean; localSessionPresent?: boolean; }> = []; let revokeAttempt = 0; const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { const url = new URL(request.url); const issuerUrl = `http://127.0.0.1:${server.port}`; if (url.pathname === "/.well-known/openid-configuration") { requests.push({ method: request.method, path: url.pathname }); return Response.json({ issuer: issuerUrl, device_authorization_endpoint: `${issuerUrl}/oauth/device`, token_endpoint: `${issuerUrl}/oauth/token`, ...(revocationEndpoint === null ? {} : { revocation_endpoint: revocationEndpoint ?? `${issuerUrl}/oauth/revoke`, }), }); } if (url.pathname === "/oauth/revoke") { const form = await request.formData(); const tokenTypeHint = form.get("token_type_hint"); const expectedToken = tokenTypeHint === "refresh_token" ? "seeded-refresh-token" : tokenTypeHint === "access_token" ? SEEDED_GATEWAY_TOKEN : null; const validForm = form.get("client_id") === "test-client" && expectedToken !== null && form.get("token") === expectedToken; requests.push({ method: request.method, path: url.pathname, tokenTypeHint: typeof tokenTypeHint === "string" ? tokenTypeHint : "missing", validForm, ...(authPath ? { localSessionPresent: existsSync(authPath) } : {}), }); const configuredStatus = revokeStatuses[revokeAttempt] ?? 200; revokeAttempt += 1; const revokeStatus = validForm ? configuredStatus : 400; return Response.json( revokeStatus >= 400 ? { error: providerDetail } : { revoked: true }, { status: revokeStatus }, ); } return new Response("not found", { status: 404 }); }, }); return { issuerUrl: `http://127.0.0.1:${server.port}`, providerDetail, requests, stop() { server.stop(true); }, }; } function snapshotTree(root: string): string[] { const entries: string[] = []; const visit = (path: string, relative: string): void => { const info = lstatSync(path); entries.push( `${relative}|${info.isDirectory() ? "dir" : "file"}|${info.mode & 0o777}|${info.size}`, ); if (!info.isDirectory()) return; for (const name of readdirSync(path).sort()) { visit(join(path, name), relative ? join(relative, name) : name); } }; visit(root, ""); return entries; } function writeLegacySession( home: string, workspaceRoot: string, sessionId: string, opts: { createdAtMs?: number; updatedAtMs?: number; historyLen?: number; } = {}, ): void { const sessionDir = join(home, ".fx", "sessions", sessionId); mkdirSync(sessionDir, { recursive: true, mode: 0o700 }); chmodSync(join(home, ".fx"), 0o700); chmodSync(join(home, ".fx", "sessions"), 0o700); chmodSync(sessionDir, 0o700); const historyLen = opts.historyLen ?? 0; writeFileSync( join(sessionDir, "session.json"), JSON.stringify({ schema_version: 2, id: sessionId, created_at_ms: opts.createdAtMs ?? 1, updated_at_ms: opts.updatedAtMs ?? 2, workspace_root: workspaceRoot, conversation_language: "en", history_len: historyLen, history: historyLen > 0 ? [{ role: "user", content: "saved" }] : [], total_input_tokens: 0, total_output_tokens: 0, }) + "\n", { mode: 0o600 }, ); } describe("cli: help", () => { test( "fx help exits 0 and renders the complete navigation page", async () => { const r = await runFx(["help"]); expect(r.code).toBe(0); expect(r.stderr).toBe(""); expect(r.stdout).not.toContain("\x1b["); expect(r.stdout).toStartWith( `𝒇x v${sourceVersion()}\nFast, native coding agent for the terminal.\n`, ); expect(r.stdout).toContain("Commands:\n"); expect(r.stdout).toContain("Run one noninteractive request"); expect(r.stdout).toContain("credits|balance"); expect(r.stdout).toContain("Flags:\n"); expect(r.stdout).toContain("--context-limit <spec>"); expect(r.stdout).toContain("Set name=bytes|off; repeatable"); expect(r.stdout).toContain("--add-dir <path>"); expect(r.stdout).toContain("-c, --continue"); expect(r.stdout).toContain("-r"); expect(r.stdout).toContain("Open the saved-session picker"); expect(r.stdout).not.toContain("-c, -r, --continue"); expect(r.stdout).toContain("--resume [last|<id>]"); expect(r.stdout).toContain("--resume-last"); expect(r.stdout).toContain("-v, --version"); expect(r.stdout).not.toContain("Must appear before the command"); expect(r.stdout).toContain("Examples:\n"); expect(r.stdout).toContain("https://fx.sh/docs"); expect(r.stdout).toContain("run `/feedback` inside 𝒇x"); expect(r.stdout).not.toContain(" Work "); expect(r.stdout).not.toContain("\n\n\nRun `fx <command> --help`"); }, TIMEOUT, ); test( "fx --help exits 0", async () => { const r = await runFx(["--help"]); expect(r.code).toBe(0); expect(r.stdout).toContain("ask"); }, TIMEOUT, ); test( "fx -h exits 0", async () => { const r = await runFx(["-h"]); expect(r.code).toBe(0); expect(r.stdout).toContain("ask"); }, TIMEOUT, ); test( "fx ask help renders documented options through both aliases", async () => { const env = { ...NO_GATEWAY_AUTH, FX_DISABLE_KEYCHAIN: "1", }; const expected = `fx ask Run one noninteractive request Usage: fx ask [--auto|--yolo] [--image PATH] [--json] [--no-save] [--no-color] [--resume <last|id>|--resume-id <id>] [--continue-recovery] [--] <prompt> Options: --auto Automatically review unresolved permission requests --yolo Disable permission checks and command sandboxing --image PATH Attach an image file; repeat for multiple images --json Emit machine-readable JSON instead of text --no-save Do not save the session; incompatible with --resume and --resume-id --no-color Render TTY output without colors or hyperlinks --resume <last|id> Continue the last session or a session by id --resume-id <id> Continue a session by exact id --continue-recovery Resume the paused model response in the selected session -- Treat every following argument as prompt text The prompt may be passed as arguments or piped on stdin when no prompt args are given. TTY stdout uses the Minimal transcript presentation; redirected stdout emits raw assistant Markdown. Operational progress and diagnostics are written to stderr. JSON output keeps raw Markdown in \`output\`. `; for (const alias of ["--help", "-h"]) { const result = await runFx(["ask", alias], { env }); expect(result.code).toBe(0); expect(result.stderr).toBe(""); expect(result.stdout).toBe(expected); } }, TIMEOUT, ); test( "fx acp help documents accepted options", async () => { for (const alias of ["--help", "-h"]) { const r = await runFx(["acp", alias]); expect(r.code).toBe(0); expect(r.stderr).toBe(""); expect(r.stdout).toContain( "Usage:\n fx acp [--model <id>] [--log-file <path>]", ); expect(r.stdout).toContain("--model <id>"); expect(r.stdout).toContain("--log-file <path>"); } }, TIMEOUT, ); test( "fx replay help describes golden output", async () => { const r = await runFx(["replay", "--help"]); expect(r.code).toBe(0); expect(r.stderr).toBe(""); expect(r.stdout).toContain("--golden <path>"); expect(r.stdout).toContain("Write the final rendered grid to a file"); expect(r.stdout).not.toContain("Compare output against a golden file"); }, TIMEOUT, ); test( "fx acp rejects unknown options and missing option values", async () => { for (const args of [["--bogus"], ["--model"], ["--log-file"]]) { const result = await runFx(["acp", ...args]); expect(result.code).toBe(1); expect(result.stdout).toBe(""); expect(result.stderr).toBe( "usage: fx acp [--model <id>] [--log-file <path>]\n", ); } }, TIMEOUT, ); for (const alias of ["help", "--help", "-h"]) { test( `fx ${alias} respects COLUMNS=60`, async () => { const r = await runFx([alias], { env: { COLUMNS: "60" } }); expect(r.code).toBe(0); expect(r.stderr).toBe(""); expect(r.stdout).toContain("Commands:"); expect(r.stdout).toContain("ask"); expect(r.stdout).toContain("setup"); expect(r.stdout).toContain("status"); expect(r.stdout).toContain("doctor"); expect(maxLineWidth(r.stdout)).toBeLessThanOrEqual(60); }, TIMEOUT, ); } for (const alias of ["help", "--help", "-h"]) { test( `fx ${alias} --record rejects the interactive-only modifier`, async () => { const r = await runFx([alias, "--record"]); expect(r.code).not.toBe(0); expect(r.stderr).toContain( "usage: fx --record is only supported for interactive startup", ); }, TIMEOUT, ); } }); describe("cli: version", () => { for (const alias of ["--version", "-v"]) { test( `fx ${alias} prints the source version`, async () => { const r = await runFx([alias]); expect(r.code).toBe(0); expect(r.stdout).toBe(`${sourceVersion()}\n`); expect(r.stderr).toBe(""); }, TIMEOUT, ); } }); describe("cli: status", () => { test( "status and doctor expose the MCP profile error that blocks ask startup", async () => { const root = mkdtempSync(join(tmpdir(), "fx-e2e-mcp-config-diagnostic-")); const home = join(root, "home"); const workspace = join(root, "workspace"); const fxDir = join(home, ".fx"); mkdirSync(fxDir, { recursive: true, mode: 0o700 }); mkdirSync(workspace); writeFileSync(join(fxDir, "mcp.json"), "{invalid json", { mode: 0o600 }); const gateway = startFakeGateway([]); try { const env = { HOME: realpathSync(home), AI_GATEWAY_API_KEY: "mcp-config-diagnostic-key", VERCEL_OIDC_TOKEN: undefined, FX_DISABLE_KEYCHAIN: "1", FX_AUTO_UPGRADE: "0", FX_MODEL: FAKE_GATEWAY_MODEL, FX_E2E_GATEWAY_CHAT_URL: gateway.chatUrl, }; const cwd = realpathSync(workspace); const before = snapshotTree(home); const statusText = await runFx(["status"], { cwd, env }); const statusJsonResult = await runFx(["status", "--json"], { cwd, env }); const doctorText = await runFx(["doctor"], { cwd, env }); const doctorJsonResult = await runFx(["doctor", "--json"], { cwd, env }); const ask = await runFx( ["ask", "--json", "--no-save", "Do nothing."], { cwd, env }, ); for (const result of [statusText, statusJsonResult, doctorText, doctorJsonResult]) { expect(result.code).toBe(0); expect(result.stderr).toBe(""); } expect(statusText.stdout).toContain( "[status] mcp_config_error=McpConfigInvalidJson\n", ); expect(JSON.parse(statusJsonResult.stdout)).toMatchObject({ kind: "status", mcp_config_error: "McpConfigInvalidJson", }); expect(doctorText.stdout).toContain( "[fail] mcp_config: failed to load ~/.fx/mcp.json: McpConfigInvalidJson\n", ); const doctorJson = JSON.parse(doctorJsonResult.stdout); expect(doctorJson.fail_count).toBe(1); expect( doctorJson.checks.filter( (check: { name: string }) => check.name === "mcp_config", ), ).toEqual([ { name: "mcp_config", status: "fail", detail: "failed to load ~/.fx/mcp.json: McpConfigInvalidJson", }, ]); expect(ask.code).toBe(1); expect(ask.stderr).toBe(""); expect(JSON.parse(ask.stdout)).toMatchObject({ exit_code: 1, error: "McpConfigInvalidJson", });