diff --git a/.changeset/add-vitest-plugin.md b/.changeset/add-vitest-plugin.md new file mode 100644 index 00000000..ce99dfac --- /dev/null +++ b/.changeset/add-vitest-plugin.md @@ -0,0 +1,8 @@ +--- +"@rrlab/vitest-plugin": minor +"@rrlab/cli": minor +--- + +Add `@rrlab/vitest-plugin` and the `rr test` command — a passthrough to vitest. + +`rr test` forwards every flag and argument (and `--help`) straight to vitest, and loads an env file first (the first existing of `.env.test`, then `.env`; override with `--env `). This adds a streaming `test` capability to the kernel (`TestRunner`, `ToolService.runStreamed`) alongside the existing captured verbs. diff --git a/decisions/016-vitest-passthrough-plugin.md b/decisions/016-vitest-passthrough-plugin.md new file mode 100644 index 00000000..a7a801be --- /dev/null +++ b/decisions/016-vitest-passthrough-plugin.md @@ -0,0 +1,38 @@ +# 016: How does a streaming passthrough (`rr test` → vitest) fit a kernel contract built entirely around captured output? + +- **Date**: 2026-06-24 +- **Status**: Pending human review +- **Files affected**: `run-run/cli/src/lib/plugin/tool-service.ts`, `run-run/cli/src/types/tool.ts`, `run-run/cli/src/lib/plugin/{types,index}.ts`, `run-run/cli/src/actions/test.ts`, `run-run/cli/src/program/commands/test.ts`, `run-run/cli/src/program/index.ts`, `run-run/cli/src/lib/plugin/directory.ts`, new package `run-run/vitest-plugin/` + +## Context + +Every existing capability (`lint`/`format`/`tsc`/`pack`) **captures** its tool's output (`ToolService.runReport` → `runCaptured` → task-board) and returns a `RunReport`. A test runner is categorically different: it needs an inherited TTY for watch mode, colors, interactive UI, and to forward `--help` to the tool. So `rr test` is a *streaming* passthrough that returns an exit code, not a `RunReport`. `arch-critic` was consulted on how this fits the kernel. + +## Options considered + +- **A — capability/contract shape**: A1 add a generic `runStreamed()` to `ToolService` (sibling of `runReport`), capability returns an exit code · A2 force `test` through the captured board · A3 give the service its own shell, bypass `ToolService`. +- **B — capability name**: B1 `test` (verb-of-intent, like `lint`/`pack`) · B2 generic `run` (kernel only knows "spawn a bin"). +- **C — `doctor` subcommand**: C1 keep it (repo convention) · C2 drop it (passthrough purity). +- **D — env-file resolution**: D1 plugin fs-checks, explicit-missing errors · D2 silently skip missing. +- **E — override flag name** (discovered during impl): `--env-file` vs a non-colliding token. + +## Decision + +- **A → A1.** `ToolService.runStreamed(command, args, opts): Promise` wraps the existing streaming `shell.run()` (`stdio:"inherit"`, `verbose:false`). It's as tool-agnostic as `runReport`; any future passthrough reuses it. A2 contradicts D-013's streaming-vs-capture split; A3 duplicates `getBinDir`/`doctor`. +- **B → B1 (diverges from arch-critic, which recommended B2).** The capability key is `test`. Capability keys are the command's resolution key (`getServiceOrThrow(cap)`); a generic `run` would collide across unrelated passthrough plugins (two providers → `MultipleProvidersError`). The streaming *mechanism* is shared via `runStreamed`; the capability *name* stays 1:1 with the command, matching `lint`/`format`/`pack`. +- **C → C1.** Keep `doctor` (inherited from `ToolService`). `rr test doctor` reserves only the bare leading `doctor` token; `rr test run doctor` still forwards. Documented in the command description. +- **D → D1.** The plugin owns `resolveEnvFile`: defaults are first-existing-wins (`.env.test`, then `.env`); an explicit override that's missing throws (typo protection). The `.env` semantics and the node `--env-file=` mechanism stay in the plugin (kernel-agnostic). +- **E → rename to `--env `** (user-confirmed). Node has an early scanner that consumes `--env-file`/`--env-file-if-exists` from the *entire* argv regardless of position; since the `rr` bin runs `node src/run.ts "$@"`, a user's `--env-file` token is eaten by the `rr` process before Commander runs. `--env` is not a node flag (and vitest uses `--environment`, not `--env`), so it passes through cleanly. The plugin still spawns the child as `node --env-file= ` (there the flag precedes the script, so it's a legit node flag). + +## Alternatives rejected + +- A2: breaks watch/colors/`--help`; contradicts D-013. +- A3: duplicates bin-resolution + doctor that `ToolService` exists to share. +- B2: a generic `run` capability key collides across passthrough plugins. +- C2: makes vitest the lone plugin without `doctor`, which it still needs for top-level `rr doctor`. +- E `--env-file`: only recoverable by hardening the shared `rr` bin/entry (inject `--` + strip in `run.ts`) — a CLI-wide change to the kernel entrypoint for one plugin's flag, rejected in favor of a clean rename. + +## Notes for human review + +- New contract surface: `TestRunner`/`TestRunOptions` in `src/types/tool.ts`, `test?` in `PluginServices`, `runStreamed` on `ToolService`. No existing plugin needs to change. +- `--env` (not `--env-file`) is the one user-facing deviation from the original request, forced by Node's `--env-file` argv scanner. Default auto-load (`.env.test`→`.env`) is unaffected. diff --git a/package.json b/package.json index 8ecf8907..b6ee221f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@rrlab/ts-plugin": "workspace:*", "@rrlab/tsdown-config": "workspace:*", "@rrlab/tsdown-plugin": "workspace:*", + "@rrlab/vitest-plugin": "workspace:*", "@types/node": "latest", "@vlandoss/vland": "workspace:*", "lefthook": "2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebe2f85a..36930413 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@rrlab/tsdown-plugin': specifier: workspace:* version: link:run-run/tsdown-plugin + '@rrlab/vitest-plugin': + specifier: workspace:* + version: link:run-run/vitest-plugin '@types/node': specifier: latest version: 25.9.1 @@ -248,6 +251,28 @@ importers: specifier: 0.22.0 version: 0.22.0(typescript@6.0.3)(unrun@0.2.39) + run-run/vitest-plugin: + dependencies: + '@vlandoss/clibuddy': + specifier: workspace:* + version: link:../../shared/clibuddy + devDependencies: + '@rrlab/cli': + specifier: workspace:* + version: link:../cli + '@rrlab/tsdown-config': + specifier: workspace:^ + version: link:../tsdown-config + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 + semver: + specifier: ^7.8.1 + version: 7.8.1 + vitest: + specifier: ^4.0.0 + version: 4.1.6(@types/node@25.9.1)(vite@8.0.13(@types/node@25.9.1)(yaml@2.8.4)) + shared/clibuddy: dependencies: '@pnpm/fs.find-packages': @@ -2470,7 +2495,7 @@ snapshots: outdent: 0.5.0 prettier: 2.8.8 resolve-from: 5.0.0 - semver: 7.8.0 + semver: 7.8.1 '@changesets/assemble-release-plan@6.0.10': dependencies: @@ -2479,7 +2504,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 - semver: 7.8.0 + semver: 7.8.1 '@changesets/changelog-git@0.2.1': dependencies: @@ -2546,7 +2571,7 @@ snapshots: '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 picocolors: 1.1.1 - semver: 7.8.0 + semver: 7.8.1 '@changesets/get-github-info@0.7.0': dependencies: @@ -2932,7 +2957,7 @@ snapshots: '@pnpm/logger': 1001.0.1 '@pnpm/semver.peer-range': 1000.0.0 '@pnpm/types': 1001.3.0 - semver: 7.8.0 + semver: 7.8.1 '@pnpm/read-project-manifest@1001.2.6(@pnpm/logger@1001.0.1)': dependencies: @@ -2953,7 +2978,7 @@ snapshots: '@pnpm/semver.peer-range@1000.0.0': dependencies: - semver: 7.8.0 + semver: 7.8.1 '@pnpm/text.comments-parser@1000.0.0': dependencies: diff --git a/run-run/cli/src/actions/test.ts b/run-run/cli/src/actions/test.ts new file mode 100644 index 00000000..fc457f4b --- /dev/null +++ b/run-run/cli/src/actions/test.ts @@ -0,0 +1,20 @@ +import type { ContextValue } from "#src/services/context.ts"; +import type { Doctor, TestRunner } from "#src/types/tool.ts"; + +export type TestActionConfig = { + ctx: ContextValue; + runner: TestRunner & Doctor; + options: { envFile?: string }; + args: string[]; +}; + +/** + * The action for the `test` passthrough. Unlike the captured verbs (lint, pack, + * …) it doesn't drive the task-board: the runner streams straight to the + * terminal and resolves with the tool's exit code, which we mirror onto + * `process.exitCode` so `rr test` fails when the suite fails. + */ +export async function testAction({ runner, options, args }: TestActionConfig): Promise { + const code = await runner.test({ envFile: options.envFile, args }); + if (code !== 0) process.exitCode = code; +} diff --git a/run-run/cli/src/lib/plugin/directory.ts b/run-run/cli/src/lib/plugin/directory.ts index a3f07b97..5ab1e7ce 100644 --- a/run-run/cli/src/lib/plugin/directory.ts +++ b/run-run/cli/src/lib/plugin/directory.ts @@ -11,6 +11,7 @@ export const PLUGINS_DIRECTORY = { biome: { pkg: "@rrlab/biome-plugin", name: "biome", capabilities: ["format", "jscheck", "lint"] }, oxc: { pkg: "@rrlab/oxc-plugin", name: "oxc", capabilities: ["format", "lint", "jscheck", "typecheck"] }, tsdown: { pkg: "@rrlab/tsdown-plugin", name: "tsdown", capabilities: ["pack"] }, + vitest: { pkg: "@rrlab/vitest-plugin", name: "vitest", capabilities: ["test"] }, } as const satisfies Record; export type PluginName = keyof typeof PLUGINS_DIRECTORY; diff --git a/run-run/cli/src/lib/plugin/index.ts b/run-run/cli/src/lib/plugin/index.ts index 30f7830a..9ed44c3b 100644 --- a/run-run/cli/src/lib/plugin/index.ts +++ b/run-run/cli/src/lib/plugin/index.ts @@ -24,6 +24,8 @@ export type { RunReport, StaticChecker, StaticCheckerOptions, + TestRunner, + TestRunOptions, TypeChecker, TypeCheckOptions, UninstallContext, diff --git a/run-run/cli/src/lib/plugin/tool-service.ts b/run-run/cli/src/lib/plugin/tool-service.ts index afe29f20..273eed79 100644 --- a/run-run/cli/src/lib/plugin/tool-service.ts +++ b/run-run/cli/src/lib/plugin/tool-service.ts @@ -70,6 +70,24 @@ export class ToolService { return { ok: output.exitCode === 0, output: body ? `${header}\n${body}` : header }; } + /** + * Streams the command straight to the terminal (`stdio: "inherit"`) and + * returns its exit code — the counterpart to `runReport` for passthrough + * verbs (e.g. a test runner) where capturing would break watch mode, colors, + * interactive UI, and `--help` forwarding. `verbose: false` suppresses the + * `$ ` line so the wrapped tool fully owns the terminal. Takes an + * explicit `command` because a passthrough may need to wrap its bin (e.g. + * `node --env-file=… `) rather than spawn it directly. + * + * A missing exit code (signal-killed, e.g. OOM) is reported as failure (`1`), + * mirroring `runReport`'s strict `=== 0`. + */ + async runStreamed(command: string, args: string[] = [], options: RunReportOptions = {}): Promise { + const sh = options.cwd ? this.#shellService.at(options.cwd) : this.#shellService; + const result = await sh.run(command, args, { throwOnError: false, verbose: false }); + return result.exitCode ?? 1; + } + async doctor(): Promise { const output = await this.#shellService.runCaptured(await this.getBinDir(), ["--help"], { throwOnError: false }); const ok = output.exitCode === 0; diff --git a/run-run/cli/src/lib/plugin/types.ts b/run-run/cli/src/lib/plugin/types.ts index 10e00c7c..4976ec5f 100644 --- a/run-run/cli/src/lib/plugin/types.ts +++ b/run-run/cli/src/lib/plugin/types.ts @@ -1,7 +1,7 @@ import type { Pkg, ShellService } from "@vlandoss/clibuddy"; import type { AnyLogger as Logger } from "@vlandoss/loggy"; import type { ReleaseService } from "#src/services/release.ts"; -import type { Doctor, Formatter, Linter, Packer, StaticChecker, TypeChecker } from "#src/types/tool.ts"; +import type { Doctor, Formatter, Linter, Packer, StaticChecker, TestRunner, TypeChecker } from "#src/types/tool.ts"; export type * from "#src/types/tool.ts"; @@ -13,6 +13,7 @@ export type PluginServices = { jscheck?: StaticChecker & Doctor; typecheck?: TypeChecker & Doctor; pack?: Packer & Doctor; + test?: TestRunner & Doctor; }; export type PluginContext = { diff --git a/run-run/cli/src/program/commands/test.ts b/run-run/cli/src/program/commands/test.ts new file mode 100644 index 00000000..c7230245 --- /dev/null +++ b/run-run/cli/src/program/commands/test.ts @@ -0,0 +1,36 @@ +import { doctorOneAction } from "#src/actions/doctor.ts"; +import { testAction } from "#src/actions/test.ts"; +import type { ContextValue } from "#src/services/context.ts"; +import { createCommand } from "../base.ts"; + +type ActionOptions = { + env?: string; +}; + +export function createTestCommand(ctx: ContextValue) { + return ( + createCommand("test") + .addCapabilities(["test"]) + .summary("run the test suite") + .description( + "Passthrough to the configured test runner. Forwards every flag and argument (e.g. --project, --watch) — including --help — straight to the tool. Loads .env.test or .env by default. ('rr test doctor' is reserved for the health check.)", + ) + // Pure passthrough: don't parse the tool's flags, don't capture --help. + // `--env` is consumed only when it precedes the forwarded args. It's not + // named `--env-file`: Node's early `--env-file` scanner would grab that + // token off the `rr` process's argv before Commander ever sees it. + .allowUnknownOption(true) + .passThroughOptions(true) + .helpOption(false) + .option("--env ", "load this env file before running (default: .env.test, then .env)") + .argument("[args...]", "arguments forwarded to the test runner") + .action(async (args: string[] = [], options: ActionOptions = {}) => { + const runner = ctx.plugins.getServiceOrThrow("test"); + await testAction({ ctx, runner, options: { envFile: options.env }, args }); + }) + .addDoctorCommand(async () => { + const runner = ctx.plugins.getServiceOrThrow("test"); + await doctorOneAction({ ctx, service: runner }); + }) + ); +} diff --git a/run-run/cli/src/program/index.ts b/run-run/cli/src/program/index.ts index 64f26c11..d00c437c 100644 --- a/run-run/cli/src/program/index.ts +++ b/run-run/cli/src/program/index.ts @@ -11,6 +11,7 @@ import { createJsCheckCommand } from "./commands/jscheck.ts"; import { createLintCommand } from "./commands/lint.ts"; import { createPackCommand } from "./commands/pack.ts"; import { createPluginsCommand } from "./commands/plugins.ts"; +import { createTestCommand } from "./commands/test.ts"; import { createTsCheckCommand } from "./commands/tscheck.ts"; import { RunRunCmd } from "./root.ts"; @@ -29,6 +30,8 @@ export async function createProgram(meta: ImportMeta) { .addCommand(createTsCheckCommand(ctx)) .addCommand(createLintCommand(ctx)) .addCommand(createFormatCommand(ctx)) + .commandsGroup("Testing:") + .addCommand(createTestCommand(ctx)) .commandsGroup("Build:") .addCommand(createPackCommand(ctx)) .commandsGroup("Maintenance:") diff --git a/run-run/cli/src/types/tool.ts b/run-run/cli/src/types/tool.ts index 444719ad..263faa22 100644 --- a/run-run/cli/src/types/tool.ts +++ b/run-run/cli/src/types/tool.ts @@ -48,3 +48,21 @@ export type Packer = { readonly ui: string; pack: () => Promise; }; + +export type TestRunOptions = { + /** Env file to load before running (resolved by the provider). */ + envFile?: string; + /** Arguments forwarded verbatim to the test runner. */ + args: string[]; +}; + +export type TestRunner = { + readonly ui: string; + /** + * Streams a passthrough test run and resolves with the tool's exit code. + * Unlike the other verbs it returns a number, not a `RunReport`: a test + * runner needs an inherited TTY (watch mode, colors, `--help`), so its output + * is never captured for the board. + */ + test: (options: TestRunOptions) => Promise; +}; diff --git a/run-run/cli/test/integration/plugin-discipline.test.ts b/run-run/cli/test/integration/plugin-discipline.test.ts index 378af0f4..e2e8dc21 100644 --- a/run-run/cli/test/integration/plugin-discipline.test.ts +++ b/run-run/cli/test/integration/plugin-discipline.test.ts @@ -11,6 +11,7 @@ const PLUGIN_DIRS = [ path.resolve(RUN_RUN, "oxc-plugin/src"), path.resolve(RUN_RUN, "ts-plugin/src"), path.resolve(RUN_RUN, "tsdown-plugin/src"), + path.resolve(RUN_RUN, "vitest-plugin/src"), ] as const; function walk(dir: string): string[] { diff --git a/run-run/cli/test/integration/plugins.test.ts b/run-run/cli/test/integration/plugins.test.ts index 4c84047b..3a59387d 100644 --- a/run-run/cli/test/integration/plugins.test.ts +++ b/run-run/cli/test/integration/plugins.test.ts @@ -54,7 +54,7 @@ describe("rr plugins", () => { const r = cli("plugins add not-an-alias --dry-run", { cwd: fixture.dir }); const combined = r.stderr + r.stdout; expect(combined).toMatch(/'not-an-alias' is invalid for argument 'name'/); - expect(combined).toMatch(/Allowed choices are ts, biome, oxc, tsdown/); + expect(combined).toMatch(/Allowed choices are ts, biome, oxc, tsdown, vitest/); expect(r.status).not.toBe(0); }); diff --git a/run-run/cli/test/integration/test.test.ts b/run-run/cli/test/integration/test.test.ts new file mode 100644 index 00000000..e9925011 --- /dev/null +++ b/run-run/cli/test/integration/test.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { createTestCli, fixtures, makeFixture } from "#test/helpers.ts"; + +const cli = createTestCli(); + +const PASSING_TEST = `import { expect, test } from "vitest";\ntest("passes", () => { expect(1).toBe(1); });\n`; +const FAILING_TEST = `import { expect, test } from "vitest";\ntest("fails", () => { expect(1).toBe(2); });\n`; +const ENV_TEST = `import { expect, test } from "vitest";\ntest("reads env", () => { expect(process.env.RR_TEST_VAR).toBe("from-env-test"); });\n`; + +describe("rr test", () => { + let fixture: { dir: string; cleanup: () => void }; + + afterEach(() => fixture?.cleanup()); + + test("forwards `run` and exits 0 when the suite passes", () => { + fixture = makeFixture("test-pass", { + "package.json": fixtures.pkg(), + "run-run.config.mts": fixtures.config(["vitest"]), + "src/ok.test.ts": PASSING_TEST, + }); + const r = cli("test run", { cwd: fixture.dir }); + expect(r.status).toBe(0); + }); + + test("exits non-zero when the suite fails", () => { + fixture = makeFixture("test-fail", { + "package.json": fixtures.pkg(), + "run-run.config.mts": fixtures.config(["vitest"]), + "src/bad.test.ts": FAILING_TEST, + }); + const r = cli("test run", { cwd: fixture.dir }); + expect(r.status).not.toBe(0); + }); + + test("loads .env.test by default", () => { + fixture = makeFixture("test-env-default", { + "package.json": fixtures.pkg(), + "run-run.config.mts": fixtures.config(["vitest"]), + ".env.test": "RR_TEST_VAR=from-env-test\n", + "src/env.test.ts": ENV_TEST, + }); + const r = cli("test run", { cwd: fixture.dir }); + expect(r.status).toBe(0); + }); + + test("loads the file named by --env, overriding the defaults", () => { + fixture = makeFixture("test-env-override", { + "package.json": fixtures.pkg(), + "run-run.config.mts": fixtures.config(["vitest"]), + // A default that would set the WRONG value, plus the override with the right one. + ".env.test": "RR_TEST_VAR=from-default\n", + ".env.ci": "RR_TEST_VAR=from-env-test\n", + "src/env.test.ts": ENV_TEST, + }); + const r = cli("test --env=.env.ci run", { cwd: fixture.dir }); + expect(r.status).toBe(0); + }); + + test("errors when an explicit --env file is missing", () => { + fixture = makeFixture("test-env-missing", { + "package.json": fixtures.pkg(), + "run-run.config.mts": fixtures.config(["vitest"]), + "src/ok.test.ts": PASSING_TEST, + }); + const r = cli("test --env=.env.nope run", { cwd: fixture.dir }); + expect(r.stderr + r.stdout).toMatch(/env file not found: \.env\.nope/); + expect(r.status).not.toBe(0); + }); + + test("forwards --help to vitest instead of showing rr's help", () => { + fixture = makeFixture("test-help", { + "package.json": fixtures.pkg(), + "run-run.config.mts": fixtures.config(["vitest"]), + "src/ok.test.ts": PASSING_TEST, + }); + const r = cli("test --help", { cwd: fixture.dir }); + const combined = r.stdout + r.stderr; + expect(combined).toContain("vitest"); + expect(combined).toMatch(/Usage:/); + expect(combined).not.toMatch(/Powered by:/); // not rr's command help + expect(r.status).toBe(0); + }); + + test("doctor reports vitest healthy", () => { + fixture = makeFixture("test-doctor", { + "package.json": fixtures.pkg(), + "run-run.config.mts": fixtures.config(["vitest"]), + }); + const r = cli("test doctor", { cwd: fixture.dir }); + expect(r.stdout + r.stderr).toContain("vitest"); + expect(r.status).toBe(0); + }); + + test("hints `rr plugins add vitest` when no test provider is configured", () => { + fixture = makeFixture("test-missing-plugin", { + "package.json": fixtures.pkg(), + "run-run.config.mts": fixtures.config(["ts"]), + "src/ok.test.ts": PASSING_TEST, + }); + const r = cli("test run", { cwd: fixture.dir }); + expect(r.stderr + r.stdout).toMatch(/rr plugins add vitest/); + expect(r.status).not.toBe(0); + }); +}); diff --git a/run-run/vitest-plugin/README.md b/run-run/vitest-plugin/README.md new file mode 100644 index 00000000..1c663482 --- /dev/null +++ b/run-run/vitest-plugin/README.md @@ -0,0 +1,38 @@ +# @rrlab/vitest-plugin + +Adds the `test` capability to [`@rrlab/cli`](../cli) — a thin passthrough to [vitest](https://vitest.dev). + +```bash +rr plugins add vitest +``` + +## Usage + +`rr test` forwards every flag and argument straight to vitest, so anything you'd type after `vitest` works unchanged: + +```bash +rr test # run the suite +rr test run --coverage # forwarded verbatim +rr test --project unit # forwarded verbatim +rr test --help # vitest's own help +rr test doctor # health check (reserved by rr, not forwarded) +``` + +### Env files + +By default `rr test` loads an env file before running, picking the **first that exists**: + +1. `.env.test` +2. `.env` + +Override it with `--env` (must come before the forwarded args): + +```bash +rr test --env=.env.ci run +``` + +> It's `--env`, not `--env-file`: Node's early `--env-file` scanner would grab +> that exact token off the `rr` process before the CLI could read it. + +An explicit `--env` file that doesn't exist is an error (typo protection); the +defaults above are loaded only when present. diff --git a/run-run/vitest-plugin/package.json b/run-run/vitest-plugin/package.json new file mode 100644 index 00000000..d3fb21f0 --- /dev/null +++ b/run-run/vitest-plugin/package.json @@ -0,0 +1,57 @@ +{ + "name": "@rrlab/vitest-plugin", + "version": "0.0.0", + "description": "vitest plugin for @rrlab/cli — provides the test capability as a passthrough to vitest.", + "homepage": "https://github.com/variableland/dx/tree/main/run-run/vitest-plugin#readme", + "bugs": { + "url": "https://github.com/variableland/dx/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/variableland/dx.git", + "directory": "run-run/vitest-plugin" + }, + "license": "MIT", + "author": "rcrd ", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "dist", + "src", + "!src/**/__tests__", + "!src/**/*.test.*" + ], + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + } + } + }, + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "build": "tsdown", + "test": "vitest run", + "prepublishOnly": "pnpm build" + }, + "dependencies": { + "@vlandoss/clibuddy": "workspace:*" + }, + "peerDependencies": { + "@rrlab/cli": "workspace:^", + "vitest": ">=3.0.0" + }, + "devDependencies": { + "@rrlab/cli": "workspace:*", + "@rrlab/tsdown-config": "workspace:^", + "@types/semver": "^7.7.1", + "semver": "^7.8.1", + "vitest": "^4.0.0" + } +} diff --git a/run-run/vitest-plugin/src/__tests__/env-file.test.ts b/run-run/vitest-plugin/src/__tests__/env-file.test.ts new file mode 100644 index 00000000..c94f184b --- /dev/null +++ b/run-run/vitest-plugin/src/__tests__/env-file.test.ts @@ -0,0 +1,50 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveEnvFile } from "../index.ts"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rr-vitest-plugin-")); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +const touch = (name: string) => fs.writeFile(path.join(tmpDir, name), ""); + +describe("resolveEnvFile — defaults (first existing wins)", () => { + it("returns null when neither .env.test nor .env exists", async () => { + expect(await resolveEnvFile(tmpDir)).toBeNull(); + }); + + it("picks .env when only .env exists", async () => { + await touch(".env"); + expect(await resolveEnvFile(tmpDir)).toBe(path.join(tmpDir, ".env")); + }); + + it("prefers .env.test over .env when both exist", async () => { + await touch(".env"); + await touch(".env.test"); + expect(await resolveEnvFile(tmpDir)).toBe(path.join(tmpDir, ".env.test")); + }); +}); + +describe("resolveEnvFile — explicit --env-file override", () => { + it("resolves an existing override against cwd", async () => { + await touch("env.local"); + expect(await resolveEnvFile(tmpDir, "env.local")).toBe(path.join(tmpDir, "env.local")); + }); + + it("throws when the explicit override is missing (typo protection)", async () => { + await expect(resolveEnvFile(tmpDir, ".env.nope")).rejects.toThrow(/env file not found: \.env\.nope/); + }); + + it("ignores the .env.test/.env defaults when an override is given", async () => { + await touch(".env.test"); + await expect(resolveEnvFile(tmpDir, "missing.env")).rejects.toThrow(/env file not found/); + }); +}); diff --git a/run-run/vitest-plugin/src/__tests__/install.test.ts b/run-run/vitest-plugin/src/__tests__/install.test.ts new file mode 100644 index 00000000..8b79d919 --- /dev/null +++ b/run-run/vitest-plugin/src/__tests__/install.test.ts @@ -0,0 +1,25 @@ +import type { InstallContext, UninstallContext } from "@rrlab/cli/plugin"; +import { describe, expect, it } from "vitest"; +import { install, TOOL_VERSIONS, uninstall } from "../index.ts"; + +// The vitest plugin is a pure passthrough: install/uninstall don't scaffold any +// config, they only ensure the vitest peer is added/removed. The hooks ignore +// their context, so a bare cast is enough at the test boundary. +const noopInstallCtx = {} as InstallContext; +const noopUninstallCtx = {} as UninstallContext; + +describe("@rrlab/vitest-plugin install()", () => { + it("adds vitest as a devDependency and scaffolds no files", async () => { + const result = await install(noopInstallCtx); + expect(result.devDependencies).toEqual({ vitest: TOOL_VERSIONS.vitest.install }); + expect(result.files).toBeUndefined(); + }); +}); + +describe("@rrlab/vitest-plugin uninstall()", () => { + it("removes vitest and touches no files", async () => { + const result = await uninstall(noopUninstallCtx); + expect(result.removeDependencies).toEqual(["vitest"]); + expect(result.files).toBeUndefined(); + }); +}); diff --git a/run-run/vitest-plugin/src/__tests__/tool-versions.test.ts b/run-run/vitest-plugin/src/__tests__/tool-versions.test.ts new file mode 100644 index 00000000..35c4a989 --- /dev/null +++ b/run-run/vitest-plugin/src/__tests__/tool-versions.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { minVersion, satisfies, subset } from "semver"; +import { describe, expect, it } from "vitest"; +import { TOOL_VERSIONS } from "../tool-versions.ts"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const pkg = JSON.parse(readFileSync(path.resolve(here, "../../package.json"), "utf8")) as { + peerDependencies?: Record; +}; + +describe("TOOL_VERSIONS coherence with this plugin's package.json", () => { + for (const [name, entry] of Object.entries(TOOL_VERSIONS)) { + const peerRange = pkg.peerDependencies?.[name]; + if (!peerRange) continue; + + it(`${name}: install range fits within the declared peer range`, () => { + const ok = subset(entry.install, peerRange) || satisfies(minVersion(entry.install)?.version ?? "", peerRange); + expect(ok, `install=${entry.install} does not fit peer=${peerRange}`).toBe(true); + }); + } +}); diff --git a/run-run/vitest-plugin/src/index.ts b/run-run/vitest-plugin/src/index.ts new file mode 100644 index 00000000..3357bf02 --- /dev/null +++ b/run-run/vitest-plugin/src/index.ts @@ -0,0 +1,92 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + definePlugin, + type InstallContext, + type InstallResult, + type TestRunOptions, + ToolService, + type UninstallContext, + type UninstallResult, +} from "@rrlab/cli/plugin"; +import { colorize, type ShellService } from "@vlandoss/clibuddy"; +import { TOOL_VERSIONS } from "./tool-versions.ts"; + +export { TOOL_VERSIONS } from "./tool-versions.ts"; + +const FROM = import.meta.url; +// Default env files, in order of preference — first existing wins. +const DEFAULT_ENV_FILES = [".env.test", ".env"] as const; +const vitestColor = colorize("#729B1B"); + +export class VitestService extends ToolService { + #cwd: string; + + constructor(shellService: ShellService, cwd: string) { + super({ bin: "vitest", color: vitestColor, shellService, from: FROM }); + this.#cwd = cwd; + } + + async test({ envFile, args }: TestRunOptions): Promise { + const resolved = await resolveEnvFile(this.#cwd, envFile); + const bin = await this.getBinDir(); + // Spawn `node [--env-file=] `: wrapping with node + // is how the env file is loaded (the user's original `node --env-file-…` + // script), and streaming keeps watch mode / colors / --help intact. + const nodeArgs = resolved ? [`--env-file=${resolved}`, bin, ...args] : [bin, ...args]; + return this.runStreamed(process.execPath, nodeArgs); + } +} + +/** + * Resolves which env file vitest should load. + * + * - An explicit `override` (`--env-file`) is an assertion the file exists: it's + * resolved against `cwd` and we throw if it's missing (typo protection), + * rather than silently running with the wrong environment. + * - With no override, the first existing of `.env.test`, then `.env` wins; + * `null` when neither exists, so vitest runs with no env file. + */ +export async function resolveEnvFile(cwd: string, override?: string): Promise { + if (override) { + const abs = path.resolve(cwd, override); + if (await exists(abs)) return abs; + throw new Error(`env file not found: ${override}`); + } + + for (const name of DEFAULT_ENV_FILES) { + const abs = path.resolve(cwd, name); + if (await exists(abs)) return abs; + } + + return null; +} + +async function exists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +// Pure passthrough: no config scaffolding, just make sure the peer is present. +export function install(_ctx: InstallContext): Promise { + return Promise.resolve({ devDependencies: { vitest: TOOL_VERSIONS.vitest.install } }); +} + +export function uninstall(_ctx: UninstallContext): Promise { + return Promise.resolve({ removeDependencies: ["vitest"] }); +} + +const vitest = definePlugin({ + apiVersion: 1, + name: "vitest", + color: vitestColor, + install, + uninstall, + services: ({ shell, cwd }) => ({ test: new VitestService(shell, cwd) }), +}); + +export default vitest; diff --git a/run-run/vitest-plugin/src/tool-versions.ts b/run-run/vitest-plugin/src/tool-versions.ts new file mode 100644 index 00000000..a71cd1fa --- /dev/null +++ b/run-run/vitest-plugin/src/tool-versions.ts @@ -0,0 +1,5 @@ +export const TOOL_VERSIONS = { + // `install` is the prescriptive pin used by `rr plugins add`'s nypm call. + // The looser `>=3.0.0` contract lives in package.json#peerDependencies. + vitest: { install: "^4.0.0" }, +} as const; diff --git a/run-run/vitest-plugin/tsconfig.json b/run-run/vitest-plugin/tsconfig.json new file mode 100644 index 00000000..7221df38 --- /dev/null +++ b/run-run/vitest-plugin/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": ["@rrlab/ts-config/no-dom/lib"] +} diff --git a/run-run/vitest-plugin/tsdown.config.ts b/run-run/vitest-plugin/tsdown.config.ts new file mode 100644 index 00000000..5f826ad7 --- /dev/null +++ b/run-run/vitest-plugin/tsdown.config.ts @@ -0,0 +1,3 @@ +import { defineLibConfig } from "@rrlab/tsdown-config"; + +export default defineLibConfig(); diff --git a/run-run/vitest-plugin/vitest.config.ts b/run-run/vitest-plugin/vitest.config.ts new file mode 100644 index 00000000..8fb6f2dc --- /dev/null +++ b/run-run/vitest-plugin/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({});