@fohte/storybook-addon 0.1.4 → 0.1.6
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 +2 -0
- package/lib/checks/unhandled-api-request-check.d.ts +3 -0
- package/lib/checks/unhandled-api-request-check.js +81 -7
- package/lib/find-transparent-rows.d.ts +2 -0
- package/lib/find-transparent-rows.js +16 -0
- package/lib/preview.js +7 -2
- package/lib/vitest-plugin.d.ts +9 -0
- package/lib/vitest-plugin.js +43 -3
- package/lib/vitest-viewport-setup.d.ts +10 -0
- package/lib/vitest-viewport-setup.js +50 -0
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -78,6 +78,8 @@ initialize({
|
|
|
78
78
|
|
|
79
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 and to fix a tiling bug in its fullPage capture (`clip` staying anchored to the top of the viewport instead of following the browser's clamped scroll position, which duplicated content and cut off the bottom of any story taller than the viewport).
|
|
80
80
|
|
|
81
|
+
`viewport` applies for the whole test, not just the screenshot: mount and `play()` render at it too. `@storybook/addon-vitest` would otherwise silently mount/run every story at its own fixed 1200x900 default instead.
|
|
82
|
+
|
|
81
83
|
```ts
|
|
82
84
|
import path from 'node:path'
|
|
83
85
|
import { fileURLToPath } from 'node:url'
|
|
@@ -2,6 +2,8 @@ import type { StorybookCheck } from '#checks/check.js';
|
|
|
2
2
|
interface UnhandledApiRequestState {
|
|
3
3
|
pathPrefixes: string[];
|
|
4
4
|
unhandledApiRequestUrls: string[];
|
|
5
|
+
pendingFetches: Set<Promise<void>>;
|
|
6
|
+
fetchTrackerInstalled: boolean;
|
|
5
7
|
}
|
|
6
8
|
declare global {
|
|
7
9
|
var __fohteStorybookAddonUnhandledApiRequestState__: UnhandledApiRequestState | undefined;
|
|
@@ -10,6 +12,7 @@ export declare function configureUnhandledApiRequestCheck(options: {
|
|
|
10
12
|
pathPrefixes: string[];
|
|
11
13
|
}): void;
|
|
12
14
|
export declare function reportUnhandledApiRequest(url: string): boolean;
|
|
15
|
+
export declare function waitForPendingApiRequests(): Promise<void>;
|
|
13
16
|
export declare const unhandledApiRequestCheck: StorybookCheck;
|
|
14
17
|
export {};
|
|
15
18
|
//# sourceMappingURL=unhandled-api-request-check.d.ts.map
|
|
@@ -3,28 +3,102 @@ function globalState() {
|
|
|
3
3
|
globalThis.__fohteStorybookAddonUnhandledApiRequestState__ ??= {
|
|
4
4
|
pathPrefixes: [],
|
|
5
5
|
unhandledApiRequestUrls: [],
|
|
6
|
+
pendingFetches: new Set(),
|
|
7
|
+
fetchTrackerInstalled: false,
|
|
6
8
|
};
|
|
7
9
|
return globalThis.__fohteStorybookAddonUnhandledApiRequestState__;
|
|
8
10
|
}
|
|
9
11
|
export function configureUnhandledApiRequestCheck(options) {
|
|
10
12
|
globalState().pathPrefixes = options.pathPrefixes;
|
|
11
13
|
}
|
|
14
|
+
// `url` may be relative (e.g. a same-origin fetch('/api/tasks')), so it's
|
|
15
|
+
// always resolved against the current page rather than parsed on its own.
|
|
16
|
+
function trackedRequestUrl(url) {
|
|
17
|
+
const parsed = new URL(url, window.location.href);
|
|
18
|
+
if (parsed.origin !== window.location.origin)
|
|
19
|
+
return undefined;
|
|
20
|
+
const isTracked = globalState().pathPrefixes.some((prefix) => parsed.pathname.startsWith(prefix));
|
|
21
|
+
return isTracked ? parsed : undefined;
|
|
22
|
+
}
|
|
23
|
+
function isTrackedRequestUrl(url) {
|
|
24
|
+
return trackedRequestUrl(url) !== undefined;
|
|
25
|
+
}
|
|
12
26
|
// Returns whether the request was recorded, so the caller can decide whether
|
|
13
27
|
// to also print MSW's own error for it.
|
|
14
28
|
export function reportUnhandledApiRequest(url) {
|
|
15
|
-
const parsed =
|
|
16
|
-
if (parsed
|
|
29
|
+
const parsed = trackedRequestUrl(url);
|
|
30
|
+
if (!parsed)
|
|
17
31
|
return false;
|
|
32
|
+
globalState().unhandledApiRequestUrls.push(parsed.pathname);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
function requestUrl(input) {
|
|
36
|
+
if (typeof input === 'string')
|
|
37
|
+
return input;
|
|
38
|
+
if (input instanceof URL)
|
|
39
|
+
return input.href;
|
|
40
|
+
return input.url;
|
|
41
|
+
}
|
|
42
|
+
// A story's own render (e.g. a mount-time useEffect) can fire a fetch() it
|
|
43
|
+
// never awaits. MSW only learns whether that request is unhandled once its
|
|
44
|
+
// interception finishes, which — unlike a same-tick function call — takes at
|
|
45
|
+
// least one extra tick, so a check running immediately after render can run
|
|
46
|
+
// before MSW has had the chance to call reportUnhandledApiRequest() for it.
|
|
47
|
+
// Tracking matching fetch() calls lets assert() wait for them to settle
|
|
48
|
+
// first instead of racing that interception.
|
|
49
|
+
function installFetchTracker() {
|
|
18
50
|
const state = globalState();
|
|
19
|
-
if (
|
|
20
|
-
return
|
|
51
|
+
if (state.fetchTrackerInstalled)
|
|
52
|
+
return;
|
|
53
|
+
state.fetchTrackerInstalled = true;
|
|
54
|
+
const originalFetch = globalThis.fetch;
|
|
55
|
+
globalThis.fetch = (input, init) => {
|
|
56
|
+
const response = originalFetch(input, init);
|
|
57
|
+
if (isTrackedRequestUrl(requestUrl(input))) {
|
|
58
|
+
const settled = response.then(() => undefined, () => undefined);
|
|
59
|
+
state.pendingFetches.add(settled);
|
|
60
|
+
void settled.finally(() => state.pendingFetches.delete(settled));
|
|
61
|
+
}
|
|
62
|
+
return response;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
// ponytail: bounded wait, so a story that deliberately leaves a matching
|
|
66
|
+
// fetch pending forever (e.g. to render a "loading" state) doesn't hang the
|
|
67
|
+
// suite -- raise this if MSW's interception proves slower in practice.
|
|
68
|
+
const PENDING_FETCH_TIMEOUT_MS = 2000;
|
|
69
|
+
// preview.ts's afterEach awaits this before running any check's assert(),
|
|
70
|
+
// so unhandledApiRequestCheck.assert() itself can stay synchronous like
|
|
71
|
+
// every other check.
|
|
72
|
+
export async function waitForPendingApiRequests() {
|
|
73
|
+
const { pendingFetches } = globalState();
|
|
74
|
+
const deadline = Date.now() + PENDING_FETCH_TIMEOUT_MS;
|
|
75
|
+
// A tracked fetch's own resolution can synchronously trigger another
|
|
76
|
+
// tracked fetch (e.g. fetch(user).then(() => fetch(user.posts))) — re-check
|
|
77
|
+
// pendingFetches after each round instead of racing a single snapshot of
|
|
78
|
+
// it, so a same-story follow-up fetch is also waited for, within the same
|
|
79
|
+
// overall deadline.
|
|
80
|
+
while (pendingFetches.size > 0 && Date.now() < deadline) {
|
|
81
|
+
// Each iteration re-reads pendingFetches, which the previous iteration's
|
|
82
|
+
// wait may have grown, so this can't be hoisted out of the loop.
|
|
83
|
+
await Promise.race([
|
|
84
|
+
Promise.all(pendingFetches),
|
|
85
|
+
new Promise((resolve) => setTimeout(resolve, deadline - Date.now())),
|
|
86
|
+
]);
|
|
87
|
+
}
|
|
88
|
+
if (pendingFetches.size > 0) {
|
|
89
|
+
console.warn(`[unhandled-api-request-check] gave up waiting for ${String(pendingFetches.size)} pending fetch(es) after ${String(PENDING_FETCH_TIMEOUT_MS)}ms`);
|
|
21
90
|
}
|
|
22
|
-
state.unhandledApiRequestUrls.push(parsed.pathname);
|
|
23
|
-
return true;
|
|
24
91
|
}
|
|
25
92
|
export const unhandledApiRequestCheck = {
|
|
26
93
|
reset: () => {
|
|
27
|
-
|
|
94
|
+
installFetchTracker();
|
|
95
|
+
const state = globalState();
|
|
96
|
+
state.unhandledApiRequestUrls.length = 0;
|
|
97
|
+
// Discard fetches left over from a previous story (e.g. one that never
|
|
98
|
+
// settles, to render a "loading" state) — its own afterEach already ran
|
|
99
|
+
// waitForPendingApiRequests() for it, so continuing to track it here
|
|
100
|
+
// would only force every later story to pay the full timeout too.
|
|
101
|
+
state.pendingFetches.clear();
|
|
28
102
|
},
|
|
29
103
|
assert: () => {
|
|
30
104
|
throwIfNotEmpty(globalState().unhandledApiRequestUrls, 'Story made unhandled API request(s); add an MSW handler for');
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function findTransparentRows(data, width, height) {
|
|
2
|
+
const transparentRows = [];
|
|
3
|
+
for (let y = 0; y < height; y++) {
|
|
4
|
+
let hasOpaquePixel = false;
|
|
5
|
+
for (let x = 0; x < width; x++) {
|
|
6
|
+
if (data[(y * width + x) * 4 + 3] !== 0) {
|
|
7
|
+
hasOpaquePixel = true;
|
|
8
|
+
break;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
if (!hasOpaquePixel)
|
|
12
|
+
transparentRows.push(y);
|
|
13
|
+
}
|
|
14
|
+
return transparentRows;
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=find-transparent-rows.js.map
|
package/lib/preview.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { externalResourceCheck } from '#checks/external-resource-check.js';
|
|
2
2
|
import { overflowCheck } from '#checks/overflow-check.js';
|
|
3
|
-
import { unhandledApiRequestCheck } from '#checks/unhandled-api-request-check.js';
|
|
3
|
+
import { unhandledApiRequestCheck, waitForPendingApiRequests, } from '#checks/unhandled-api-request-check.js';
|
|
4
4
|
export { configureUnhandledApiRequestCheck, reportUnhandledApiRequest, } from '#checks/unhandled-api-request-check.js';
|
|
5
5
|
function injectStyle(css) {
|
|
6
6
|
const style = document.createElement('style');
|
|
@@ -36,7 +36,12 @@ export const beforeEach = () => {
|
|
|
36
36
|
for (const check of checks)
|
|
37
37
|
check.reset();
|
|
38
38
|
};
|
|
39
|
-
export const afterEach = (context) => {
|
|
39
|
+
export const afterEach = async (context) => {
|
|
40
|
+
// A story with no play function only has its render awaited before
|
|
41
|
+
// afterEach runs (see storybook/dist/preview/runtime.js's runStory()), so
|
|
42
|
+
// a fire-and-forget fetch() from a mount-time effect can still be in
|
|
43
|
+
// flight here — wait for it before the checks read their state.
|
|
44
|
+
await waitForPendingApiRequests();
|
|
40
45
|
for (const check of checks) {
|
|
41
46
|
check.assert(context.parameters, context.canvasElement);
|
|
42
47
|
}
|
package/lib/vitest-plugin.d.ts
CHANGED
|
@@ -48,7 +48,16 @@ export declare function createStorybookProject({ name, rootDir, viewport, screen
|
|
|
48
48
|
}[];
|
|
49
49
|
};
|
|
50
50
|
setupFiles: string[];
|
|
51
|
+
provide: {
|
|
52
|
+
fohteStorybookAddonViewport: {
|
|
53
|
+
width: number;
|
|
54
|
+
height: number;
|
|
55
|
+
};
|
|
56
|
+
};
|
|
51
57
|
maxWorkers?: number;
|
|
58
|
+
sequence?: {
|
|
59
|
+
groupOrder: number;
|
|
60
|
+
};
|
|
52
61
|
name: string;
|
|
53
62
|
};
|
|
54
63
|
};
|
package/lib/vitest-plugin.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
1
2
|
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
2
4
|
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
|
3
5
|
import storycap from '@storycap-testrun/browser/vitest-plugin';
|
|
4
6
|
import { playwright } from '@vitest/browser-playwright';
|
|
@@ -47,6 +49,17 @@ export function createStorybookProject({ name, rootDir, viewport, screenshotsSub
|
|
|
47
49
|
const configDir = path.join(rootDir, '.storybook');
|
|
48
50
|
const screenshotsDir = path.join(rootDir, '__screenshots__', screenshotsSubdir);
|
|
49
51
|
const maxWorkers = process.env['CI'] != null ? ciMaxWorkers : undefined;
|
|
52
|
+
const viewportSetupFile = fileURLToPath(import.meta.resolve('#vitest-viewport-setup.js'));
|
|
53
|
+
// `import.meta.resolve` maps `#*.js` straight to `./lib/*.js` (this
|
|
54
|
+
// package's own build output) without checking the file exists — running
|
|
55
|
+
// against a fresh clone or a stale `lib/` (before `pnpm run build`, or
|
|
56
|
+
// after editing vitest-viewport-setup.ts without rebuilding) would
|
|
57
|
+
// otherwise leave Vitest to fail on a missing/stale setupFile with no clue
|
|
58
|
+
// this is why.
|
|
59
|
+
if (!existsSync(viewportSetupFile)) {
|
|
60
|
+
// eslint-disable-next-line no-restricted-syntax -- config-load-time invariant check, mirrors storycapNetworkIdle's fail-fast pattern above
|
|
61
|
+
throw new Error(`createStorybookProject: ${viewportSetupFile} not found. Run \`pnpm run build\` first.`);
|
|
62
|
+
}
|
|
50
63
|
const plugins = [
|
|
51
64
|
storycapNetworkIdle,
|
|
52
65
|
storybookTest({
|
|
@@ -80,17 +93,44 @@ export function createStorybookProject({ name, rootDir, viewport, screenshotsSub
|
|
|
80
93
|
plugins,
|
|
81
94
|
test: {
|
|
82
95
|
name,
|
|
83
|
-
...(maxWorkers !== undefined && {
|
|
96
|
+
...(maxWorkers !== undefined && {
|
|
97
|
+
maxWorkers,
|
|
98
|
+
// Vitest requires distinct `sequence.groupOrder` for same-group
|
|
99
|
+
// projects with different `maxWorkers`, and a consumer's sibling
|
|
100
|
+
// project (e.g. a plain "unit" project) won't have `maxWorkers`
|
|
101
|
+
// set — so this project needs its own group whenever it sets one.
|
|
102
|
+
sequence: { groupOrder: 1 },
|
|
103
|
+
}),
|
|
84
104
|
browser: {
|
|
85
105
|
enabled: true,
|
|
86
106
|
// Pin the browser's timezone so time-dependent stories (calendar
|
|
87
107
|
// "now" indicators, relative timestamps) render identically
|
|
88
108
|
// regardless of the host machine's local timezone.
|
|
89
|
-
|
|
109
|
+
//
|
|
110
|
+
// `viewport` must match the story `viewport` above: Vitest's own
|
|
111
|
+
// iframe sizing (`__storycap_prepareViewport`) only resizes the
|
|
112
|
+
// iframe's wrapper div via CSS, it never touches the Playwright
|
|
113
|
+
// page's own viewport, which otherwise stays at Chromium's 1280x720
|
|
114
|
+
// default. `page.screenshot({ clip })` clips to the page's actual
|
|
115
|
+
// viewport regardless of the CSS layout, so a shorter page viewport
|
|
116
|
+
// silently truncates every fullPage tile's clip rect below it.
|
|
117
|
+
provider: playwright({
|
|
118
|
+
contextOptions: { timezoneId: 'Asia/Tokyo', viewport },
|
|
119
|
+
}),
|
|
90
120
|
headless: true,
|
|
91
121
|
instances: [{ browser: 'chromium' }],
|
|
92
122
|
},
|
|
93
|
-
|
|
123
|
+
// @storybook/addon-vitest overrides the story viewport to its own
|
|
124
|
+
// 1200x900 default right before mount/play() unless
|
|
125
|
+
// parameters.viewport/globals.viewport are set — vitest-viewport-setup.ts
|
|
126
|
+
// sets them via setProjectAnnotations() so mount/play() see the same
|
|
127
|
+
// `viewport` as the Playwright page and the screenshot above. It must
|
|
128
|
+
// run after the consumer's own setupFiles in case any of them also
|
|
129
|
+
// call setProjectAnnotations().
|
|
130
|
+
setupFiles: [...setupFiles, viewportSetupFile],
|
|
131
|
+
provide: {
|
|
132
|
+
fohteStorybookAddonViewport: viewport,
|
|
133
|
+
},
|
|
94
134
|
},
|
|
95
135
|
};
|
|
96
136
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { setProjectAnnotations } from 'storybook/preview-api';
|
|
2
|
+
import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js';
|
|
3
|
+
import { beforeEach, inject } from 'vitest';
|
|
4
|
+
const VIEWPORT_NAME = '__fohteStorybookAddonViewport';
|
|
5
|
+
const { width, height } = inject('fohteStorybookAddonViewport');
|
|
6
|
+
// setProjectAnnotations() replaces the project annotations wholesale rather
|
|
7
|
+
// than merging into them, so composing in the consumer's own
|
|
8
|
+
// getProjectAnnotations() first is required — passing only the viewport
|
|
9
|
+
// override below would drop the consumer's own preview.ts
|
|
10
|
+
// renderer/decorators/loaders. Both are static per test file (the consumer's
|
|
11
|
+
// preview.ts and this project's `viewport` don't change mid-run), so this is
|
|
12
|
+
// computed once at setup-file top level rather than inside beforeEach below.
|
|
13
|
+
const projectAnnotations = getProjectAnnotations();
|
|
14
|
+
const viewportOverride = {
|
|
15
|
+
parameters: {
|
|
16
|
+
viewport: {
|
|
17
|
+
defaultViewport: VIEWPORT_NAME,
|
|
18
|
+
viewports: {
|
|
19
|
+
[VIEWPORT_NAME]: {
|
|
20
|
+
name: 'createStorybookProject viewport',
|
|
21
|
+
styles: {
|
|
22
|
+
width: `${String(width)}px`,
|
|
23
|
+
height: `${String(height)}px`,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
initialGlobals: { viewport: { value: VIEWPORT_NAME } },
|
|
30
|
+
};
|
|
31
|
+
// @storybook/addon-vitest's own testStory() calls setViewport() right before
|
|
32
|
+
// mount/play(), falling back to its built-in 1200x900 default whenever
|
|
33
|
+
// parameters.viewport/globals.viewport aren't set — silently overriding the
|
|
34
|
+
// `viewport` createStorybookProject() was given for the screenshot capture
|
|
35
|
+
// itself. setProjectAnnotations() is the public integration point Storybook
|
|
36
|
+
// added for exactly this in 10.3.
|
|
37
|
+
//
|
|
38
|
+
// The call itself runs in beforeEach, not at setup-file top level:
|
|
39
|
+
// @storybook/addon-vitest always injects its own
|
|
40
|
+
// internal/setup-file-with-project-annotations alongside consumer
|
|
41
|
+
// setupFiles — its "already provisioned" detection only looks inside the
|
|
42
|
+
// consumer's `.storybook` dir, which this file isn't in. That file's
|
|
43
|
+
// top-level setProjectAnnotations(getProjectAnnotations()) call would run
|
|
44
|
+
// after this file's own top level and silently wipe this override.
|
|
45
|
+
// beforeEach always runs after every setupFile finishes loading, so calling
|
|
46
|
+
// setProjectAnnotations() here instead wins regardless of setupFiles order.
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
setProjectAnnotations([projectAnnotations, viewportOverride]);
|
|
49
|
+
});
|
|
50
|
+
//# sourceMappingURL=vitest-viewport-setup.js.map
|
package/package.json
CHANGED
|
@@ -57,22 +57,24 @@
|
|
|
57
57
|
"@fohte/eslint-config": "0.4.1",
|
|
58
58
|
"@ninoseki/eslint-plugin-neverthrow": "0.2.0",
|
|
59
59
|
"@storybook/addon-vitest": "10.5.8",
|
|
60
|
+
"@storybook/html-vite": "10.5.8",
|
|
60
61
|
"@storycap-testrun/browser": "2.1.1",
|
|
61
|
-
"@tsconfig/node-lts": "24.0.
|
|
62
|
+
"@tsconfig/node-lts": "24.0.1",
|
|
62
63
|
"@tsconfig/strictest": "2.0.8",
|
|
63
64
|
"@types/jsdom": "30.0.0",
|
|
64
65
|
"@types/node": "24.13.3",
|
|
65
66
|
"@vitest/browser-playwright": "4.1.10",
|
|
66
|
-
"concurrently": "10.0.
|
|
67
|
+
"concurrently": "10.0.5",
|
|
67
68
|
"eslint": "10.6.0",
|
|
68
69
|
"jsdom": "30.0.1",
|
|
70
|
+
"playwright": "1.62.1",
|
|
69
71
|
"prettier": "3.9.6",
|
|
70
72
|
"rimraf": "6.1.3",
|
|
71
73
|
"storybook": "10.5.8",
|
|
72
74
|
"typescript": "6.0.3",
|
|
73
75
|
"vitest": "4.1.10"
|
|
74
76
|
},
|
|
75
|
-
"version": "0.1.
|
|
77
|
+
"version": "0.1.6",
|
|
76
78
|
"scripts": {
|
|
77
79
|
"clean": "rimraf lib tsconfig.tsbuildinfo",
|
|
78
80
|
"prebuild": "pnpm run clean",
|
|
@@ -83,6 +85,7 @@
|
|
|
83
85
|
"format": "conc -m 1 pnpm:format:eslint pnpm:format:prettier",
|
|
84
86
|
"test": "conc pnpm:test:type pnpm:test:unit",
|
|
85
87
|
"test:type": "tsc --noEmit",
|
|
88
|
+
"pretest:unit": "pnpm run build",
|
|
86
89
|
"test:unit": "vitest run"
|
|
87
90
|
}
|
|
88
91
|
}
|