@fohte/storybook-addon 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -1
- package/lib/vitest-plugin.d.ts +54 -0
- package/lib/vitest-plugin.js +82 -0
- package/package.json +23 -2
package/README.md
CHANGED
|
@@ -17,8 +17,13 @@ pnpm add -D @fohte/storybook-addon
|
|
|
17
17
|
|
|
18
18
|
# Peer dependencies
|
|
19
19
|
pnpm add -D storybook vitest
|
|
20
|
+
|
|
21
|
+
# Peer dependencies for ./vitest-plugin only
|
|
22
|
+
pnpm add -D @storybook/addon-vitest @storycap-testrun/browser @vitest/browser-playwright
|
|
20
23
|
```
|
|
21
24
|
|
|
25
|
+
`@vitest/browser-playwright` pins its own `vitest` peer to its exact version (e.g. `4.1.10` requires `vitest@4.1.10` precisely), even though this package's own `peerDependencies` range for both is the looser `^4.0.0`. Keep the two installed at the same version.
|
|
26
|
+
|
|
22
27
|
## Usage
|
|
23
28
|
|
|
24
29
|
Add the addon to `.storybook/main.ts`:
|
|
@@ -69,11 +74,49 @@ initialize({
|
|
|
69
74
|
})
|
|
70
75
|
```
|
|
71
76
|
|
|
77
|
+
## Vitest plugin (`./vitest-plugin`)
|
|
78
|
+
|
|
79
|
+
Builds a Vitest browser-mode project that runs Storybook stories through [`@storybook/addon-vitest`](https://storybook.js.org/docs/writing-tests/integrations/vitest-addon) and captures a screenshot of each with [`@storycap-testrun/browser`](https://github.com/reg-viz/storycap-testrun), patched to shorten storycap's hardcoded 500ms network-idle wait to 100ms.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import path from 'node:path'
|
|
83
|
+
import { fileURLToPath } from 'node:url'
|
|
84
|
+
|
|
85
|
+
import { createStorybookProject } from '@fohte/storybook-addon/vitest-plugin'
|
|
86
|
+
import { defineConfig } from 'vitest/config'
|
|
87
|
+
|
|
88
|
+
const rootDir = path.dirname(fileURLToPath(import.meta.url))
|
|
89
|
+
|
|
90
|
+
export default defineConfig({
|
|
91
|
+
test: {
|
|
92
|
+
projects: [
|
|
93
|
+
createStorybookProject({
|
|
94
|
+
name: 'storybook',
|
|
95
|
+
rootDir,
|
|
96
|
+
viewport: { width: 1280, height: 800 },
|
|
97
|
+
screenshotsSubdir: 'desktop',
|
|
98
|
+
setupFiles: ['./.storybook/vitest.setup.ts'],
|
|
99
|
+
}),
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Screenshots for a project land in `<rootDir>/__screenshots__/<screenshotsSubdir>` — downstream tooling that consumes these images depends on this exact path, so treat it as a stable contract rather than an implementation detail.
|
|
106
|
+
|
|
107
|
+
`storycapNetworkIdle` is also exported on its own, for building a project without `createStorybookProject`.
|
|
108
|
+
|
|
109
|
+
Your `setupFiles` entry needs an `afterEach` that calls storycap's own `screenshot()` — see [`@storycap-testrun/browser`'s "Setup Screenshot Capture"](https://github.com/reg-viz/storycap-testrun/tree/main/packages/browser#2-setup-screenshot-capture) for the exact shape. Don't call `setProjectAnnotations()` there, and don't even mention that identifier in a comment: `@storybook/addon-vitest` decides whether to inject this package's checks by a plain substring search over the setup file's source text, so its mere presence silently disables every check in the project, with no error.
|
|
110
|
+
|
|
111
|
+
### Check failures don't block the screenshot
|
|
112
|
+
|
|
113
|
+
This package's checks run inside `@storybook/addon-vitest`'s own render phase, as part of the generated test body — before your `setupFiles`' `afterEach` (the one that calls `screenshot()`) ever runs. A failing check throws there, but Vitest still runs every registered `afterEach` after a test regardless of pass or fail, so the screenshot capture always follows, against whatever the DOM looked like when the check failed. Verified against a story with a deliberate overflow: the check failed, and `screenshot()` still produced a non-blank image showing the actual overflowing content.
|
|
114
|
+
|
|
72
115
|
## Addon `afterEach` ordering
|
|
73
116
|
|
|
74
117
|
Storybook runs multiple addons' `afterEach` hooks serially, in the **reverse** of the order they're listed in `main.ts`'s `addons` array — the addon listed last runs its `afterEach` first. The consuming app's own `preview.ts`/`preview.tsx` `afterEach` runs before every addon's `afterEach`. If one hook throws, the remaining ones are skipped (fail-fast).
|
|
75
118
|
|
|
76
|
-
This
|
|
119
|
+
This doesn't apply to `./vitest-plugin`'s screenshot capture: it isn't a Storybook addon, so it never sits in the `addons` array at all — see "Check failures don't block the screenshot" above for how that ordering actually works.
|
|
77
120
|
|
|
78
121
|
## Development
|
|
79
122
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export declare const storycapNetworkIdle: {
|
|
2
|
+
name: string;
|
|
3
|
+
transform(code: string, id: string): string | null;
|
|
4
|
+
};
|
|
5
|
+
export interface CreateStorybookProjectOptions {
|
|
6
|
+
/** Project name, as shown by `vitest --project <name>`. */
|
|
7
|
+
name: string;
|
|
8
|
+
/**
|
|
9
|
+
* Absolute path to the app root that the default `.storybook` config dir
|
|
10
|
+
* and the screenshot output dir are resolved against — typically
|
|
11
|
+
* `path.dirname(fileURLToPath(import.meta.url))` from the consumer's own
|
|
12
|
+
* vitest.config.ts.
|
|
13
|
+
*/
|
|
14
|
+
rootDir: string;
|
|
15
|
+
viewport: {
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Screenshots for this project are written to
|
|
21
|
+
* `<rootDir>/__screenshots__/<screenshotsSubdir>`. Downstream tooling that
|
|
22
|
+
* consumes these images depends on this exact path, so treat it as a
|
|
23
|
+
* stable contract rather than an implementation detail.
|
|
24
|
+
*/
|
|
25
|
+
screenshotsSubdir: string;
|
|
26
|
+
setupFiles: string[];
|
|
27
|
+
excludeTags?: string[];
|
|
28
|
+
/**
|
|
29
|
+
* `test.maxWorkers`, applied only when `process.env.CI` is set — Vitest
|
|
30
|
+
* reads `maxWorkers` per-project rather than falling back to the root
|
|
31
|
+
* config. Screenshot capture is I/O-bound (network-idle wait, font
|
|
32
|
+
* loading, CDP metric polling), so this can exceed the CI runner's vCPU
|
|
33
|
+
* count, but the right number depends on the runner in use — tune it per
|
|
34
|
+
* consumer rather than trusting this default.
|
|
35
|
+
*/
|
|
36
|
+
ciMaxWorkers?: number;
|
|
37
|
+
}
|
|
38
|
+
export declare function createStorybookProject({ name, rootDir, viewport, screenshotsSubdir, setupFiles, excludeTags, ciMaxWorkers, }: CreateStorybookProjectOptions): {
|
|
39
|
+
plugins: any[];
|
|
40
|
+
test: {
|
|
41
|
+
browser: {
|
|
42
|
+
enabled: boolean;
|
|
43
|
+
provider: import("vitest/node").BrowserProviderOption<import("@vitest/browser-playwright").PlaywrightProviderOptions>;
|
|
44
|
+
headless: boolean;
|
|
45
|
+
instances: {
|
|
46
|
+
browser: "chromium";
|
|
47
|
+
}[];
|
|
48
|
+
};
|
|
49
|
+
setupFiles: string[];
|
|
50
|
+
maxWorkers?: number;
|
|
51
|
+
name: string;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
//# sourceMappingURL=vitest-plugin.d.ts.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
|
3
|
+
import storycap from '@storycap-testrun/browser/vitest-plugin';
|
|
4
|
+
import { playwright } from '@vitest/browser-playwright';
|
|
5
|
+
// @storycap-testrun/browser ships a bundled .d.ts with its own copy of vite's
|
|
6
|
+
// `Plugin` type, so it's structurally identical but nominally unrelated to
|
|
7
|
+
// ours — cast to sidestep the resulting "unrelated types" error. Typing this
|
|
8
|
+
// as `Plugin` (instead of `any`) reintroduces a cascading "exactOptionalPropertyTypes"
|
|
9
|
+
// mismatch between vite's own `Plugin` and rollup's, so this stays `any`.
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- see comment above
|
|
11
|
+
function asPlugin(plugin) {
|
|
12
|
+
return plugin;
|
|
13
|
+
}
|
|
14
|
+
// @storycap-testrun/browser waits for 500ms of network silence before every
|
|
15
|
+
// capture and exposes no option to shorten it, so every story pays that flat
|
|
16
|
+
// half second per viewport. Only the floor moves: the window still restarts on
|
|
17
|
+
// each resource load, and `document.fonts.ready` plus the metrics-stability
|
|
18
|
+
// poll that follow it are untouched.
|
|
19
|
+
const NETWORK_IDLE_MS = 100;
|
|
20
|
+
// The module arrives here either as the shipped `dist/index.mjs` or as an
|
|
21
|
+
// esbuild pre-bundle, which reformats the minified source but keeps the literal.
|
|
22
|
+
const NETWORK_IDLE_DEFAULT = /=\s*500\s*\)\s*=>\s*new Promise\(/;
|
|
23
|
+
// A Vite plugin that patches @storycap-testrun/browser's own minified
|
|
24
|
+
// source. If a version bump moves the 500ms literal (or removes it), this
|
|
25
|
+
// throws at config-load time instead of silently leaving the 500ms wait in
|
|
26
|
+
// place — the alternative (staying quiet) would just make every capture
|
|
27
|
+
// slower with no visible symptom.
|
|
28
|
+
export const storycapNetworkIdle = {
|
|
29
|
+
name: 'storycap-network-idle',
|
|
30
|
+
transform(code, id) {
|
|
31
|
+
if (!id.includes('@storycap-testrun') ||
|
|
32
|
+
!code.includes('PerformanceObserver')) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const patched = code.replace(NETWORK_IDLE_DEFAULT, (match) => match.replace('500', String(NETWORK_IDLE_MS)));
|
|
36
|
+
if (patched === code) {
|
|
37
|
+
// This throw fails config loading itself, before any test runs.
|
|
38
|
+
// eslint-disable-next-line no-restricted-syntax -- Vite plugin transform hook contract: throwing is how a plugin aborts config loading
|
|
39
|
+
throw new Error(`storycap-network-idle: no 500ms network-idle default found in ${id}. Drop this plugin if @storycap-testrun made the wait configurable, otherwise re-derive the pattern.`);
|
|
40
|
+
}
|
|
41
|
+
return patched;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
export function createStorybookProject({ name, rootDir, viewport, screenshotsSubdir, setupFiles, excludeTags = [], ciMaxWorkers = 8, }) {
|
|
45
|
+
const configDir = path.join(rootDir, '.storybook');
|
|
46
|
+
const screenshotsDir = path.join(rootDir, '__screenshots__', screenshotsSubdir);
|
|
47
|
+
const maxWorkers = process.env['CI'] != null ? ciMaxWorkers : undefined;
|
|
48
|
+
// storybookTest() below returns a Promise that starts loading a real
|
|
49
|
+
// Storybook config from `configDir` as soon as it's constructed — nothing
|
|
50
|
+
// but Vite's own plugin container ever awaits that Promise, so calling
|
|
51
|
+
// this factory directly against a synthetic `rootDir` in a unit test
|
|
52
|
+
// surfaces it as an unhandled rejection instead of a useful assertion.
|
|
53
|
+
// This branch is covered by running against a real Storybook config instead.
|
|
54
|
+
return {
|
|
55
|
+
plugins: [
|
|
56
|
+
storycapNetworkIdle,
|
|
57
|
+
storybookTest({
|
|
58
|
+
configDir,
|
|
59
|
+
tags: { exclude: excludeTags },
|
|
60
|
+
}),
|
|
61
|
+
asPlugin(storycap({
|
|
62
|
+
viewport,
|
|
63
|
+
output: { dir: screenshotsDir },
|
|
64
|
+
})),
|
|
65
|
+
],
|
|
66
|
+
test: {
|
|
67
|
+
name,
|
|
68
|
+
...(maxWorkers !== undefined && { maxWorkers }),
|
|
69
|
+
browser: {
|
|
70
|
+
enabled: true,
|
|
71
|
+
// Pin the browser's timezone so time-dependent stories (calendar
|
|
72
|
+
// "now" indicators, relative timestamps) render identically
|
|
73
|
+
// regardless of the host machine's local timezone.
|
|
74
|
+
provider: playwright({ contextOptions: { timezoneId: 'Asia/Tokyo' } }),
|
|
75
|
+
headless: true,
|
|
76
|
+
instances: [{ browser: 'chromium' }],
|
|
77
|
+
},
|
|
78
|
+
setupFiles,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=vitest-plugin.js.map
|
package/package.json
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
"./preview": {
|
|
13
13
|
"types": "./lib/preview.d.ts",
|
|
14
14
|
"default": "./lib/preview.js"
|
|
15
|
+
},
|
|
16
|
+
"./vitest-plugin": {
|
|
17
|
+
"types": "./lib/vitest-plugin.d.ts",
|
|
18
|
+
"default": "./lib/vitest-plugin.js"
|
|
15
19
|
}
|
|
16
20
|
},
|
|
17
21
|
"imports": {
|
|
@@ -27,21 +31,38 @@
|
|
|
27
31
|
"registry": "https://registry.npmjs.org/"
|
|
28
32
|
},
|
|
29
33
|
"peerDependencies": {
|
|
34
|
+
"@storybook/addon-vitest": "^10.0.0",
|
|
35
|
+
"@storycap-testrun/browser": "^2.0.0",
|
|
36
|
+
"@vitest/browser-playwright": "^4.0.0",
|
|
30
37
|
"storybook": "^10.0.0",
|
|
31
38
|
"vitest": "^4.0.0"
|
|
32
39
|
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"@storybook/addon-vitest": {
|
|
42
|
+
"optional": true
|
|
43
|
+
},
|
|
44
|
+
"@storycap-testrun/browser": {
|
|
45
|
+
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"@vitest/browser-playwright": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
50
|
+
},
|
|
33
51
|
"dependencies": {
|
|
34
52
|
"neverthrow": "8.2.0"
|
|
35
53
|
},
|
|
36
54
|
"devDependencies": {
|
|
37
55
|
"@commitlint/cli": "21.2.1",
|
|
38
56
|
"@eslint/eslintrc": "3.3.6",
|
|
39
|
-
"@fohte/eslint-config": "0.4.
|
|
57
|
+
"@fohte/eslint-config": "0.4.1",
|
|
40
58
|
"@ninoseki/eslint-plugin-neverthrow": "0.2.0",
|
|
59
|
+
"@storybook/addon-vitest": "10.5.7",
|
|
60
|
+
"@storycap-testrun/browser": "2.1.1",
|
|
41
61
|
"@tsconfig/node-lts": "24.0.0",
|
|
42
62
|
"@tsconfig/strictest": "2.0.8",
|
|
43
63
|
"@types/jsdom": "30.0.0",
|
|
44
64
|
"@types/node": "24.13.3",
|
|
65
|
+
"@vitest/browser-playwright": "4.1.10",
|
|
45
66
|
"concurrently": "10.0.4",
|
|
46
67
|
"eslint": "10.6.0",
|
|
47
68
|
"jsdom": "30.0.1",
|
|
@@ -51,7 +72,7 @@
|
|
|
51
72
|
"typescript": "6.0.3",
|
|
52
73
|
"vitest": "4.1.10"
|
|
53
74
|
},
|
|
54
|
-
"version": "0.1.
|
|
75
|
+
"version": "0.1.3",
|
|
55
76
|
"scripts": {
|
|
56
77
|
"clean": "rimraf lib tsconfig.tsbuildinfo",
|
|
57
78
|
"prebuild": "pnpm run clean",
|