@fohte/storybook-addon 0.1.3 → 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/README.md +2 -2
- 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/storycap-fullpage-stitch.d.ts +33 -0
- package/lib/storycap-fullpage-stitch.js +170 -0
- package/lib/vitest-plugin.d.ts +4 -0
- package/lib/vitest-plugin.js +45 -13
- package/package.json +7 -4
package/README.md
CHANGED
|
@@ -76,7 +76,7 @@ initialize({
|
|
|
76
76
|
|
|
77
77
|
## Vitest plugin (`./vitest-plugin`)
|
|
78
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.
|
|
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
81
|
```ts
|
|
82
82
|
import path from 'node:path'
|
|
@@ -104,7 +104,7 @@ export default defineConfig({
|
|
|
104
104
|
|
|
105
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
106
|
|
|
107
|
-
`storycapNetworkIdle`
|
|
107
|
+
`storycapNetworkIdle` and `storycapFullPageStitch` are also exported on their own, for building a project without `createStorybookProject`. `storycapFullPageStitch` must be placed after storycap's own plugin in the `plugins` array — it works by overriding the `__storycap_takeScreenshot` command storycap registers, and Vite resolves conflicting plugin `config()` keys in plugin order, later wins.
|
|
108
108
|
|
|
109
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
110
|
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { BrowserCommand, BrowserCommandContext } from 'vitest/node';
|
|
2
|
+
type Viewport = {
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
};
|
|
6
|
+
type Page = BrowserCommandContext['page'];
|
|
7
|
+
type ScreenshotOptions = NonNullable<Parameters<Page['screenshot']>[0]>;
|
|
8
|
+
interface TakeScreenshotOptions {
|
|
9
|
+
fullPage?: boolean;
|
|
10
|
+
omitBackground?: ScreenshotOptions['omitBackground'];
|
|
11
|
+
scale?: ScreenshotOptions['scale'];
|
|
12
|
+
type?: ScreenshotOptions['type'];
|
|
13
|
+
}
|
|
14
|
+
export declare function computeChunkClip(requestedScrollY: number, actualScrollY: number, viewportHeight: number, scrollHeight: number): {
|
|
15
|
+
clipYOffset: number;
|
|
16
|
+
chunkHeight: number;
|
|
17
|
+
};
|
|
18
|
+
export declare function storycapFullPageStitch(options?: {
|
|
19
|
+
viewport?: Viewport;
|
|
20
|
+
}): {
|
|
21
|
+
name: string;
|
|
22
|
+
config(): {
|
|
23
|
+
test: {
|
|
24
|
+
browser: {
|
|
25
|
+
commands: {
|
|
26
|
+
__storycap_takeScreenshot: BrowserCommand<[string, TakeScreenshotOptions], string>;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
export {};
|
|
33
|
+
//# sourceMappingURL=storycap-fullpage-stitch.d.ts.map
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
const IFRAME_SELECTOR = 'iframe[data-vitest]';
|
|
4
|
+
// @storycap-testrun/browser's own fullPage tiling (packages/browser/src/vitest-plugin/index.ts,
|
|
5
|
+
// `captureFullPage`) always clips each tile starting at the iframe's bounding-box
|
|
6
|
+
// top, on the assumption that `scrollTo(0, requestedScrollY)` always lands
|
|
7
|
+
// exactly at `requestedScrollY`. The browser clamps the scroll position once
|
|
8
|
+
// it exceeds `scrollHeight - viewportHeight`, so whenever the page height
|
|
9
|
+
// isn't an exact multiple of the viewport height, the last tile re-captures
|
|
10
|
+
// already-seen content instead of the page's true bottom, which is never
|
|
11
|
+
// captured. Comparing the requested position against what the browser
|
|
12
|
+
// actually applied gives the pixel offset the clip rect needs to shift down
|
|
13
|
+
// by to land on unseen content.
|
|
14
|
+
export function computeChunkClip(requestedScrollY, actualScrollY, viewportHeight, scrollHeight) {
|
|
15
|
+
const clipYOffset = requestedScrollY - actualScrollY;
|
|
16
|
+
const chunkHeight = Math.min(viewportHeight - clipYOffset, scrollHeight - requestedScrollY);
|
|
17
|
+
return { clipYOffset, chunkHeight };
|
|
18
|
+
}
|
|
19
|
+
async function scrollIframeTo(ctx, top) {
|
|
20
|
+
// `{ behavior: 'instant' }` overrides any `scroll-behavior: smooth` set by
|
|
21
|
+
// the story's own CSS. Without it, a smooth-scrolling story would still be
|
|
22
|
+
// mid-animation when the position is read back below, making the reported
|
|
23
|
+
// scroll position (and therefore the capture) non-deterministic.
|
|
24
|
+
return ctx.iframe.locator('body').evaluate((body, scrollTop) => {
|
|
25
|
+
const view = body.ownerDocument.defaultView;
|
|
26
|
+
view?.scrollTo({ top: scrollTop, left: 0, behavior: 'instant' });
|
|
27
|
+
return view?.scrollY ?? 0;
|
|
28
|
+
}, top);
|
|
29
|
+
}
|
|
30
|
+
async function getIframeBoundingBox(ctx) {
|
|
31
|
+
const box = await ctx.page.locator(IFRAME_SELECTOR).boundingBox();
|
|
32
|
+
if (box)
|
|
33
|
+
return box;
|
|
34
|
+
// eslint-disable-next-line no-restricted-syntax -- interop boundary: BrowserCommand contract signals failure by rejecting
|
|
35
|
+
throw new Error('Could not determine iframe position for screenshot');
|
|
36
|
+
}
|
|
37
|
+
function screenshotOptionsFrom(options) {
|
|
38
|
+
return {
|
|
39
|
+
animations: 'disabled',
|
|
40
|
+
caret: 'hide',
|
|
41
|
+
// Playwright's own default ('device') scales the returned buffer by
|
|
42
|
+
// deviceScaleFactor, but captureFullPage's canvas below is sized in CSS
|
|
43
|
+
// pixels — pin 'css' so a chunk's pixel dimensions always match the
|
|
44
|
+
// canvas region it gets drawn into.
|
|
45
|
+
scale: options.scale ?? 'css',
|
|
46
|
+
...(options.omitBackground != null && {
|
|
47
|
+
omitBackground: options.omitBackground,
|
|
48
|
+
}),
|
|
49
|
+
...(options.type != null && { type: options.type }),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
async function captureTiles(ctx, viewport, scrollHeight, options) {
|
|
53
|
+
// The iframe element's position on the outer page doesn't move when its
|
|
54
|
+
// own inner content scrolls, so this only needs to be read once per
|
|
55
|
+
// capture instead of once per tile.
|
|
56
|
+
const iframeBox = await getIframeBoundingBox(ctx);
|
|
57
|
+
const images = [];
|
|
58
|
+
const heights = [];
|
|
59
|
+
for (let requestedScrollY = 0; requestedScrollY < scrollHeight; requestedScrollY += viewport.height) {
|
|
60
|
+
const actualScrollY = await scrollIframeTo(ctx, requestedScrollY);
|
|
61
|
+
const { clipYOffset, chunkHeight } = computeChunkClip(requestedScrollY, actualScrollY, viewport.height, scrollHeight);
|
|
62
|
+
const chunkBuffer = await ctx.page.screenshot({
|
|
63
|
+
clip: {
|
|
64
|
+
x: iframeBox.x,
|
|
65
|
+
y: iframeBox.y + clipYOffset,
|
|
66
|
+
width: iframeBox.width,
|
|
67
|
+
height: chunkHeight,
|
|
68
|
+
},
|
|
69
|
+
...screenshotOptionsFrom(options),
|
|
70
|
+
});
|
|
71
|
+
images.push(Buffer.from(chunkBuffer).toString('base64'));
|
|
72
|
+
heights.push(chunkHeight);
|
|
73
|
+
}
|
|
74
|
+
const mimeType = options.type === 'jpeg' ? 'image/jpeg' : 'image/png';
|
|
75
|
+
const stitchedBase64 = await ctx.page.evaluate(async ({ images: chunkImages, width, heights: chunkHeights, mimeType: mime, }) => {
|
|
76
|
+
const totalHeight = chunkHeights.reduce((sum, h) => sum + h, 0);
|
|
77
|
+
const canvas = document.createElement('canvas');
|
|
78
|
+
canvas.width = width;
|
|
79
|
+
canvas.height = totalHeight;
|
|
80
|
+
const canvasContext = canvas.getContext('2d');
|
|
81
|
+
let y = 0;
|
|
82
|
+
for (let i = 0; i < chunkImages.length; i++) {
|
|
83
|
+
const img = new Image();
|
|
84
|
+
img.src = `data:${mime};base64,${chunkImages[i] ?? ''}`;
|
|
85
|
+
await new Promise((resolve, reject) => {
|
|
86
|
+
img.onload = () => {
|
|
87
|
+
resolve();
|
|
88
|
+
};
|
|
89
|
+
img.onerror = () => {
|
|
90
|
+
reject(new Error(`failed to decode screenshot chunk ${i.toString()}`));
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
canvasContext?.drawImage(img, 0, y);
|
|
94
|
+
y += chunkHeights[i] ?? 0;
|
|
95
|
+
}
|
|
96
|
+
return canvas.toDataURL(mime).split(',')[1] ?? '';
|
|
97
|
+
}, { images, width: viewport.width, heights, mimeType });
|
|
98
|
+
return Buffer.from(stitchedBase64, 'base64');
|
|
99
|
+
}
|
|
100
|
+
// Vitest's browser mode reuses one iframe per test file, so a scroll
|
|
101
|
+
// position left behind by a failed capture would bleed into whichever
|
|
102
|
+
// story runs next in the same file — reset it whether captureTiles()
|
|
103
|
+
// succeeds or rejects.
|
|
104
|
+
function captureFullPage(ctx, viewport, scrollHeight, options) {
|
|
105
|
+
return captureTiles(ctx, viewport, scrollHeight, options).finally(() => scrollIframeTo(ctx, 0));
|
|
106
|
+
}
|
|
107
|
+
function takeScreenshot(configuredViewport) {
|
|
108
|
+
return async (ctx, filepath, options) => {
|
|
109
|
+
const viewport = configuredViewport ??
|
|
110
|
+
ctx.page.viewportSize() ?? { width: 1280, height: 720 };
|
|
111
|
+
let buffer;
|
|
112
|
+
if (options.fullPage === false) {
|
|
113
|
+
await ctx.page.evaluate(() => {
|
|
114
|
+
window.scrollTo(0, 0);
|
|
115
|
+
});
|
|
116
|
+
const iframeBox = await getIframeBoundingBox(ctx);
|
|
117
|
+
buffer = Buffer.from(await ctx.page.screenshot({
|
|
118
|
+
clip: iframeBox,
|
|
119
|
+
...screenshotOptionsFrom(options),
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
const scrollHeight = await ctx.iframe
|
|
124
|
+
.locator('body')
|
|
125
|
+
.evaluate((body) => Math.max(body.scrollHeight, body.ownerDocument.documentElement.scrollHeight));
|
|
126
|
+
buffer =
|
|
127
|
+
scrollHeight > viewport.height
|
|
128
|
+
? await captureFullPage(ctx, viewport, scrollHeight, options)
|
|
129
|
+
: await ctx.iframe
|
|
130
|
+
.locator('body')
|
|
131
|
+
.screenshot(screenshotOptionsFrom(options));
|
|
132
|
+
}
|
|
133
|
+
await fs.mkdir(path.dirname(filepath), { recursive: true });
|
|
134
|
+
await fs.writeFile(filepath, buffer);
|
|
135
|
+
return Buffer.from(buffer).toString('base64');
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// Replaces @storycap-testrun/browser's `__storycap_takeScreenshot` command
|
|
139
|
+
// (registered by the `storycap` plugin passed to `createStorybookProject`,
|
|
140
|
+
// via the same `config()` → `test.browser.commands` shape used below) with
|
|
141
|
+
// a from-scratch reimplementation that fixes the fullPage tiling bug
|
|
142
|
+
// described above. Vite merges plugin `config()`
|
|
143
|
+
// hooks in plugin order, with later plugins overriding matching keys — so
|
|
144
|
+
// this must be placed after the `storycap` plugin in `createStorybookProject`'s
|
|
145
|
+
// `plugins` array for the override to take effect. `resolveScreenshotFilepath`,
|
|
146
|
+
// `__storycap_prepareViewport` and `__storycap_restoreViewport` are untouched,
|
|
147
|
+
// since this only supplies the `__storycap_takeScreenshot` key.
|
|
148
|
+
//
|
|
149
|
+
// No upstream issue tracks this bug. Drop this plugin (and its wiring in
|
|
150
|
+
// vitest-plugin.ts) once @storycap-testrun/browser's own `captureFullPage`
|
|
151
|
+
// (packages/browser/src/vitest-plugin/index.ts) accounts for `scrollTo`
|
|
152
|
+
// clamping the requested position instead of assuming it always lands
|
|
153
|
+
// exactly where requested.
|
|
154
|
+
export function storycapFullPageStitch(options = {}) {
|
|
155
|
+
return {
|
|
156
|
+
name: 'storycap-fullpage-stitch-fix',
|
|
157
|
+
config() {
|
|
158
|
+
return {
|
|
159
|
+
test: {
|
|
160
|
+
browser: {
|
|
161
|
+
commands: {
|
|
162
|
+
__storycap_takeScreenshot: takeScreenshot(options.viewport),
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
//# sourceMappingURL=storycap-fullpage-stitch.js.map
|
package/lib/vitest-plugin.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { storycapFullPageStitch } from '#storycap-fullpage-stitch.js';
|
|
1
2
|
export declare const storycapNetworkIdle: {
|
|
2
3
|
name: string;
|
|
3
4
|
transform(code: string, id: string): string | null;
|
|
@@ -48,6 +49,9 @@ export declare function createStorybookProject({ name, rootDir, viewport, screen
|
|
|
48
49
|
};
|
|
49
50
|
setupFiles: string[];
|
|
50
51
|
maxWorkers?: number;
|
|
52
|
+
sequence?: {
|
|
53
|
+
groupOrder: number;
|
|
54
|
+
};
|
|
51
55
|
name: string;
|
|
52
56
|
};
|
|
53
57
|
};
|
package/lib/vitest-plugin.js
CHANGED
|
@@ -2,6 +2,8 @@ import path from 'node:path';
|
|
|
2
2
|
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
|
3
3
|
import storycap from '@storycap-testrun/browser/vitest-plugin';
|
|
4
4
|
import { playwright } from '@vitest/browser-playwright';
|
|
5
|
+
import { storycapFullPageStitch } from '#storycap-fullpage-stitch.js';
|
|
6
|
+
export { storycapFullPageStitch } from '#storycap-fullpage-stitch.js';
|
|
5
7
|
// @storycap-testrun/browser ships a bundled .d.ts with its own copy of vite's
|
|
6
8
|
// `Plugin` type, so it's structurally identical but nominally unrelated to
|
|
7
9
|
// ours — cast to sidestep the resulting "unrelated types" error. Typing this
|
|
@@ -45,6 +47,29 @@ export function createStorybookProject({ name, rootDir, viewport, screenshotsSub
|
|
|
45
47
|
const configDir = path.join(rootDir, '.storybook');
|
|
46
48
|
const screenshotsDir = path.join(rootDir, '__screenshots__', screenshotsSubdir);
|
|
47
49
|
const maxWorkers = process.env['CI'] != null ? ciMaxWorkers : undefined;
|
|
50
|
+
const plugins = [
|
|
51
|
+
storycapNetworkIdle,
|
|
52
|
+
storybookTest({
|
|
53
|
+
configDir,
|
|
54
|
+
tags: { exclude: excludeTags },
|
|
55
|
+
}),
|
|
56
|
+
asPlugin(storycap({
|
|
57
|
+
viewport,
|
|
58
|
+
output: { dir: screenshotsDir },
|
|
59
|
+
})),
|
|
60
|
+
storycapFullPageStitch({ viewport }),
|
|
61
|
+
];
|
|
62
|
+
// storycapFullPageStitch's own comment explains why plugin order is what
|
|
63
|
+
// makes it an override of storycap's `__storycap_takeScreenshot` — check
|
|
64
|
+
// that order here (by plugin name, since storycap's own plugin type is
|
|
65
|
+
// cast to `any` above) instead of leaving a reorder to silently resurrect
|
|
66
|
+
// the fullPage tiling bug it fixes.
|
|
67
|
+
const storycapIndex = plugins.findIndex((p) => p.name === 'vitest:screenshot');
|
|
68
|
+
const fullPageStitchIndex = plugins.findIndex((p) => p.name === 'storycap-fullpage-stitch-fix');
|
|
69
|
+
if (fullPageStitchIndex < storycapIndex) {
|
|
70
|
+
// eslint-disable-next-line no-restricted-syntax -- config-load-time invariant check, mirrors storycapNetworkIdle's fail-fast pattern above
|
|
71
|
+
throw new Error('storycapFullPageStitch must be registered after storycap in the plugins array for its __storycap_takeScreenshot override to take effect');
|
|
72
|
+
}
|
|
48
73
|
// storybookTest() below returns a Promise that starts loading a real
|
|
49
74
|
// Storybook config from `configDir` as soon as it's constructed — nothing
|
|
50
75
|
// but Vite's own plugin container ever awaits that Promise, so calling
|
|
@@ -52,26 +77,33 @@ export function createStorybookProject({ name, rootDir, viewport, screenshotsSub
|
|
|
52
77
|
// surfaces it as an unhandled rejection instead of a useful assertion.
|
|
53
78
|
// This branch is covered by running against a real Storybook config instead.
|
|
54
79
|
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
|
-
],
|
|
80
|
+
plugins,
|
|
66
81
|
test: {
|
|
67
82
|
name,
|
|
68
|
-
...(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
|
+
}),
|
|
69
91
|
browser: {
|
|
70
92
|
enabled: true,
|
|
71
93
|
// Pin the browser's timezone so time-dependent stories (calendar
|
|
72
94
|
// "now" indicators, relative timestamps) render identically
|
|
73
95
|
// regardless of the host machine's local timezone.
|
|
74
|
-
|
|
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
|
+
}),
|
|
75
107
|
headless: true,
|
|
76
108
|
instances: [{ browser: 'chromium' }],
|
|
77
109
|
},
|
package/package.json
CHANGED
|
@@ -52,11 +52,12 @@
|
|
|
52
52
|
"neverthrow": "8.2.0"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"@commitlint/cli": "21.2.
|
|
55
|
+
"@commitlint/cli": "21.2.2",
|
|
56
56
|
"@eslint/eslintrc": "3.3.6",
|
|
57
57
|
"@fohte/eslint-config": "0.4.1",
|
|
58
58
|
"@ninoseki/eslint-plugin-neverthrow": "0.2.0",
|
|
59
|
-
"@storybook/addon-vitest": "10.5.
|
|
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
|
-
"storybook": "10.5.
|
|
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
|
}
|