diff --git a/test/cli-smoke.test.ts b/test/cli-smoke.test.ts new file mode 100644 index 0000000..a938c5b --- /dev/null +++ b/test/cli-smoke.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { execSync, spawnSync } from "node:child_process"; +import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(here, ".."); +const CLI = join(repoRoot, "dist", "cli.js"); +const FIXTURE_SRC = join(here, "fixtures", "smoke-project"); +const pkg = JSON.parse( + readFileSync(join(here, "..", "package.json"), "utf8"), +) as { version: string }; + +let projectRoot: string | undefined; + +function ensureCliBuilt(): void { + if (!existsSync(CLI)) { + execSync("npm run build", { cwd: repoRoot, stdio: "pipe" }); + } + if (!existsSync(CLI)) { + throw new Error(`CLI executable not found at ${CLI} after build`); + } +} + +function runMex(args: string[]): { + status: number | null; + stdout: string; + stderr: string; + output: string; +} { + ensureCliBuilt(); + if (!existsSync(CLI)) { + throw new Error(`CLI executable not found at ${CLI}`); + } + const result = spawnSync(process.execPath, [CLI, ...args], { + cwd: projectRoot, + encoding: "utf8", + env: { ...process.env, MEX_TELEMETRY: "0", NO_COLOR: "1" }, + }); + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; + return { + status: result.status, + stdout, + stderr, + output: [stdout, stderr].filter(Boolean).join("\n"), + }; +} + +function expectSuccess(result: ReturnType): void { + expect(result.status, result.output).toBe(0); +} + +beforeAll(() => { + execSync("npm run build", { cwd: repoRoot, stdio: "pipe" }); + if (!existsSync(CLI)) { + throw new Error(`CLI build failed: ${CLI} not found`); + } + projectRoot = mkdtempSync(join(tmpdir(), "mex-smoke-")); + cpSync(FIXTURE_SRC, projectRoot, { recursive: true }); + execSync("git init -q", { cwd: projectRoot }); + execSync("git add -A", { cwd: projectRoot }); + execSync('git -c user.email=smoke@test -c user.name=smoke commit -q -m "init"', { + cwd: projectRoot, + }); +}); + +afterAll(() => { + if (projectRoot) { + rmSync(projectRoot, { recursive: true, force: true }); + } +}); + +describe("CLI smoke", () => { + it("--version matches package.json", () => { + const result = runMex(["--version"]); + expectSuccess(result); + expect(result.stdout.trim()).toBe(pkg.version); + }); + + it("commands lists top-level commands", () => { + const result = runMex(["commands"]); + expectSuccess(result); + expect(result.stdout).toContain("CLI Commands"); + expect(result.stdout).toContain("mex check"); + }); + + it("check prints drift summary on fixture", () => { + const result = runMex(["check"]); + expectSuccess(result); + expect(result.stdout).toContain("Drift score"); + }); + + it("check --quiet exits successfully on fixture", () => { + const result = runMex(["check", "--quiet"]); + expectSuccess(result); + expect(result.stdout.length).toBeGreaterThan(0); + }); + + it("check --json reports clean drift on fixture scaffold", () => { + const result = runMex(["check", "--json"]); + expectSuccess(result); + const report = JSON.parse(result.stdout) as { + score: number; + issues: Array<{ code?: string; file?: string }>; + filesChecked: number; + }; + expect(report.score).toBe(100); + expect(report.issues).toEqual([]); + expect(report.filesChecked).toBeGreaterThanOrEqual(8); + }); + + it("doctor prints health summary", () => { + const result = runMex(["doctor"]); + expectSuccess(result); + expect(result.stdout).toContain("mex doctor"); + expect(result.stdout).toContain("Drift"); + }); + + it("log and timeline round-trip", () => { + const log = runMex(["log", "smoke test note", "--type", "note"]); + expectSuccess(log); + const timeline = runMex(["timeline", "--limit", "1"]); + expectSuccess(timeline); + expect(timeline.stdout).toContain("smoke test note"); + }); + + it("heartbeat --json reports status", () => { + const result = runMex(["heartbeat", "--json"]); + expectSuccess(result); + expect(result.stdout).toContain("{"); + }); + + it("completion emits scripts for bash, zsh, and fish", () => { + const bash = runMex(["completion", "bash"]); + expectSuccess(bash); + expect(bash.stdout).toContain("complete"); + + const zsh = runMex(["completion", "zsh"]); + expectSuccess(zsh); + expect(zsh.stdout).toContain("#compdef mex"); + + const fish = runMex(["completion", "fish"]); + expectSuccess(fish); + expect(fish.stdout).toContain("complete -c mex"); + }); + + it("sync --dry-run runs without error", () => { + expectSuccess(runMex(["sync", "--dry-run"])); + }); + + it("setup --dry-run previews scaffold", () => { + const result = runMex(["setup", "--dry-run"]); + expectSuccess(result); + expect(result.stdout.length).toBeGreaterThan(0); + }); + + it("init --json emits scanner brief", () => { + const result = runMex(["init", "--json"]); + expectSuccess(result); + expect(result.stdout.trim().startsWith("{")).toBe(true); + }); + + it("watch --uninstall is a no-op success", () => { + expectSuccess(runMex(["watch", "--uninstall"])); + }); + + it("pattern add creates a scaffold file", () => { + const result = runMex(["pattern", "add", "smoke-pattern"]); + expectSuccess(result); + expect(result.stdout).toContain("smoke-pattern.md"); + }); +}); diff --git a/test/fixtures/smoke-project/.mex/AGENTS.md b/test/fixtures/smoke-project/.mex/AGENTS.md new file mode 100644 index 0000000..93cb88a --- /dev/null +++ b/test/fixtures/smoke-project/.mex/AGENTS.md @@ -0,0 +1,19 @@ +--- +name: agents +description: Smoke test project anchor +last_updated: 2026-06-01 +--- + +# Smoke Project + +## What This Is + +Minimal fixture for mex CLI smoke tests. + +## Non-Negotiables + +- Used only in automated tests + +## Commands + +- npm test diff --git a/test/fixtures/smoke-project/.mex/ROUTER.md b/test/fixtures/smoke-project/.mex/ROUTER.md new file mode 100644 index 0000000..b4bbfd0 --- /dev/null +++ b/test/fixtures/smoke-project/.mex/ROUTER.md @@ -0,0 +1,18 @@ +--- +name: router +description: Smoke test scaffold router +last_updated: 2026-06-01 +--- + +# Smoke Project + +## Current Project State + +**Working:** +- CLI smoke fixture + +**Not yet built:** +- Nothing + +**Known issues:** +- None diff --git a/test/fixtures/smoke-project/.mex/context/architecture.md b/test/fixtures/smoke-project/.mex/context/architecture.md new file mode 100644 index 0000000..e1479d8 --- /dev/null +++ b/test/fixtures/smoke-project/.mex/context/architecture.md @@ -0,0 +1,9 @@ +--- +name: architecture +description: Smoke architecture context +last_updated: 2026-06-01 +--- + +# Architecture + +Single-file smoke fixture. diff --git a/test/fixtures/smoke-project/.mex/context/conventions.md b/test/fixtures/smoke-project/.mex/context/conventions.md new file mode 100644 index 0000000..fb79799 --- /dev/null +++ b/test/fixtures/smoke-project/.mex/context/conventions.md @@ -0,0 +1,9 @@ +--- +name: conventions +description: Smoke conventions context +last_updated: 2026-06-01 +--- + +# Conventions + +Test-only conventions. diff --git a/test/fixtures/smoke-project/.mex/context/decisions.md b/test/fixtures/smoke-project/.mex/context/decisions.md new file mode 100644 index 0000000..5441ad6 --- /dev/null +++ b/test/fixtures/smoke-project/.mex/context/decisions.md @@ -0,0 +1,9 @@ +--- +name: decisions +description: Smoke decisions log +last_updated: 2026-06-01 +--- + +# Decisions + +None yet. diff --git a/test/fixtures/smoke-project/.mex/context/setup.md b/test/fixtures/smoke-project/.mex/context/setup.md new file mode 100644 index 0000000..d156d00 --- /dev/null +++ b/test/fixtures/smoke-project/.mex/context/setup.md @@ -0,0 +1,9 @@ +--- +name: setup +description: Smoke setup context +last_updated: 2026-06-01 +--- + +# Setup + +Run `npm test` from the mex repo. diff --git a/test/fixtures/smoke-project/.mex/context/stack.md b/test/fixtures/smoke-project/.mex/context/stack.md new file mode 100644 index 0000000..8e96de8 --- /dev/null +++ b/test/fixtures/smoke-project/.mex/context/stack.md @@ -0,0 +1,9 @@ +--- +name: stack +description: Smoke stack context +last_updated: 2026-06-01 +--- + +# Stack + +Node.js test fixture. diff --git a/test/fixtures/smoke-project/.mex/patterns/INDEX.md b/test/fixtures/smoke-project/.mex/patterns/INDEX.md new file mode 100644 index 0000000..006d742 --- /dev/null +++ b/test/fixtures/smoke-project/.mex/patterns/INDEX.md @@ -0,0 +1,9 @@ +--- +name: patterns-index +description: Pattern index for smoke tests +last_updated: 2026-06-01 +--- + +# Patterns + +No patterns in this fixture. diff --git a/test/fixtures/smoke-project/package.json b/test/fixtures/smoke-project/package.json new file mode 100644 index 0000000..fd6260f --- /dev/null +++ b/test/fixtures/smoke-project/package.json @@ -0,0 +1,4 @@ +{ + "name": "mex-smoke-fixture", + "private": true +} diff --git a/test/fixtures/smoke-project/src/index.ts b/test/fixtures/smoke-project/src/index.ts new file mode 100644 index 0000000..fc551e1 --- /dev/null +++ b/test/fixtures/smoke-project/src/index.ts @@ -0,0 +1 @@ +// Smoke fixture entry point for path drift checks. diff --git a/test/global-setup.ts b/test/global-setup.ts new file mode 100644 index 0000000..6c9a832 --- /dev/null +++ b/test/global-setup.ts @@ -0,0 +1,26 @@ +import { execSync, spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const cli = join(repoRoot, "dist", "cli.js"); + +export default function setup(): void { + execSync("npm run build", { cwd: repoRoot, stdio: "pipe" }); + if (!existsSync(cli)) { + throw new Error(`CLI build failed: ${cli} not found before running tests`); + } + + const probe = spawnSync(process.execPath, [cli, "--version"], { + cwd: repoRoot, + encoding: "utf8", + env: { ...process.env, MEX_TELEMETRY: "0", NO_COLOR: "1" }, + }); + if (probe.status !== 0) { + const detail = [probe.stdout, probe.stderr].filter(Boolean).join("\n"); + throw new Error( + `CLI probe failed (run npm install if dependencies are missing): ${detail}`, + ); + } +} diff --git a/vitest.config.ts b/vitest.config.ts index b26d4f1..15ad485 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { + globalSetup: ["./test/global-setup.ts"], // Tests must NEVER emit real telemetry to PostHog. The dev-repo guard only // catches commands run from inside this repo; tests spawn the built CLI in // temp dirs where that guard does not fire, so disable telemetry globally.