# Ut

> Quick command to generate Vitest unit tests. Usage: /ut --src=<path> [--coverage=<number>]. Accepts a source file/directory path and optional coverage threshold (default 60%). Triggers full test generation workflow: analyze → generate → run → coverage gate → scenario review. Alias for generate-user-flow-tests with structured arguments.

- Skill: `migoxlab/ut` (Agent Skill)
- Install (CLI): `npx skillmds@latest add migoxlab/ut`
- Raw SKILL.md: https://api.skillmd.com/api/skills/migoxlab/ut/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: migoxlab (https://skillmd.com/u/migoxlab)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/migoxlab/ut

---


# ut

Generate Vitest unit tests for this repo's React 18 + TypeScript + Antd + react-router-dom v6 stack.

**This is a shorthand command.** It shares the same workflow, rules, and templates as `generate-user-flow-tests`. The only addition is structured argument parsing.

## Argument parsing (MANDATORY — do this FIRST)

Parse the user's input to extract two parameters:

| Parameter | Flag form | Positional form | Default |
|-----------|-----------|-----------------|---------|
| **Source path** | `--src=<path>` | 1st bare argument | *(required)* |
| **Coverage threshold** | `--coverage=<number>` | 2nd bare argument (a number) | `60` |

Accepted invocation examples:

```
/ut --src=src/pages/config-editor/Editor.tsx --coverage=70
/ut --src=src/components/ConfigPreview
/ut src/pages/config-editor/Editor.tsx 70
/ut src/pages/config-editor/Editor.tsx
```

**Rules:**
1. `--src` is required. If missing, ask ONE clarifying question: "请提供要生成单测的源文件路径".
2. `--coverage` must be an integer between 1 and 100. If out of range or non-numeric, fall back to `60` and inform the user.
3. Store the parsed coverage threshold as `COVERAGE_THRESHOLD` and use it everywhere the workflow references the baseline gate.

After parsing, announce the parameters:

> 目标文件: `{src}`
> 覆盖率目标: `{COVERAGE_THRESHOLD}%`

Then proceed to the workflow below.

## Hard rules (do not deviate)

These rules were agreed with the project owner. Read them before generating anything.

1. **Runner:** Vitest with `environment: 'jsdom'`.
2. **Rendering / queries:** `@testing-library/react` + `@testing-library/jest-dom` matchers.
3. **User interactions:** `@testing-library/user-event` (v14+). Use `userEvent.setup()` at the top of each test — do NOT use `fireEvent` unless the interaction has no `user-event` equivalent (rare; document why in a one-line comment).
4. **API mocking:** **MSW only.** Never `vi.mock('axios')` / `vi.mock('@/services/*')` for HTTP behavior. Write handlers that return the shape the component actually receives, including axios response envelope (`{ data: ... }`) if the service layer forwards it.
5. **Routing:** wrap the rendered tree in `<MemoryRouter initialEntries={[...]}>`. Do NOT mock `useNavigate` or `useLocation`. To assert navigation, render a sibling route that displays a probe and check it appears.
6. **Providers:** always render via the `renderWithProviders` helper (see templates below). It injects `MemoryRouter`, Antd `ConfigProvider`, and any app-level context. Never import `render` from `@testing-library/react` directly in test files.
7. **Antd components:** render for real. Use role-based queries:
   - `Select`: `getByRole('combobox')` to open; options appear via `findByRole('option', { name })`.
   - `DatePicker`: open via click, then assert on the cell role `gridcell`.
   - `Modal` / `Drawer`: assert via `findByRole('dialog')` — they portal to `document.body`, not the container.
   - `message` / `notification`: use `findByText` on `document.body` scope, not the container.
