@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.
- 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 +3 -0
- package/lib/vitest-plugin.js +19 -2
- package/package.json +4 -1
|
@@ -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
package/lib/vitest-plugin.js
CHANGED
|
@@ -80,13 +80,30 @@ export function createStorybookProject({ name, rootDir, viewport, screenshotsSub
|
|
|
80
80
|
plugins,
|
|
81
81
|
test: {
|
|
82
82
|
name,
|
|
83
|
-
...(maxWorkers !== undefined && {
|
|
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
|
-
|
|
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.
|
|
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
|
}
|