describe(me) docs
early v0.12.1 browser mode · jsdom Live · Foundations
Examples npm GitHub

Configuration

One plugin line is the whole integration. This page lists what the plugin changes in your Vitest config, every option it takes, and how to wire describe-me by hand.

What the plugin changes #

describeMe() from @describe-me/vitest/plugin is a Vite plugin with enforce: 'pre', so its render redirect runs before other plugins resolve the import. It adds config through Vite's config hook, which Vite merges with yours. With enabled: false it adds nothing, and its resolveId and transform hooks do nothing: the plugin stays in the list but is inert.

Always

Browser mode

DOM

describe-me: `test.css` is false, so imported stylesheets are stubbed and will be missing from the snapshots.

DOM with styled-components

When styled-components 5 or later is installed and styledComponentsBrowserBuild is not false, the plugin logs one line and adds three things:

describe-me: using the browser build of styled-components, for createGlobalStyle

Styles and fonts explains why.

Options #

describeMe() takes one object, DescribeMeOptions. Every option is optional.

Option Type Default Effect
enabled boolean true false runs the same tests without recording. The plugin stays inert (what it changes).
framework 'react' 'react' Which framework adapter replaces the test library's render.
environment 'browser' | 'dom' detected Where the tests run. browser when test.browser.enabled is set, dom otherwise (detection).
outDir string '.describe-me' Output directory, relative to the Vitest root. When you change it, pass the same directory to the CLI with --data (storage).
registerExports boolean true Name components after their export (naming).
styledComponentsBrowserBuild boolean true DOM only. Load the browser build of styled-components (styled-components).
include string | string[] every test file Test files to record. An empty list records none, with a warning (which tests record).
exclude string | string[] none Test files never to record. Wins over include (which tests record).
stories 'all' | 'tagged' 'all' Which tests of the recorded files become stories: all of them, or only those tagged docs. A test tagged no-docs never does (which tests become stories).
previewHead string none HTML the viewer adds to the start of the <head> of every replayed frame. It never reaches the test page (what is captured).
renderFrame 'eager' | 'lazy' |
{ pending, timeout? }
'eager' When the render frame is taken (render frame timing).
live false |
{ setupFiles }
on, with test.setupFiles The live preview. setupFiles, a path or a list, replaces test.setupFiles on the live page, for a setup file that needs Node, such as one with MSW's setupServer (MSW in Live). false switches the preview off, and makes describe-me build --live fail. Test runs ignore it (Live).
foundations false |
{ theme }
on, without a theme module What the reporter writes to foundations.json: the design systems the project uses, the recorded stylesheets with tokens or @font-face and the Tailwind 4 theme. theme adds the tokens of a theme module, relative to the root, a Material UI theme or a plain object. 'src/theme.ts' reads its export theme, else default, and 'src/theme.ts#name' reads name. A light-dark() value is read as light and dark. A type style of a plain object, such as typography.title, is one token with its parts. A Material UI theme module also marks the tokens that equal Material UI's defaults. A project that uses Material UI without theme still gets foundations.json, so Foundations can say what to set. false switches it off (foundations in the manifest).
sidebar { groups } no groups Groups of test files in the sidebar. groups maps a group path, with / between levels, to a glob or a list of globs. A file joins the first group that matches it (sidebar groups).

vitest.config.ts

import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { describeMe } from '@describe-me/vitest/plugin'

export default defineConfig({
  plugins: [
    react(),
    describeMe({
      // document the components, not the slow integration suites
      include: 'src/components/**',
      exclude: ['**/*.integration.test.tsx'],
      // the loader the app's providers show until they are ready
      renderFrame: { pending: '[data-testid="app-loader"]' },
    }),
  ],
  test: { environment: 'jsdom' },
})

Which tests record #

include and exclude take a glob or a list of globs. They are matched with picomatch against the path of the test file relative to the Vitest root, dotfiles included. A leading ./ is ignored, and exclude wins over include.

Tests in other files still run and report as usual. They record nothing, and their modules leave the manifest, also those kept from an earlier run.

Setting Records Warns
include left out every test file no
include: [] no test file once, when Vitest starts
exclude: '**' nothing, with the plugin still on no
enabled: false nothing, with the plugin inert no

The warning for an empty include:

describe-me: include is an empty list, so no test file is recorded. Leave include out to record every test file, or set exclude: '**' to record none without this warning.

An empty string or anything other than a string throws before any test runs:

describe-me: include and exclude expect non-empty glob strings, got ''

Projects with their own root