8. **Query priority (strict):** `getByRole` > `getByLabelText` > `getByPlaceholderText` > `getByText` > `getByDisplayValue` > `getByTestId`. Only fall back to `data-testid` when none of the above fit. If you add a `data-testid` to source code just to make a test pass, reconsider — usually the component is missing a proper label.
9. **Assertions:** behavior-only. **No `toMatchSnapshot` / `toMatchInlineSnapshot`** anywhere — not even for "small" components. Assert text content, element presence, call arguments, role states, URL changes, request payloads.
10. **Test file location:** repo root `tests/` mirroring `src/`. Example: `src/pages/config-editor/Editor.tsx` → `tests/pages/config-editor/Editor.test.tsx`. Never co-locate next to source; never use `__tests__`.
11. **Naming:** `*.test.tsx` for components, `*.test.ts` for pure utilities. File name mirrors the source module's base name.
12. **Async:** always `await` user interactions and queries. Prefer `findBy*` / `waitFor` over manual timers. If the code uses `setTimeout`, fake timers with `vi.useFakeTimers({ shouldAdvanceTime: true })` and advance explicitly.

## Workflow

Follow these steps in order.

### Step 1 — Check the environment (one-time setup)

Run all these checks before writing any test file. If any artifact is missing, create it using the templates in the next section.

- `package.json` has `vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`, `msw`, `jsdom`, `@vitest/coverage-v8` in `devDependencies`.
- `vitest.config.ts` exists at repo root with `test.environment: 'jsdom'`, `test.setupFiles`, and alias `@ → src`.
- `tests/setup.ts` exists and imports `@testing-library/jest-dom` + wires MSW lifecycle.
- `tests/msw/server.ts` exists and exports `server`.
- `tests/msw/handlers.ts` exists (can be empty array initially — handlers are added per-test via `server.use(...)`).
- `tests/utils/renderWithProviders.tsx` exists and exports `renderWithProviders` + re-exports `screen`, `userEvent`.
- `package.json` has a `test` script: `"test": "vitest"` and `"test:ui": "vitest --ui"` (optional).

If any are missing, report exactly which to the user, show the install command, and create the config files. Do not silently install packages — print the command and wait for user to run it, OR ask for permission to run `pnpm add -D ...`.

### Step 2 — Analyze the target

For each target (file or description), produce a short mental list:

- **Entry points:** what route / props the component is rendered with.
- **User actions:** every `onClick`, `onChange`, form `onFinish`, keyboard handler, external trigger.
- **Side effects:** HTTP calls (which endpoints, which services), `useNavigate` calls, `message.success/error` calls, modal open/close.
- **Async boundaries:** where the UI shows a loading state and when it resolves.
- **Error branches:** network failure, validation error, empty response, permission denied.

Each item in the list maps to one `it(...)` block.

### Step 3 — Generate the test file

- File path: `tests/<mirror-of-src-path>/<base>.test.tsx`.
- Top of file: `import { describe, it, expect, vi, beforeEach } from 'vitest'`, `import { http, HttpResponse } from 'msw'`, `import { server } from '@tests/msw/server'`, `import { renderWithProviders, screen, userEvent } from '@tests/utils/renderWithProviders'`, plus the component.
- Structure: one top-level `describe('<ComponentName>', ...)`, one `it(...)` per behavior. Nest `describe` for sub-flows only if the list has >6 cases.
- Each `it`:
  1. `const user = userEvent.setup()` (unless using fake timers — then `userEvent.setup({ advanceTimers: vi.advanceTimersByTime })`).
  2. `server.use(...)` to register the request handlers this case needs.
  3. `renderWithProviders(<Component ... />, { route: '/...' })`.
  4. Await user interactions.
  5. Behavioral assertions.
- Never group assertions for multiple behaviors into one `it`. One behavior = one test.

### Step 4 — Run and self-check

- Run `./node_modules/.bin/vitest run tests/<path-to-new-file>` (or `pnpm exec vitest run ...`) to confirm the new tests pass.
- If any fail, fix the test (not the source) unless the test surfaces a real bug. Ask the user before modifying source.

### Step 5 — Measure coverage and iterate until ≥ COVERAGE_THRESHOLD (MANDATORY)

A "passing" test file is not done until coverage of the target source file meets the baseline gate. Do not stop at Step 4.

#### 5a. Run coverage scoped to the target source

```sh
./node_modules/.bin/vitest run tests/<path-to-new-file> \
  --coverage.enabled \
  --coverage.include='src/<path-to-target-dir>/**' \
  --coverage.reporter=json \
  --coverage.reporter=text-summary
```

Use `--coverage.include` to scope to the file/directory under test — otherwise the report dilutes with unrelated files. Dot notation is required with Vitest 0.34 (`--coverage.enabled`, not `--coverage`).

