diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30d0ae1dad..b4fd537762 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -374,6 +374,7 @@ jobs: cache: npm - run: npm ci - run: npm run docs:check + - run: npm run docs:api:cached - uses: actions/upload-artifact@v4 with: name: api-docs diff --git a/AGENTS.md b/AGENTS.md index 03dacd9519..81c0743713 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,9 +30,12 @@ REA is a layered ESM TypeScript application. Dependencies flow inward from pure - `npm ci`: install the locked dependencies. - `npm run build:cached`: build the CLI and MCP server. -- `npm run test:local`: run focused tests for changed code. +- `npm run test:local`: run changed source tests without building; pass exact source test paths to run them regardless of Git status. +- `npm run test:focused -- PATH...`: run exact test files; build first for boundary, acceptance, or process-global tests. +- `npm run check:changed`: run cached static checks and source tests affected since the branch merge base (default `origin/main`). - `npm run check:fast`: run cached typecheck and lint checks. -- `npm run check:pr`: run the deterministic PR gate and generated-document checks. +- `npm run check:pr`: opt into the complete local deterministic gate and generated-document checks for broad changes; CI owns full coverage. Routine iterations need focused tests and relevant checks, not the whole gate each time. +- `npm run docs:check`: check committed generated documents; `npm run docs:api:cached` separately renders API HTML when needed. - For provider-dependent changes, see [docs/testing.md](docs/testing.md) and run the matching real-provider verification. - Keep each verification lane's prerequisites limited to the claim it checks. Use host-native fixtures for host/provider acceptance; put optional cross-target formats and their external toolchains in a separate lane. Preflight required commands and report the missing dependency and lane clearly. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f08aaab0c0..d2767f0e2a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ REA development requires Node.js 24.18.x and npm 11.16.x. Real-Hopper verificati ```bash npm ci -npm test +npm run check:fast ``` `npm ci` installs the exact dependencies and prepares the Husky hooks without @@ -22,36 +22,43 @@ worktrees. After a package, lockfile, or managed-skill version change, run Keep dependencies flowing inward through the existing domain, contracts, provider, application, server, and adapter layers. Parse unknown values at process and protocol boundaries, model expected failures with `Result`, and preserve the canonical tool inventory defined by `TOOL_CONTRACTS` unless a deliberate contract change updates every verifier, generated catalog artifact, and snapshot. Prefer capability- and session-scoped tool advertisement over schema truncation. -Before submitting a pull request, run: +## Development feedback and PR verification + +For an ordinary edit, run the relevant regression and cached static checks: ```bash -npm run check:pr -npm run verify:package -npm pack --dry-run +npm run test:focused -- src/config.test.ts +npm run check:fast ``` -`npm run check:fast` runs cached typecheck and lint for rapid local feedback. -`npm run check` adds formatting, dead-code, and package-metadata freshness -checks; use `npm run check:test` when the complete test suite is relevant. -Pre-commit formats then lints staged source files, and pre-push runs -`check:fast`. `check:pr` additionally renders API documentation and checks all -committed generated metadata. Real -provider and replay execution remains uncached, but their deterministic build -prerequisite uses Turbo. CI uploads the rendered TypeDoc site as an `api-docs` -artifact; the generated HTML is not committed. +`test:local` selects dirty source tests without building; explicit source test +paths run even on a clean tree. `check:changed` adds source tests affected since +the branch merge base with `origin/main`. Use `npm run test:changed -- --base +REVISION` to choose another base. Changed-test selection follows the import +graph and is feedback, not complete correctness evidence. Inspect relevant +boundary and provider behavior explicitly; see [docs/testing.md](docs/testing.md). -Local `npm test` runs every deterministic Vitest project without coverage, -retries, or verbose output. Focused boundary, acceptance, changed-test, and -watch commands are documented in [docs/testing.md](docs/testing.md), together -with the path-based behavioral-depth rules. Changed-test selection follows the -import graph and is feedback, not the release gate. CI splits the complete suite -across four native Vitest shards, then merges coverage, JUnit, and JSON timing -reports. Coverage thresholds remain in `vitest.config.ts`. +Before handing off a PR, run the relevant tests, `npm run check`, and +`npm run docs:check` when contracts or generated metadata change. Record which +checks ran. CI owns complete deterministic tests and aggregate coverage. Use +`npm run check:pr` for a deliberate full local gate on broad changes or when +investigating CI failures; it is not required after each edit, rebase, or push. +Packaging, setup, installation, or distribution changes also require +`npm run verify:package` and `npm pack --dry-run`. Provider behavior changes +require the matching real-provider `verify:*` lane. -CI installs dependencies once for all static checks, cancels superseded PR -runs, and skips package, Windows, and full test lanes for documentation-only -pull requests. TypeDoc renders only in pull-request CI; it does not run in local -commit or pre-push hooks, or in post-merge `main` CI. +`check:fast` runs cached typecheck and lint, reporting diagnostics on failure. +`check` adds formatting, dead-code, and package-metadata freshness checks. +Pre-commit formats and lints staged files; pre-push runs `check:fast`. +`docs:check` checks committed generated metadata without rendering API HTML. +`docs:generate` regenerates those files; render API HTML separately with +`npm run docs:api:cached`. PR CI renders and uploads the `api-docs` artifact. +Real-provider execution remains uncached; deterministic builds use Turbo. + +Local `npm test` runs every deterministic Vitest project without coverage or +retries. CI runs four coverage shards and merges JUnit and timing reports. +CI cancels superseded PR runs and skips package, Windows, and full test lanes +for documentation-only PRs. Coverage thresholds remain in `vitest.config.ts`. Tests that need a temporary directory must use `createTestTempDirectory` from `tests/fixtures/temporaryDirectory.ts`. The @@ -92,7 +99,7 @@ Describe the behavior change and verification performed in the pull request. Nev ## Maintainer release checklist -Run the full checks, isolated package verifier, package dry run, and two-target real-Hopper verifier described above. Build a local tarball and exercise the executable through the package boundary: +Run `npm run check:pr`, `npm run docs:api:cached`, the isolated package verifier, package dry run, and two-target real-Hopper verifier described above. Build a local tarball and exercise the executable through the package boundary: ```bash npm pack diff --git a/docs/testing.md b/docs/testing.md index 2004c77a06..fa9ec63fc7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -164,26 +164,55 @@ not make native host acceptance unavailable. ## Developer commands -`npm test` runs every deterministic project once. The narrower feedback loops -are: +Use source feedback while editing, explicit boundary checks for the changed +behavior, and complete CI evidence before merging. -| Command | Scope | -| ------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| `npm run test:fast` | Domain, service, adapter, composition, boundary, MCP boundary, and conformance projects | -| `npm run test:local` | Changed and related tests without the build step; lightweight local feedback loop | -| `npm run test:boundary` | Boundary, MCP boundary, and process-global projects | -| `npm run test:mcp` | MCP boundary project only | -| `npm run test:acceptance` | Complete CLI and MCP acceptance workflows | -| `npm run test:changed` | Changed tests in non-serial projects via Vitest's import graph | -| `npm run test:watch` | Changed domain, service, adapter, composition, boundary, MCP boundary, conformance, and evaluation tests in watch mode | -| `npm run test:watch:all` | Changed tests from every deterministic project in watch mode | -| `npm run check:changed` | Cached static checks followed by changed tests | -| `npm run check:pr` | Static, generated-document, and complete deterministic PR gate | +| Command | Scope | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `npm run test:local` | Dirty source tests via the import graph, without build; explicit source paths run regardless of Git status | +| `npm run test:focused -- PATH...` | Exact existing test files; compiled boundaries build first, and unmatched paths fail | +| `npm run test:changed` | Source tests affected since the merge base with `origin/main`, including committed and dirty changes | +| `npm run test:fast` | All domain, service, adapter, composition, conformance, and evaluation tests without build | +| `npm run test:boundary` | Boundary, MCP boundary, process boundary, and process-global projects | +| `npm run test:mcp` | MCP boundary project | +| `npm run test:acceptance` | Complete compiled CLI and MCP acceptance workflows | +| `npm run test:watch` | Dirty source tests in watch mode, without build | +| `npm run test:watch:all` | Changed tests from every project; builds at startup, so rebuild after production edits before relying on compiled tests | +| `npm run check:changed` | Cached typecheck/lint and branch-related source feedback | +| `npm run check:pr` | Opt-in complete local deterministic gate and generated-file checks | +| `npm run docs:check` | Committed generated-document freshness, without API HTML rendering | +| `npm run docs:api:cached` | Explicit cached API HTML rendering | -Changed-test selection is a fast feedback aid, not release evidence. It can -miss behavior connected through runtime registration, generated data, shell -entrypoints, or other relationships that are absent from the import graph. -Use `npm run check:pr` before handing off a contribution. +For example: + +```bash +npm run test:local -- src/config.test.ts +npm run test:focused -- tests/acceptance/applications/runtime.test.ts +npm run test:changed -- --base origin/main +npm run test:changed -- --dry-run +``` + +`test:focused` accepts exact repository-relative test file paths. Source-only +paths do not build; boundary, acceptance, process-global, or unfamiliar `tests/` +paths build conservatively. The build and test run share the repository test +lock. Explicit selections do not use `--changed` or permit zero-test success. +The dry-run option reports the chosen merge base, scope and build prerequisite +without executing tests or building. A missing Git base reports how to fetch +it or select another revision. + +Changed selection can miss runtime registration, generated data, shell +entrypoints, bridges, or other relationships absent from the import graph. +An empty changed selection means no tests were selected, not verified +correctness. Select relevant boundary files and real-provider lanes explicitly. +Source projects also contain large capacity regressions; `test:fast` promises +no compiled-runtime prerequisite, not a fixed time budget. + +Routine iterations and rebases need focused regressions and relevant checks. +Before handing off a PR, run `npm run check` and generated-document checks when +applicable; CI owns the full suite and coverage. Use the full local gate for +broad changes or diagnosing CI, rather than after every edit. Package/install +changes additionally need package verification; provider changes need actual +provider evidence. Local full-suite Vitest runs use up to two workers and schedule projects one at a time. Process, acceptance and process-global projects serialize their @@ -197,8 +226,8 @@ process-global, and other boundary projects retain per-file isolation. `npm test`, `npm run docs:check`, and `npm run docs:generate` share repository-local locks and fail fast when the same class of command is already running. The `npm test` build is inside that lock. `check:pr` runs its test task -before starting documentation validation, so TypeDoc does not compete with the -full suite for memory. +before starting generated-document validation. TypeDoc rendering is a separate +command and CI step. Vitest and Node persistent compile caches are deliberately not enabled by default. To evaluate repeated local runs, opt in for both cold and warm @@ -232,8 +261,9 @@ for another. The PR acceptance target is a median `npm run check:pr` wall time below three minutes across three warm-build runs on the benchmark host. Keep Vitest caches cold unless separately identified. A PR that touches packaging or real-system -behavior also requires `npm run verify:package` and the applicable -`verify:*` lanes. +behavior requires the applicable `verify:*` lanes; packaging and installation +changes also require `npm run verify:package`. The full-gate benchmark measures +that explicit lane, not the routine iteration requirement. ## Apple native metadata and UI diff --git a/knip.json b/knip.json index 1d00501790..1fdacc0083 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,11 @@ { "$schema": "./node_modules/knip/schema.json", - "entry": ["src/main.ts", "src/cli.ts", "src/contracts/errorSchemas.ts"], + "entry": [ + "src/main.ts", + "src/cli.ts", + "src/contracts/errorSchemas.ts", + "scripts/run-development-tests.mjs" + ], "project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"], "ignoreDependencies": ["electron"], "ignoreBinaries": ["ps"], diff --git a/package.json b/package.json index 0485ae3232..c827277c2f 100644 --- a/package.json +++ b/package.json @@ -66,9 +66,9 @@ "precheck": "npm run deps:check", "check": "turbo run typecheck lint format:check knip metadata:check", "precheck:fast": "npm run deps:check", - "check:fast": "turbo run typecheck lint", + "check:fast": "turbo run typecheck lint --output-logs=errors-only", "precheck:changed": "npm run deps:check", - "check:changed": "turbo run typecheck lint && npm run test:changed", + "check:changed": "npm run check:fast && npm run test:changed", "precheck:test": "npm run deps:check", "check:test": "turbo run typecheck lint format:check knip metadata:check test:run", "precheck:pr": "npm run deps:check", @@ -88,17 +88,15 @@ "test": "node scripts/run-exclusive.mjs test npm run test:with-build", "test:with-build": "npm run build:cached && vitest run", "test:run": "node scripts/run-exclusive.mjs test vitest run", - "test:local": "node scripts/run-exclusive.mjs test vitest run --changed --passWithNoTests --maxWorkers=1", - "pretest:fast": "npm run build:cached", - "test:fast": "vitest run --project domain --project services --project adapters --project composition --project boundary --project process-boundary --project mcp-boundary --project conformance", + "test:local": "node scripts/run-exclusive.mjs test node scripts/run-development-tests.mjs local", + "test:fast": "vitest run --project domain --project services --project adapters --project composition --project conformance --project evaluation", "pretest:boundary": "npm run build:cached", "test:boundary": "vitest run --project boundary --project process-boundary --project mcp-boundary --project process-global", "pretest:mcp": "npm run build:cached", "test:mcp": "vitest run --project mcp-boundary", "pretest:acceptance": "npm run build:cached", "test:acceptance": "vitest run --project acceptance", - "pretest:changed": "npm run build:cached", - "test:changed": "vitest run --project domain --project services --project adapters --project composition --project boundary --project process-boundary --project mcp-boundary --project conformance --project evaluation --changed", + "test:changed": "node scripts/run-exclusive.mjs test node scripts/run-development-tests.mjs changed", "pretest:integration": "npm run build:cached", "test:integration": "vitest run --project boundary --project process-boundary --project mcp-boundary --project process-global --project acceptance", "pretest:coverage": "npm run build:cached", @@ -110,8 +108,7 @@ "test:ci:merge": "vitest --merge-reports --coverage.enabled --coverage.provider=v8", "pretest:verbose": "npm run build:cached", "test:verbose": "vitest run --reporter=verbose", - "pretest:watch": "npm run build:cached", - "test:watch": "vitest --project domain --project services --project adapters --project composition --project boundary --project process-boundary --project mcp-boundary --project conformance --project evaluation --changed", + "test:watch": "vitest --project domain --project services --project adapters --project composition --project conformance --project evaluation --changed", "pretest:watch:all": "npm run build:cached", "test:watch:all": "vitest --changed", "typecheck": "tsc --noEmit", @@ -150,14 +147,15 @@ "docs:api": "typedoc", "docs:api:cached": "turbo run docs:api", "docs:generate": "node scripts/run-exclusive.mjs docs npm run docs:generate:unlocked", - "docs:generate:unlocked": "npm run metadata:generate && npm run build:cached && node scripts/generate-mcp-tool-catalog.mjs && node scripts/generate-skill-metadata.mjs && node scripts/generate-product-catalog.mjs && node scripts/generate-completion-ledger.mjs && npm run docs:api:cached && node scripts/generate-error-schema.mjs", + "docs:generate:unlocked": "npm run metadata:generate && npm run build:cached && node scripts/generate-mcp-tool-catalog.mjs && node scripts/generate-skill-metadata.mjs && node scripts/generate-product-catalog.mjs && node scripts/generate-completion-ledger.mjs && node scripts/generate-error-schema.mjs", "docs:check": "node scripts/run-exclusive.mjs docs turbo run docs:check:built", "docs:check:built": "node scripts/generate-package-metadata.mjs --check && node scripts/generate-mcp-tool-catalog.mjs --check && node scripts/generate-skill-metadata.mjs --check && node scripts/generate-product-catalog.mjs --check && node scripts/generate-completion-ledger.mjs --check && node scripts/check-doc-facts.mjs && node scripts/generate-error-schema.mjs --check", "docs:error-schema": "npm run build:cached && node scripts/generate-error-schema.mjs", "prepack": "node scripts/prepack.mjs", "prepare": "node scripts/prepare.mjs", "verify:asset-catalog": "npm run build:cached && node scripts/verify-asset-catalog.mjs", - "verify:native-ui:permissions": "npm run build:cached && node scripts/verify-native-ui.mjs --allow-permission-denial" + "verify:native-ui:permissions": "npm run build:cached && node scripts/verify-native-ui.mjs --allow-permission-denial", + "test:focused": "node scripts/run-exclusive.mjs test node scripts/run-development-tests.mjs focused" }, "lint-staged": { "*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [ diff --git a/scripts/lib/development-tests.d.mts b/scripts/lib/development-tests.d.mts new file mode 100644 index 0000000000..0a02e0c4e1 --- /dev/null +++ b/scripts/lib/development-tests.d.mts @@ -0,0 +1,21 @@ +export interface DevelopmentTestRequest { + readonly mode: "local" | "changed" | "focused"; + readonly paths: readonly string[]; + readonly base: string; + readonly dryRun: boolean; +} + +/** Parse explicit test paths and an optional branch-comparison base. */ +export function parseDevelopmentTestRequest( + mode: string, + arguments_: readonly string[], +): DevelopmentTestRequest; + +/** Plan source feedback or a strict explicit run with its build prerequisite. */ +export function developmentTestPlan( + request: DevelopmentTestRequest, + baseCommit?: string, +): { + readonly needsBuild: boolean; + readonly vitestArguments: readonly string[]; +}; diff --git a/scripts/lib/development-tests.mjs b/scripts/lib/development-tests.mjs new file mode 100644 index 0000000000..0bbd5ed1e9 --- /dev/null +++ b/scripts/lib/development-tests.mjs @@ -0,0 +1,77 @@ +const sourceProjects = [ + "domain", + "services", + "adapters", + "composition", + "conformance", + "evaluation", +]; + +const sourceTest = (path) => + path.startsWith("src/") || + ["composition", "conformance", "evaluation"].some((project) => + path.startsWith(`tests/${project}/`), + ); + +/** Parse explicit test paths and an optional branch-comparison base. */ +export const parseDevelopmentTestRequest = (mode, arguments_) => { + if (!["local", "changed", "focused"].includes(mode)) + throw new Error("Expected local, changed, or focused test mode"); + const paths = []; + let base = "origin/main"; + let dryRun = false; + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index]; + if (argument === "--dry-run") dryRun = true; + else if (argument === "--base" || argument.startsWith("--base=")) { + if (mode !== "changed") throw new Error("--base requires changed mode"); + base = argument === "--base" ? arguments_[++index] : argument.slice(7); + if (!base || base.startsWith("-") || base.includes("\0")) + throw new Error("--base requires a Git revision"); + } else { + const path = argument.replaceAll("\\", "/").replace(/^\.\//u, ""); + if ( + !/^(?:src|tests)\/.+\.test\.ts$/u.test(path) || + path.split("/").some((part) => part === ".." || part === ".") + ) + throw new Error(`Expected an exact repository test path: ${argument}`); + if (mode !== "focused" && !sourceTest(path)) + throw new Error( + `Use test:focused for compiled boundary tests: ${path}`, + ); + paths.push(path); + } + } + if (mode === "focused" && paths.length === 0) + throw new Error("test:focused requires at least one exact test path"); + if (mode === "changed" && paths.length > 0) + throw new Error("Use test:focused for explicit test paths"); + return { mode, paths: [...new Set(paths)], base, dryRun }; +}; + +/** Plan source feedback or a strict explicit run with its build prerequisite. */ +export const developmentTestPlan = (request, baseCommit) => { + const explicit = request.paths.length > 0; + if ( + request.mode === "changed" && + !/^[a-f0-9]{40,64}$/u.test(baseCommit ?? "") + ) + throw new Error("Changed tests require a resolved Git merge base"); + const projects = request.mode === "focused" ? [] : sourceProjects; + return { + needsBuild: request.paths.some((path) => !sourceTest(path)), + vitestArguments: [ + "run", + "--maxWorkers=1", + ...projects.flatMap((project) => ["--project", project]), + ...(explicit + ? request.paths + : [ + request.mode === "changed" + ? `--changed=${baseCommit}` + : "--changed", + "--passWithNoTests", + ]), + ], + }; +}; diff --git a/scripts/run-development-tests.mjs b/scripts/run-development-tests.mjs new file mode 100644 index 0000000000..2b1ac5a3f3 --- /dev/null +++ b/scripts/run-development-tests.mjs @@ -0,0 +1,90 @@ +import { spawn } from "node:child_process"; +import { stat } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { + developmentTestPlan, + parseDevelopmentTestRequest, +} from "./lib/development-tests.mjs"; + +let child; +let shutdownSignal; +const signalExitCodes = { SIGINT: 130, SIGTERM: 143 }; +for (const signal of Object.keys(signalExitCodes)) + process.on(signal, () => { + shutdownSignal ??= signal; + child?.kill(signal); + }); + +try { + const [mode, ...arguments_] = process.argv.slice(2); + const request = parseDevelopmentTestRequest(mode, arguments_); + for (const path of request.paths) { + if (!(await stat(path)).isFile()) + throw new Error(`Test path is not a file: ${path}`); + } + let baseCommit; + if (request.mode === "changed") { + const base = await run("git", ["merge-base", "HEAD", request.base], true); + if (base.code !== 0) + throw new Error( + `Cannot resolve test base ${request.base}. Fetch origin/main or pass --base REVISION.\n${base.output.trim()}`, + ); + baseCommit = base.output.trim(); + } + const plan = developmentTestPlan(request, baseCommit); + if (request.dryRun) { + console.log(JSON.stringify({ ...request, baseCommit, ...plan }, null, 2)); + } else { + console.error( + request.mode === "focused" + ? `Focused tests: ${request.paths.join(", ")}` + : `Source-test feedback${baseCommit ? ` from merge base ${baseCommit}` : ""}; compiled boundaries and real providers need explicit verification.`, + ); + if (plan.needsBuild) { + if (!process.env.npm_execpath) + throw new Error("Run compiled tests through npm run test:focused"); + const built = await run(process.execPath, [ + process.env.npm_execpath, + "run", + "build:cached", + ]); + if (built.code !== 0) process.exitCode = built.code; + } + if (!process.exitCode && !shutdownSignal) { + const tested = await run(process.execPath, [ + resolve("node_modules/vitest/vitest.mjs"), + ...plan.vitestArguments, + ]); + process.exitCode = tested.code; + } + } +} catch (cause) { + console.error(cause instanceof Error ? cause.message : String(cause)); + process.exitCode = 1; +} +if (shutdownSignal) process.exitCode = signalExitCodes[shutdownSignal]; + +function run(executable, arguments_, capture = false) { + if (shutdownSignal) + return Promise.resolve({ + code: signalExitCodes[shutdownSignal], + output: "", + }); + return new Promise((resolveResult, reject) => { + let output = ""; + child = spawn(executable, arguments_, { + stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit", + }); + if (capture) { + child.stdout.on("data", (data) => { + output += data.toString(); + }); + child.stderr.on("data", (data) => { + output += data.toString(); + }); + } + child.once("error", reject); + child.once("close", (code) => resolveResult({ code: code ?? 1, output })); + }); +} diff --git a/tests/boundary/cli/developmentTests.test.ts b/tests/boundary/cli/developmentTests.test.ts new file mode 100644 index 0000000000..21d8c5127f --- /dev/null +++ b/tests/boundary/cli/developmentTests.test.ts @@ -0,0 +1,100 @@ +import { execFile } from "node:child_process"; +import { mkdir, symlink, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; + +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const execute = promisify(execFile); +const runner = resolve("scripts/run-development-tests.mjs"); + +describe("development test runner", () => { + it("rejects nonexistent explicit paths instead of returning a zero-test success", async () => { + await expect( + execute(process.execPath, [ + runner, + "focused", + "src/missing.test.ts", + "--dry-run", + ]), + ).rejects.toMatchObject({ code: 1 }); + }); + + it("reports actionable remediation when the comparison base is unavailable", async () => { + await expect( + execute(process.execPath, [ + runner, + "changed", + "--base", + "missing-test-base", + "--dry-run", + ]), + ).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining( + "Fetch origin/main or pass --base REVISION", + ), + }); + }); + + it("runs a committed branch regression through real Git and Vitest without dist", async () => { + const root = await createTestTempDirectory("rea-development-tests-"); + await mkdir(join(root, "src")); + await symlink( + resolve("node_modules"), + join(root, "node_modules"), + "junction", + ); + await writeFile( + join(root, "package.json"), + JSON.stringify({ type: "module" }), + ); + await writeFile(join(root, ".gitignore"), "node_modules\n"); + await writeFile( + join(root, "vitest.config.ts"), + `export default { test: { projects: [{ test: { name: "domain", include: ["src/**/*.test.ts"] } }] } };`, + ); + await writeFile(join(root, "src/value.ts"), "export const value = 1;\n"); + await writeFile( + join(root, "src/value.test.ts"), + `import { it, expect } from "vitest"; import { value } from "./value"; it("checks current source", () => expect(value).toBe(1));\n`, + ); + const git = (arguments_: string[]) => + execute("git", arguments_, { cwd: root }); + await git(["init", "-b", "main"]); + await git(["add", ".gitignore", "package.json", "vitest.config.ts", "src"]); + const commit = [ + "-c", + "user.name=REA fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "-m", + "fixture", + ]; + await git(commit); + const { stdout: baseline } = await git(["rev-parse", "HEAD"]); + await git(["update-ref", "refs/remotes/origin/main", baseline.trim()]); + await git(["switch", "-c", "regression"]); + await writeFile(join(root, "src/value.ts"), "export const value = 2;\n"); + await writeFile( + join(root, "src/value.test.ts"), + `import { it, expect } from "vitest"; import { value } from "./value"; it("checks current source", () => expect(value).toBe(2));\n`, + ); + await git(["add", "src"]); + await git(commit); + expect((await git(["status", "--porcelain"])).stdout).toBe(""); + const { stdout } = await execute(process.execPath, [runner, "changed"], { + cwd: root, + }); + expect(stdout).toContain("1 passed"); + const explicit = await execute( + process.execPath, + [runner, "focused", "src/value.test.ts"], + { cwd: root }, + ); + expect(explicit.stdout).toContain("1 passed"); + }, 30_000); +}); diff --git a/tests/evaluation/developmentTestPlan.test.ts b/tests/evaluation/developmentTestPlan.test.ts new file mode 100644 index 0000000000..90ed57ca58 --- /dev/null +++ b/tests/evaluation/developmentTestPlan.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import { + developmentTestPlan, + parseDevelopmentTestRequest, +} from "../../scripts/lib/development-tests.mjs"; + +describe("development test selection", () => { + it("keeps dirty source feedback independent of compiled artifacts", () => { + const plan = developmentTestPlan(parseDevelopmentTestRequest("local", [])); + expect(plan.needsBuild).toBe(false); + expect(plan.vitestArguments).toContain("--changed"); + expect(plan.vitestArguments).not.toContain("acceptance"); + expect(plan.vitestArguments).not.toContain("process-global"); + }); + + it("always runs explicitly selected source tests, including on a clean tree", () => { + const path = "src/config.test.ts"; + const plan = developmentTestPlan( + parseDevelopmentTestRequest("local", [path, path]), + ); + expect(plan.needsBuild).toBe(false); + expect( + plan.vitestArguments.filter((argument) => argument === path), + ).toHaveLength(1); + expect( + plan.vitestArguments.some((argument) => argument.startsWith("--changed")), + ).toBe(false); + expect(plan.vitestArguments).not.toContain("--passWithNoTests"); + }); + + it("builds before explicitly selected compiled boundaries", () => { + const plan = developmentTestPlan( + parseDevelopmentTestRequest("focused", [ + "src/config.test.ts", + "tests/acceptance/applications/runtime.test.ts", + ]), + ); + expect(plan.needsBuild).toBe(true); + expect(plan.vitestArguments).not.toContain("--passWithNoTests"); + expect(plan.vitestArguments).not.toContain("--changed"); + }); + + it("uses the resolved branch merge base for committed source changes", () => { + const request = parseDevelopmentTestRequest("changed", ["--base", "main"]); + const plan = developmentTestPlan(request, "a".repeat(40)); + expect(request.base).toBe("main"); + expect(plan.vitestArguments).toContain(`--changed=${"a".repeat(40)}`); + expect(() => developmentTestPlan(request)).toThrow( + "resolved Git merge base", + ); + }); + + it.each([ + ["focused", []], + ["focused", ["../src/config.test.ts"]], + ["focused", ["src/../config.test.ts"]], + ["focused", ["--passWithNoTests"]], + ["focused", ["--changed"]], + ["local", ["tests/acceptance/applications/runtime.test.ts"]], + ["changed", ["--base"]], + ["changed", ["--base=--help"]], + ["changed", ["src/config.test.ts"]], + ["local", ["--base", "main"]], + ["unknown", []], + ])("rejects invalid %s selections: %j", (mode, paths) => { + expect(() => parseDevelopmentTestRequest(mode, paths)).toThrow(); + }); +}); diff --git a/turbo.json b/turbo.json index 8293b2a305..a7ff64f44d 100644 --- a/turbo.json +++ b/turbo.json @@ -12,6 +12,8 @@ "scripts/lib/generated-file.mjs", "skills/reverse-engineer-anything/SKILL.md", "src/**/*.ts", + "!src/**/*.test.ts", + "!src/**/*.fixture.ts", "tsconfig.json", "tsconfig.build.json" ], @@ -90,8 +92,12 @@ "package.json", "package-lock.json", "src/**/*.ts", + "!src/**/*.test.ts", + "!src/**/*.fixture.ts", "tsconfig.json", - "typedoc.json" + "typedoc.json", + ".nvmrc", + "tsconfig.build.json" ], "outputs": ["build/api-docs/**"], "outputLogs": "new-only" @@ -101,7 +107,7 @@ "cache": false }, "docs:check:built": { - "dependsOn": ["build", "docs:api"], + "dependsOn": ["build"], "cache": false } } diff --git a/typedoc.json b/typedoc.json index 96e20d8092..0506bc925d 100644 --- a/typedoc.json +++ b/typedoc.json @@ -15,8 +15,14 @@ ], "entryPointStrategy": "expand", "out": "build/api-docs", - "tsconfig": "tsconfig.json", - "exclude": ["**/*.test.ts", "tests/**", "dist/**", "node_modules/**"], + "tsconfig": "tsconfig.build.json", + "exclude": [ + "**/*.test.ts", + "**/*.fixture.ts", + "tests/**", + "dist/**", + "node_modules/**" + ], "excludeExternals": true, "excludePrivate": true, "excludeProtected": false,