Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/add-vitest-plugin.md
Original file line number Diff line number Diff line change
@@ -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 <path>`). This adds a streaming `test` capability to the kernel (`TestRunner`, `ToolService.runStreamed`) alongside the existing captured verbs.
38 changes: 38 additions & 0 deletions decisions/016-vitest-passthrough-plugin.md
Original file line number Diff line number Diff line change
@@ -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<number>` 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 <path>`** (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=<resolved> <vitest-bin>` (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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
35 changes: 30 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions run-run/cli/src/actions/test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const code = await runner.test({ envFile: options.envFile, args });
if (code !== 0) process.exitCode = code;
}
1 change: 1 addition & 0 deletions run-run/cli/src/lib/plugin/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, PluginInfo>;

export type PluginName = keyof typeof PLUGINS_DIRECTORY;
Expand Down
2 changes: 2 additions & 0 deletions run-run/cli/src/lib/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export type {
RunReport,
StaticChecker,
StaticCheckerOptions,
TestRunner,
TestRunOptions,
TypeChecker,
TypeCheckOptions,
UninstallContext,
Expand Down
18 changes: 18 additions & 0 deletions run-run/cli/src/lib/plugin/tool-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* `$ <cmd>` 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=… <bin>`) 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<number> {
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<RunReport> {
const output = await this.#shellService.runCaptured(await this.getBinDir(), ["--help"], { throwOnError: false });
const ok = output.exitCode === 0;
Expand Down
3 changes: 2 additions & 1 deletion run-run/cli/src/lib/plugin/types.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -13,6 +13,7 @@ export type PluginServices = {
jscheck?: StaticChecker & Doctor;
typecheck?: TypeChecker & Doctor;
pack?: Packer & Doctor;
test?: TestRunner & Doctor;
};

export type PluginContext = {
Expand Down
36 changes: 36 additions & 0 deletions run-run/cli/src/program/commands/test.ts
Original file line number Diff line number Diff line change
@@ -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 <path>", "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 });
})
);
}
3 changes: 3 additions & 0 deletions run-run/cli/src/program/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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:")
Expand Down
18 changes: 18 additions & 0 deletions run-run/cli/src/types/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,21 @@ export type Packer = {
readonly ui: string;
pack: () => Promise<RunReport>;
};

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<number>;
};
1 change: 1 addition & 0 deletions run-run/cli/test/integration/plugin-discipline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down
2 changes: 1 addition & 1 deletion run-run/cli/test/integration/plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
Loading
Loading