#### 5b. Parse the precise uncovered lines / branches / functions

The text report truncates long line lists with `...`. Always parse the JSON output to see everything:

```sh
node -e "
const d = require('./coverage/coverage-final.json');
const target = '/absolute/path/to/src/<target-file>.tsx';
const f = d[target];
if (!f) { console.log('file not in report'); process.exit(0); }

const uncoveredFns = [];
for (const [id, hit] of Object.entries(f.f)) {
  if (hit === 0) { const fn = f.fnMap[id]; uncoveredFns.push('L' + fn.loc.start.line + ' ' + fn.name); }
}
console.log('UNCOVERED FUNCTIONS:');
uncoveredFns.forEach(x => console.log('  ' + x));

const uncoveredBranches = [];
for (const [id, hits] of Object.entries(f.b)) {
  hits.forEach((h, idx) => {
    if (h === 0) {
      const loc = f.branchMap[id].locations[idx];
      uncoveredBranches.push('L' + (loc?.start?.line ?? f.branchMap[id].loc.start.line) + ' ' + f.branchMap[id].type);
    }
  });
}
console.log('\nUNCOVERED BRANCHES:');
uncoveredBranches.forEach(x => console.log('  ' + x));

const lines = new Set();
for (const [id, hit] of Object.entries(f.s)) {
  if (hit === 0) { const loc = f.statementMap[id]; for (let l = loc.start.line; l <= loc.end.line; l++) lines.add(l); }
}
const sorted = [...lines].sort((a,b)=>a-b);
let range = []; const ranges = [];
sorted.forEach(l => { if (!range.length || l === range[range.length-1] + 1) range.push(l); else { ranges.push('L' + range[0] + (range.length > 1 ? '-' + range[range.length-1] : '')); range = [l]; } });
if (range.length) ranges.push('L' + range[0] + (range.length > 1 ? '-' + range[range.length-1] : ''));
console.log('\nUNCOVERED LINE RANGES: ' + ranges.join(', '));
"
```

#### 5c. Coverage gate — iterate until met

**Primary gate (baseline):** `lines ≥ COVERAGE_THRESHOLD` AND `statements ≥ COVERAGE_THRESHOLD`.
**Secondary signal:** report `branches` and `functions` too; they are informational — no hard gate.

If the baseline gate is not met, iterate: for each uncovered line range, open the source at that range, identify the user-facing behavior it represents, and add one `it(...)` per behavior. Then re-run Step 5a. Repeat until the gate is met OR the remaining gap is legitimately out of scope (see 5d).

High-value gaps to target first:

- **Error branches** of handlers that already have a happy-path test (delete failed, update failed, save failed).
- **Alternate sub-flows** under a mode switch (e.g. "JSON mode" vs "manual mode" in a form).
- **Cancel / close paths** with side effects (warning toasts, state resets, confirm prompts).
- **Conditional rendering** you haven't triggered (empty states, permission-dependent UI).

Once the baseline gate is met, **stop iterating coverage** and proceed directly to Step 6 (scenario enumeration). The user will decide in Step 6 whether to supplement more cases to push coverage higher.

#### 5d. Legitimate uncovered code — document, don't chase

Some lines will never be hit by this test file. Do NOT write contortion tests to reach them. List them explicitly in the final report as "deferred to <which test file>". Common examples:

- **Callbacks of mocked child components.** If you mocked `@/components/SchemaEditor`, its `onChange` / `ref.format()` / `ref.validate()` in the parent will never fire. Those lines belong in `SchemaEditor.test.tsx`.
- **Heavy side-effect handlers.** OSS uploads, file downloads, `window.open` — cover in service-level unit tests, not page flow tests.
- **Third-party component internal wiring.** `DynamicConfigForm.onSubmit` / `onChange` prop callbacks — cover in the child's own test.

If the baseline gate is blocked ONLY by these, report the ceiling honestly: "lines 58% — remaining gap is X/Y/Z which belong in A/B/C.test.tsx" and stop.

#### 5e. Flag real source bugs surfaced by coverage