With test.projects entries that set their own root, the two sides disagree on the path. The setup file matches against the path relative to the project root, the reporter against the path relative to the Vitest root. With include: 'src/**' and a project root of packages/app, a file records frames but its module is missing from the manifest. Use globs that start with **/, such as **/src/components/**, which match both forms, or leave include and exclude out.

Concurrent tests are never recorded, because they would share one recorder. See Environments.

Which tests become stories #

Within the files that record, stories and two Vitest tags pick single tests, so a file can keep its edge cases and regression tests next to the stories without showing them in the docs. Tag a test or a describe:

src/Button.test.tsx

import { describe, expect, it } from 'vitest'

describe('Button', { tags: ['docs'] }, () => {
  it('primary', async () => { … })
  // a regression test, left out of the docs
  it('keeps its width without children', { tags: ['no-docs'] }, async () => { … })
})

it('disabled', { tags: ['docs'] }, async () => { … })

Or a whole file, with @module-tag in a comment. Vitest reads every // or * comment line of the file that holds @module-tag, and takes the word after it as a tag, so mention it nowhere else in the file:

src/Card.test.tsx

/**
 * @module-tag docs
 */
import { expect, it } from 'vitest'
Tags of the test stories: 'all' stories: 'tagged'
none a story left out
docs a story a story
no-docs, also next to docs left out left out

A test carries its own tags and those of its describe blocks and its file, so no-docs on a test wins over docs on its suite. The reverse does not work: a test inside a no-docs suite cannot opt back in. Tags apply after include and exclude, and a test still needs a frame to be a story (tests that are left out).

A test left out this way still runs and reports as usual. It also leaves the manifest in a filtered run, and so do tests that an earlier run kept but the current stories no longer selects. The manifest stores each test's tags for that.

The plugin defines both tags in test.tags. Tags arrived in Vitest 4.1. Vitest 4.0 ignores them, so there stories: 'all' records every test as before, also one tagged no-docs, and stories: 'tagged' throws before any test runs:

describe-me: stories: 'tagged' needs test tags, which arrived in Vitest 4.1. This project resolves Vitest 4.0.

Your config may type its tags by extending Vitest's TestTags interface. Add 'docs' | 'no-docs' to that type then. describe-me does not extend it itself, because a typed list would reject your other tags.

Without groups, the sidebar lists the recorded test files in path order. sidebar.groups sorts them into named groups, which can nest, such as one group per layer of a design system or per feature. Each key is a group path with / between levels, and each value a glob or a list of globs:

vitest.config.ts

describeMe({
  sidebar: {
    groups: {
      Foundations: 'src/tokens/**',
      'Components/Forms': 'src/components/{Input,Select,Checkbox}/**',
      Components: 'src/components/**',
      'Features/*': 'src/features/*/**',
    },
  },
})

Six test files land in these groups, in sidebar order:

Foundations
  src/tokens/Colors.test.tsx
Components
  Forms
    src/components/Input/Input.test.tsx
  src/components/Button/Button.test.tsx
Features
  auth
    src/features/auth/Login.test.tsx
  checkout
    src/features/checkout/Cart.test.tsx
src/App.test.tsx

Which group a file joins

Order

How the sidebar shows the groups, and when a group shows as its one file, is in Groups on the viewer page.

Errors and warnings

A malformed option throws before any test runs: a sidebar or groups that is not an object, another key next to groups, an empty key or level, two * levels, a value that is not a non-empty glob or a non-empty list of them, or a glob of a * key without its * segment:

describe-me: sidebar.groups['Features/*'] names its groups after a * segment, so the glob 'src/features/**' needs a segment that is exactly *, before any ** and not the last

A key that gets no test file adds a setup warning, which the run prints at its end and the viewer lists with its issues (setupWarnings). When earlier keys take the files it matches, the warning names them, as with Components above 'Components/Forms':

describe-me: sidebar.groups['Components/Forms'] gets no test file, because an earlier key takes the 3 files it matches: 'Components'. Move it above that key or narrow that key's globs.

Which files take part

Only recorded files take part, so include and exclude decide which (which tests record). With test.projects entries that set their own root, the paths are still relative to the Vitest root.

The viewer and the CLI read the groups only from the manifest (sidebar in the manifest). A change to the option takes a test run to show. Any run, even of one file, groups every file of the manifest again.

Render frame timing #

A component behind async providers or data loading shows a loader first. The render frame is taken right after mount by default, so the docs show that loader, as in the jsdom example's AsyncGreeting. renderFrame takes the frame later:

renderFrame The render frame is taken
'eager' (default) right after mount
'lazy' right before the test's next interaction, or at the end of the test
{ pending, timeout? } as soon as nothing matches the CSS selector pending and the page shows content

Every render frame follows the option, rerender included. An interaction is a user event, fireEvent, step(), another render, rerender, unmount or cleanup.

pending watches the page with a MutationObserver. After timeout ms (default 2000) it stops watching, and the frame waits as in 'lazy'. An interaction that comes first takes the frame too. A pending selector the DOM rejects also falls back to 'lazy', with one warning per test file:

describe-me: renderFrame.pending is not a valid CSS selector: "…". Render frames fall back to 'lazy'.

Any other value, such as an empty selector or a timeout that is not a number above 0, throws before any test runs.

In browser mode, await render() returns before a deferred frame is taken. Polling with expect.element is not an interaction, so the frame waits for the next one. To take it earlier, call recorder.flush(), which takes a deferred frame after one settle:

import { expect, test } from 'vitest'
import { render } from 'vitest-browser-react'
import { recorder } from '@describe-me/vitest'
import { Profile } from './Profile'

test('shows the profile', async () => {
  const screen = await render(<Profile />)

  await expect.element(screen.getByText('Ada')).toBeVisible()
  await recorder.flush()
})

Reporters #

The plugin adds its reporter to the reporters of your run. Which others run depends on your config:

--reporter on the command line replaces the whole list, describe-me's reporter included, so that run writes no output. A run of the jsdom example with vitest run --reporter=dot on Vitest 5.0.1 left its manifest untouched. Name extra reporters in the config instead.

A config of its own #

To record only when you ask for it, keep the plugin out of vitest.config.ts and add it in a second config that extends the first:

vitest.describe-me.config.ts

import { defineConfig, mergeConfig } from 'vitest/config'
import { describeMe } from '@describe-me/vitest/plugin'
import base from './vitest.config'

export default mergeConfig(
  base,
  defineConfig({
    plugins: [describeMe()],
  }),
)
vitest run --config vitest.describe-me.config.ts

Your usual vitest runs stay free of describe-me, and only this run records. mergeConfig takes objects, so vitest.config.ts must export an object, not a function.

Without the plugin #

You can wire the pieces by hand: the setup file and the reporter, the default export of @describe-me/vitest/reporter. In browser mode:

vitest.config.ts

import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser-playwright'
import DescribeMeReporter from '@describe-me/vitest/reporter'

export default defineConfig({
  test: {
    setupFiles: ['@describe-me/vitest/setup'],
    reporters: ['default', new DescribeMeReporter()],
    browser: {
      enabled: true,
      provider: playwright(),
      instances: [{ browser: 'chromium' }],
    },
  },
})

Then import render from @describe-me/react instead of vitest-browser-react. For jsdom, use the DOM setup file, and set css: true and RTL_SKIP_AUTO_CLEANUP yourself:

vitest.config.ts

import { defineConfig } from 'vitest/config'
import DescribeMeReporter from '@describe-me/vitest/reporter'

export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: ['@describe-me/vitest/setup-dom'],
    reporters: ['default', new DescribeMeReporter()],
    css: true,
    env: { RTL_SKIP_AUTO_CLEANUP: 'true' },
  },
})

Then import render, fireEvent and cleanup from @describe-me/react/testing-library instead of @testing-library/react.

Reporter options

Option Default What it does
outDir '.describe-me' Output directory, relative to the Vitest root.
include every test file Test files whose modules appear in the manifest. An empty list warns.
exclude none Test files to leave out of the manifest. Wins over include.
stories 'all' Tests that appear in the manifest: all, or only those tagged docs. Tests tagged no-docs never do.
previewHead none HTML the viewer adds to the start of every replayed frame's head.
setupWarnings none Problems in the project's setup. The plugin fills it, and the reporter adds its own, such as a sidebar group that gets no test file.
foundations on, without a theme module What the reporter writes to foundations.json, as the plugin's foundations. false switches it off.
sidebar no groups Groups of test files in the viewer's sidebar, as the plugin's sidebar. A key that gets no test file adds a setup warning.

A hand-wired setup records every test, because only the plugin passes include and exclude to the setup file. The reporter still leaves the other modules out of the manifest. renderFrame also reaches the setup file only through the plugin. Set it with recorder.configure() in a setup file of your own, listed after describe-me's:

