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

Styles and fonts

A frame is DOM plus stylesheets. What was on the page during the test is what the viewer shows, plus the preview head. This page covers how to get the rest in: global styles, fonts and the files your CSS points at.

What a frame captures #

Each frame is the DOM of the page and its stylesheets, serialized by rrweb-snapshot. There is no layout and no screenshot. The viewer rebuilds the DOM in an iframe and your browser lays it out again. The iframe is sandboxed with allow-same-origin only, so no script runs in the viewer, neither the page's nor the preview head's.

So a style shows when it was on the page during the test, in a form rrweb can read. The table below says which forms that covers. Each frame also records hover and focus, and the replay applies their rules. :active is not recorded (both environments).

previewHead is the one addition. The viewer adds it at the start of every frame's <head> at replay. It never reaches the test page, so the test cannot see it. Snapshots and replay explain the internals.

Styling techniques #

Technique Used by Browser mode jsdom
style attributes everyone yes yes
<style> in the document CSS imports, CSS modules, Tailwind, vanilla-extract, dev builds of emotion and styled-components yes yes. CSS imports need test.css, which the plugin turns on (what the plugin changes)
<link rel="stylesheet"> stylesheets and web fonts that a setup file or a library links same-origin sheets are inlined, cross-origin ones stay links the link never loads during the test. A project file is copied with its url() targets (assets). Remote URLs load from the network, except font stylesheets that describe-me build downloads (vendoring)
CSSOM insertRule into an empty <style> emotion and styled-components in production ("speedy") mode yes, rrweb reads cssRules yes, for simple rules
document.adoptedStyleSheets Lit and other web components yes, copied into a temporary <style> during capture no, jsdom has no constructable stylesheets
adoptedStyleSheets on a shadow root web components with shadow DOM not yet no

StyledText.test.tsx in react-browser and react-jsdom renders one line per technique. Compare it in the browser-mode viewer and the jsdom viewer: the adoptedStyleSheets line loses its orange in jsdom. In examples/react-jsdom, pnpm check-styles checks that each rule and its colour are in the snapshots.

In jsdom, rules go through jsdom's own CSS parser, which drops what it does not understand. Simple rules are verified. Nesting, @layer and @container are not verified yet (limitations).

Global styles #

Your app shell or Storybook's preview usually adds a reset and a body { font-family } around every component. A test renders the component alone, so they are missing from the frames too: buttons show the browser's grey default and text shows in Times. Put them in the wrapper of your custom render, next to your providers:

src/test-utils.tsx

import { render, type RenderOptions, type RenderResult } from '@testing-library/react'
import type { ReactElement, ReactNode } from 'react'
import { ThemeProvider } from 'styled-components'
import { GlobalStyles } from './GlobalStyles'
import { theme } from './theme'

export * from '@testing-library/react'

function AllTheProviders({ children }: { children: ReactNode }) {
  return (
    <ThemeProvider theme={theme}>
      <GlobalStyles />
      {children}
    </ThemeProvider>
  )
}

function customRender(ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>): RenderResult {
  return render(ui, { wrapper: AllTheProviders, ...options })
}

export { customRender as render }

Tests import render from this file. It imports @testing-library/react itself, so the plugin's redirect records through it and neither the tests nor this file need to know. The jsdom example's Heading test renders this way and takes its colour from the theme. In jsdom with styled-components, also read the next section.

styled-components in jsdom #

Vitest loads the Node build of styled-components in jsdom. Its createGlobalStyle never inserts its CSS on the client, so <GlobalStyles /> silently does nothing and the reset and fonts are missing from every frame. With styled-components 5 or later in a DOM environment, the plugin fixes this on its own. What the plugin changes lists the config it adds. This is why:

Cannot destructure property '__extends'

So the plugin points tslib at the tslib.es6.mjs of the tslib that styled-components resolves, and you need no tslib alias of your own. tslib ships that file from 2.5.3. styled-components 6.1.3 to 6.1.9 pin tslib 2.5.0, which has none. Then the plugin leaves tslib alone, and the reporter prints a setup warning after the run, for example:

styled-components 6.1.5 resolves tslib 2.5.0, which has no tslib.es6.mjs, so its browser build may fail to load ("Cannot destructure property '__extends'"). Upgrade styled-components to 6.1.10 or later, or override tslib to 2.5.3 or later.

To keep the Node build, turn all of this off with styledComponentsBrowserBuild: false. Global styles from createGlobalStyle are then missing again.

