From 20bb5796c3c788a27c2689979ff7987b0364c291 Mon Sep 17 00:00:00 2001 From: Altay Date: Thu, 20 Aug 2026 05:37:26 +0300 Subject: [PATCH] test: run CLI entry tests in-process so V8 coverage attributes them Mirrors the design landed in putdotio/taizn#62. Vitest 4 cannot attribute V8 coverage from spawned subprocesses (coverage.autoAttachSubprocess lands in vitest 5), and NODE_V8_COVERAGE plumbing plus bundle remapping is disproportionate machinery. rokit's mainEffect(argv) was already importable, so the completions and usage-error spawn tests now run it in-process with console spies; only the boot test still spawns the packaged binary, and that blind spot (the src/rokit.ts shim, already excluded) is documented in vite.config.ts and AGENTS.md. Coverage: statements 65.91->68.96, branches 53.42->55.88, functions 57.86->60.73, lines 65.83->68.73 (same denominators). Floors raised 63/51/55/63 -> 66/53/58/66. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 4 ++++ test/cli.test.ts | 51 +++++++++++++++++++++++++++++++++++++++++++----- vite.config.ts | 14 +++++++++---- 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1be34ff..605b4cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,10 @@ guide doesn't cover, search through the source code in `node_modules/effect/src` ## Sharp Edges - Missing config/env and child-command failures should not print stack traces. +- CLI tests default to in-process entry points (`mainEffect` from `src/cli.ts` + or the command effects) so V8 coverage attributes them. Spawn + `dist/rokit.mjs` only when the process boundary itself is under test; those + runs are invisible to coverage on vitest 4 (see `vite.config.ts`). - `ROKIT_PASSWORD` is required only for developer-installer operations such as install and screenshot. - `ROKU_DEV_TARGET` and `ROKU_DEV_PASSWORD` are optional fallback aliases, not diff --git a/test/cli.test.ts b/test/cli.test.ts index 9a249f9..242d314 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -4,7 +4,8 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { NodeServices } from "@effect/platform-node"; import { Effect } from "effect"; -import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vite-plus/test"; +import { mainEffect } from "../src/cli.js"; import { parseEffectCliEffect } from "../src/cli-command.js"; import { describeCli } from "../src/cli-describe.js"; import { parseInputJsonEffect } from "../src/cli-input-json.js"; @@ -27,6 +28,9 @@ afterAll(() => { rmSync(testDistDir, { force: true, recursive: true }); }); +// Kept for the boot test below, which proves the packaged binary itself. +// Everything else runs the same CLI entry in-process (mainEffect or the +// command effects) so V8 coverage attributes it. const runRokit = (args: readonly string[]) => spawnSync(process.execPath, [cliPath, ...args], { cwd: repoRoot, @@ -34,6 +38,37 @@ const runRokit = (args: readonly string[]) => env: childCliEnv(), }); +const runRokitInProcess = async ( + args: readonly string[], +): Promise<{ readonly status: number; readonly stderr: string; readonly stdout: string }> => { + const stdout: string[] = []; + const stderr: string[] = []; + const format = (parts: readonly unknown[]) => parts.map(String).join(" "); + const logSpy = vi.spyOn(console, "log").mockImplementation((...parts: unknown[]) => { + stdout.push(format(parts)); + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation((...parts: unknown[]) => { + stderr.push(format(parts)); + }); + const previousExitCode = process.exitCode; + process.exitCode = 0; + + try { + await Effect.runPromise(mainEffect(args).pipe(Effect.provide(NodeServices.layer))); + const joinLines = (lines: readonly string[]) => lines.map((line) => `${line}\n`).join(""); + + return { + status: process.exitCode === 0 ? 0 : 1, + stderr: joinLines(stderr), + stdout: joinLines(stdout), + }; + } finally { + process.exitCode = previousExitCode; + logSpy.mockRestore(); + errorSpy.mockRestore(); + } +}; + const childCliEnv = (): NodeJS.ProcessEnv => { const excludedNames = new Set([ "NODE_OPTIONS", @@ -89,8 +124,8 @@ describe("rokit CLI functionality", () => { expect(shortResult.stderr).toBe(""); }); - it("prints advertised shell completions", () => { - const result = runRokit(["--completions", "bash"]); + it("prints advertised shell completions", async () => { + const result = await runRokitInProcess(["--completions", "bash"]); expect(result.status).toBe(0); expect(result.stdout).toContain("rokit"); @@ -272,8 +307,14 @@ describe("rokit CLI functionality", () => { ); }); - it("prints package output usage errors without stack traces", () => { - const result = runRokit(["--json", "package", "out/channel", "--out", "out/other"]); + it("prints package output usage errors without stack traces", async () => { + const result = await runRokitInProcess([ + "--json", + "package", + "out/channel", + "--out", + "out/other", + ]); expect(result.status).toBe(1); expect(result.stdout).toBe(""); diff --git a/vite.config.ts b/vite.config.ts index 9cdf898..2598f74 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,15 +3,21 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ test: { coverage: { + // Coverage blind spot: the boot test spawns the packaged dist/rokit.mjs + // to prove the real binary starts; V8 coverage cannot attribute + // subprocess execution on vitest 4, so the bin shim src/rokit.ts is + // excluded by design. All other CLI tests run the same entry in-process + // (mainEffect and the command effects). When vite-plus ships vitest 5, + // coverage.autoAttachSubprocess can close the remaining gap. exclude: ["src/**/*.d.ts", "src/rokit.ts"], include: ["src/**/*.ts"], provider: "v8", reporter: ["text", "lcov"], thresholds: { - branches: 51, - functions: 55, - lines: 63, - statements: 63, + branches: 53, + functions: 58, + lines: 66, + statements: 66, }, }, exclude: ["dist/**", "node_modules/**", ".repos/**"],