@fohte/storybook-addon 0.1.4 → 0.1.5

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.
@@ -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
  }
@@ -49,6 +49,9 @@ export declare function createStorybookProject({ name, rootDir, viewport, screen
49
49
  };
50
50
  setupFiles: string[];
51
51
  maxWorkers?: number;
52
+ sequence?: {
53
+ groupOrder: number;
54
+ };
52
55
  name: string;
53
56
  };
54
57
  };
@@ -80,13 +80,30 @@ export function createStorybookProject({ name, rootDir, viewport, screenshotsSub
80
80
  plugins,
81
81
  test: {
82
82
  name,
83
- ...(maxWorkers !== undefined && { maxWorkers }),
83
+ ...(maxWorkers !== undefined && {
84
+ maxWorkers,
85
+ // Vitest requires distinct `sequence.groupOrder` for same-group
86
+ // projects with different `maxWorkers`, and a consumer's sibling
87
+ // project (e.g. a plain "unit" project) won't have `maxWorkers`
88
+ // set — so this project needs its own group whenever it sets one.
89
+ sequence: { groupOrder: 1 },
90
+ }),
84
91
  browser: {
85
92
  enabled: true,
86
93
  // Pin the browser's timezone so time-dependent stories (calendar
87
94
  // "now" indicators, relative timestamps) render identically
88
95
  // regardless of the host machine's local timezone.
89
- provider: playwright({ contextOptions: { timezoneId: 'Asia/Tokyo' } }),
96
+ //
97
+ // `viewport` must match the story `viewport` above: Vitest's own
98
+ // iframe sizing (`__storycap_prepareViewport`) only resizes the
99
+ // iframe's wrapper div via CSS, it never touches the Playwright
100
+ // page's own viewport, which otherwise stays at Chromium's 1280x720
101
+ // default. `page.screenshot({ clip })` clips to the page's actual
102
+ // viewport regardless of the CSS layout, so a shorter page viewport
103
+ // silently truncates every fullPage tile's clip rect below it.
104
+ provider: playwright({
105
+ contextOptions: { timezoneId: 'Asia/Tokyo', viewport },
106
+ }),
90
107
  headless: true,
91
108
  instances: [{ browser: 'chromium' }],
92
109
  },
package/package.json CHANGED
@@ -57,6 +57,7 @@
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
62
  "@tsconfig/node-lts": "24.0.0",
62
63
  "@tsconfig/strictest": "2.0.8",
@@ -66,13 +67,14 @@
66
67
  "concurrently": "10.0.4",
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.5",
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
  }