While iterating you will occasionally hit a branch that reveals a bug (`JSON.parse(undefined)`, a missing `await`, an incorrect error envelope). Do NOT silently fix source. Add a one-line entry to the final report: `<file>:<line> — <what's wrong> — suggested fix: <one-line suggestion>`. Let the user decide whether to patch.

### Step 6 — Scenario enumeration & human review (user-gated)

**Coverage is a proxy, not the goal.** A file at the threshold can still miss meaningful user scenarios — combinations of inputs, edge cases on empty/max states, interactions between features — because a single `it(...)` can touch many lines without asserting anything about them. This step closes that gap by listing **user-facing scenarios** (not code branches) and letting the human decide whether more cases are needed.

Run this step **as soon as** Step 5's baseline gate is met. It is user-gated: the human decides what happens next.

#### 6a. Enumerate scenarios from the source

Re-read the target source with a scenario lens (different from the coverage-line lens in Step 5c). For every feature in the file, list the **observable user scenarios** — each should be a sentence a PM or QA could write without looking at code.

Categories to walk through systematically:

- **Every user interaction × every outcome.** Click submit → success. Click submit → server 500. Click submit → validation blocked. Click submit while another submit is in flight.
- **Every input field × edge cases.** Empty. Max length. Whitespace-only. Paste vs type. Special characters. IME composition (for Chinese input).
- **Every conditional render.** Empty list. Single item. Many items → pagination. Loading. Error with retry. Permission-denied.
- **Every mode / toggle combination.** Create vs edit. List vs detail. Manual vs auto. Normal vs read-only.
- **Every async race.** Component unmount during fetch. Second click before first resolves. Debounced input with stale response.
- **Every keyboard path.** Enter to submit. Esc to close. Tab order. Focus trap in modal.
- **Cross-feature effects.** Search + pagination resets to page 1. Delete last item on a page → navigate back. Edit → cancel discards unsaved input.

Aim for 15–40 scenarios for a typical page. Component-level test files usually have 5–15.

#### 6b. Cross-reference with existing `it(...)` blocks

For each enumerated scenario, check whether an existing test asserts its observable outcome (not just "executes the code"). Mark:

- ✅ **Covered** — an `it(...)` exists whose assertions would fail if this scenario broke
- 🟡 **Partially covered** — touched by a test but not asserted (e.g. search flow tested once, but only with a matching query — not with no matches)
- ❌ **Not covered** — no test exercises this scenario

Be honest on "partially covered". Coverage tools count it as hit; a reviewer would not.

#### 6c. Present the table to the user

Output a markdown table with three columns: `#` / `Scenario` / `Status`. Group by feature area (list / create / edit / delete / permission / etc.) with small subheadings. Keep each scenario to one line.

Then call `AskUserQuestion` with one question: should I add cases for the uncovered / partially-covered scenarios? Offer:

1. **Add all uncovered scenarios** — generate cases for every ❌ and 🟡
2. **Add selected scenarios** — user picks which (follow up with the list)
3. **Continue iterating coverage** — keep adding tests to push coverage higher before stopping
4. **Skip** — accept the current coverage and scenario state; close the task

Do NOT auto-iterate. This step is explicitly human-gated. If the user says "skip", the task is done.

#### 6d. If user chose "add selected", "add all", or "continue iterating coverage"

- **Add all / Add selected:** For each approved scenario, add one `it(...)`. Re-run Step 4 (all pass) and Step 5a (coverage unchanged or improved). Present the final summary.
- **Continue iterating coverage:** Go back to Step 5c and keep adding tests for uncovered lines/branches until the user is satisfied or coverage plateaus at the legitimate ceiling (5d). After each iteration, report updated coverage and ask if the user wants to continue or stop.

If the user chose "skip", proceed to the Output section below and explicitly list the scenarios that were enumerated but not covered — so the skipped items are on record and a future engineer can pick them up.

## File templates

Use these verbatim when creating missing infrastructure. Adjust imports/paths if the repo's TS config differs.

### `vitest.config.ts`

> **Important:** this project pins Vite 4.x. Vitest 1.0+ requires Vite 5+, Vitest 4.0+ requires Vite 7+. Use Vitest 0.34.6 until the project upgrades Vite.
>
> Do NOT put `@vitejs/plugin-react` in this config — the project's plugin version (3.1.0) injects a dev-only Fast Refresh preamble that crashes Vitest's module loader. esbuild's automatic JSX transform is enough for tests.