The setup file of a hand-wired setup skips tests tagged no-docs, as with stories: 'all'. The reporter's stories: 'tagged' still keeps only tests tagged docs in the manifest, but the setup file records the others too, which costs time. Define both tags in test.tags yourself, or Vitest throws on them.

describe-me-timing.ts

import { recorder } from '@describe-me/vitest'

recorder.configure({ renderFrame: 'lazy' })
setupFiles: ['@describe-me/vitest/setup-dom', './describe-me-timing.ts'],

What a hand-wired setup does not get from the plugin:

Environment variables #

The CLI sets DESCRIBE_ME_DIR for the viewer, and the live preview sets DESCRIBE_ME_LIVE while it loads your config. The other variables below are switches of this repository's examples. The describe-me packages read none of them. The examples' vitest.config.ts files read them, for benchmarks and checks. BENCH_SKIP, ACT_PARITY, PROJECTS, STORIES and the values no-live-setup and off of LIVE_FIXTURES work only in examples/react-jsdom, and MUI_CLASSIC and MUI_THEME only in examples/mui. Set them inline for one run. There is no .env.

Variable Read by Effect
DESCRIBE_ME_DIR the viewer's Vite config in describe-me The data directory. describe-me dev and describe-me build set it. Pass --data instead of setting it.
DESCRIBE_ME_LIVE describeMe(), and your config when it needs to 1 while createLiveServer() and buildLive() load the config, and only then, so describeMe() returns its live mode. Read it in the config for what only the live page needs, such as MSW's worker script and an alias for the tests (MSW in Live), or to leave out a plugin that needs a dev server. Never set it yourself.
DESCRIBE_ME examples/react-browser and examples/react-jsdom off runs the same tests with enabled: false. idle runs them with exclude: '**', writing to node_modules/.describe-me-idle.
BENCH_OUT examples/react-browser and examples/react-jsdom A file name. Replaces the console reporter with JSON per-test durations in that file.
BENCH_MICRO examples/react-browser and examples/react-jsdom Runs bench/ instead of src/.
BENCH_SKIP examples/react-jsdom With DESCRIBE_ME=idle, a list of css, styled and exports that switches those parts of the plugin off. The browser example throws when it is set.
ACT_PARITY examples/react-jsdom Runs act-parity/ instead of src/, with lazy render frames. eager takes render frames right away and adds act-parity/setup.ts, which loads Testing Library before describe-me's setup file. pnpm check-act-parity sets it.
LIVE_FIXTURES examples/react-browser and examples/react-jsdom 1 runs live-fixtures/ instead of src/, with their own setup files, writing to node_modules/.describe-me-live-fixtures. In examples/react-browser it also adds a CSS plugin split in two by apply, as @tailwindcss/vite is. In examples/react-jsdom the setup files answer requests with MSW, and the live page gets the recipe of MSW in Live. There no-live-setup leaves out live.setupFiles and off sets live: false, for the live preview of the same fixtures. pnpm check-live sets it.
PROJECTS examples/react-jsdom 1 runs src/ as an inline project with extends: true, projects/leak.test.tsx as one without extends, which Vitest 5 extends from the root config too, and projects/isolated.test.tsx as one with extends: false, writing to node_modules/.describe-me-projects. pnpm check-projects sets it.
STORIES examples/react-jsdom all or tagged runs stories/ instead of src/, with that stories option, writing to node_modules/.describe-me-stories. pnpm check-stories sets it.
MUI_CLASSIC examples/mui 1 builds the theme without cssVariables, so Material UI writes the light scheme's colors into the recorded CSS. It writes to node_modules/.describe-me-classic, so .describe-me/ keeps the default run. pnpm check-foundations sets it.
MUI_THEME examples/mui off leaves out foundations.theme, as a plain Material UI project does, and writes to node_modules/.describe-me-no-theme, where Foundations shows only the setup hint. It builds the classic theme too, without cssVariables, and does not combine with MUI_CLASSIC=1. pnpm check-foundations sets it.

The same switch in your own project:

vitest.config.ts

import { describeMe } from '@describe-me/vitest/plugin'

describeMe({ enabled: process.env.DESCRIBE_ME !== 'off' })

An idle variant keeps the plugin on and records nothing. Its run writes an empty manifest and deletes the snapshots no frame points at, so give it a scratch directory:

const idle = process.env.DESCRIBE_ME === 'idle'

describeMe({
  enabled: process.env.DESCRIBE_ME !== 'off',
  ...(idle ? { exclude: '**', outDir: 'node_modules/.describe-me-idle' } : {}),
})

Performance shows how to measure with these variants.