Fonts #

Fonts usually go missing because the app shell loads them, and tests never run the shell: a Google Fonts <link> in index.html, a font loader called at startup, or a fonts.css that only the app entry imports. Give the viewer the same links with previewHead, like Storybook's preview-head.html:

vitest.config.ts

describeMe({
  previewHead: `
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap">
    <link rel="stylesheet" href="/src/fonts.css">
  `,
})

A project that already has a preview-head.html can pass it as it is:

import { readFileSync } from 'node:fs'

describeMe({ previewHead: readFileSync('.storybook/preview-head.html', 'utf8') })

The head is stored once, in the manifest. The viewer adds it to the start of every frame's <head>, so the captured styles win ties. It waits at most 3 seconds for the fonts and fits the frame again once they have loaded. A note under the frame names the fonts and stylesheets that did not load in your browser.

Local files that the head links with href or src, and the url()s in its <style> blocks, are copied into the output directory. A relative path counts from the project root. /src/fonts.css above is copied together with the fonts it points at. The browser-mode example loads Inter from Google Fonts and a local Lora this way. Its Postcard test shows the caption in Lora.

Local fonts of an app shell

Say the app shell loads src/app-shell/fonts.css, with @font-face rules that point at .ttf files. There are two ways in. The first is the preview head:

describeMe({ previewHead: '<link rel="stylesheet" href="/src/app-shell/fonts.css">' })

The CSS is stored once, outside the snapshots, and copied with its .ttf files. It must be plain CSS, see the list below.

The second is a setup file that only the describe-me run loads:

describe-me.setup.ts

import './src/app-shell/fonts.css'

Add it to the test block of a config of its own, and the regular run stays as it is:

test: { setupFiles: ['./describe-me.setup.ts'] },

The CSS goes through Vite, so aliases and PostCSS work. It lands in the styles of every snapshot, its fonts are copied, and the font check counts the family as loaded. The same import in an existing setup file works too, in every run.

Do not call the app's font loaders in tests. The <link> a loader adds lands in every snapshot, and in jsdom it never loads. The preview head does the same job once.

Keep in mind

Fonts in the static site #

describe-me build copies the data into the site, then downloads the web fonts that the copy loads from known font hosts. It scans the preview head, the snapshots, the style chunks and the CSS assets of the copy, so a link that a font loader added during a test is covered too, and so is data written by 0.4. Vendoring also points the copy's foundations.json at the chunks it moves. The data directory itself never changes. describe-me dev never downloads fonts.

Provider Hosts In the build
Google Fonts fonts.googleapis.com, fonts.gstatic.com downloaded
Bunny Fonts fonts.bunny.net downloaded
Fontsource on jsDelivr cdn.jsdelivr.net, Fontsource paths only downloaded
Adobe Fonts use.typekit.net and others stays remote: "Adobe Fonts do not allow self-hosting"
any other host a stylesheet or font file elsewhere stays remote: "not on the font allowlist"

The build says what it did:

describe-me: vendored 1 stylesheet from fonts.googleapis.com and 7 font files (…) into __data/assets
describe-me: left remote: use.typekit.net (Adobe Fonts do not allow self-hosting)
describe-me: https://fonts.bunny.net/css?family=… did not download (…). It keeps loading from the network.

It lists at most five URLs that did not download, then how many more. To keep the build off the network, pass --no-vendor-fonts. Fonts then load from their hosts, as in describe-me dev.

The font check #

After each run, the reporter warns about font families that the captured CSS uses but nothing loads. The viewer lists them in the issues menu as "Fonts not loaded", with the number of tests and a link to the first. This is the rule:

The reporter also lists the hosts that frames load stylesheets from. The test server (localhost, 127.0.0.1) is never one of them. The check reads the data, so it cannot see a font that fails only in your browser. The note under the frame names those. Troubleshooting says what to do about each family.

Assets #

rrweb records images, videos, fonts and CSS url()s as absolute URLs on the test page, such as http://localhost:3000/src/logo.svg in jsdom or the Vitest server in browser mode. Those stop working once the tests end. So when the snapshots are written, every one of them that points at a project file is copied into .describe-me/assets/, and the snapshot points at the copy. They load in describe-me dev and in the static site alike.

The Postcard test shows a photo imported as a module on a background from a stylesheet url(), both copied. See it in the browser-mode viewer and the jsdom viewer. Storage covers file names and garbage collection.