```ts
import { defineConfig } from 'vitest/config'
import path from 'path'

export default defineConfig({
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
      '@tests': path.resolve(__dirname, 'tests'),
    },
  },
  esbuild: {
    jsx: 'automatic',
  },
  test: {
    environment: 'jsdom',
    globals: false,
    setupFiles: ['./tests/setup.ts'],
    css: false,
    include: ['tests/**/*.test.{ts,tsx}'],
  },
})
```

### `tests/setup.ts`

```ts
import '@testing-library/jest-dom/vitest'
import { afterAll, afterEach, beforeAll } from 'vitest'
import { cleanup } from '@testing-library/react'
import { server } from './msw/server'

// Antd 在 jsdom 下需要的全局 stub
if (typeof window !== 'undefined') {
  if (!window.matchMedia) {
    Object.defineProperty(window, 'matchMedia', {
      writable: true,
      value: (query: string) => ({
        matches: false,
        media: query,
        onchange: null,
        addListener: () => {},
        removeListener: () => {},
        addEventListener: () => {},
        removeEventListener: () => {},
        dispatchEvent: () => false,
      }),
    })
  }
  if (!(globalThis as any).ResizeObserver) {
    ;(globalThis as any).ResizeObserver = class {
      observe() {}
      unobserve() {}
      disconnect() {}
    }
  }
  if (!(globalThis as any).IntersectionObserver) {
    ;(globalThis as any).IntersectionObserver = class {
      observe() {}
      unobserve() {}
      disconnect() {}
      takeRecords() { return [] }
    }
  }
}

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => {
  cleanup() // 必须手动调用——globals: false 时 RTL 不会自动清理 DOM
  server.resetHandlers()
})
afterAll(() => server.close())
```

### `tests/msw/server.ts`

```ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)
```

### `tests/msw/handlers.ts`

```ts
import type { HttpHandler } from 'msw'

// 默认 handler 列表；每个 case 用 server.use(...) 注册自己的。
export const handlers: HttpHandler[] = []
```

### `tests/utils/renderWithProviders.tsx`

> **Important:** `button: { autoInsertSpace: false }` is required. Antd 默认在中文按钮文本里插 `&emsp;`（如"保存"渲染成"保 存"），导致 `getByRole('button', { name: '保存' })` 失败。测试环境里关掉它，断言保持直观。

```tsx
import { render, type RenderOptions } from '@testing-library/react'
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import { MemoryRouter } from 'react-router-dom'
import type { ReactElement, ReactNode } from 'react'

type Options = Omit<RenderOptions, 'wrapper'> & {
  route?: string
  routerEntries?: string[]
}

const createWrapper = ({ route, routerEntries }: Options) => {
  const entries = routerEntries ?? (route ? [route] : ['/'])
  const Wrapper = ({ children }: { children: ReactNode }) => (
    <MemoryRouter initialEntries={entries}>
      <ConfigProvider locale={zhCN} button={{ autoInsertSpace: false }}>
        {children}
      </ConfigProvider>
    </MemoryRouter>
  )
  return Wrapper
}

export const renderWithProviders = (ui: ReactElement, options: Options = {}) => {
  const { route, routerEntries, ...rtlOptions } = options
  return render(ui, { wrapper: createWrapper({ route, routerEntries }), ...rtlOptions })
}

export { screen, waitFor, within, act } from '@testing-library/react'
export { default as userEvent } from '@testing-library/user-event'
```

### `package.json` script additions

```json
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest run --coverage"
  }
}
```

### Install command (one-time)

> **Vite 4 constraint:** pin Vitest to 0.34.6. Later versions require Vite 5+/7+.

```sh
pnpm add -D vitest@0.34.6 jsdom @vitest/ui@0.34.6 @vitest/coverage-v8@0.34.6 \
  @testing-library/react @testing-library/jest-dom @testing-library/user-event \
  msw
```

> Run tests via `./node_modules/.bin/vitest run <path>` or `pnpm exec vitest run <path>` rather than `pnpm vitest` — `pnpm vitest` triggers a preflight `pnpm install` that can fail on unapproved build scripts in this repo.

