4616 lines · 149.9 KB · TypeScript
RawDownload
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.

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t