From 5eb761be3c6d299f095967de919b8de9b3fa69e2 Mon Sep 17 00:00:00 2001 From: Christopher Burns Date: Thu, 19 Feb 2026 22:46:49 +0000 Subject: [PATCH 1/2] Update dependencies and enhance testing configuration for Vitest - Updated bun.lock and package.json to specify exact versions for dependencies, improving stability and compatibility. - Added coverage configuration in vitest.config.ts to enable detailed test coverage reporting. - Introduced new test scripts in package.json for running coverage checks. - Created new documentation files for Vitest skills and references, enhancing the overall testing framework documentation. --- .agents/skills/vitest/GENERATION.md | 5 + .agents/skills/vitest/SKILL.md | 53 ++++ .../references/advanced-environments.md | 264 +++++++++++++++ .../vitest/references/advanced-projects.md | 300 ++++++++++++++++++ .../references/advanced-type-testing.md | 242 ++++++++++++++ .../skills/vitest/references/advanced-vi.md | 251 +++++++++++++++ .agents/skills/vitest/references/core-cli.md | 167 ++++++++++ .../skills/vitest/references/core-config.md | 177 +++++++++++ .../skills/vitest/references/core-describe.md | 193 +++++++++++ .../skills/vitest/references/core-expect.md | 211 ++++++++++++ .../skills/vitest/references/core-hooks.md | 245 ++++++++++++++ .../skills/vitest/references/core-test-api.md | 237 ++++++++++++++ .../vitest/references/features-concurrency.md | 250 +++++++++++++++ .../vitest/references/features-context.md | 240 ++++++++++++++ .../vitest/references/features-coverage.md | 202 ++++++++++++ .../vitest/references/features-filtering.md | 208 ++++++++++++ .../vitest/references/features-mocking.md | 272 ++++++++++++++++ .../vitest/references/features-snapshots.md | 207 ++++++++++++ .claude/skills/vitest | 1 + .cursor/skills/vitest | 1 + bun.lock | 81 +++-- package.json | 2 + test/generator-single.test.ts | 140 ++++++++ test/generator-workspace.test.ts | 110 +++++++ test/load-global-config.test.ts | 76 +++++ test/resolve-invocation.test.ts | 22 ++ test/template.test.ts | 138 +++++++- vitest.config.ts | 17 + 28 files changed, 4281 insertions(+), 31 deletions(-) create mode 100644 .agents/skills/vitest/GENERATION.md create mode 100644 .agents/skills/vitest/SKILL.md create mode 100644 .agents/skills/vitest/references/advanced-environments.md create mode 100644 .agents/skills/vitest/references/advanced-projects.md create mode 100644 .agents/skills/vitest/references/advanced-type-testing.md create mode 100644 .agents/skills/vitest/references/advanced-vi.md create mode 100644 .agents/skills/vitest/references/core-cli.md create mode 100644 .agents/skills/vitest/references/core-config.md create mode 100644 .agents/skills/vitest/references/core-describe.md create mode 100644 .agents/skills/vitest/references/core-expect.md create mode 100644 .agents/skills/vitest/references/core-hooks.md create mode 100644 .agents/skills/vitest/references/core-test-api.md create mode 100644 .agents/skills/vitest/references/features-concurrency.md create mode 100644 .agents/skills/vitest/references/features-context.md create mode 100644 .agents/skills/vitest/references/features-coverage.md create mode 100644 .agents/skills/vitest/references/features-filtering.md create mode 100644 .agents/skills/vitest/references/features-mocking.md create mode 100644 .agents/skills/vitest/references/features-snapshots.md create mode 120000 .claude/skills/vitest create mode 120000 .cursor/skills/vitest create mode 100644 test/generator-single.test.ts create mode 100644 test/generator-workspace.test.ts create mode 100644 test/load-global-config.test.ts diff --git a/.agents/skills/vitest/GENERATION.md b/.agents/skills/vitest/GENERATION.md new file mode 100644 index 0000000..9bc7664 --- /dev/null +++ b/.agents/skills/vitest/GENERATION.md @@ -0,0 +1,5 @@ +# Generation Info + +- **Source:** `sources/vitest` +- **Git SHA:** `4a7321e10672f00f0bb698823a381c2cc245b8f7` +- **Generated:** 2026-01-28 diff --git a/.agents/skills/vitest/SKILL.md b/.agents/skills/vitest/SKILL.md new file mode 100644 index 0000000..d813e2c --- /dev/null +++ b/.agents/skills/vitest/SKILL.md @@ -0,0 +1,53 @@ +--- +name: vitest +description: Vitest fast unit testing framework powered by Vite with Jest-compatible API. Use when writing tests, mocking, configuring coverage, or working with test filtering and fixtures. +metadata: + author: Anthony Fu + version: "2026.1.28" + source: Generated from https://github.com/vitest-dev/vitest, scripts located at https://github.com/antfu/skills +--- + +Vitest is a next-generation testing framework powered by Vite. It provides a Jest-compatible API with native ESM, TypeScript, and JSX support out of the box. Vitest shares the same config, transformers, resolvers, and plugins with your Vite app. + +**Key Features:** + +- Vite-native: Uses Vite's transformation pipeline for fast HMR-like test updates +- Jest-compatible: Drop-in replacement for most Jest test suites +- Smart watch mode: Only reruns affected tests based on module graph +- Native ESM, TypeScript, JSX support without configuration +- Multi-threaded workers for parallel test execution +- Built-in coverage via V8 or Istanbul +- Snapshot testing, mocking, and spy utilities + +> The skill is based on Vitest 3.x, generated at 2026-01-28. + +## Core + +| Topic | Description | Reference | +| ------------- | --------------------------------------------------------------- | -------------------------------------------- | +| Configuration | Vitest and Vite config integration, defineConfig usage | [core-config](references/core-config.md) | +| CLI | Command line interface, commands and options | [core-cli](references/core-cli.md) | +| Test API | test/it function, modifiers like skip, only, concurrent | [core-test-api](references/core-test-api.md) | +| Describe API | describe/suite for grouping tests and nested suites | [core-describe](references/core-describe.md) | +| Expect API | Assertions with toBe, toEqual, matchers and asymmetric matchers | [core-expect](references/core-expect.md) | +| Hooks | beforeEach, afterEach, beforeAll, afterAll, aroundEach | [core-hooks](references/core-hooks.md) | + +## Features + +| Topic | Description | Reference | +| ------------ | -------------------------------------------------------------- | ---------------------------------------------------------- | +| Mocking | Mock functions, modules, timers, dates with vi utilities | [features-mocking](references/features-mocking.md) | +| Snapshots | Snapshot testing with toMatchSnapshot and inline snapshots | [features-snapshots](references/features-snapshots.md) | +| Coverage | Code coverage with V8 or Istanbul providers | [features-coverage](references/features-coverage.md) | +| Test Context | Test fixtures, context.expect, test.extend for custom fixtures | [features-context](references/features-context.md) | +| Concurrency | Concurrent tests, parallel execution, sharding | [features-concurrency](references/features-concurrency.md) | +| Filtering | Filter tests by name, file patterns, tags | [features-filtering](references/features-filtering.md) | + +## Advanced + +| Topic | Description | Reference | +| ------------ | ------------------------------------------------------- | ------------------------------------------------------------ | +| Vi Utilities | vi helper: mock, spyOn, fake timers, hoisted, waitFor | [advanced-vi](references/advanced-vi.md) | +| Environments | Test environments: node, jsdom, happy-dom, custom | [advanced-environments](references/advanced-environments.md) | +| Type Testing | Type-level testing with expectTypeOf and assertType | [advanced-type-testing](references/advanced-type-testing.md) | +| Projects | Multi-project workspaces, different configs per project | [advanced-projects](references/advanced-projects.md) | diff --git a/.agents/skills/vitest/references/advanced-environments.md b/.agents/skills/vitest/references/advanced-environments.md new file mode 100644 index 0000000..686eb43 --- /dev/null +++ b/.agents/skills/vitest/references/advanced-environments.md @@ -0,0 +1,264 @@ +--- +name: test-environments +description: Configure environments like jsdom, happy-dom for browser APIs +--- + +# Test Environments + +## Available Environments + +- `node` (default) - Node.js environment +- `jsdom` - Browser-like with DOM APIs +- `happy-dom` - Faster alternative to jsdom +- `edge-runtime` - Vercel Edge Runtime + +## Configuration + +```ts +// vitest.config.ts +defineConfig({ + test: { + environment: "jsdom", + + // Environment-specific options + environmentOptions: { + jsdom: { + url: "http://localhost", + }, + }, + }, +}); +``` + +## Installing Environment Packages + +```bash +# jsdom +npm i -D jsdom + +# happy-dom (faster, fewer APIs) +npm i -D happy-dom +``` + +## Per-File Environment + +Use magic comment at top of file: + +```ts +// @vitest-environment jsdom + +import { expect, test } from "vitest"; + +test("DOM test", () => { + const div = document.createElement("div"); + expect(div).toBeInstanceOf(HTMLDivElement); +}); +``` + +## jsdom Environment + +Full browser environment simulation: + +```ts +// @vitest-environment jsdom + +test("DOM manipulation", () => { + document.body.innerHTML = '
'; + + const app = document.getElementById("app"); + app.textContent = "Hello"; + + expect(app.textContent).toBe("Hello"); +}); + +test("window APIs", () => { + expect(window.location.href).toBeDefined(); + expect(localStorage).toBeDefined(); +}); +``` + +### jsdom Options + +```ts +defineConfig({ + test: { + environmentOptions: { + jsdom: { + url: "http://localhost:3000", + html: "", + userAgent: "custom-agent", + resources: "usable", + }, + }, + }, +}); +``` + +## happy-dom Environment + +Faster but fewer APIs: + +```ts +// @vitest-environment happy-dom + +test("basic DOM", () => { + const el = document.createElement("div"); + el.className = "test"; + expect(el.className).toBe("test"); +}); +``` + +## Multiple Environments per Project + +Use projects for different environments: + +```ts +defineConfig({ + test: { + projects: [ + { + test: { + name: "unit", + include: ["tests/unit/**/*.test.ts"], + environment: "node", + }, + }, + { + test: { + name: "dom", + include: ["tests/dom/**/*.test.ts"], + environment: "jsdom", + }, + }, + ], + }, +}); +``` + +## Custom Environment + +Create custom environment package: + +```ts +// vitest-environment-custom/index.ts +import type { Environment } from "vitest/runtime"; + +export default { + name: "custom", + viteEnvironment: "ssr", // or 'client' + + setup() { + // Setup global state + globalThis.myGlobal = "value"; + + return { + teardown() { + delete globalThis.myGlobal; + }, + }; + }, +}; +``` + +Use with: + +```ts +defineConfig({ + test: { + environment: "custom", + }, +}); +``` + +## Environment with VM + +For full isolation: + +```ts +export default { + name: "isolated", + viteEnvironment: "ssr", + + async setupVM() { + const vm = await import("node:vm"); + const context = vm.createContext(); + + return { + getVmContext() { + return context; + }, + teardown() {}, + }; + }, + + setup() { + return { teardown() {} }; + }, +}; +``` + +## Browser Mode (Separate from Environments) + +For real browser testing, use Vitest Browser Mode: + +```ts +defineConfig({ + test: { + browser: { + enabled: true, + name: "chromium", // or 'firefox', 'webkit' + provider: "playwright", + }, + }, +}); +``` + +## CSS and Assets + +In jsdom/happy-dom, configure CSS handling: + +```ts +defineConfig({ + test: { + css: true, // Process CSS + + // Or with options + css: { + include: /\.module\.css$/, + modules: { + classNameStrategy: "non-scoped", + }, + }, + }, +}); +``` + +## Fixing External Dependencies + +If external deps fail with CSS/asset errors: + +```ts +defineConfig({ + test: { + server: { + deps: { + inline: ["problematic-package"], + }, + }, + }, +}); +``` + +## Key Points + +- Default is `node` - no browser APIs +- Use `jsdom` for full browser simulation +- Use `happy-dom` for faster tests with basic DOM +- Per-file environment via `// @vitest-environment` comment +- Use projects for multiple environment configurations +- Browser Mode is for real browser testing, not environment + + diff --git a/.agents/skills/vitest/references/advanced-projects.md b/.agents/skills/vitest/references/advanced-projects.md new file mode 100644 index 0000000..3e0ec49 --- /dev/null +++ b/.agents/skills/vitest/references/advanced-projects.md @@ -0,0 +1,300 @@ +--- +name: projects-workspaces +description: Multi-project configuration for monorepos and different test types +--- + +# Projects + +Run different test configurations in the same Vitest process. + +## Basic Projects Setup + +```ts +// vitest.config.ts +defineConfig({ + test: { + projects: [ + // Glob patterns for config files + "packages/*", + + // Inline config + { + test: { + name: "unit", + include: ["tests/unit/**/*.test.ts"], + environment: "node", + }, + }, + { + test: { + name: "integration", + include: ["tests/integration/**/*.test.ts"], + environment: "jsdom", + }, + }, + ], + }, +}); +``` + +## Monorepo Pattern + +```ts +defineConfig({ + test: { + projects: [ + // Each package has its own vitest.config.ts + "packages/core", + "packages/cli", + "packages/utils", + ], + }, +}); +``` + +Package config: + +```ts +// packages/core/vitest.config.ts +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + name: "core", + include: ["src/**/*.test.ts"], + environment: "node", + }, +}); +``` + +## Different Environments + +Run same tests in different environments: + +```ts +defineConfig({ + test: { + projects: [ + { + test: { + name: "happy-dom", + root: "./shared-tests", + environment: "happy-dom", + setupFiles: ["./setup.happy-dom.ts"], + }, + }, + { + test: { + name: "node", + root: "./shared-tests", + environment: "node", + setupFiles: ["./setup.node.ts"], + }, + }, + ], + }, +}); +``` + +## Browser + Node Projects + +```ts +defineConfig({ + test: { + projects: [ + { + test: { + name: "unit", + include: ["tests/unit/**/*.test.ts"], + environment: "node", + }, + }, + { + test: { + name: "browser", + include: ["tests/browser/**/*.test.ts"], + browser: { + enabled: true, + name: "chromium", + provider: "playwright", + }, + }, + }, + ], + }, +}); +``` + +## Shared Configuration + +```ts +// vitest.shared.ts +export const sharedConfig = { + testTimeout: 10000, + setupFiles: ["./tests/setup.ts"], +}; + +// vitest.config.ts +import { sharedConfig } from "./vitest.shared"; + +defineConfig({ + test: { + projects: [ + { + test: { + ...sharedConfig, + name: "unit", + include: ["tests/unit/**/*.test.ts"], + }, + }, + { + test: { + ...sharedConfig, + name: "e2e", + include: ["tests/e2e/**/*.test.ts"], + }, + }, + ], + }, +}); +``` + +## Project-Specific Dependencies + +Each project can have different dependencies inlined: + +```ts +defineConfig({ + test: { + projects: [ + { + test: { + name: "project-a", + server: { + deps: { + inline: ["package-a"], + }, + }, + }, + }, + ], + }, +}); +``` + +## Running Specific Projects + +```bash +# Run specific project +vitest --project unit +vitest --project integration + +# Multiple projects +vitest --project unit --project e2e + +# Exclude project +vitest --project.ignore browser +``` + +## Providing Values to Projects + +Share values from config to tests: + +```ts +// vitest.config.ts +defineConfig({ + test: { + projects: [ + { + test: { + name: "staging", + provide: { + apiUrl: "https://staging.api.com", + debug: true, + }, + }, + }, + { + test: { + name: "production", + provide: { + apiUrl: "https://api.com", + debug: false, + }, + }, + }, + ], + }, +}); + +// In tests, use inject +import { inject } from "vitest"; + +test("uses correct api", () => { + const url = inject("apiUrl"); + expect(url).toContain("api.com"); +}); +``` + +## With Fixtures + +```ts +const test = base.extend({ + apiUrl: ["/default", { injected: true }], +}); + +test("uses injected url", ({ apiUrl }) => { + // apiUrl comes from project's provide config +}); +``` + +## Project Isolation + +Each project runs in its own thread pool by default: + +```ts +defineConfig({ + test: { + projects: [ + { + test: { + name: "isolated", + isolate: true, // Full isolation + pool: "forks", + }, + }, + ], + }, +}); +``` + +## Global Setup per Project + +```ts +defineConfig({ + test: { + projects: [ + { + test: { + name: "with-db", + globalSetup: ["./tests/db-setup.ts"], + }, + }, + ], + }, +}); +``` + +## Key Points + +- Projects run in same Vitest process +- Each project can have different environment, config +- Use glob patterns for monorepo packages +- Run specific projects with `--project` flag +- Use `provide` to inject config values into tests +- Projects inherit from root config unless overridden + + diff --git a/.agents/skills/vitest/references/advanced-type-testing.md b/.agents/skills/vitest/references/advanced-type-testing.md new file mode 100644 index 0000000..87d20f8 --- /dev/null +++ b/.agents/skills/vitest/references/advanced-type-testing.md @@ -0,0 +1,242 @@ +--- +name: type-testing +description: Test TypeScript types with expectTypeOf and assertType +--- + +# Type Testing + +Test TypeScript types without runtime execution. + +## Setup + +Type tests use `.test-d.ts` extension: + +```ts +// math.test-d.ts +import { expectTypeOf } from "vitest"; +import { add } from "./math"; + +test("add returns number", () => { + expectTypeOf(add).returns.toBeNumber(); +}); +``` + +## Configuration + +```ts +defineConfig({ + test: { + typecheck: { + enabled: true, + + // Only type check + only: false, + + // Checker: 'tsc' or 'vue-tsc' + checker: "tsc", + + // Include patterns + include: ["**/*.test-d.ts"], + + // tsconfig to use + tsconfig: "./tsconfig.json", + }, + }, +}); +``` + +## expectTypeOf API + +```ts +import { expectTypeOf } from "vitest"; + +// Basic type checks +expectTypeOf().toBeString(); +expectTypeOf().toBeNumber(); +expectTypeOf().toBeBoolean(); +expectTypeOf().toBeNull(); +expectTypeOf().toBeUndefined(); +expectTypeOf().toBeVoid(); +expectTypeOf().toBeNever(); +expectTypeOf().toBeAny(); +expectTypeOf().toBeUnknown(); +expectTypeOf().toBeObject(); +expectTypeOf().toBeFunction(); +expectTypeOf<[]>().toBeArray(); +expectTypeOf().toBeSymbol(); +``` + +## Value Type Checking + +```ts +const value = "hello"; +expectTypeOf(value).toBeString(); + +const obj = { name: "test", count: 42 }; +expectTypeOf(obj).toMatchTypeOf<{ name: string }>(); +expectTypeOf(obj).toHaveProperty("name"); +``` + +## Function Types + +```ts +function greet(name: string): string { + return `Hello, ${name}`; +} + +expectTypeOf(greet).toBeFunction(); +expectTypeOf(greet).parameters.toEqualTypeOf<[string]>(); +expectTypeOf(greet).returns.toBeString(); + +// Parameter checking +expectTypeOf(greet).parameter(0).toBeString(); +``` + +## Object Types + +```ts +interface User { + id: number; + name: string; + email?: string; +} + +expectTypeOf().toHaveProperty("id"); +expectTypeOf().toHaveProperty("name").toBeString(); + +// Check shape +expectTypeOf({ id: 1, name: "test" }).toMatchTypeOf(); +``` + +## Equality vs Matching + +```ts +interface A { + x: number; +} +interface B { + x: number; + y: string; +} + +// toMatchTypeOf - subset matching +expectTypeOf().toMatchTypeOf(); // B extends A + +// toEqualTypeOf - exact match +expectTypeOf().not.toEqualTypeOf(); // Not exact match +expectTypeOf().toEqualTypeOf<{ x: number }>(); // Exact match +``` + +## Branded Types + +```ts +type UserId = number & { __brand: "UserId" }; +type PostId = number & { __brand: "PostId" }; + +expectTypeOf().not.toEqualTypeOf(); +expectTypeOf().not.toEqualTypeOf(); +``` + +## Generic Types + +```ts +function identity(value: T): T { + return value; +} + +expectTypeOf(identity).returns.toBeString(); +expectTypeOf(identity).returns.toBeNumber(); +``` + +## Nullable Types + +```ts +type MaybeString = string | null | undefined; + +expectTypeOf().toBeNullable(); +expectTypeOf().not.toBeNullable(); +``` + +## assertType + +Assert a value matches a type (no assertion at runtime): + +```ts +import { assertType } from "vitest"; + +function getUser(): User | null { + return { id: 1, name: "test" }; +} + +test("returns user", () => { + const result = getUser(); + + // @ts-expect-error - should fail type check + assertType(result); + + // Correct type + assertType(result); +}); +``` + +## Using @ts-expect-error + +Test that code produces type error: + +```ts +test("rejects wrong types", () => { + function requireString(s: string) {} + + // @ts-expect-error - number not assignable to string + requireString(123); +}); +``` + +## Running Type Tests + +```bash +# Run type tests +vitest typecheck + +# Run alongside unit tests +vitest --typecheck + +# Type tests only +vitest --typecheck.only +``` + +## Mixed Test Files + +Combine runtime and type tests: + +```ts +// user.test.ts +import { describe, expect, expectTypeOf, test } from "vitest"; +import { createUser } from "./user"; + +describe("createUser", () => { + test("runtime: creates user", () => { + const user = createUser("John"); + expect(user.name).toBe("John"); + }); + + test("types: returns User type", () => { + expectTypeOf(createUser).returns.toMatchTypeOf<{ name: string }>(); + }); +}); +``` + +## Key Points + +- Use `.test-d.ts` for type-only tests +- `expectTypeOf` for type assertions +- `toMatchTypeOf` for subset matching +- `toEqualTypeOf` for exact type matching +- Use `@ts-expect-error` to test type errors +- Run with `vitest typecheck` or `--typecheck` + + diff --git a/.agents/skills/vitest/references/advanced-vi.md b/.agents/skills/vitest/references/advanced-vi.md new file mode 100644 index 0000000..23df949 --- /dev/null +++ b/.agents/skills/vitest/references/advanced-vi.md @@ -0,0 +1,251 @@ +--- +name: vi-utilities +description: vi helper for mocking, timers, utilities +--- + +# Vi Utilities + +The `vi` helper provides mocking and utility functions. + +```ts +import { vi } from "vitest"; +``` + +## Mock Functions + +```ts +// Create mock +const fn = vi.fn(); +const fnWithImpl = vi.fn((x) => x * 2); + +// Check if mock +vi.isMockFunction(fn); // true + +// Mock methods +fn.mockReturnValue(42); +fn.mockReturnValueOnce(1); +fn.mockResolvedValue(data); +fn.mockRejectedValue(error); +fn.mockImplementation(() => "result"); +fn.mockImplementationOnce(() => "once"); + +// Clear/reset +fn.mockClear(); // Clear call history +fn.mockReset(); // Clear history + implementation +fn.mockRestore(); // Restore original (for spies) +``` + +## Spying + +```ts +const obj = { method: () => "original" }; + +const spy = vi.spyOn(obj, "method"); +obj.method(); + +expect(spy).toHaveBeenCalled(); + +// Mock implementation +spy.mockReturnValue("mocked"); + +// Spy on getter/setter +vi.spyOn(obj, "prop", "get").mockReturnValue("value"); +``` + +## Module Mocking + +```ts +// Hoisted to top of file +vi.mock("./module", () => ({ + fn: vi.fn(), +})); + +// Partial mock +vi.mock("./module", async (importOriginal) => ({ + ...(await importOriginal()), + specificFn: vi.fn(), +})); + +// Spy mode - keep implementation +vi.mock("./module", { spy: true }); + +// Import actual module inside mock +const actual = await vi.importActual("./module"); + +// Import as mock +const mocked = await vi.importMock("./module"); +``` + +## Dynamic Mocking + +```ts +// Not hoisted - use with dynamic imports +vi.doMock("./config", () => ({ key: "value" })); +const config = await import("./config"); + +// Unmock +vi.doUnmock("./config"); +vi.unmock("./module"); // Hoisted +``` + +## Reset Modules + +```ts +// Clear module cache +vi.resetModules(); + +// Wait for dynamic imports +await vi.dynamicImportSettled(); +``` + +## Fake Timers + +```ts +vi.useFakeTimers(); + +setTimeout(() => console.log("done"), 1000); + +// Advance time +vi.advanceTimersByTime(1000); +vi.advanceTimersByTimeAsync(1000); // For async callbacks +vi.advanceTimersToNextTimer(); +vi.advanceTimersToNextFrame(); // requestAnimationFrame + +// Run all timers +vi.runAllTimers(); +vi.runAllTimersAsync(); +vi.runOnlyPendingTimers(); + +// Clear timers +vi.clearAllTimers(); + +// Check state +vi.getTimerCount(); +vi.isFakeTimers(); + +// Restore +vi.useRealTimers(); +``` + +## Mock Date/Time + +```ts +vi.setSystemTime(new Date("2024-01-01")); +expect(new Date().getFullYear()).toBe(2024); + +vi.getMockedSystemTime(); // Get mocked date +vi.getRealSystemTime(); // Get real time (ms) +``` + +## Global/Env Mocking + +```ts +// Stub global +vi.stubGlobal("fetch", vi.fn()); +vi.unstubAllGlobals(); + +// Stub environment +vi.stubEnv("API_KEY", "test"); +vi.stubEnv("NODE_ENV", "test"); +vi.unstubAllEnvs(); +``` + +## Hoisted Code + +Run code before imports: + +```ts +const mock = vi.hoisted(() => vi.fn()); + +vi.mock("./module", () => ({ + fn: mock, // Can reference hoisted variable +})); +``` + +## Waiting Utilities + +```ts +// Wait for callback to succeed +await vi.waitFor( + async () => { + const el = document.querySelector(".loaded"); + expect(el).toBeTruthy(); + }, + { timeout: 5000, interval: 100 } +); + +// Wait for truthy value +const element = await vi.waitUntil(() => document.querySelector(".loaded"), { + timeout: 5000, +}); +``` + +## Mock Object + +Mock all methods of an object: + +```ts +const original = { + method: () => "real", + nested: { fn: () => "nested" }, +}; + +const mocked = vi.mockObject(original); +mocked.method(); // undefined (mocked) +mocked.method.mockReturnValue("mocked"); + +// Spy mode +const spied = vi.mockObject(original, { spy: true }); +spied.method(); // 'real' +expect(spied.method).toHaveBeenCalled(); +``` + +## Test Configuration + +```ts +vi.setConfig({ + testTimeout: 10_000, + hookTimeout: 10_000, +}); + +vi.resetConfig(); +``` + +## Global Mock Management + +```ts +vi.clearAllMocks(); // Clear all mock call history +vi.resetAllMocks(); // Reset + clear implementation +vi.restoreAllMocks(); // Restore originals (spies) +``` + +## vi.mocked Type Helper + +TypeScript helper for mocked values: + +```ts +import { myFn } from "./module"; +vi.mock("./module"); + +// Type as mock +vi.mocked(myFn).mockReturnValue("typed"); + +// Deep mocking +vi.mocked(myModule, { deep: true }); + +// Partial mock typing +vi.mocked(fn, { partial: true }).mockResolvedValue({ ok: true }); +``` + +## Key Points + +- `vi.mock` is hoisted - use `vi.doMock` for dynamic mocking +- `vi.hoisted` lets you reference variables in mock factories +- Use `vi.spyOn` to spy on existing methods +- Fake timers require explicit setup and teardown +- `vi.waitFor` retries until assertion passes + + diff --git a/.agents/skills/vitest/references/core-cli.md b/.agents/skills/vitest/references/core-cli.md new file mode 100644 index 0000000..d46c8dd --- /dev/null +++ b/.agents/skills/vitest/references/core-cli.md @@ -0,0 +1,167 @@ +--- +name: vitest-cli +description: Command line interface commands and options +--- + +# Command Line Interface + +## Commands + +### `vitest` + +Start Vitest in watch mode (dev) or run mode (CI): + +```bash +vitest # Watch mode in dev, run mode in CI +vitest foobar # Run tests containing "foobar" in path +vitest basic/foo.test.ts:10 # Run specific test by file and line number +``` + +### `vitest run` + +Run tests once without watch mode: + +```bash +vitest run +vitest run --coverage +``` + +### `vitest watch` + +Explicitly start watch mode: + +```bash +vitest watch +``` + +### `vitest related` + +Run tests that import specific files (useful with lint-staged): + +```bash +vitest related src/index.ts src/utils.ts --run +``` + +### `vitest bench` + +Run only benchmark tests: + +```bash +vitest bench +``` + +### `vitest list` + +List all matching tests without running them: + +```bash +vitest list # List test names +vitest list --json # Output as JSON +vitest list --filesOnly # List only test files +``` + +### `vitest init` + +Initialize project setup: + +```bash +vitest init browser # Set up browser testing +``` + +## Common Options + +```bash +# Configuration +--config # Path to config file +--project # Run specific project + +# Filtering +--testNamePattern, -t # Run tests matching pattern +--changed # Run tests for changed files +--changed HEAD~1 # Tests for last commit changes + +# Reporters +--reporter # default, verbose, dot, json, html +--reporter=html --outputFile=report.html + +# Coverage +--coverage # Enable coverage +--coverage.provider v8 # Use v8 provider +--coverage.reporter text,html + +# Execution +--shard / # Split tests across machines +--bail # Stop after n failures +--retry # Retry failed tests n times +--sequence.shuffle # Randomize test order + +# Watch mode +--no-watch # Disable watch mode +--standalone # Start without running tests + +# Environment +--environment # jsdom, happy-dom, node +--globals # Enable global APIs + +# Debugging +--inspect # Enable Node inspector +--inspect-brk # Break on start + +# Output +--silent # Suppress console output +--no-color # Disable colors +``` + +## Package.json Scripts + +```json +{ + "scripts": { + "test": "vitest", + "test:run": "vitest run", + "test:ui": "vitest --ui", + "coverage": "vitest run --coverage" + } +} +``` + +## Sharding for CI + +Split tests across multiple machines: + +```bash +# Machine 1 +vitest run --shard=1/3 --reporter=blob + +# Machine 2 +vitest run --shard=2/3 --reporter=blob + +# Machine 3 +vitest run --shard=3/3 --reporter=blob + +# Merge reports +vitest --merge-reports --reporter=junit +``` + +## Watch Mode Keyboard Shortcuts + +In watch mode, press: + +- `a` - Run all tests +- `f` - Run only failed tests +- `u` - Update snapshots +- `p` - Filter by filename pattern +- `t` - Filter by test name pattern +- `q` - Quit + +## Key Points + +- Watch mode is default in dev, run mode in CI (when `process.env.CI` is set) +- Use `--run` flag to ensure single run (important for lint-staged) +- Both camelCase (`--testTimeout`) and kebab-case (`--test-timeout`) work +- Boolean options can be negated with `--no-` prefix + + diff --git a/.agents/skills/vitest/references/core-config.md b/.agents/skills/vitest/references/core-config.md new file mode 100644 index 0000000..6e39f76 --- /dev/null +++ b/.agents/skills/vitest/references/core-config.md @@ -0,0 +1,177 @@ +--- +name: vitest-configuration +description: Configure Vitest with vite.config.ts or vitest.config.ts +--- + +# Configuration + +Vitest reads configuration from `vitest.config.ts` or `vite.config.ts`. It shares the same config format as Vite. + +## Basic Setup + +```ts +// vitest.config.ts +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // test options + }, +}); +``` + +## Using with Existing Vite Config + +Add Vitest types reference and use the `test` property: + +```ts +// vite.config.ts +/// +import { defineConfig } from "vite"; + +export default defineConfig({ + test: { + globals: true, + environment: "jsdom", + }, +}); +``` + +## Merging Configs + +If you have separate config files, use `mergeConfig`: + +```ts +// vitest.config.ts +import { defineConfig, mergeConfig } from "vitest/config"; +import viteConfig from "./vite.config"; + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + environment: "jsdom", + }, + }) +); +``` + +## Common Options + +```ts +defineConfig({ + test: { + // Enable global APIs (describe, it, expect) without imports + globals: true, + + // Test environment: 'node', 'jsdom', 'happy-dom' + environment: "node", + + // Setup files to run before each test file + setupFiles: ["./tests/setup.ts"], + + // Include patterns for test files + include: ["**/*.{test,spec}.{js,ts,jsx,tsx}"], + + // Exclude patterns + exclude: ["**/node_modules/**", "**/dist/**"], + + // Test timeout in ms + testTimeout: 5000, + + // Hook timeout in ms + hookTimeout: 10000, + + // Enable watch mode by default + watch: true, + + // Coverage configuration + coverage: { + provider: "v8", // or 'istanbul' + reporter: ["text", "html"], + include: ["src/**/*.ts"], + }, + + // Run tests in isolation (each file in separate process) + isolate: true, + + // Pool for running tests: 'threads', 'forks', 'vmThreads' + pool: "threads", + + // Number of threads/processes + poolOptions: { + threads: { + maxThreads: 4, + minThreads: 1, + }, + }, + + // Automatically clear mocks between tests + clearMocks: true, + + // Restore mocks between tests + restoreMocks: true, + + // Retry failed tests + retry: 0, + + // Stop after first failure + bail: 0, + }, +}); +``` + +## Conditional Configuration + +Use `mode` or `process.env.VITEST` for test-specific config: + +```ts +export default defineConfig(({ mode }) => ({ + plugins: mode === "test" ? [] : [myPlugin()], + test: { + // test options + }, +})); +``` + +## Projects (Monorepos) + +Run different configurations in the same Vitest process: + +```ts +defineConfig({ + test: { + projects: [ + "packages/*", + { + test: { + name: "unit", + include: ["tests/unit/**/*.test.ts"], + environment: "node", + }, + }, + { + test: { + name: "integration", + include: ["tests/integration/**/*.test.ts"], + environment: "jsdom", + }, + }, + ], + }, +}); +``` + +## Key Points + +- Vitest uses Vite's transformation pipeline - same `resolve.alias`, plugins work +- `vitest.config.ts` takes priority over `vite.config.ts` +- Use `--config` flag to specify a custom config path +- `process.env.VITEST` is set to `true` when running tests +- Test config uses `test` property, rest is Vite config + + diff --git a/.agents/skills/vitest/references/core-describe.md b/.agents/skills/vitest/references/core-describe.md new file mode 100644 index 0000000..474e136 --- /dev/null +++ b/.agents/skills/vitest/references/core-describe.md @@ -0,0 +1,193 @@ +--- +name: describe-api +description: describe/suite for grouping tests into logical blocks +--- + +# Describe API + +Group related tests into suites for organization and shared setup. + +## Basic Usage + +```ts +import { describe, expect, test } from "vitest"; + +describe("Math", () => { + test("adds numbers", () => { + expect(1 + 1).toBe(2); + }); + + test("subtracts numbers", () => { + expect(3 - 1).toBe(2); + }); +}); + +// Alias: suite +import { suite } from "vitest"; +suite("equivalent to describe", () => {}); +``` + +## Nested Suites + +```ts +describe("User", () => { + describe("when logged in", () => { + test("shows dashboard", () => {}); + test("can update profile", () => {}); + }); + + describe("when logged out", () => { + test("shows login page", () => {}); + }); +}); +``` + +## Suite Options + +```ts +// All tests inherit options +describe("slow tests", { timeout: 30_000 }, () => { + test("test 1", () => {}); // 30s timeout + test("test 2", () => {}); // 30s timeout +}); +``` + +## Suite Modifiers + +### Skip Suites + +```ts +describe.skip("skipped suite", () => { + test("wont run", () => {}); +}); + +// Conditional +describe.skipIf(process.env.CI)("not in CI", () => {}); +describe.runIf(!process.env.CI)("only local", () => {}); +``` + +### Focus Suites + +```ts +describe.only("only this suite runs", () => { + test("runs", () => {}); +}); +``` + +### Todo Suites + +```ts +describe.todo("implement later"); +``` + +### Concurrent Suites + +```ts +// All tests run in parallel +describe.concurrent("parallel tests", () => { + test("test 1", async ({ expect }) => {}); + test("test 2", async ({ expect }) => {}); +}); +``` + +### Sequential in Concurrent + +```ts +describe.concurrent("parallel", () => { + test("concurrent 1", async () => {}); + + describe.sequential("must be sequential", () => { + test("step 1", async () => {}); + test("step 2", async () => {}); + }); +}); +``` + +### Shuffle Tests + +```ts +describe.shuffle("random order", () => { + test("test 1", () => {}); + test("test 2", () => {}); + test("test 3", () => {}); +}); + +// Or with option +describe("random", { shuffle: true }, () => {}); +``` + +## Parameterized Suites + +### describe.each + +```ts +describe.each([ + { name: "Chrome", version: 100 }, + { name: "Firefox", version: 90 }, +])("$name browser", ({ name, version }) => { + test("has version", () => { + expect(version).toBeGreaterThan(0); + }); +}); +``` + +### describe.for + +```ts +describe.for([ + ["Chrome", 100], + ["Firefox", 90], +])("%s browser", ([name, version]) => { + test("has version", () => { + expect(version).toBeGreaterThan(0); + }); +}); +``` + +## Hooks in Suites + +```ts +describe("Database", () => { + let db; + + beforeAll(async () => { + db = await createDb(); + }); + + afterAll(async () => { + await db.close(); + }); + + beforeEach(async () => { + await db.clear(); + }); + + test("insert works", async () => { + await db.insert({ name: "test" }); + expect(await db.count()).toBe(1); + }); +}); +``` + +## Modifier Combinations + +All modifiers can be chained: + +```ts +describe.skip.concurrent("skipped concurrent", () => {}); +describe.only.shuffle("only and shuffled", () => {}); +describe.concurrent.skip("equivalent", () => {}); +``` + +## Key Points + +- Top-level tests belong to an implicit file suite +- Nested suites inherit parent's options (timeout, retry, etc.) +- Hooks are scoped to their suite and nested suites +- Use `describe.concurrent` with context's `expect` for snapshots +- Shuffle order depends on `sequence.seed` config + + diff --git a/.agents/skills/vitest/references/core-expect.md b/.agents/skills/vitest/references/core-expect.md new file mode 100644 index 0000000..e0d439d --- /dev/null +++ b/.agents/skills/vitest/references/core-expect.md @@ -0,0 +1,211 @@ +--- +name: expect-api +description: Assertions with matchers, asymmetric matchers, and custom matchers +--- + +# Expect API + +Vitest uses Chai assertions with Jest-compatible API. + +## Basic Assertions + +```ts +import { expect, test } from "vitest"; + +test("assertions", () => { + // Equality + expect(1 + 1).toBe(2); // Strict equality (===) + expect({ a: 1 }).toEqual({ a: 1 }); // Deep equality + + // Truthiness + expect(true).toBeTruthy(); + expect(false).toBeFalsy(); + expect(null).toBeNull(); + expect(undefined).toBeUndefined(); + expect("value").toBeDefined(); + + // Numbers + expect(10).toBeGreaterThan(5); + expect(10).toBeGreaterThanOrEqual(10); + expect(5).toBeLessThan(10); + expect(0.1 + 0.2).toBeCloseTo(0.3, 5); + + // Strings + expect("hello world").toMatch(/world/); + expect("hello").toContain("ell"); + + // Arrays + expect([1, 2, 3]).toContain(2); + expect([{ a: 1 }]).toContainEqual({ a: 1 }); + expect([1, 2, 3]).toHaveLength(3); + + // Objects + expect({ a: 1, b: 2 }).toHaveProperty("a"); + expect({ a: 1, b: 2 }).toHaveProperty("a", 1); + expect({ a: { b: 1 } }).toHaveProperty("a.b", 1); + expect({ a: 1 }).toMatchObject({ a: 1 }); + + // Types + expect("string").toBeTypeOf("string"); + expect(new Date()).toBeInstanceOf(Date); +}); +``` + +## Negation + +```ts +expect(1).not.toBe(2); +expect({ a: 1 }).not.toEqual({ a: 2 }); +``` + +## Error Assertions + +```ts +// Sync errors - wrap in function +expect(() => throwError()).toThrow(); +expect(() => throwError()).toThrow("message"); +expect(() => throwError()).toThrow(/pattern/); +expect(() => throwError()).toThrow(CustomError); + +// Async errors - use rejects +await expect(asyncThrow()).rejects.toThrow("error"); +``` + +## Promise Assertions + +```ts +// Resolves +await expect(Promise.resolve(1)).resolves.toBe(1); +await expect(fetchData()).resolves.toEqual({ data: true }); + +// Rejects +await expect(Promise.reject("error")).rejects.toBe("error"); +await expect(failingFetch()).rejects.toThrow(); +``` + +## Spy/Mock Assertions + +```ts +const fn = vi.fn(); +fn("arg1", "arg2"); +fn("arg3"); + +expect(fn).toHaveBeenCalled(); +expect(fn).toHaveBeenCalledTimes(2); +expect(fn).toHaveBeenCalledWith("arg1", "arg2"); +expect(fn).toHaveBeenLastCalledWith("arg3"); +expect(fn).toHaveBeenNthCalledWith(1, "arg1", "arg2"); + +expect(fn).toHaveReturned(); +expect(fn).toHaveReturnedWith(value); +``` + +## Asymmetric Matchers + +Use inside `toEqual`, `toHaveBeenCalledWith`, etc: + +```ts +expect({ id: 1, name: "test" }).toEqual({ + id: expect.any(Number), + name: expect.any(String), +}); + +expect({ a: 1, b: 2, c: 3 }).toEqual(expect.objectContaining({ a: 1 })); + +expect([1, 2, 3, 4]).toEqual(expect.arrayContaining([1, 3])); + +expect("hello world").toEqual(expect.stringContaining("world")); + +expect("hello world").toEqual(expect.stringMatching(/world$/)); + +expect({ value: null }).toEqual({ + value: expect.anything(), // Matches anything except null/undefined +}); + +// Negate with expect.not +expect([1, 2]).toEqual(expect.not.arrayContaining([3])); +``` + +## Soft Assertions + +Continue test after failure: + +```ts +expect.soft(1).toBe(2); // Marks test failed but continues +expect.soft(2).toBe(3); // Also runs +// All failures reported at end +``` + +## Poll Assertions + +Retry until passes: + +```ts +await expect.poll(() => fetchStatus()).toBe("ready"); + +await expect + .poll(() => document.querySelector(".element"), { + interval: 100, + timeout: 5000, + }) + .toBeTruthy(); +``` + +## Assertion Count + +```ts +test("async assertions", async () => { + expect.assertions(2); // Exactly 2 assertions must run + + await doAsync((data) => { + expect(data).toBeDefined(); + expect(data.id).toBe(1); + }); +}); + +test("at least one", () => { + expect.hasAssertions(); // At least 1 assertion must run +}); +``` + +## Extending Matchers + +```ts +expect.extend({ + toBeWithinRange(received, floor, ceiling) { + const pass = received >= floor && received <= ceiling; + return { + pass, + message: () => + `expected ${received} to be within range ${floor} - ${ceiling}`, + }; + }, +}); + +test("custom matcher", () => { + expect(100).toBeWithinRange(90, 110); +}); +``` + +## Snapshot Assertions + +```ts +expect(data).toMatchSnapshot() +expect(data).toMatchInlineSnapshot(`{ "id": 1 }`) +await expect(result).toMatchFileSnapshot('./expected.json') + +expect(() => throw new Error('fail')).toThrowErrorMatchingSnapshot() +``` + +## Key Points + +- Use `toBe` for primitives, `toEqual` for objects/arrays +- `toStrictEqual` checks undefined properties and array sparseness +- Always `await` async assertions (`resolves`, `rejects`, `poll`) +- Use context's `expect` in concurrent tests for correct tracking +- `toThrow` requires wrapping sync code in a function + + diff --git a/.agents/skills/vitest/references/core-hooks.md b/.agents/skills/vitest/references/core-hooks.md new file mode 100644 index 0000000..3b21377 --- /dev/null +++ b/.agents/skills/vitest/references/core-hooks.md @@ -0,0 +1,245 @@ +--- +name: lifecycle-hooks +description: beforeEach, afterEach, beforeAll, afterAll, and around hooks +--- + +# Lifecycle Hooks + +## Basic Hooks + +```ts +import { afterAll, afterEach, beforeAll, beforeEach, test } from "vitest"; + +beforeAll(async () => { + // Runs once before all tests in file/suite + await setupDatabase(); +}); + +afterAll(async () => { + // Runs once after all tests in file/suite + await teardownDatabase(); +}); + +beforeEach(async () => { + // Runs before each test + await clearTestData(); +}); + +afterEach(async () => { + // Runs after each test + await cleanupMocks(); +}); +``` + +## Cleanup Return Pattern + +Return cleanup function from `before*` hooks: + +```ts +beforeAll(async () => { + const server = await startServer(); + + // Returned function runs as afterAll + return async () => { + await server.close(); + }; +}); + +beforeEach(async () => { + const connection = await connect(); + + // Runs as afterEach + return () => connection.close(); +}); +``` + +## Scoped Hooks + +Hooks apply to current suite and nested suites: + +```ts +describe("outer", () => { + beforeEach(() => console.log("outer before")); + + test("test 1", () => {}); // outer before → test + + describe("inner", () => { + beforeEach(() => console.log("inner before")); + + test("test 2", () => {}); // outer before → inner before → test + }); +}); +``` + +## Hook Timeout + +```ts +beforeAll(async () => { + await slowSetup(); +}, 30_000); // 30 second timeout +``` + +## Around Hooks + +Wrap tests with setup/teardown context: + +```ts +import { aroundEach, test } from "vitest"; + +// Wrap each test in database transaction +aroundEach(async (runTest) => { + await db.beginTransaction(); + await runTest(); // Must be called! + await db.rollback(); +}); + +test("insert user", async () => { + await db.insert({ name: "Alice" }); + // Automatically rolled back after test +}); +``` + +### aroundAll + +Wrap entire suite: + +```ts +import { aroundAll, test } from "vitest"; + +aroundAll(async (runSuite) => { + console.log("before all tests"); + await runSuite(); // Must be called! + console.log("after all tests"); +}); +``` + +### Multiple Around Hooks + +Nested like onion layers: + +```ts +aroundEach(async (runTest) => { + console.log("outer before"); + await runTest(); + console.log("outer after"); +}); + +aroundEach(async (runTest) => { + console.log("inner before"); + await runTest(); + console.log("inner after"); +}); + +// Order: outer before → inner before → test → inner after → outer after +``` + +## Test Hooks + +Inside test body: + +```ts +import { onTestFailed, onTestFinished, test } from "vitest"; + +test("with cleanup", () => { + const db = connect(); + + // Runs after test finishes (pass or fail) + onTestFinished(() => db.close()); + + // Only runs if test fails + onTestFailed(({ task }) => { + console.log("Failed:", task.result?.errors); + }); + + db.query("SELECT * FROM users"); +}); +``` + +### Reusable Cleanup Pattern + +```ts +function useTestDb() { + const db = connect(); + onTestFinished(() => db.close()); + return db; +} + +test("query users", () => { + const db = useTestDb(); + expect(db.query("SELECT * FROM users")).toBeDefined(); +}); + +test("query orders", () => { + const db = useTestDb(); // Fresh connection, auto-closed + expect(db.query("SELECT * FROM orders")).toBeDefined(); +}); +``` + +## Concurrent Test Hooks + +For concurrent tests, use context's hooks: + +```ts +test.concurrent("concurrent", ({ onTestFinished }) => { + const resource = allocate(); + onTestFinished(() => resource.release()); +}); +``` + +## Extended Test Hooks + +With `test.extend`, hooks are type-aware: + +```ts +const test = base.extend<{ db: Database }>({ + db: async ({}, use) => { + const db = await createDb(); + await use(db); + await db.close(); + }, +}); + +// These hooks know about `db` fixture +test.beforeEach(({ db }) => { + db.seed(); +}); + +test.afterEach(({ db }) => { + db.clear(); +}); +``` + +## Hook Execution Order + +Default order (stack): + +1. `beforeAll` (in order) +2. `beforeEach` (in order) +3. Test +4. `afterEach` (reverse order) +5. `afterAll` (reverse order) + +Configure with `sequence.hooks`: + +```ts +defineConfig({ + test: { + sequence: { + hooks: "list", // 'stack' (default), 'list', 'parallel' + }, + }, +}); +``` + +## Key Points + +- Hooks are not called during type checking +- Return cleanup function from `before*` to avoid `after*` duplication +- `aroundEach`/`aroundAll` must call `runTest()`/`runSuite()` +- `onTestFinished` always runs, even if test fails +- Use context hooks for concurrent tests + + diff --git a/.agents/skills/vitest/references/core-test-api.md b/.agents/skills/vitest/references/core-test-api.md new file mode 100644 index 0000000..c526893 --- /dev/null +++ b/.agents/skills/vitest/references/core-test-api.md @@ -0,0 +1,237 @@ +--- +name: test-api +description: test/it function for defining tests with modifiers +--- + +# Test API + +## Basic Test + +```ts +import { expect, test } from "vitest"; + +test("adds numbers", () => { + expect(1 + 1).toBe(2); +}); + +// Alias: it +import { it } from "vitest"; + +it("works the same", () => { + expect(true).toBe(true); +}); +``` + +## Async Tests + +```ts +test("async test", async () => { + const result = await fetchData(); + expect(result).toBeDefined(); +}); + +// Promises are automatically awaited +test("returns promise", () => { + return fetchData().then((result) => { + expect(result).toBeDefined(); + }); +}); +``` + +## Test Options + +```ts +// Timeout (default: 5000ms) +test("slow test", async () => { + // ... +}, 10_000); + +// Or with options object +test("with options", { timeout: 10_000, retry: 2 }, async () => { + // ... +}); +``` + +## Test Modifiers + +### Skip Tests + +```ts +test.skip("skipped test", () => { + // Won't run +}); + +// Conditional skip +test.skipIf(process.env.CI)("not in CI", () => {}); +test.runIf(process.env.CI)("only in CI", () => {}); + +// Dynamic skip via context +test("dynamic skip", ({ skip }) => { + skip(someCondition, "reason"); + // ... +}); +``` + +### Focus Tests + +```ts +test.only("only this runs", () => { + // Other tests in file are skipped +}); +``` + +### Todo Tests + +```ts +test.todo("implement later"); + +test.todo("with body", () => { + // Not run, shows in report +}); +``` + +### Failing Tests + +```ts +test.fails("expected to fail", () => { + expect(1).toBe(2); // Test passes because assertion fails +}); +``` + +### Concurrent Tests + +```ts +// Run tests in parallel +test.concurrent("test 1", async ({ expect }) => { + // Use context.expect for concurrent tests + expect(await fetch1()).toBe("result"); +}); + +test.concurrent("test 2", async ({ expect }) => { + expect(await fetch2()).toBe("result"); +}); +``` + +### Sequential Tests + +```ts +// Force sequential in concurrent context +test.sequential("must run alone", async () => {}); +``` + +## Parameterized Tests + +### test.each + +```ts +test.each([ + [1, 1, 2], + [1, 2, 3], + [2, 1, 3], +])("add(%i, %i) = %i", (a, b, expected) => { + expect(a + b).toBe(expected); +}); + +// With objects +test.each([ + { a: 1, b: 1, expected: 2 }, + { a: 1, b: 2, expected: 3 }, +])("add($a, $b) = $expected", ({ a, b, expected }) => { + expect(a + b).toBe(expected); +}); + +// Template literal +test.each` + a | b | expected + ${1} | ${1} | ${2} + ${1} | ${2} | ${3} +`("add($a, $b) = $expected", ({ a, b, expected }) => { + expect(a + b).toBe(expected); +}); +``` + +### test.for + +Preferred over `.each` - doesn't spread arrays: + +```ts +test.for([ + [1, 1, 2], + [1, 2, 3], +])("add(%i, %i) = %i", ([a, b, expected], { expect }) => { + // Second arg is TestContext + expect(a + b).toBe(expected); +}); +``` + +## Test Context + +First argument provides context utilities: + +```ts +test("with context", ({ expect, skip, task }) => { + console.log(task.name); // Test name + skip(someCondition); // Skip dynamically + expect(1).toBe(1); // Context-bound expect +}); +``` + +## Custom Test with Fixtures + +```ts +import { test as base } from "vitest"; + +const test = base.extend({ + db: async ({}, use) => { + const db = await createDb(); + await use(db); + await db.close(); + }, +}); + +test("query", async ({ db }) => { + const users = await db.query("SELECT * FROM users"); + expect(users).toBeDefined(); +}); +``` + +## Retry Configuration + +```ts +test("flaky test", { retry: 3 }, async () => { + // Retries up to 3 times on failure +}); + +// Advanced retry options +test( + "with delay", + { + retry: { + count: 3, + delay: 1000, + condition: /timeout/i, // Only retry on timeout errors + }, + }, + async () => {} +); +``` + +## Tags + +```ts +test("database test", { tags: ["db", "slow"] }, async () => {}); + +// Run with: vitest --tags db +``` + +## Key Points + +- Tests with no body are marked as `todo` +- `test.only` throws in CI unless `allowOnly: true` +- Use context's `expect` for concurrent tests and snapshots +- Function name is used as test name if passed as first arg + + diff --git a/.agents/skills/vitest/references/features-concurrency.md b/.agents/skills/vitest/references/features-concurrency.md new file mode 100644 index 0000000..6711799 --- /dev/null +++ b/.agents/skills/vitest/references/features-concurrency.md @@ -0,0 +1,250 @@ +--- +name: concurrency-parallelism +description: Concurrent tests, parallel execution, and sharding +--- + +# Concurrency & Parallelism + +## File Parallelism + +By default, Vitest runs test files in parallel across workers: + +```ts +defineConfig({ + test: { + // Run files in parallel (default: true) + fileParallelism: true, + + // Number of worker threads + maxWorkers: 4, + minWorkers: 1, + + // Pool type: 'threads', 'forks', 'vmThreads' + pool: "threads", + }, +}); +``` + +## Concurrent Tests + +Run tests within a file in parallel: + +```ts +// Individual concurrent tests +test.concurrent("test 1", async ({ expect }) => { + expect(await fetch1()).toBe("result"); +}); + +test.concurrent("test 2", async ({ expect }) => { + expect(await fetch2()).toBe("result"); +}); + +// All tests in suite concurrent +describe.concurrent("parallel suite", () => { + test("test 1", async ({ expect }) => {}); + test("test 2", async ({ expect }) => {}); +}); +``` + +**Important:** Use `{ expect }` from context for concurrent tests. + +## Sequential in Concurrent Context + +Force sequential execution: + +```ts +describe.concurrent("mostly parallel", () => { + test("parallel 1", async () => {}); + test("parallel 2", async () => {}); + + test.sequential("must run alone 1", async () => {}); + test.sequential("must run alone 2", async () => {}); +}); + +// Or entire suite +describe.sequential("sequential suite", () => { + test("first", () => {}); + test("second", () => {}); +}); +``` + +## Max Concurrency + +Limit concurrent tests: + +```ts +defineConfig({ + test: { + maxConcurrency: 5, // Max concurrent tests per file + }, +}); +``` + +## Isolation + +Each file runs in isolated environment by default: + +```ts +defineConfig({ + test: { + // Disable isolation for faster runs (less safe) + isolate: false, + }, +}); +``` + +## Sharding + +Split tests across machines: + +```bash +# Machine 1 +vitest run --shard=1/3 + +# Machine 2 +vitest run --shard=2/3 + +# Machine 3 +vitest run --shard=3/3 +``` + +### CI Example (GitHub Actions) + +```yaml +jobs: + test: + strategy: + matrix: + shard: [1, 2, 3] + steps: + - run: vitest run --shard=${{ matrix.shard }}/3 --reporter=blob + + merge: + needs: test + steps: + - run: vitest --merge-reports --reporter=junit +``` + +### Merge Reports + +```bash +# Each shard outputs blob +vitest run --shard=1/3 --reporter=blob --coverage +vitest run --shard=2/3 --reporter=blob --coverage + +# Merge all blobs +vitest --merge-reports --reporter=json --coverage +``` + +## Test Sequence + +Control test order: + +```ts +defineConfig({ + test: { + sequence: { + // Run tests in random order + shuffle: true, + + // Seed for reproducible shuffle + seed: 12345, + + // Hook execution order + hooks: "stack", // 'stack', 'list', 'parallel' + + // All tests concurrent by default + concurrent: true, + }, + }, +}); +``` + +## Shuffle Tests + +Randomize to catch hidden dependencies: + +```ts +// Via CLI +vitest --sequence.shuffle + +// Per suite +describe.shuffle('random order', () => { + test('test 1', () => {}) + test('test 2', () => {}) + test('test 3', () => {}) +}) +``` + +## Pool Options + +### Threads (Default) + +```ts +defineConfig({ + test: { + pool: "threads", + poolOptions: { + threads: { + maxThreads: 8, + minThreads: 2, + isolate: true, + }, + }, + }, +}); +``` + +### Forks + +Better isolation, slower: + +```ts +defineConfig({ + test: { + pool: "forks", + poolOptions: { + forks: { + maxForks: 4, + isolate: true, + }, + }, + }, +}); +``` + +### VM Threads + +Full VM isolation per file: + +```ts +defineConfig({ + test: { + pool: "vmThreads", + }, +}); +``` + +## Bail on Failure + +Stop after first failure: + +```bash +vitest --bail 1 # Stop after 1 failure +vitest --bail # Stop on first failure (same as --bail 1) +``` + +## Key Points + +- Files run in parallel by default +- Use `.concurrent` for parallel tests within file +- Always use context's `expect` in concurrent tests +- Sharding splits tests across CI machines +- Use `--merge-reports` to combine sharded results +- Shuffle tests to find hidden dependencies + + diff --git a/.agents/skills/vitest/references/features-context.md b/.agents/skills/vitest/references/features-context.md new file mode 100644 index 0000000..3f90ebd --- /dev/null +++ b/.agents/skills/vitest/references/features-context.md @@ -0,0 +1,240 @@ +--- +name: test-context-fixtures +description: Test context, custom fixtures with test.extend +--- + +# Test Context & Fixtures + +## Built-in Context + +Every test receives context as first argument: + +```ts +test("context", ({ task, expect, skip }) => { + console.log(task.name); // Test name + expect(1).toBe(1); // Context-bound expect + skip(); // Skip test dynamically +}); +``` + +### Context Properties + +- `task` - Test metadata (name, file, etc.) +- `expect` - Expect bound to this test (important for concurrent tests) +- `skip(condition?, message?)` - Skip the test +- `onTestFinished(fn)` - Cleanup after test +- `onTestFailed(fn)` - Run on failure only + +## Custom Fixtures with test.extend + +Create reusable test utilities: + +```ts +import { test as base } from "vitest"; + +// Define fixture types +interface Fixtures { + db: Database; + user: User; +} + +// Create extended test +export const test = base.extend({ + // Fixture with setup/teardown + db: async ({}, use) => { + const db = await createDatabase(); + await use(db); // Provide to test + await db.close(); // Cleanup + }, + + // Fixture depending on another fixture + user: async ({ db }, use) => { + const user = await db.createUser({ name: "Test" }); + await use(user); + await db.deleteUser(user.id); + }, +}); +``` + +Using fixtures: + +```ts +test("query user", async ({ db, user }) => { + const found = await db.findUser(user.id); + expect(found).toEqual(user); +}); +``` + +## Fixture Initialization + +Fixtures only initialize when accessed: + +```ts +const test = base.extend({ + expensive: async ({}, use) => { + console.log("initializing"); // Only runs if test uses it + await use("value"); + }, +}); + +test("no fixture", () => {}); // expensive not called +test("uses fixture", ({ expensive }) => {}); // expensive called +``` + +## Auto Fixtures + +Run fixture for every test: + +```ts +const test = base.extend({ + setup: [ + async ({}, use) => { + await globalSetup(); + await use(); + await globalTeardown(); + }, + { auto: true }, // Always run + ], +}); +``` + +## Scoped Fixtures + +### File Scope + +Initialize once per file: + +```ts +const test = base.extend({ + connection: [ + async ({}, use) => { + const conn = await connect(); + await use(conn); + await conn.close(); + }, + { scope: "file" }, + ], +}); +``` + +### Worker Scope + +Initialize once per worker: + +```ts +const test = base.extend({ + sharedResource: [ + async ({}, use) => { + await use(globalResource); + }, + { scope: "worker" }, + ], +}); +``` + +## Injected Fixtures (from Config) + +Override fixtures per project: + +```ts +// test file +const test = base.extend({ + apiUrl: ["/default", { injected: true }], +}); + +// vitest.config.ts +defineConfig({ + test: { + projects: [ + { + test: { + name: "prod", + provide: { apiUrl: "https://api.prod.com" }, + }, + }, + ], + }, +}); +``` + +## Scoped Values per Suite + +Override fixture for specific suite: + +```ts +const test = base.extend({ + environment: "development", +}); + +describe("production tests", () => { + test.scoped({ environment: "production" }); + + test("uses production", ({ environment }) => { + expect(environment).toBe("production"); + }); +}); + +test("uses default", ({ environment }) => { + expect(environment).toBe("development"); +}); +``` + +## Extended Test Hooks + +Type-aware hooks with fixtures: + +```ts +const test = base.extend<{ db: Database }>({ + db: async ({}, use) => { + const db = await createDb(); + await use(db); + await db.close(); + }, +}); + +// Hooks know about fixtures +test.beforeEach(({ db }) => { + db.seed(); +}); + +test.afterEach(({ db }) => { + db.clear(); +}); +``` + +## Composing Fixtures + +Extend from another extended test: + +```ts +// base-test.ts +export const test = base.extend<{ db: Database }>({ + db: async ({}, use) => { + /* ... */ + }, +}); + +// admin-test.ts +import { test as dbTest } from "./base-test"; + +export const test = dbTest.extend<{ admin: User }>({ + admin: async ({ db }, use) => { + const admin = await db.createAdmin(); + await use(admin); + }, +}); +``` + +## Key Points + +- Use `{ }` destructuring to access fixtures +- Fixtures are lazy - only initialize when accessed +- Return cleanup function from fixtures +- Use `{ auto: true }` for setup fixtures +- Use `{ scope: 'file' }` for expensive shared resources +- Fixtures compose - extend from extended tests + + diff --git a/.agents/skills/vitest/references/features-coverage.md b/.agents/skills/vitest/references/features-coverage.md new file mode 100644 index 0000000..266c9f4 --- /dev/null +++ b/.agents/skills/vitest/references/features-coverage.md @@ -0,0 +1,202 @@ +--- +name: code-coverage +description: Code coverage with V8 or Istanbul providers +--- + +# Code Coverage + +## Setup + +```bash +# Run tests with coverage +vitest run --coverage +``` + +## Configuration + +```ts +// vitest.config.ts +defineConfig({ + test: { + coverage: { + // Provider: 'v8' (default, faster) or 'istanbul' (more compatible) + provider: "v8", + + // Enable coverage + enabled: true, + + // Reporters + reporter: ["text", "json", "html"], + + // Files to include + include: ["src/**/*.{ts,tsx}"], + + // Files to exclude + exclude: ["node_modules/", "tests/", "**/*.d.ts", "**/*.test.ts"], + + // Report uncovered files + all: true, + + // Thresholds + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, + }, + }, +}); +``` + +## Providers + +### V8 (Default) + +```bash +npm i -D @vitest/coverage-v8 +``` + +- Faster, no pre-instrumentation +- Uses V8's native coverage +- Recommended for most projects + +### Istanbul + +```bash +npm i -D @vitest/coverage-istanbul +``` + +- Pre-instruments code +- Works in any JS runtime +- More overhead but widely compatible + +## Reporters + +```ts +coverage: { + reporter: [ + 'text', // Terminal output + 'text-summary', // Summary only + 'json', // JSON file + 'html', // HTML report + 'lcov', // For CI tools + 'cobertura', // XML format + ], + reportsDirectory: './coverage', +} +``` + +## Thresholds + +Fail tests if coverage is below threshold: + +```ts +coverage: { + thresholds: { + // Global thresholds + lines: 80, + functions: 75, + branches: 70, + statements: 80, + + // Per-file thresholds + perFile: true, + + // Auto-update thresholds (for gradual improvement) + autoUpdate: true, + }, +} +``` + +## Ignoring Code + +### V8 + +```ts +/* v8 ignore next -- @preserve */ +function ignored() { + return "not covered"; +} + +/* v8 ignore start -- @preserve */ +// All code here ignored +/* v8 ignore stop -- @preserve */ +``` + +### Istanbul + +```ts +/* istanbul ignore next -- @preserve */ +function ignored() {} + +/* istanbul ignore if -- @preserve */ +if (condition) { + // ignored +} +``` + +Note: `@preserve` keeps comments through esbuild. + +## Package.json Scripts + +```json +{ + "scripts": { + "test": "vitest", + "test:coverage": "vitest run --coverage", + "test:coverage:watch": "vitest --coverage" + } +} +``` + +## Vitest UI Coverage + +Enable HTML coverage in Vitest UI: + +```ts +coverage: { + enabled: true, + reporter: ['text', 'html'], +} +``` + +Run with `vitest --ui` to view coverage visually. + +## CI Integration + +```yaml +# GitHub Actions +- name: Run tests with coverage + run: npm run test:coverage + +- name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./coverage/lcov.info +``` + +## Coverage with Sharding + +Merge coverage from sharded runs: + +```bash +vitest run --shard=1/3 --coverage --reporter=blob +vitest run --shard=2/3 --coverage --reporter=blob +vitest run --shard=3/3 --coverage --reporter=blob + +vitest --merge-reports --coverage --reporter=json +``` + +## Key Points + +- V8 is faster, Istanbul is more compatible +- Use `--coverage` flag or `coverage.enabled: true` +- Include `all: true` to see uncovered files +- Set thresholds to enforce minimum coverage +- Use `@preserve` comment to keep ignore hints + + diff --git a/.agents/skills/vitest/references/features-filtering.md b/.agents/skills/vitest/references/features-filtering.md new file mode 100644 index 0000000..fdbc98e --- /dev/null +++ b/.agents/skills/vitest/references/features-filtering.md @@ -0,0 +1,208 @@ +--- +name: test-filtering +description: Filter tests by name, file patterns, and tags +--- + +# Test Filtering + +## CLI Filtering + +### By File Path + +```bash +# Run files containing "user" +vitest user + +# Multiple patterns +vitest user auth + +# Specific file +vitest src/user.test.ts + +# By line number +vitest src/user.test.ts:25 +``` + +### By Test Name + +```bash +# Tests matching pattern +vitest -t "login" +vitest --testNamePattern "should.*work" + +# Regex patterns +vitest -t "/user|auth/" +``` + +## Changed Files + +```bash +# Uncommitted changes +vitest --changed + +# Since specific commit +vitest --changed HEAD~1 +vitest --changed abc123 + +# Since branch +vitest --changed origin/main +``` + +## Related Files + +Run tests that import specific files: + +```bash +vitest related src/utils.ts src/api.ts --run +``` + +Useful with lint-staged: + +```js +// .lintstagedrc.js +export default { + "*.{ts,tsx}": "vitest related --run", +}; +``` + +## Focus Tests (.only) + +```ts +test.only("only this runs", () => {}); + +describe.only("only this suite", () => { + test("runs", () => {}); +}); +``` + +In CI, `.only` throws error unless configured: + +```ts +defineConfig({ + test: { + allowOnly: true, // Allow .only in CI + }, +}); +``` + +## Skip Tests + +```ts +test.skip("skipped", () => {}); + +// Conditional +test.skipIf(process.env.CI)("not in CI", () => {}); +test.runIf(!process.env.CI)("local only", () => {}); + +// Dynamic skip +test("dynamic", ({ skip }) => { + skip(someCondition, "reason"); +}); +``` + +## Tags + +Filter by custom tags: + +```ts +test("database test", { tags: ["db"] }, () => {}); +test("slow test", { tags: ["slow", "integration"] }, () => {}); +``` + +Run tagged tests: + +```bash +vitest --tags db +vitest --tags "db,slow" # OR +vitest --tags db --tags slow # OR +``` + +Configure allowed tags: + +```ts +defineConfig({ + test: { + tags: ["db", "slow", "integration"], + strictTags: true, // Fail on unknown tags + }, +}); +``` + +## Include/Exclude Patterns + +```ts +defineConfig({ + test: { + // Test file patterns + include: ["**/*.{test,spec}.{ts,tsx}"], + + // Exclude patterns + exclude: ["**/node_modules/**", "**/e2e/**", "**/*.skip.test.ts"], + + // Include source for in-source testing + includeSource: ["src/**/*.ts"], + }, +}); +``` + +## Watch Mode Filtering + +In watch mode, press: + +- `p` - Filter by filename pattern +- `t` - Filter by test name pattern +- `a` - Run all tests +- `f` - Run only failed tests + +## Projects Filtering + +Run specific project: + +```bash +vitest --project unit +vitest --project integration --project e2e +``` + +## Environment-based Filtering + +```ts +const isDev = process.env.NODE_ENV === "development"; +const isCI = process.env.CI; + +describe.skipIf(isCI)("local only tests", () => {}); +describe.runIf(isDev)("dev tests", () => {}); +``` + +## Combining Filters + +```bash +# File pattern + test name + changed +vitest user -t "login" --changed + +# Related files + run mode +vitest related src/auth.ts --run +``` + +## List Tests Without Running + +```bash +vitest list # Show all test names +vitest list -t "user" # Filter by name +vitest list --filesOnly # Show only file paths +vitest list --json # JSON output +``` + +## Key Points + +- Use `-t` for test name pattern filtering +- `--changed` runs only tests affected by changes +- `--related` runs tests importing specific files +- Tags provide semantic test grouping +- Use `.only` for debugging, but configure CI to reject it +- Watch mode has interactive filtering + + diff --git a/.agents/skills/vitest/references/features-mocking.md b/.agents/skills/vitest/references/features-mocking.md new file mode 100644 index 0000000..e6f19b1 --- /dev/null +++ b/.agents/skills/vitest/references/features-mocking.md @@ -0,0 +1,272 @@ +--- +name: mocking +description: Mock functions, modules, timers, and dates with vi utilities +--- + +# Mocking + +## Mock Functions + +```ts +import { expect, vi } from "vitest"; + +// Create mock function +const fn = vi.fn(); +fn("hello"); + +expect(fn).toHaveBeenCalled(); +expect(fn).toHaveBeenCalledWith("hello"); + +// With implementation +const add = vi.fn((a, b) => a + b); +expect(add(1, 2)).toBe(3); + +// Mock return values +fn.mockReturnValue(42); +fn.mockReturnValueOnce(1).mockReturnValueOnce(2); +fn.mockResolvedValue({ data: true }); +fn.mockRejectedValue(new Error("fail")); + +// Mock implementation +fn.mockImplementation((x) => x * 2); +fn.mockImplementationOnce(() => "first call"); +``` + +## Spying on Objects + +```ts +const cart = { + getTotal: () => 100, +}; + +const spy = vi.spyOn(cart, "getTotal"); +cart.getTotal(); + +expect(spy).toHaveBeenCalled(); + +// Mock implementation +spy.mockReturnValue(200); +expect(cart.getTotal()).toBe(200); + +// Restore original +spy.mockRestore(); +``` + +## Module Mocking + +```ts +// vi.mock is hoisted to top of file +vi.mock("./api", () => ({ + fetchUser: vi.fn(() => ({ id: 1, name: "Mock" })), +})); + +import { fetchUser } from "./api"; + +test("mocked module", () => { + expect(fetchUser()).toEqual({ id: 1, name: "Mock" }); +}); +``` + +### Partial Mock + +```ts +vi.mock("./utils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + specificFunction: vi.fn(), + }; +}); +``` + +### Auto-mock with Spy + +```ts +// Keep implementation but spy on calls +vi.mock("./calculator", { spy: true }); + +import { add } from "./calculator"; + +test("spy on module", () => { + const result = add(1, 2); // Real implementation + expect(result).toBe(3); + expect(add).toHaveBeenCalledWith(1, 2); +}); +``` + +### Manual Mocks (**mocks**) + +``` +src/ + __mocks__/ + axios.ts # Mocks 'axios' + api/ + __mocks__/ + client.ts # Mocks './client' + client.ts +``` + +```ts +// Just call vi.mock with no factory +vi.mock("axios"); +vi.mock("./api/client"); +``` + +## Dynamic Mocking (vi.doMock) + +Not hoisted - use for dynamic imports: + +```ts +test("dynamic mock", async () => { + vi.doMock("./config", () => ({ + apiUrl: "http://test.local", + })); + + const { apiUrl } = await import("./config"); + expect(apiUrl).toBe("http://test.local"); + + vi.doUnmock("./config"); +}); +``` + +## Mock Timers + +```ts +import { afterEach, beforeEach, vi } from "vitest"; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +test("timers", () => { + const fn = vi.fn(); + setTimeout(fn, 1000); + + expect(fn).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1000); + expect(fn).toHaveBeenCalled(); +}); + +// Other timer methods +vi.runAllTimers(); // Run all pending timers +vi.runOnlyPendingTimers(); // Run only currently pending +vi.advanceTimersToNextTimer(); // Advance to next timer +``` + +### Async Timer Methods + +```ts +test("async timers", async () => { + vi.useFakeTimers(); + + let resolved = false; + setTimeout( + () => + Promise.resolve().then(() => { + resolved = true; + }), + 100 + ); + + await vi.advanceTimersByTimeAsync(100); + expect(resolved).toBe(true); +}); +``` + +## Mock Dates + +```ts +vi.setSystemTime(new Date("2024-01-01")); +expect(new Date().getFullYear()).toBe(2024); + +vi.useRealTimers(); // Restore +``` + +## Mock Globals + +```ts +vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve({ json: () => ({ data: "mock" }) })) +); + +// Restore +vi.unstubAllGlobals(); +``` + +## Mock Environment Variables + +```ts +vi.stubEnv("API_KEY", "test-key"); +expect(import.meta.env.API_KEY).toBe("test-key"); + +// Restore +vi.unstubAllEnvs(); +``` + +## Clearing Mocks + +```ts +const fn = vi.fn(); +fn(); + +fn.mockClear(); // Clear call history +fn.mockReset(); // Clear history + implementation +fn.mockRestore(); // Restore original (for spies) + +// Global +vi.clearAllMocks(); +vi.resetAllMocks(); +vi.restoreAllMocks(); +``` + +## Config Auto-Reset + +```ts +// vitest.config.ts +defineConfig({ + test: { + clearMocks: true, // Clear before each test + mockReset: true, // Reset before each test + restoreMocks: true, // Restore after each test + unstubEnvs: true, // Restore env vars + unstubGlobals: true, // Restore globals + }, +}); +``` + +## Hoisted Variables for Mocks + +```ts +const mockFn = vi.hoisted(() => vi.fn()); + +vi.mock("./module", () => ({ + getData: mockFn, +})); + +import { getData } from "./module"; + +test("hoisted mock", () => { + mockFn.mockReturnValue("test"); + expect(getData()).toBe("test"); +}); +``` + +## Key Points + +- `vi.mock` is hoisted - called before imports +- Use `vi.doMock` for dynamic, non-hoisted mocking +- Always restore mocks to avoid test pollution +- Use `{ spy: true }` to keep implementation but track calls +- `vi.hoisted` lets you reference variables in mock factories + + diff --git a/.agents/skills/vitest/references/features-snapshots.md b/.agents/skills/vitest/references/features-snapshots.md new file mode 100644 index 0000000..849c0cf --- /dev/null +++ b/.agents/skills/vitest/references/features-snapshots.md @@ -0,0 +1,207 @@ +--- +name: snapshot-testing +description: Snapshot testing with file, inline, and file snapshots +--- + +# Snapshot Testing + +Snapshot tests capture output and compare against stored references. + +## Basic Snapshot + +```ts +import { expect, test } from "vitest"; + +test("snapshot", () => { + const result = generateOutput(); + expect(result).toMatchSnapshot(); +}); +``` + +First run creates `.snap` file: + +```js +// __snapshots__/test.spec.ts.snap +exports["snapshot 1"] = ` +{ + "id": 1, + "name": "test" +} +`; +``` + +## Inline Snapshots + +Stored directly in test file: + +```ts +test("inline snapshot", () => { + const data = { foo: "bar" }; + expect(data).toMatchInlineSnapshot(); +}); +``` + +Vitest updates the test file: + +```ts +test("inline snapshot", () => { + const data = { foo: "bar" }; + expect(data).toMatchInlineSnapshot(` + { + "foo": "bar", + } + `); +}); +``` + +## File Snapshots + +Compare against explicit file: + +```ts +test("render html", async () => { + const html = renderComponent(); + await expect(html).toMatchFileSnapshot("./expected/component.html"); +}); +``` + +## Snapshot Hints + +Add descriptive hints: + +```ts +test("multiple snapshots", () => { + expect(header).toMatchSnapshot("header"); + expect(body).toMatchSnapshot("body content"); + expect(footer).toMatchSnapshot("footer"); +}); +``` + +## Object Shape Matching + +Match partial structure: + +```ts +test("shape snapshot", () => { + const data = { + id: Math.random(), + created: new Date(), + name: "test", + }; + + expect(data).toMatchSnapshot({ + id: expect.any(Number), + created: expect.any(Date), + }); +}); +``` + +## Error Snapshots + +```ts +test("error message", () => { + expect(() => { + throw new Error("Something went wrong"); + }).toThrowErrorMatchingSnapshot(); +}); + +test("inline error", () => { + expect(() => { + throw new Error("Bad input"); + }).toThrowErrorMatchingInlineSnapshot(`[Error: Bad input]`); +}); +``` + +## Updating Snapshots + +```bash +# Update all snapshots +vitest -u +vitest --update + +# In watch mode, press 'u' to update failed snapshots +``` + +## Custom Serializers + +Add custom snapshot formatting: + +```ts +expect.addSnapshotSerializer({ + test(val) { + return val && typeof val.toJSON === "function"; + }, + serialize(val, config, indentation, depth, refs, printer) { + return printer(val.toJSON(), config, indentation, depth, refs); + }, +}); +``` + +Or via config: + +```ts +// vitest.config.ts +defineConfig({ + test: { + snapshotSerializers: ["./my-serializer.ts"], + }, +}); +``` + +## Snapshot Format Options + +```ts +defineConfig({ + test: { + snapshotFormat: { + printBasicPrototype: false, // Don't print Array/Object prototypes + escapeString: false, + }, + }, +}); +``` + +## Concurrent Test Snapshots + +Use context's expect: + +```ts +test.concurrent("concurrent 1", async ({ expect }) => { + expect(await getData()).toMatchSnapshot(); +}); + +test.concurrent("concurrent 2", async ({ expect }) => { + expect(await getOther()).toMatchSnapshot(); +}); +``` + +## Snapshot File Location + +Default: `__snapshots__/.snap` + +Customize: + +```ts +defineConfig({ + test: { + resolveSnapshotPath: (testPath, snapExtension) => { + return testPath.replace("__tests__", "__snapshots__") + snapExtension; + }, + }, +}); +``` + +## Key Points + +- Commit snapshot files to version control +- Review snapshot changes in code review +- Use hints for multiple snapshots in one test +- Use `toMatchFileSnapshot` for large outputs (HTML, JSON) +- Inline snapshots auto-update in test file +- Use context's `expect` for concurrent tests + + diff --git a/.claude/skills/vitest b/.claude/skills/vitest new file mode 120000 index 0000000..7661536 --- /dev/null +++ b/.claude/skills/vitest @@ -0,0 +1 @@ +../../.agents/skills/vitest \ No newline at end of file diff --git a/.cursor/skills/vitest b/.cursor/skills/vitest new file mode 120000 index 0000000..7661536 --- /dev/null +++ b/.cursor/skills/vitest @@ -0,0 +1 @@ +../../.agents/skills/vitest \ No newline at end of file diff --git a/bun.lock b/bun.lock index 241dc61..2640810 100644 --- a/bun.lock +++ b/bun.lock @@ -5,34 +5,45 @@ "": { "name": "readie", "dependencies": { - "@effect/cli": "latest", - "@effect/platform": "latest", - "@effect/platform-node": "latest", - "@effect/printer": "latest", - "@effect/printer-ansi": "latest", - "effect": "latest", + "@effect/cli": "^0.73.2", + "@effect/platform": "^0.94.5", + "@effect/platform-node": "^0.104.1", + "@effect/printer": "^0.47.0", + "@effect/printer-ansi": "^0.47.0", + "effect": "^3.19.18", }, "devDependencies": { - "@changesets/changelog-github": "latest", - "@changesets/cli": "latest", - "@types/fs-extra": "latest", - "@types/node": "latest", - "changeset-conventional-commits": "latest", - "fs-extra": "latest", - "lefthook": "latest", - "oxfmt": "latest", - "oxlint": "latest", - "pathe": "latest", - "tempy": "latest", - "typescript": "latest", - "ultracite": "latest", - "vitest": "latest", + "@changesets/changelog-github": "^0.5.2", + "@changesets/cli": "^2.29.8", + "@types/fs-extra": "^11.0.4", + "@types/node": "^25.3.0", + "@vitest/coverage-v8": "^4.0.18", + "changeset-conventional-commits": "^0.2.5", + "fs-extra": "^11.3.3", + "lefthook": "^2.1.1", + "oxfmt": "^0.34.0", + "oxlint": "^1.49.0", + "pathe": "^2.0.3", + "tempy": "^3.2.0", + "typescript": "^5.9.3", + "ultracite": "^7.2.3", + "vitest": "^4.0.18", }, }, }, "packages": { + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + "@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.0.14", "", { "dependencies": { "@changesets/config": "^3.1.2", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-ddBvf9PHdy2YY0OUiEl3TV78mH9sckndJR14QAt87KLEbIov81XO0q0QAmvooBxXlqRRP8I9B7XOzZwQG7JkWA=="], "@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.9", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ=="], @@ -153,8 +164,12 @@ "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@types/node": "^12.7.1", "find-up": "^4.1.0", "fs-extra": "^8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="], "@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@changesets/types": "^4.0.1", "@manypkg/find-root": "^1.1.0", "fs-extra": "^8.1.0", "globby": "^11.0.0", "read-yaml-file": "^1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="], @@ -347,6 +362,8 @@ "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.18", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.18", "ast-v8-to-istanbul": "^0.3.10", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.18", "vitest": "4.0.18" }, "optionalPeers": ["@vitest/browser"] }, "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg=="], + "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], "@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="], @@ -373,6 +390,8 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.11", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw=="], + "balanced-match": ["balanced-match@4.0.3", "", {}, "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g=="], "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], @@ -463,7 +482,9 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], "human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="], @@ -487,8 +508,16 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + "jju": ["jju@1.4.0", "", {}, "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA=="], + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], @@ -525,6 +554,10 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="], + + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], @@ -643,7 +676,7 @@ "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], - "supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "temp-dir": ["temp-dir@3.0.0", "", {}, "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw=="], @@ -803,6 +836,8 @@ "changeset-conventional-commits/@changesets/read/@changesets/parse/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "changeset-conventional-commits/@changesets/read/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + "changeset-conventional-commits/@changesets/read/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "changeset-conventional-commits/@changesets/read/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], @@ -821,6 +856,8 @@ "changeset-conventional-commits/@changesets/read/@changesets/parse/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "changeset-conventional-commits/@changesets/read/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + "changeset-conventional-commits/@changesets/read/@changesets/git/@manypkg/get-packages/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "changeset-conventional-commits/@changesets/read/@changesets/git/@manypkg/get-packages/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], diff --git a/package.json b/package.json index 51c1fc1..c4865f5 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "fix": "ultracite fix", "prepare": "lefthook install", "test": "bunx vitest run", + "test:coverage": "bunx vitest run --coverage", "test:watch": "bunx vitest", "typecheck": "bunx tsc -p tsconfig.json --noEmit" }, @@ -62,6 +63,7 @@ "@changesets/cli": "^2.29.8", "@types/fs-extra": "^11.0.4", "@types/node": "^25.3.0", + "@vitest/coverage-v8": "^4.0.18", "changeset-conventional-commits": "^0.2.5", "fs-extra": "^11.3.3", "lefthook": "^2.1.1", diff --git a/test/generator-single.test.ts b/test/generator-single.test.ts new file mode 100644 index 0000000..b864803 --- /dev/null +++ b/test/generator-single.test.ts @@ -0,0 +1,140 @@ +import { ensureDir, pathExists, readFile, remove, writeFile } from "fs-extra"; +import { dirname, join, resolve } from "pathe"; +import { temporaryDirectory } from "tempy"; + +import { generateReadmeFromConfig } from "#src/readme-generator/generator.js"; + +const writeJson = async (filePath: string, value: unknown) => { + await ensureDir(dirname(filePath)); + await writeFile(filePath, JSON.stringify(value, null, 2), "utf8"); +}; + +const setupProject = async (configOverrides: Record = {}) => { + const projectDir = temporaryDirectory(); + const configPath = join(projectDir, "readie.json"); + await writeJson(configPath, { + description: "Generated from tests.", + title: "Generator Test", + ...configOverrides, + }); + return { configPath, projectDir }; +}; + +const withProject = async ( + run: (fixture: { configPath: string; projectDir: string }) => Promise, + configOverrides?: Record +) => { + const fixture = await setupProject(configOverrides); + try { + await run(fixture); + } finally { + await remove(fixture.projectDir); + } +}; + +const withTwoProjects = async ( + run: ( + first: { configPath: string; projectDir: string }, + second: { configPath: string; projectDir: string } + ) => Promise +) => { + const first = await setupProject({ output: "README.config.md" }); + const second = await setupProject(); + try { + await run(first, second); + } finally { + await remove(first.projectDir); + await remove(second.projectDir); + } +}; + +const createGlobalConfigFixture = async () => { + const rootDir = temporaryDirectory(); + const packageDir = join(rootDir, "packages", "api"); + const configPath = join(packageDir, "readie.json"); + await writeJson(join(rootDir, "readie.global.json"), { + banner: "GLOBAL_BANNER_SHOULD_NOT_APPEAR", + }); + await writeJson(configPath, { + description: "Package docs.", + title: "Package API", + }); + return { configPath, rootDir }; +}; + +describe("generator single behavior", () => { + it("returns updated false when output content is unchanged", async () => { + await withProject(async (fixture) => { + const firstResult = await generateReadmeFromConfig({ + configPath: fixture.configPath, + dryRun: false, + }); + const secondResult = await generateReadmeFromConfig({ + configPath: fixture.configPath, + dryRun: false, + }); + + expect(firstResult.updated).toBeTruthy(); + expect(secondResult.updated).toBeFalsy(); + }); + }); + + it("reports an update on dry run without writing output file", async () => { + await withProject(async (fixture) => { + const outputPath = join(fixture.projectDir, "README.dry-run.md"); + const result = await generateReadmeFromConfig({ + configPath: fixture.configPath, + dryRun: true, + outputPath, + }); + + expect(result.updated).toBeTruthy(); + expect(result.outputPath).toBe(resolve(outputPath)); + await expect(pathExists(outputPath)).resolves.toBeFalsy(); + }); + }); + + it("uses output precedence of cli output > config output > default", async () => { + await withTwoProjects(async (fixture, defaultFixture) => { + const cliOutput = join(fixture.projectDir, "README.cli.md"); + const cliResult = await generateReadmeFromConfig({ + configPath: fixture.configPath, + dryRun: true, + outputPath: cliOutput, + }); + const configResult = await generateReadmeFromConfig({ + configPath: fixture.configPath, + dryRun: true, + }); + const defaultResult = await generateReadmeFromConfig({ + configPath: defaultFixture.configPath, + dryRun: true, + }); + + expect(cliResult.outputPath).toBe(resolve(cliOutput)); + expect(configResult.outputPath).toBe( + resolve(fixture.projectDir, "README.config.md") + ); + expect(defaultResult.outputPath).toBe( + resolve(defaultFixture.projectDir, "README.md") + ); + }); + }); + + it("ignores readie.global.json when useGlobalConfig is false", async () => { + const fixture = await createGlobalConfigFixture(); + try { + const result = await generateReadmeFromConfig({ + configPath: fixture.configPath, + dryRun: false, + useGlobalConfig: false, + }); + const content = await readFile(result.outputPath, "utf8"); + + expect(content).not.toContain("GLOBAL_BANNER_SHOULD_NOT_APPEAR"); + expect(content).toContain("# Package API"); + } finally { + await remove(fixture.rootDir); + } + }); +}); diff --git a/test/generator-workspace.test.ts b/test/generator-workspace.test.ts new file mode 100644 index 0000000..2031c56 --- /dev/null +++ b/test/generator-workspace.test.ts @@ -0,0 +1,110 @@ +import { ensureDir, remove, writeFile } from "fs-extra"; +import { dirname, join } from "pathe"; +import { temporaryDirectory } from "tempy"; + +import { + generateReadmeFromConfig, + generateWorkspaceReadmes, +} from "#src/readme-generator/generator.js"; + +const writeJson = async (filePath: string, value: unknown) => { + await ensureDir(dirname(filePath)); + await writeFile(filePath, JSON.stringify(value, null, 2), "utf8"); +}; + +const withSilencedConsole = async (run: () => Promise) => { + const logSpy = vi.spyOn(console, "log").mockReturnValue(); + const errorSpy = vi.spyOn(console, "error").mockReturnValue(); + try { + await run(); + } finally { + logSpy.mockRestore(); + errorSpy.mockRestore(); + } +}; + +const setupWorkspacePackages = async ( + rootDir: string, + entries: { name: string; config: Record }[] +) => { + for (const entry of entries) { + await writeJson(join(rootDir, entry.name, "readie.json"), entry.config); + } +}; + +describe("generator workspace behavior", () => { + it("throws when workspace root does not exist", async () => { + const missingRoot = join(temporaryDirectory(), "missing-root"); + + await expect( + generateWorkspaceReadmes({ + configName: "readie.json", + dryRun: false, + packageFilter: new Set(), + rootDir: missingRoot, + }) + ).rejects.toThrow("Workspace root not found"); + }); + + it("applies package filters and tracks updated, unchanged, and skipped", async () => { + const rootDir = temporaryDirectory(); + const betaConfigPath = join(rootDir, "beta", "readie.json"); + await setupWorkspacePackages(rootDir, [ + { config: { description: "Alpha docs", title: "Alpha" }, name: "alpha" }, + { config: { description: "Beta docs", title: "Beta" }, name: "beta" }, + { config: { description: "Gamma docs", title: "Gamma" }, name: "gamma" }, + ]); + + try { + await withSilencedConsole(async () => { + await generateReadmeFromConfig({ + configPath: betaConfigPath, + dryRun: false, + useGlobalConfig: false, + }); + const result = await generateWorkspaceReadmes({ + configName: "readie.json", + dryRun: false, + packageFilter: new Set(["alpha", "beta"]), + rootDir, + useGlobalConfig: false, + }); + expect(result.updated).toStrictEqual(expect.arrayContaining(["alpha"])); + expect(result.unchanged).toStrictEqual( + expect.arrayContaining(["beta"]) + ); + expect(result.skippedByFilter).toStrictEqual(["gamma"]); + expect(result.failed).toHaveLength(0); + }); + } finally { + await remove(rootDir); + } + }); + + it("continues processing when one workspace project fails", async () => { + const rootDir = temporaryDirectory(); + + await setupWorkspacePackages(rootDir, [ + { config: { description: "Good docs", title: "Good" }, name: "good" }, + { config: { description: "Missing title fails schema" }, name: "bad" }, + ]); + + try { + await withSilencedConsole(async () => { + const result = await generateWorkspaceReadmes({ + configName: "readie.json", + dryRun: false, + packageFilter: new Set(), + rootDir, + useGlobalConfig: false, + }); + expect(result.updated).toStrictEqual(expect.arrayContaining(["good"])); + expect(result.failed).toHaveLength(1); + expect(result.failed[0]?.projectDir).toContain("/bad"); + expect(result.unchanged).toHaveLength(0); + }); + } finally { + await remove(rootDir); + } + }); +}); diff --git a/test/load-global-config.test.ts b/test/load-global-config.test.ts new file mode 100644 index 0000000..c06e3f6 --- /dev/null +++ b/test/load-global-config.test.ts @@ -0,0 +1,76 @@ +import { ensureDir, remove, writeFile } from "fs-extra"; +import { dirname, join } from "pathe"; +import { temporaryDirectory } from "tempy"; + +import { loadGlobalConfig, loadReadieConfig } from "#src/config/load-config.js"; + +const writeJson = async (filePath: string, value: unknown) => { + await ensureDir(dirname(filePath)); + await writeFile(filePath, JSON.stringify(value, null, 2), "utf8"); +}; + +describe("config loading edge cases", () => { + it("includes file path when JSON parsing fails", async () => { + const rootDir = temporaryDirectory(); + const filePath = join(rootDir, "readie.json"); + await writeFile(filePath, "{ invalid json", "utf8"); + + try { + await expect(loadReadieConfig(filePath)).rejects.toThrow( + `Failed to parse JSON in ${filePath}` + ); + } finally { + await remove(rootDir); + } + }); + + it("loads nearest readie.global.json while traversing parent directories", async () => { + const rootDir = temporaryDirectory(); + const packagesDir = join(rootDir, "packages"); + const deepDir = join(packagesDir, "react", "src"); + + await writeJson(join(rootDir, "readie.global.json"), { + footer: "ROOT GLOBAL", + }); + await writeJson(join(packagesDir, "readie.global.json"), { + footer: "PACKAGES GLOBAL", + }); + await ensureDir(deepDir); + + try { + const globalConfig = await loadGlobalConfig(deepDir); + expect(globalConfig?.footer).toBe("PACKAGES GLOBAL"); + } finally { + await remove(rootDir); + } + }); + + it("returns null when no global config is found", async () => { + const rootDir = temporaryDirectory(); + const deepDir = join(rootDir, "one", "two"); + await ensureDir(deepDir); + + try { + await expect(loadGlobalConfig(deepDir)).resolves.toBeNull(); + } finally { + await remove(rootDir); + } + }); + + it("throws for invalid global config schema", async () => { + const rootDir = temporaryDirectory(); + const deepDir = join(rootDir, "app"); + await ensureDir(deepDir); + await writeJson(join(rootDir, "readie.global.json"), { + title: 123, + }); + + try { + await expect(loadGlobalConfig(deepDir)).rejects.toThrow( + `Global configuration validation failed for ${join(rootDir, "readie.global.json")}` + ); + } finally { + await remove(rootDir); + } + }); +}); diff --git a/test/resolve-invocation.test.ts b/test/resolve-invocation.test.ts index 6c8f805..188bb34 100644 --- a/test/resolve-invocation.test.ts +++ b/test/resolve-invocation.test.ts @@ -5,6 +5,15 @@ describe("resolve invocation routing", () => { const resolved = resolveInvocation([]); expect(resolved.mode).toBe("generate"); expect(resolved.commandArgs).toStrictEqual([]); + expect(resolved.originalArgs).toStrictEqual([]); + }); + + it("routes explicit generate command", () => { + const args = ["generate", "--config", "readie.json"]; + const resolved = resolveInvocation(args); + expect(resolved.mode).toBe("generate"); + expect(resolved.commandArgs).toStrictEqual(["--config", "readie.json"]); + expect(resolved.originalArgs).toStrictEqual(args); }); it("routes workspace subcommand", () => { @@ -15,6 +24,11 @@ describe("resolve invocation routing", () => { ]); expect(resolved.mode).toBe("generate:workspace"); expect(resolved.commandArgs).toStrictEqual(["--root", "./packages"]); + expect(resolved.originalArgs).toStrictEqual([ + "generate:workspace", + "--root", + "./packages", + ]); }); it("routes init subcommand", () => { @@ -29,6 +43,13 @@ describe("resolve invocation routing", () => { expect(resolved.commandArgs).toStrictEqual([]); }); + it("routes -h to help mode", () => { + const resolved = resolveInvocation(["-h"]); + expect(resolved.mode).toBe("help"); + expect(resolved.commandArgs).toStrictEqual([]); + expect(resolved.originalArgs).toStrictEqual(["-h"]); + }); + it("routes help command to help mode", () => { const resolved = resolveInvocation(["help"]); expect(resolved.mode).toBe("help"); @@ -40,5 +61,6 @@ describe("resolve invocation routing", () => { const resolved = resolveInvocation(args); expect(resolved.mode).toBe("unknown"); expect(resolved.commandArgs).toStrictEqual(args); + expect(resolved.originalArgs).toStrictEqual(args); }); }); diff --git a/test/template.test.ts b/test/template.test.ts index 5bf1218..6da6ae4 100644 --- a/test/template.test.ts +++ b/test/template.test.ts @@ -1,16 +1,23 @@ +import type { ReadieConfig } from "#src/config/types.js"; import { baseReadmeTemplate } from "#src/readme-generator/template.js"; +const createConfig = (overrides: Partial = {}): ReadieConfig => ({ + description: "A neutral README.", + title: "Readie Demo", + ...overrides, +}); + describe("base readme template", () => { it("renders neutral markdown without c15t defaults", () => { - const markdown = baseReadmeTemplate({ - description: "A neutral README.", - features: ["Fast", "Simple"], - includeTableOfContents: true, - installation: ["```bash\nnpm install readie-demo\n```"], - security: "Please report issues privately.", - title: "Readie Demo", - usage: ["Run the command", "```bash\nnpx readie\n```"], - }); + const markdown = baseReadmeTemplate( + createConfig({ + features: ["Fast", "Simple"], + includeTableOfContents: true, + installation: ["```bash\nnpm install readie-demo\n```"], + security: "Please report issues privately.", + usage: ["Run the command", "```bash\nnpx readie\n```"], + }) + ); const requiredHeadings = [ "# Readie Demo", @@ -24,4 +31,117 @@ describe("base readme template", () => { expect(missingHeadings).toStrictEqual([]); expect(markdown).not.toMatch(/c15t|consent\.io/); }); + + it("omits table of contents when includeTableOfContents is false", () => { + const markdown = baseReadmeTemplate( + createConfig({ + features: ["Fast"], + includeTableOfContents: false, + }) + ); + + expect(markdown).not.toContain("## Table of Contents"); + }); + + it("creates unique toc links for repeated section titles", () => { + const markdown = baseReadmeTemplate( + createConfig({ + customSections: { + "Quick Start": "Project-specific quick start details.", + }, + includeTableOfContents: true, + quickStart: "Install and run.", + }) + ); + + expect(markdown).toContain("- [Quick Start](#quick-start)"); + expect(markdown).toContain("- [Quick Start](#quick-start-1)"); + }); + + it("suppresses top-level title when banner already contains an h1", () => { + const markdown = baseReadmeTemplate( + createConfig({ + banner: "

Readie Banner

", + }) + ); + + expect(markdown).toContain("

Readie Banner

"); + expect(markdown).not.toContain("# Readie Demo"); + }); + + it("keeps top-level title when banner does not contain an h1", () => { + const markdown = baseReadmeTemplate( + createConfig({ + banner: "
Readie Banner
", + }) + ); + + expect(markdown).toContain("
Readie Banner
"); + expect(markdown).toContain("# Readie Demo"); + }); + + it("renders usage as numbered steps while preserving fenced code blocks", () => { + const markdown = baseReadmeTemplate( + createConfig({ + usage: [ + "Install dependencies", + "```bash\nbun install\n```", + "- Run checks", + "```bash\nbun run test\n```", + ], + }) + ); + + expect(markdown).toContain("1. Install dependencies"); + expect(markdown).toContain("2. Run checks"); + expect(markdown).toContain("```bash\nbun install\n```"); + expect(markdown).toContain("```bash\nbun run test\n```"); + }); + + it("renders string license section", () => { + const markdown = baseReadmeTemplate( + createConfig({ + license: "MIT", + }) + ); + + expect(markdown).toContain("## License\n\nMIT"); + }); + + it("renders object license section", () => { + const markdown = baseReadmeTemplate( + createConfig({ + license: { name: "MIT", url: "https://opensource.org/license/mit" }, + }) + ); + + expect(markdown).toContain( + "## License\n\n[MIT](https://opensource.org/license/mit)" + ); + }); + + it("renders linked and unlinked badges", () => { + const markdown = baseReadmeTemplate( + createConfig({ + badges: [ + { + image: "https://img.shields.io/npm/v/readie", + label: "Version", + }, + { + image: "https://img.shields.io/npm/l/readie", + label: "License", + link: "https://opensource.org/license/mit", + }, + ], + }) + ); + + expect(markdown).toContain( + "![Version](https://img.shields.io/npm/v/readie)" + ); + expect(markdown).toContain( + "[![License](https://img.shields.io/npm/l/readie)](https://opensource.org/license/mit)" + ); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 076c92f..0d053cc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,23 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { + coverage: { + all: true, + exclude: ["**/*.d.ts", "dist/**", "node_modules/**", "test/**"], + include: [ + "src/cli/resolve-invocation.ts", + "src/config/load-config.ts", + "src/readme-generator/**/*.ts", + ], + provider: "v8", + reporter: ["text", "html", "lcov"], + thresholds: { + branches: 70, + functions: 80, + lines: 80, + statements: 80, + }, + }, globals: true, }, }); From 53922decfca2fe5ad6ce2eb915b8efedd23b7a07 Mon Sep 17 00:00:00 2001 From: Christopher Burns Date: Thu, 19 Feb 2026 23:03:01 +0000 Subject: [PATCH 2/2] Refactor Vitest documentation and improve test utility functions - Updated core-expect.md to enhance snapshot assertion examples with consistent formatting. - Expanded core-hooks.md to clarify around hooks usage for Vitest 4.1.0 and above. - Modified features-coverage.md to update Codecov action version for better compatibility. - Adjusted features-mocking.md to correct code block formatting. - Refactored generator-single.test.ts and load-global-config.test.ts to utilize a new writeJson utility function for improved code reuse. - Enhanced resolve-invocation.test.ts to include original arguments in assertions for better test clarity. - Introduced a new utils.ts file to centralize JSON writing functionality. --- .../skills/vitest/references/core-expect.md | 10 ++++++---- .agents/skills/vitest/references/core-hooks.md | 10 +++++++--- .../vitest/references/features-coverage.md | 2 +- .../vitest/references/features-mocking.md | 2 +- test/generator-single.test.ts | 18 ++++++++++++------ test/load-global-config.test.ts | 7 ++----- test/resolve-invocation.test.ts | 3 +++ test/utils.ts | 7 +++++++ 8 files changed, 39 insertions(+), 20 deletions(-) create mode 100644 test/utils.ts diff --git a/.agents/skills/vitest/references/core-expect.md b/.agents/skills/vitest/references/core-expect.md index e0d439d..7b46eec 100644 --- a/.agents/skills/vitest/references/core-expect.md +++ b/.agents/skills/vitest/references/core-expect.md @@ -190,11 +190,13 @@ test("custom matcher", () => { ## Snapshot Assertions ```ts -expect(data).toMatchSnapshot() -expect(data).toMatchInlineSnapshot(`{ "id": 1 }`) -await expect(result).toMatchFileSnapshot('./expected.json') +expect(data).toMatchSnapshot(); +expect(data).toMatchInlineSnapshot(`{ "id": 1 }`); +await expect(result).toMatchFileSnapshot("./expected.json"); -expect(() => throw new Error('fail')).toThrowErrorMatchingSnapshot() +expect(() => { + throw new Error("fail"); +}).toThrowErrorMatchingSnapshot(); ``` ## Key Points diff --git a/.agents/skills/vitest/references/core-hooks.md b/.agents/skills/vitest/references/core-hooks.md index 3b21377..e8c1219 100644 --- a/.agents/skills/vitest/references/core-hooks.md +++ b/.agents/skills/vitest/references/core-hooks.md @@ -79,7 +79,11 @@ beforeAll(async () => { }, 30_000); // 30 second timeout ``` -## Around Hooks +## Vitest 4.1.0+ Around Hooks + +Requires Vitest 4.1.0 or newer. If you're on Vitest 3.x, skip this section. + +### aroundEach Wrap tests with setup/teardown context: @@ -104,7 +108,7 @@ test("insert user", async () => { Wrap entire suite: ```ts -import { aroundAll, test } from "vitest"; +import { aroundAll } from "vitest"; aroundAll(async (runSuite) => { console.log("before all tests"); @@ -113,7 +117,7 @@ aroundAll(async (runSuite) => { }); ``` -### Multiple Around Hooks +### Multiple aroundEach Hooks Nested like onion layers: diff --git a/.agents/skills/vitest/references/features-coverage.md b/.agents/skills/vitest/references/features-coverage.md index 266c9f4..28bc421 100644 --- a/.agents/skills/vitest/references/features-coverage.md +++ b/.agents/skills/vitest/references/features-coverage.md @@ -171,7 +171,7 @@ Run with `vitest --ui` to view coverage visually. run: npm run test:coverage - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: files: ./coverage/lcov.info ``` diff --git a/.agents/skills/vitest/references/features-mocking.md b/.agents/skills/vitest/references/features-mocking.md index e6f19b1..dd5ba9a 100644 --- a/.agents/skills/vitest/references/features-mocking.md +++ b/.agents/skills/vitest/references/features-mocking.md @@ -96,7 +96,7 @@ test("spy on module", () => { ### Manual Mocks (**mocks**) -``` +```text src/ __mocks__/ axios.ts # Mocks 'axios' diff --git a/test/generator-single.test.ts b/test/generator-single.test.ts index b864803..c54495e 100644 --- a/test/generator-single.test.ts +++ b/test/generator-single.test.ts @@ -1,13 +1,10 @@ -import { ensureDir, pathExists, readFile, remove, writeFile } from "fs-extra"; -import { dirname, join, resolve } from "pathe"; +import { pathExists, readFile, remove } from "fs-extra"; +import { join, resolve } from "pathe"; import { temporaryDirectory } from "tempy"; import { generateReadmeFromConfig } from "#src/readme-generator/generator.js"; -const writeJson = async (filePath: string, value: unknown) => { - await ensureDir(dirname(filePath)); - await writeFile(filePath, JSON.stringify(value, null, 2), "utf8"); -}; +import { writeJson } from "./utils.js"; const setupProject = async (configOverrides: Record = {}) => { const projectDir = temporaryDirectory(); @@ -73,9 +70,18 @@ describe("generator single behavior", () => { configPath: fixture.configPath, dryRun: false, }); + await writeJson(fixture.configPath, { + description: "Generated from tests.", + title: "Generator Test Updated", + }); + const thirdResult = await generateReadmeFromConfig({ + configPath: fixture.configPath, + dryRun: false, + }); expect(firstResult.updated).toBeTruthy(); expect(secondResult.updated).toBeFalsy(); + expect(thirdResult.updated).toBeTruthy(); }); }); diff --git a/test/load-global-config.test.ts b/test/load-global-config.test.ts index c06e3f6..9138c07 100644 --- a/test/load-global-config.test.ts +++ b/test/load-global-config.test.ts @@ -1,13 +1,10 @@ import { ensureDir, remove, writeFile } from "fs-extra"; -import { dirname, join } from "pathe"; +import { join } from "pathe"; import { temporaryDirectory } from "tempy"; import { loadGlobalConfig, loadReadieConfig } from "#src/config/load-config.js"; -const writeJson = async (filePath: string, value: unknown) => { - await ensureDir(dirname(filePath)); - await writeFile(filePath, JSON.stringify(value, null, 2), "utf8"); -}; +import { writeJson } from "./utils.js"; describe("config loading edge cases", () => { it("includes file path when JSON parsing fails", async () => { diff --git a/test/resolve-invocation.test.ts b/test/resolve-invocation.test.ts index 188bb34..3cb763e 100644 --- a/test/resolve-invocation.test.ts +++ b/test/resolve-invocation.test.ts @@ -35,12 +35,14 @@ describe("resolve invocation routing", () => { const resolved = resolveInvocation(["init", "--force"]); expect(resolved.mode).toBe("init"); expect(resolved.commandArgs).toStrictEqual(["--force"]); + expect(resolved.originalArgs).toStrictEqual(["init", "--force"]); }); it("routes --help to help mode", () => { const resolved = resolveInvocation(["--help"]); expect(resolved.mode).toBe("help"); expect(resolved.commandArgs).toStrictEqual([]); + expect(resolved.originalArgs).toStrictEqual(["--help"]); }); it("routes -h to help mode", () => { @@ -54,6 +56,7 @@ describe("resolve invocation routing", () => { const resolved = resolveInvocation(["help"]); expect(resolved.mode).toBe("help"); expect(resolved.commandArgs).toStrictEqual([]); + expect(resolved.originalArgs).toStrictEqual(["help"]); }); it("routes unknown commands to unknown mode", () => { diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 0000000..d4d1cc6 --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,7 @@ +import { ensureDir, writeFile } from "fs-extra"; +import { dirname } from "pathe"; + +export const writeJson = async (filePath: string, value: unknown) => { + await ensureDir(dirname(filePath)); + await writeFile(filePath, JSON.stringify(value, null, 2), "utf8"); +};