## Test templates

### Component-level interaction test

```tsx
import { describe, it, expect, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '@tests/msw/server'
import { renderWithProviders, screen, userEvent, waitFor } from '@tests/utils/renderWithProviders'
import { ConfigForm } from '@/components/ConfigForm'

describe('<ConfigForm />', () => {
  it('submits the form with trimmed name when required fields are filled', async () => {
    const onSubmit = vi.fn()
    const user = userEvent.setup()

    renderWithProviders(<ConfigForm onSubmit={onSubmit} />)

    await user.type(screen.getByLabelText('配置名称'), '  hello  ')
    await user.click(screen.getByRole('button', { name: '保存' }))

    await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
    expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ name: 'hello' }))
  })

  it('shows a validation error when name is empty', async () => {
    const user = userEvent.setup()
    renderWithProviders(<ConfigForm onSubmit={vi.fn()} />)

    await user.click(screen.getByRole('button', { name: '保存' }))

    expect(await screen.findByText('请输入配置名称')).toBeInTheDocument()
  })
})
```

### Page-level flow test (with MSW + routing)

```tsx
import { describe, it, expect } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '@tests/msw/server'
import { renderWithProviders, screen, userEvent } from '@tests/utils/renderWithProviders'
import { Routes, Route } from 'react-router-dom'
import ConfigEditorPage from '@/pages/config-editor'

const renderApp = (route = '/config-editor/1') =>
  renderWithProviders(
    <Routes>
      <Route path="/config-editor/:id" element={<ConfigEditorPage />} />
      <Route path="/config-list" element={<div>配置列表页</div>} />
    </Routes>,
    { route },
  )

describe('config editor flow', () => {
  it('loads config, edits name, saves, and navigates back to list', async () => {
    server.use(
      http.get('/api/v1/configs/1', () =>
        HttpResponse.json({ data: { id: 1, name: '原始名称', schema: {} } }),
      ),
      http.put('/api/v1/configs/1', async ({ request }) => {
        const body = (await request.json()) as { name: string }
        expect(body.name).toBe('新名称')
        return HttpResponse.json({ data: { success: true } })
      }),
    )

    const user = userEvent.setup()
    renderApp()

    expect(await screen.findByDisplayValue('原始名称')).toBeInTheDocument()

    const nameInput = screen.getByLabelText('配置名称')
    await user.clear(nameInput)
    await user.type(nameInput, '新名称')

    await user.click(screen.getByRole('button', { name: '保存' }))

    expect(await screen.findByText('配置列表页')).toBeInTheDocument()
  })

  it('shows an error toast when save fails', async () => {
    server.use(
      http.get('/api/v1/configs/1', () =>
        HttpResponse.json({ data: { id: 1, name: 'X', schema: {} } }),
      ),
      http.put('/api/v1/configs/1', () => HttpResponse.json({ message: '保存失败' }, { status: 500 })),
    )

    const user = userEvent.setup()
    renderApp()

    await screen.findByDisplayValue('X')
    await user.click(screen.getByRole('button', { name: '保存' }))

    expect(await screen.findByText('保存失败')).toBeInTheDocument()
  })
})
```

### Antd Select / DatePicker pattern

```tsx
// Open select and pick an option
await user.click(screen.getByRole('combobox', { name: '环境' }))
await user.click(await screen.findByRole('option', { name: '生产环境' }))
expect(screen.getByRole('combobox', { name: '环境' })).toHaveTextContent('生产环境')

// DatePicker
await user.click(screen.getByPlaceholderText('选择日期'))
await user.click(await screen.findByRole('gridcell', { name: /^15$/ }))
```

### Clipboard copy / `document.execCommand` pattern

Components that copy text to clipboard typically use `navigator.clipboard.writeText` with an `execCommand('copy')` fallback. In jsdom 29, `navigator.clipboard` cannot be reliably mocked to propagate into the component's runtime scope. Test the **user-visible outcome** (Antd toast messages) instead of spying on `writeText`.

