From 95c23565e7929ff2b1750c84b4633f05eba50593 Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Mon, 4 May 2026 19:03:30 -0700 Subject: [PATCH 1/2] test(e2e): spawn the real CLI binary in tests, add smoke coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the second "honest follow-up" flagged in #115: replace the fully mocked `flows/cli-push-flow.test.ts` with tests that exercise the real binary. This PR ships the spawn-helper infrastructure plus six smoke tests; full push-flow e2e (which needs ccusage stubbing or a real local stack) is deferred to a follow-up. What's new: - packages/cli/__tests__/e2e/spawn.ts — helper that spawns `node dist/index.js` with a tmpdir HOME, controlled env, and captured stdout/stderr/exit. No mocks of fs, env, or fetch. - packages/cli/__tests__/e2e/cli-smoke.e2e.test.ts — six smoke cases: --help / -h alias / --version / -v alias / unknown-command exit / --version is a side-effect-free read. What this catches that in-process tests cannot: - Build-pipeline regressions (tsc emits broken JS, missing imports). - argv parsing breakage that doesn't surface when calling main() directly. - Real exit codes for help/version/error paths. - Real stdout output the user reads. Test count: 220 → 226. CI runs the e2e tests as part of the existing `bun run --cwd packages/cli test` step (no new step needed; the build step that runs before tests has always built dist/). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/CHANGELOG.md | 1 + .../cli/__tests__/e2e/cli-smoke.e2e.test.ts | 97 +++++++++++++++ packages/cli/__tests__/e2e/spawn.ts | 111 ++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 packages/cli/__tests__/e2e/cli-smoke.e2e.test.ts create mode 100644 packages/cli/__tests__/e2e/spawn.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index af82aed1..74ee57d0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- **CLI binary e2e smoke tests (`packages/cli/__tests__/e2e/`).** New `spawn.ts` helper spawns the *built* `dist/index.js` as a separate Node process with a tmpdir HOME and captures stdout/stderr/exit. The exemplar `cli-smoke.e2e.test.ts` covers `--help` / `-h` / `--version` / `-v` / unknown-command exit codes — testing what the user actually sees rather than calling `main()` in-process. Catches build-pipeline regressions, argv-parser breakage, and exit-code drift that in-process tests can't see. Foundation for the deferred full push-flow e2e (which needs ccusage stubbing infrastructure or a real local stack). - **Real-Supabase integration tests for API routes (`bun run --cwd apps/web test:integration`).** New `apps/web/__tests__/integration/` directory + `vitest.integration.config.ts` with a globalSetup that asserts a local Supabase stack is reachable, reads its ephemeral keys via `bunx supabase status -o env`, and exposes them to test workers. The exemplar `usage-submit.test.ts` calls the real `POST /api/usage/submit` handler with a real Request, mints a real CLI JWT via `createCliToken`, and asserts on rows actually written to Postgres — no mocks of the Supabase client, the auth helper, or the chained queries. Catches bug classes the existing mocked `__tests__/api/usage-submit.test.ts` cannot: missing columns (the `collector_meta` cache-mismatch class), real CHECK constraints, FK behavior, NUMERIC→JS roundtrip precision, and end-to-end JWT signing with real `CLI_JWT_SECRET`. CI step added: `supabase/setup-cli@v1` plus `bunx supabase start` before `test:integration`. Existing mocked tests stay for now; future PRs can migrate cases incrementally to the integration directory. - **Team affiliation badge.** Users can enter an organization URL on `/settings` ("Team" field, between Website and GitHub Username). On save, the server validates the URL, fetches the favicon from Google's `s2/favicons` endpoint, uploads the PNG bytes to a new public-read `team-favicons` Supabase Storage bucket keyed by `.png`, and writes the resulting public URL to two new columns on `users` (`team_url`, `team_favicon_url`). Subsequent users that enter the same domain reuse the cached favicon — no extra Google fetch, no extra upload. A new `` component (`apps/web/components/app/shared/TeamBadge.tsx`) renders the favicon as a clickable badge inline next to the user's @handle on every surface it appears: profile header (next to display name + LevelBadge), feed cards (next to @handle in `ActivityCard`), leaderboard rows (desktop table + mobile list), and the sidebar current-user chip. Click target opens the team URL in a new tab with `rel="noopener noreferrer"`; alt text is derived from the hostname (e.g. `anthropic.com logo`); a Building2 lucide icon is rendered as a fallback when the favicon URL fails to load. The favicon resolver is server-only with a 5s fetch timeout and gracefully degrades to `team_favicon_url: null` (renders the fallback icon) on Google or Storage failure rather than blocking the save. Two migrations: `20260501120000_add_team_affiliation.sql` adds the columns + Storage bucket, and `20260501130000_team_affiliation_full_redaction.sql` re-states the sanitized `public.users` SELECT list with `team_url`/`team_favicon_url` added and refreshes `get_feed` to surface them via the existing `jsonb_build_object` allow-list (the previous list was bound to the pre-team-affiliation column set). 13 vitest cases cover the resolver (validation, normalization, cache hit/miss, graceful degradation); a Playwright spec at `apps/web/e2e/team-badge.spec.ts` asserts the badge renders correctly on `/u/{user}` and `/leaderboard`. Modeled on X Premium Organizations. diff --git a/packages/cli/__tests__/e2e/cli-smoke.e2e.test.ts b/packages/cli/__tests__/e2e/cli-smoke.e2e.test.ts new file mode 100644 index 00000000..50ec8072 --- /dev/null +++ b/packages/cli/__tests__/e2e/cli-smoke.e2e.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnCli, rmDir, CLI_DIST_ENTRY } from "./spawn"; + +/** + * Real-binary e2e smoke tests. The previous CLI tests all import functions + * from src/ and exercise them in-process. These tests do something none of + * those can: they spawn the *built* `dist/index.js` as a separate Node + * process with a controlled HOME, capture stdout/stderr/exit, and assert + * on the observable behavior a user actually sees. + * + * What this catches that in-process tests cannot: + * - Build pipeline regressions (tsc emits broken JS, missing imports). + * - argv parsing breakage that doesn't surface when calling main() directly. + * - Real exit codes for help/version/error paths. + * - Real stdout output the user reads. + * - Future regressions to the CLI's startup-time behavior under real Node. + * + * Out of scope for this exemplar suite (tracked as follow-ups): + * - Full `straude push` flow — needs ccusage on PATH or a stub binary. + * - `straude login` — needs an HTTP listener mocking the auth poll. + * - Auto-push / hooks — needs Claude Code's settings.json scaffold. + */ + +const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +beforeAll(() => { + // The CLI binary is what we're testing, so make sure it exists. If the + // suite is run cold (no `bun run build` first), build it now. tsc takes + // ~3s; the alternative is a confusing "ENOENT dist/index.js" failure. + if (!existsSync(CLI_DIST_ENTRY)) { + const built = spawnSync("bun", ["run", "build"], { cwd: PKG_DIR, stdio: "inherit" }); + if (built.status !== 0) { + throw new Error(`failed to build CLI before e2e suite (exit ${built.status})`); + } + } +}); + +let home: string; +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "straude-e2e-")); +}); +afterEach(() => { + rmDir(home); +}); + +describe("straude binary — smoke", () => { + it("--help prints usage and exits 0", async () => { + const r = await spawnCli({ args: ["--help"], home }); + expect(r.exitCode).toBe(0); + expect(r.stderr).toBe(""); + expect(r.stdout).toMatch(/Usage:/); + expect(r.stdout).toMatch(/straude\s*/); + }); + + it("-h is an alias for --help", async () => { + const r = await spawnCli({ args: ["-h"], home }); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/Usage:/); + }); + + it("--version prints the package version and exits 0", async () => { + // Read the pinned version straight from package.json — no source-of-truth + // drift between this assertion and what the build embeds. + const pkg = await import(join(PKG_DIR, "package.json"), { with: { type: "json" } }); + const version = (pkg as unknown as { default: { version: string } }).default.version; + + const r = await spawnCli({ args: ["--version"], home }); + expect(r.exitCode).toBe(0); + expect(r.stdout.trim()).toBe(`straude v${version}`); + }); + + it("-v is an alias for --version", async () => { + const r = await spawnCli({ args: ["-v"], home }); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/^straude v/); + }); + + it("unknown command prints the help and exits non-zero", async () => { + const r = await spawnCli({ args: ["nonsense"], home }); + expect(r.exitCode).not.toBe(0); + expect(r.stderr).toMatch(/Unknown command/); + }); + + it("--version is a side-effect-free read (no ~/.straude written)", async () => { + // Sanity that the version path doesn't accidentally trigger config + // creation or machine_id generation. Independent of any first-run + // telemetry that may land in other PRs. + const r = await spawnCli({ args: ["--version"], home }); + expect(r.exitCode).toBe(0); + expect(existsSync(join(home, ".straude"))).toBe(false); + }); +}); diff --git a/packages/cli/__tests__/e2e/spawn.ts b/packages/cli/__tests__/e2e/spawn.ts new file mode 100644 index 00000000..f294350a --- /dev/null +++ b/packages/cli/__tests__/e2e/spawn.ts @@ -0,0 +1,111 @@ +import { spawn, type SpawnOptions } from "node:child_process"; +import { mkdtempSync, rmSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Locate the built CLI binary. Tests assume `bun run --cwd packages/cli build` + * has run — see `e2e/setup.ts`. We don't rebuild from inside each test + * because tsc takes ~3s and the e2e suite shares one binary. + */ +const __dirname = dirname(fileURLToPath(import.meta.url)); +export const CLI_DIST_ENTRY = resolve(__dirname, "../../dist/index.js"); + +export interface SpawnResult { + /** Full captured stdout (utf-8). */ + stdout: string; + /** Full captured stderr (utf-8). */ + stderr: string; + /** Process exit code. `null` if the process was killed by a signal. */ + exitCode: number | null; + /** Signal that killed the process, or `null` if it exited normally. */ + signal: NodeJS.Signals | null; +} + +export interface SpawnCliOptions { + /** Args passed to `node dist/index.js`. */ + args: string[]; + /** + * Directory used as $HOME — config and machine_id live under + * `${home}/.straude`. A fresh tmpdir is created if not supplied so + * tests don't accidentally read or mutate the real user's config. + */ + home?: string; + /** Environment overrides merged on top of a minimal scrubbed base. */ + env?: NodeJS.ProcessEnv; + /** + * If true, child stdout is piped to a child of /dev/null-equivalent so + * EPIPE behaves like `straude … | head` would. Default false. + */ + truncateStdout?: boolean; + /** Timeout in ms before killing the child. Default 15s. */ + timeoutMs?: number; +} + +/** + * Spawn the real CLI binary in a controlled environment and return + * exit/output. No mocks of fs, env, or fetch — this is the real argv + * parser running the real entry point against the real filesystem + * (in a tmpdir HOME) and the real Node runtime. + */ +export function spawnCli(opts: SpawnCliOptions): Promise { + const home = opts.home ?? mkdtempSync(join(tmpdir(), "straude-e2e-")); + // Make sure ~/.straude exists if the test wants it; otherwise the CLI's + // own first-run path creates it. Either is fine. + mkdirSync(home, { recursive: true }); + + const env: NodeJS.ProcessEnv = { + PATH: process.env.PATH, + // Scrub anything that could make the CLI behave differently in tests. + STRAUDE_TELEMETRY_DISABLED: "1", + NODE_ENV: "test", + ...opts.env, + HOME: home, + }; + + return new Promise((resolveResult, reject) => { + const spawnOpts: SpawnOptions = { + env, + stdio: opts.truncateStdout ? ["ignore", "pipe", "pipe"] : "pipe", + }; + const child = spawn(process.execPath, [CLI_DIST_ENTRY, ...opts.args], spawnOpts); + + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf-8"); + if (opts.truncateStdout) { + // Close the pipe after the first chunk so the writer hits EPIPE on + // the next write — same shape as `straude --help | head -1`. + child.stdout?.destroy(); + } + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf-8"); + }); + + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`spawnCli timed out after ${opts.timeoutMs ?? 15_000}ms`)); + }, opts.timeoutMs ?? 15_000); + + child.on("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + child.on("close", (exitCode, signal) => { + clearTimeout(timeout); + resolveResult({ stdout, stderr, exitCode, signal }); + }); + }); +} + +/** Cleanup helper — removes a tmpdir HOME from `spawnCli`. Best effort. */ +export function rmDir(dir: string): void { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore + } +} From 28256f345c14657fadfd9f7a8cf66c769e927183 Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Tue, 5 May 2026 14:02:46 -0700 Subject: [PATCH 2/2] test: keep CLI version smoke test compatible with telemetry --- packages/cli/__tests__/e2e/cli-smoke.e2e.test.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/cli/__tests__/e2e/cli-smoke.e2e.test.ts b/packages/cli/__tests__/e2e/cli-smoke.e2e.test.ts index 50ec8072..8489789c 100644 --- a/packages/cli/__tests__/e2e/cli-smoke.e2e.test.ts +++ b/packages/cli/__tests__/e2e/cli-smoke.e2e.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -86,12 +86,11 @@ describe("straude binary — smoke", () => { expect(r.stderr).toMatch(/Unknown command/); }); - it("--version is a side-effect-free read (no ~/.straude written)", async () => { - // Sanity that the version path doesn't accidentally trigger config - // creation or machine_id generation. Independent of any first-run - // telemetry that may land in other PRs. + it("--version does not create an authenticated config", async () => { + // First-run telemetry may create ~/.straude metadata, but version reads + // must never create an auth config or require a logged-in account. const r = await spawnCli({ args: ["--version"], home }); expect(r.exitCode).toBe(0); - expect(existsSync(join(home, ".straude"))).toBe(false); + expect(existsSync(join(home, ".straude", "config.json"))).toBe(false); }); });