Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Generate snapshot test files for Sentry frontend React components. Use when asked to "generate snapshot tests", "add snapshot tests", "create visual snapshots", "write snapshot tests", "add visual regression tests", or "snapshot this component". Accepts an optional component path or name via $ARGUMENTS.
.claude/skills/generate-snapshot-tests/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-07 | ✗→✓ | ▲ Improved | — | — |
| case-18 | ✗→✓ | ▲ Improved | — | — |
| case-17 | ✗→✓ | ▲ Improved | — | — |
| case-21 | ✗→✓ | ▲ Improved | — | — |
| case-14 | ✗→✓ | ▲ Improved | — | — |
Generate a *.snapshots.tsx file colocated with a Sentry React component, following the established pattern used by core design system components.
If $ARGUMENTS is provided, treat it as a path or component name. Otherwise ask the user which component to snapshot.
Search strategies:
static/app/components/core/<name>/<name>.tsx
static/app/components/core/<name>/index.tsx
static/app/components/<name>.tsx
static/app/components/<name>/index.tsxUse Glob or Grep to find the file if the exact path is unknown.
Read the component source file to understand:
Props / <ComponentName>Props typevariant, priority, size)disabled, checked, busy)onChange={() => {}} or similar no-op handlers)| Condition | Import style | | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Component lives under static/app/components/core/ AND is published as @sentry/scraps/<name> | import {ComponentName, type ComponentNameProps} from '@sentry/scraps/<name>'; | | Component lives under static/app/components/core/ but is NOT in @sentry/scraps | // eslint-disable-next-line @sentry/scraps/no-core-import -- SSR snapshot needs direct import to avoid barrel re-exports with heavy deps<br>import {ComponentName, type ComponentNameProps} from 'sentry/components/core/<path>'; | | All other components | import {ComponentName, type ComponentNameProps} from 'sentry/components/<path>'; |
To check if a component is in @sentry/scraps, look for an existing import using @sentry/scraps/<name> in neighboring files, or check if other snapshot files in the same directory use @sentry/scraps.
Read the TypeScript props and classify them:
| Prop type | Action | | --------------------------------------------------------- | -------------------------------------------- | | Union of string literals ('sm' \| 'md' \| 'lg') | Snapshot each value with it.snapshot.each | | Boolean toggle with visual impact (disabled, checked) | Snapshot true and false states | | Boolean flag with no visual test value | Skip or add a single named snapshot | | children / className / style / event handlers | Skip — not visually interesting on their own |
Prioritize props that change the component's visual appearance substantially. For interactive components (inputs, toggles), always include disabled/checked states.
Name the output file <component-name>.snapshots.tsx, colocated with the component source file.
tsximport {ThemeProvider} from '@emotion/react'; import {ComponentName, type ComponentNameProps} from '@sentry/scraps/<name>'; // or appropriate path // eslint-disable-next-line no-restricted-imports -- SSR snapshot rendering needs direct theme access import {darkTheme, lightTheme} from 'sentry/utils/theme/theme'; const themes = {light: lightTheme, dark: darkTheme};
Always wrap in light/dark theme loop:
tsxdescribe('ComponentName', () => { describe.each(['light', 'dark'] as const)('%s', themeName => { // ... snapshot cases here }); });
it.snapshot.each — for union prop variantsUse when iterating over multiple values of a single prop:
tsxit.snapshot.each<ComponentProps['variant']>(['info', 'warning', 'success', 'danger'])( '%s', variant => ( <ThemeProvider theme={themes[themeName]}> <div style={{padding: 8}}> <Component variant={variant}>Label</Component> </div> </ThemeProvider> ), variant => ({theme: themeName, variant: String(variant)}) );
The third argument to it.snapshot.each is the metadata function — include all props that vary in the snapshot. This metadata is used for snapshot naming and diffing.
it.snapshot — for single named snapshotsUse for one-off states (disabled, checked combinations, etc.):
tsxit.snapshot('disabled-unchecked', () => ( <ThemeProvider theme={themes[themeName]}> <div style={{padding: 8}}> <Component disabled onChange={() => {}} /> </div> </ThemeProvider> ));
Pass metadata as a third argument when it adds useful snapshot context:
tsxit.snapshot( 'bold', () => ( <ThemeProvider theme={themes[themeName]}> <div style={{padding: 8}}> <Component bold>Bold text</Component> </div> </ThemeProvider> ), {theme: themeName} );
Match container sizing to what makes the component readable:
| Situation | Wrapper | | ------------------------------ | ---------------------------------------- | | Default | <div style={{padding: 8}}> | | Width-sensitive (alerts, text) | <div style={{padding: 8, width: 400}}> | | Narrow (icons, small controls) | <div style={{padding: 8}}> |
For components that require event handlers (inputs, checkboxes, radios, switches), pass no-op handlers to satisfy required props:
tsx<Component onChange={() => {}} /> <Component checked onChange={() => {}} />
Order cases from most impactful to least:
tsximport {ThemeProvider} from '@emotion/react'; import {Button, type ButtonProps} from '@sentry/scraps/button'; // eslint-disable-next-line no-restricted-imports -- SSR snapshot rendering needs direct theme access import {darkTheme, lightTheme} from 'sentry/utils/theme/theme'; const themes = {light: lightTheme, dark: darkTheme}; describe('Button', () => { describe.each(['light', 'dark'] as const)('%s', themeName => { it.snapshot.each<ButtonProps['priority']>([ 'default', 'primary', 'danger', 'warning', 'link', 'transparent', ])( '%s', priority => ( <ThemeProvider theme={themes[themeName]}> <div style={{padding: 8}}> <Button priority={priority}>{priority}</Button> </div> </ThemeProvider> ), priority => ({theme: themeName, priority: String(priority)}) ); }); });
tsximport {ThemeProvider} from '@emotion/react'; import {Switch, type SwitchProps} from '@sentry/scraps/switch'; // eslint-disable-next-line no-restricted-imports -- SSR snapshot rendering needs direct theme access import {darkTheme, lightTheme} from 'sentry/utils/theme/theme'; const themes = {light: lightTheme, dark: darkTheme}; describe('Switch', () => { describe.each(['light', 'dark'] as const)('theme-%s', themeName => { it.snapshot.each<SwitchProps['size']>(['sm', 'lg'])('size-%s-unchecked', size => ( <ThemeProvider theme={themes[themeName]}> <div style={{padding: 8}}> <Switch size={size} onChange={() => {}} /> </div> </ThemeProvider> )); it.snapshot.each<SwitchProps['size']>(['sm', 'lg'])('size-%s-checked', size => ( <ThemeProvider theme={themes[themeName]}> <div style={{padding: 8}}> <Switch checked size={size} onChange={() => {}} /> </div> </ThemeProvider> )); it.snapshot('disabled-unchecked', () => ( <ThemeProvider theme={themes[themeName]}> <div style={{padding: 8}}> <Switch disabled onChange={() => {}} /> </div> </ThemeProvider> )); it.snapshot('disabled-checked', () => ( <ThemeProvider theme={themes[themeName]}> <div style={{padding: 8}}> <Switch checked disabled onChange={() => {}} /> </div> </ThemeProvider> )); }); });
When a component has multiple meaningful boolean or variant props that combine independently, add separate it.snapshot.each blocks per combination:
tsxdescribe('Alert', () => { describe.each(['light', 'dark'] as const)('%s', themeName => { // Primary variants it.snapshot.each<AlertProps['variant']>([ 'info', 'warning', 'success', 'danger', 'muted', ])( '%s', variant => ( <ThemeProvider theme={themes[themeName]}> <div style={{padding: 8, width: 400}}> <Alert variant={variant}>This is a {variant} alert</Alert> </div> </ThemeProvider> ), variant => ({theme: themeName, variant: String(variant)}) ); // Modifier combination: same variants but with showIcon={false} it.snapshot.each<AlertProps['variant']>([ 'info', 'warning', 'success', 'danger', 'muted', ])( '%s-no-icon', variant => ( <ThemeProvider theme={themes[themeName]}> <div style={{padding: 8, width: 400}}> <Alert variant={variant} showIcon={false}> This is a {variant} alert without icon </Alert> </div> </ThemeProvider> ), variant => ({theme: themeName, variant: String(variant), showIcon: 'false'}) ); }); });
tsx// ❌ Don't import theme from the barrel re-export import {theme} from 'sentry/utils/theme'; // ✅ Import directly and suppress the lint warning // eslint-disable-next-line no-restricted-imports -- SSR snapshot rendering needs direct theme access import {darkTheme, lightTheme} from 'sentry/utils/theme/theme';
tsx// ❌ Don't omit the metadata argument — snapshot names become ambiguous it.snapshot.each<Props['variant']>(['a', 'b'])('%s', variant => ( <Component variant={variant} /> )); // ✅ Include metadata that reflects all varying props it.snapshot.each<Props['variant']>(['a', 'b'])( '%s', variant => <Component variant={variant} />, variant => ({theme: themeName, variant: String(variant)}) );
tsx// ❌ Don't snapshot implementation-detail props like className or style it.snapshot('custom-class', () => <Component className="foo" />);
tsx// ❌ Don't use @sentry/scraps barrel import for components not in the scraps package import {Badge} from '@sentry/scraps/badge'; // if Badge isn't published there // ✅ Use the direct path with the no-core-import suppression comment // eslint-disable-next-line @sentry/scraps/no-core-import -- SSR snapshot needs direct import to avoid barrel re-exports with heavy deps import {Badge} from 'sentry/components/core/badge/badge';
Before finishing:
<component-name>.snapshots.tsx and colocated with the componentlight and dark themes are covered via describe.eachno-restricted-imports ESLint suppression comment is present on the theme importit.snapshot.each callsonChange={() => {}}) provided for required event props@sentry/scraps/<name> if available, otherwise the direct sentry/components/... path with the no-core-import suppression| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-07 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-06 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-18 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-21 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-03 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-11 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted, and 19 counted toward the lift figure. The other 3 produced results that are not comparable between the two arms, so they are excluded from the headline rather than averaged into it. The headline lift of +41 percentage points is the difference between those two pass rates over the 19 comparable cases. 3 cases got worse with the skill loaded, and they are included in that figure.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.