```tsx
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderWithProviders, screen, userEvent } from '@tests/utils/renderWithProviders'
import MyComponent from '@/components/MyComponent'

describe('copy behavior', () => {
  beforeEach(() => {
    document.querySelectorAll('.ant-message').forEach(el => el.remove())
  })

  afterEach(() => {
    document.querySelectorAll('.ant-message').forEach(el => el.remove())
  })

  it('shows success toast when copy button is clicked', async () => {
    const user = userEvent.setup()
    renderWithProviders(<MyComponent data="hello" />)

    await user.click(screen.getByRole('button', { name: /复制/ }))

    const msgs = await screen.findAllByText('已复制到剪贴板')
    expect(msgs.length).toBeGreaterThanOrEqual(1)
  })

  it('shows error toast when copy fails', async () => {
    const execSpy = vi.spyOn(document, 'execCommand').mockReturnValue(false)
    Object.defineProperty(Object.getPrototypeOf(navigator), 'clipboard', {
      value: undefined,
      configurable: true,
    })

    const user = userEvent.setup()
    renderWithProviders(<MyComponent data="hello" />)

    await user.click(screen.getByRole('button', { name: /复制/ }))

    const msgs = await screen.findAllByText('复制失败，请手动复制')
    expect(msgs.length).toBeGreaterThanOrEqual(1)
    execSpy.mockRestore()
  })
})
```

Key rules for clipboard tests:
1. **Never assert on `navigator.clipboard.writeText` spy** — the mock does not propagate to the component runtime in jsdom 29.
2. **Always use `vi.spyOn(document, 'execCommand')`** — never direct-assign `document.execCommand = vi.fn()`.
3. **Always clean up `document.querySelectorAll('.ant-message')`** in `beforeEach` + `afterEach`.
4. **Always use `findAllByText` (not `findByText`)** for Antd message assertions — `rc-motion` may render duplicate nodes.

## Common pitfalls (check before shipping a test)

- **`act(...)` warnings:** usually means a missing `await`. Add `await` to the user action or `findBy*` query.
- **`TypeError: window.matchMedia is not a function`:** Antd needs it in jsdom. Add to `tests/setup.ts`:
  ```ts
  Object.defineProperty(window, 'matchMedia', { value: () => ({ matches: false, addListener: () => {}, removeListener: () => {} }) })
  ```
