@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 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 = new URL(url);
16
- if (parsed.origin !== window.location.origin)
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 (!state.pathPrefixes.some((prefix) => parsed.pathname.startsWith(prefix))) {
20
- return false;
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
- globalState().unhandledApiRequestUrls.length = 0;
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,2 @@
1
+ export declare function findTransparentRows(data: Uint8ClampedArray, width: number, height: number): number[];
2
+ //# sourceMappingURL=find-transparent-rows.d.ts.map
@@ -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
  }
@@ -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
  };
@@ -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 && { maxWorkers }),
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
- provider: playwright({ contextOptions: { timezoneId: 'Asia/Tokyo' } }),
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
- setupFiles,
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,10 @@
1
+ declare module 'vitest' {
2
+ interface ProvidedContext {
3
+ fohteStorybookAddonViewport: {
4
+ width: number;
5
+ height: number;
6
+ };
7
+ }
8
+ }
9
+ export {};
10
+ //# sourceMappingURL=vitest-viewport-setup.d.ts.map
@@ -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.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.4",
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.4",
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
  }