- **`ResizeObserver is not defined`:** stub it in `tests/setup.ts`: `globalThis.ResizeObserver = class { observe(){} unobserve(){} disconnect(){} }`.
- **axios base URL:** vite uses a proxy on `/api/v1`, but axios in node has no proxy. Either make MSW handlers match the absolute URL the service uses, or configure axios to use a relative base URL. Verify with `onUnhandledRequest: 'error'` — it surfaces mismatches immediately.
- **MSW v2 import style:** `import { http, HttpResponse } from 'msw'` (not `rest`). v1 syntax is obsolete. Prefer wildcard paths (`'*/api/v1/...'`) so handlers match regardless of jsdom's origin.
- **Forgetting `server.resetHandlers()`:** already handled by `tests/setup.ts`. Do not re-register global handlers inside tests — use `server.use(...)` for per-test overrides.
- **Querying Antd Modal inside the container:** it portals to body. Use `screen.findByRole('dialog')` (default scope is the whole document).
- **Fake timers + user-event:** must pass `advanceTimers: vi.advanceTimersByTime` to `userEvent.setup`, otherwise `user.click` hangs.
- **Antd Button with icon — accessible name includes the icon's `aria-label`:** `<Button icon={<PlusOutlined />}>新建配置</Button>` has accessible name `"plus 新建配置"`, not `"新建配置"`. Always use a regex query: `getByRole('button', { name: /新建配置/ })`.
- **Antd Select in jsdom renders duplicate option nodes:** the real option + a virtual-scroll placeholder may both contain the same text. Use `getAllByText(label)` and click the last match, or scope the query to `findByRole('listbox')` first. `getByRole('option', { name })` is unreliable because of virtualization.
- **Antd Modal exit animation does not complete in jsdom:** `rc-motion` waits for a CSS `transitionend` event that jsdom never fires, so the dialog DOM node remains after `open=false`. Do NOT assert the dialog disappears. Instead assert the behavior that should follow: success toast, state change, request payload, etc.
- **Antd `autoInsertSpace` on buttons:** already disabled in `renderWithProviders`. If you see `"保 存"` in failing assertions, check you're rendering via the helper and not `render` directly.
- **Production-side module-level caches (e.g. `cachedPlatformOptions`):** if the component fetches options once and caches the result in a module variable, an empty response in one test may poison every subsequent test. In `beforeEach`, register a handler that returns a non-empty result so every test starts from the same cached state.
- **`handleSubmit` that calls `JSON.parse(error.message)` in its catch:** form validation errors have no `message`, so the catch itself throws `SyntaxError: "undefined" is not valid JSON` into the test log. The behavior-level assertion (validation errors visible) still works — but flag this to the user as a real code smell; do not fix source without permission.
- **`navigator.clipboard` mock does not propagate to component scope in jsdom 29:** direct assignment (`navigator.clipboard = { writeText: vi.fn() }`) and `Object.defineProperty` on the instance both set the property at the test level, but the component's runtime may resolve `navigator.clipboard` through a different lookup path (vitest module sandbox or jsdom prototype chain). **Do NOT try to spy on `navigator.clipboard.writeText`.** Instead, test clipboard behavior by asserting on the user-visible outcome only — the Antd toast message (`findAllByText('已复制到剪贴板')`) — without asserting on the spy's call count or arguments. To test the fallback (execCommand) path, use `vi.spyOn(document, 'execCommand')` which correctly intercepts the prototype method.
- **`document.execCommand` mock — use `vi.spyOn`, not direct assignment:** jsdom 29 defines `execCommand` on `Document.prototype`. Direct assignment `document.execCommand = vi.fn()` creates an own property, but jsdom may still call the prototype method internally. Always use `vi.spyOn(document, 'execCommand').mockReturnValue(true/false)` — this correctly replaces the prototype method. Remember to `execSpy.mockRestore()` or let vitest's auto-restore handle cleanup.
- **Antd `message` singleton leaks across tests:** Antd's `message.success/error/info` renders into a singleton `<div class="ant-message">` appended directly to `document.body`, outside React's render tree. `cleanup()` from RTL only unmounts the component tree — it does NOT remove the Antd message container. Messages from previous tests bleed into subsequent tests, causing `findByText` to match stale elements. **Fix:** add cleanup in `afterEach` (or `beforeEach`) in every test file that triggers Antd messages: `document.querySelectorAll('.ant-message').forEach(el => el.remove())`. For message assertions, always use `findAllByText` (not `findByText`) and assert `length >= 1`, since Antd may render duplicate nodes during `rc-motion` animation.

## What NOT to do (skill is wrong if it does any of these)

- Write `expect(...).toMatchSnapshot()` or `toMatchInlineSnapshot()`.
- `vi.mock('axios')` or `vi.mock('@/services/...')` for HTTP — always use MSW.
- Mock `useNavigate` / `useLocation` — always use `MemoryRouter`.
- Add `data-testid` to source code without first trying role/label/text queries.
- Place test files next to source or under `__tests__/`.
- Skip `await` on user interactions.
- Bundle multiple behaviors into one `it(...)`.
- Generate tests for a file it could not read — if the source is missing, stop and ask.

## Output format when done

After generating + meeting the coverage baseline + completing Step 6 (scenario review), report to the user:

1. **Files created / modified** (full paths).
2. **Test cases generated** (bullet list, one line per `it`).
3. **Coverage numbers** for the target file: lines / statements / branches / functions. Call out explicitly whether the baseline gate (`lines ≥ COVERAGE_THRESHOLD` + `statements ≥ COVERAGE_THRESHOLD`) was met.
4. **Scenario checklist** from Step 6b — the table of ✅ / 🟡 / ❌ scenarios, grouped by feature area.
5. **User's decision on next steps** (add all / add selected / continue iterating coverage / skip) and, if skipped, the list of scenarios that remain uncovered — so they are on record for a future pass.
6. **Deferred coverage** — uncovered code that legitimately belongs in other test files, with the suggested target.
7. **Source bugs flagged** (if any) — `file:line` + what's wrong + one-line suggested fix. Do not patch without user approval.
8. **Command to re-run** them: `./node_modules/.bin/vitest run tests/<path>` (bare run) and the `--coverage.enabled --coverage.include=...` variant for future verification.

