@forgebook/testing 0.1.8 → 0.2.0

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.
Files changed (90) hide show
  1. package/README.md +33 -0
  2. package/dist/bin/forgebook-heights/forgebook-heights.d.mts +0 -1
  3. package/dist/bin/forgebook-heights/forgebook-heights.mjs +0 -1
  4. package/dist/bin/forgebook-heights/measureHeights.d.ts +0 -1
  5. package/dist/bin/forgebook-heights/measureHeights.js +2 -2
  6. package/dist/bin/forgebook-heights/measureHeights.types.d.ts +0 -1
  7. package/dist/bin/forgebook-heights/measureHeights.types.js +0 -1
  8. package/dist/bin/forgebook-shot/forgebook-shot.d.mts +0 -1
  9. package/dist/bin/forgebook-shot/forgebook-shot.mjs +0 -1
  10. package/dist/bin/forgebook-shot/storyShot.d.ts +0 -1
  11. package/dist/bin/forgebook-shot/storyShot.js +2 -2
  12. package/dist/bin/forgebook-shot/storyShot.types.d.ts +0 -1
  13. package/dist/bin/forgebook-shot/storyShot.types.js +0 -1
  14. package/dist/src/browserTests.d.ts +0 -17
  15. package/dist/src/browserTests.js +72 -35
  16. package/dist/src/checkPage.d.ts +16 -0
  17. package/dist/src/checkPage.js +68 -0
  18. package/dist/src/checkStories.d.ts +0 -1
  19. package/dist/src/checkStories.js +18 -11
  20. package/dist/src/conformance.d.ts +46 -0
  21. package/dist/src/conformance.js +146 -0
  22. package/dist/src/deviceShot.d.ts +18 -0
  23. package/dist/src/deviceShot.js +29 -0
  24. package/dist/src/diffStories.d.ts +77 -0
  25. package/dist/src/diffStories.js +251 -0
  26. package/dist/src/overlays.d.ts +0 -1
  27. package/dist/src/overlays.js +0 -1
  28. package/dist/src/playwrightConfig.d.ts +0 -1
  29. package/dist/src/playwrightConfig.js +8 -3
  30. package/dist/src/semanticJudge.d.ts +50 -0
  31. package/dist/src/semanticJudge.js +111 -0
  32. package/dist/src/serverTests.d.ts +0 -1
  33. package/dist/src/serverTests.js +51 -39
  34. package/dist/src/shotStories.d.ts +2 -2
  35. package/dist/src/shotStories.js +31 -6
  36. package/dist/src/storyCheck.d.ts +9 -2
  37. package/dist/src/storyCheck.js +45 -3
  38. package/dist/src/storyControls.d.ts +0 -1
  39. package/dist/src/storyControls.js +0 -1
  40. package/dist/src/storyFiles.d.ts +0 -1
  41. package/dist/src/storyFiles.js +0 -1
  42. package/dist/src/storyPages.d.ts +9 -2
  43. package/dist/src/storyPages.js +19 -5
  44. package/dist/src/storyTestPlan.d.ts +44 -0
  45. package/dist/src/storyTestPlan.js +20 -0
  46. package/dist/src/testViteConfig.d.ts +2 -3
  47. package/dist/src/testViteConfig.js +4 -4
  48. package/dist/src/touchWindow.d.ts +0 -1
  49. package/dist/src/touchWindow.js +0 -1
  50. package/dist/src/visualBrowser.d.ts +0 -1
  51. package/dist/src/visualBrowser.js +0 -1
  52. package/package.json +16 -4
  53. package/dist/bin/forgebook-heights/forgebook-heights.d.mts.map +0 -1
  54. package/dist/bin/forgebook-heights/forgebook-heights.mjs.map +0 -1
  55. package/dist/bin/forgebook-heights/measureHeights.d.ts.map +0 -1
  56. package/dist/bin/forgebook-heights/measureHeights.js.map +0 -1
  57. package/dist/bin/forgebook-heights/measureHeights.types.d.ts.map +0 -1
  58. package/dist/bin/forgebook-heights/measureHeights.types.js.map +0 -1
  59. package/dist/bin/forgebook-shot/forgebook-shot.d.mts.map +0 -1
  60. package/dist/bin/forgebook-shot/forgebook-shot.mjs.map +0 -1
  61. package/dist/bin/forgebook-shot/storyShot.d.ts.map +0 -1
  62. package/dist/bin/forgebook-shot/storyShot.js.map +0 -1
  63. package/dist/bin/forgebook-shot/storyShot.types.d.ts.map +0 -1
  64. package/dist/bin/forgebook-shot/storyShot.types.js.map +0 -1
  65. package/dist/src/browserTests.d.ts.map +0 -1
  66. package/dist/src/browserTests.js.map +0 -1
  67. package/dist/src/checkStories.d.ts.map +0 -1
  68. package/dist/src/checkStories.js.map +0 -1
  69. package/dist/src/overlays.d.ts.map +0 -1
  70. package/dist/src/overlays.js.map +0 -1
  71. package/dist/src/playwrightConfig.d.ts.map +0 -1
  72. package/dist/src/playwrightConfig.js.map +0 -1
  73. package/dist/src/serverTests.d.ts.map +0 -1
  74. package/dist/src/serverTests.js.map +0 -1
  75. package/dist/src/shotStories.d.ts.map +0 -1
  76. package/dist/src/shotStories.js.map +0 -1
  77. package/dist/src/storyCheck.d.ts.map +0 -1
  78. package/dist/src/storyCheck.js.map +0 -1
  79. package/dist/src/storyControls.d.ts.map +0 -1
  80. package/dist/src/storyControls.js.map +0 -1
  81. package/dist/src/storyFiles.d.ts.map +0 -1
  82. package/dist/src/storyFiles.js.map +0 -1
  83. package/dist/src/storyPages.d.ts.map +0 -1
  84. package/dist/src/storyPages.js.map +0 -1
  85. package/dist/src/testViteConfig.d.ts.map +0 -1
  86. package/dist/src/testViteConfig.js.map +0 -1
  87. package/dist/src/touchWindow.d.ts.map +0 -1
  88. package/dist/src/touchWindow.js.map +0 -1
  89. package/dist/src/visualBrowser.d.ts.map +0 -1
  90. package/dist/src/visualBrowser.js.map +0 -1
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The contract every framework adapter passes, as tests: what makes an adapter one, so React is not a special case.
3
+ *
4
+ * @remarks
5
+ * An adapter's package calls `describeAdapter` in its own tests with a small subject of its framework: a story that shows
6
+ * a greeting and counts its clicks, and throws for one name. The suite then says what the frame relies on (`renderer.ts`,
7
+ * `adapter.ts`): a story is drawn in its canvas inside its decorators, new args keep its state, it goes away, its errors
8
+ * reach the frame (those of the args shown only), and it can be drawn on a server, at once or in time. It runs in the adapter's own Vitest, with a simulated page.
9
+ * A framework that compiles a component apart for a page and for a server (Svelte) runs it twice, each run checking its
10
+ * half: in a simulated page, and in Node, where its modules are compiled for the server.
11
+ *
12
+ * @module
13
+ */
14
+ import { isComponentDocs } from "forgebook/component-docs";
15
+ import { afterEach, describe, expect, it } from "vitest";
16
+ /** The name a subject's story throws for. */
17
+ export const THROWING_NAME = "boom";
18
+ /**
19
+ * How long the docs case may take, in milliseconds: it builds a TypeScript program from nothing, which takes seconds on a
20
+ * runner where the other packages' tests build theirs at the same time.
21
+ */
22
+ const DOCS_TIMEOUT = 60_000;
23
+ /** The canvases of the running test, emptied after it. */
24
+ const canvases = [];
25
+ /** A canvas in the page, as the frame gives a story one. */
26
+ const canvas = () => {
27
+ const element = document.createElement("div");
28
+ document.body.append(element);
29
+ canvases.push(element);
30
+ return element;
31
+ };
32
+ /** Waits until a condition holds, for a framework that draws on its own schedule. */
33
+ const settled = async (holds) => {
34
+ await expect.poll(holds, { timeout: 2000 }).toBe(true);
35
+ };
36
+ /**
37
+ * A server render as a promise: a framework may throw at once or reject, and the frame's tests treat both as a failure.
38
+ *
39
+ * @param renderer - the framework's renderer.
40
+ * @param story - the drawing.
41
+ */
42
+ const asyncRender = async (renderer, story) => renderer.renderToString(story);
43
+ /**
44
+ * The suite of an adapter.
45
+ *
46
+ * @param subject - the adapter's subject.
47
+ */
48
+ export const describeAdapter = (subject) => {
49
+ const { name, renderer, drawing, decorator, codeOf, docsOf, half } = subject;
50
+ const inPage = half !== "server";
51
+ const onServer = half !== "page";
52
+ /** The story inside a decorator of the catalogue's and one of its own. */
53
+ const decorated = () => {
54
+ const plain = drawing("Ada");
55
+ return {
56
+ ...plain,
57
+ story: { ...plain.story, decorators: [decorator("story")] },
58
+ decorators: [decorator("catalogue")]
59
+ };
60
+ };
61
+ describe(`the ${name} adapter`, () => {
62
+ afterEach(() => {
63
+ for (const each of canvases.splice(0))
64
+ each.remove();
65
+ });
66
+ it.runIf(inPage)("draws a story in its canvas, and says when it is drawn", async () => {
67
+ const element = canvas();
68
+ const story = renderer.mount(element, drawing("Ada"), () => undefined);
69
+ await story.drawn;
70
+ expect(element.textContent).toContain("Hello Ada");
71
+ story.unmount();
72
+ });
73
+ it.runIf(inPage)("draws new args again, keeping the story's own state", async () => {
74
+ const element = canvas();
75
+ const story = renderer.mount(element, drawing("Ada"), () => undefined);
76
+ await story.drawn;
77
+ element.querySelector("button")?.click();
78
+ await settled(() => element.textContent?.includes("1") === true);
79
+ story.update(drawing("Grace"));
80
+ await settled(() => element.textContent?.includes("Hello Grace 1") === true);
81
+ story.unmount();
82
+ });
83
+ it.runIf(inPage)("draws a story inside its decorators, the catalogue's outside the story's", async () => {
84
+ const element = canvas();
85
+ const story = renderer.mount(element, decorated(), () => undefined);
86
+ await story.drawn;
87
+ const inner = element.querySelector('[data-decorator="catalogue"] [data-decorator="story"] button');
88
+ expect(inner?.textContent).toContain("Hello Ada");
89
+ story.unmount();
90
+ });
91
+ it.runIf(onServer)("draws a story inside its decorators on a server", async () => {
92
+ const html = await renderer.renderToString(decorated());
93
+ expect(html).toMatch(/data-decorator="catalogue"[\s\S]*data-decorator="story"[\s\S]*Ada/);
94
+ });
95
+ it.runIf(inPage)("takes a story away from its canvas", async () => {
96
+ const element = canvas();
97
+ const story = renderer.mount(element, drawing("Ada"), () => undefined);
98
+ await story.drawn;
99
+ story.unmount();
100
+ await settled(() => element.textContent === "");
101
+ });
102
+ it.runIf(inPage)("hands the frame an error thrown while drawing, and draws again when the args change", async () => {
103
+ const errors = [];
104
+ const element = canvas();
105
+ const story = renderer.mount(element, drawing(THROWING_NAME), (error) => errors.push(error));
106
+ await settled(() => errors.length > 0);
107
+ let isDrawn = false;
108
+ void story.drawn.then(() => {
109
+ isDrawn = true;
110
+ });
111
+ story.update(drawing("Ada"));
112
+ await settled(() => element.textContent?.includes("Hello Ada") === true);
113
+ // the frame waits for this before it runs the play function: a story drawn at last is drawn
114
+ await settled(() => isDrawn);
115
+ story.unmount();
116
+ });
117
+ it.runIf(inPage)("says nothing of a drawing the args replaced, even one still being drawn when they changed", async () => {
118
+ const errors = [];
119
+ const element = canvas();
120
+ const story = renderer.mount(element, drawing(THROWING_NAME), (error) => errors.push(error));
121
+ await settled(() => errors.length > 0);
122
+ // the frame draws the story again with the same args (the events, the language), then the args change back
123
+ // while that drawing is under way: its error, whenever it comes, is of args no longer shown
124
+ story.update(drawing(THROWING_NAME));
125
+ await Promise.resolve();
126
+ const before = errors.length;
127
+ story.update(drawing("Grace"));
128
+ await settled(() => element.textContent?.includes("Hello Grace") === true);
129
+ await new Promise((resolve) => setTimeout(resolve, 100));
130
+ expect(errors.slice(before)).toEqual([]);
131
+ story.unmount();
132
+ });
133
+ it.runIf(onServer)("draws a story as HTML on a server, and fails with what the story throws", async () => {
134
+ expect(await renderer.renderToString(drawing("Ada"))).toContain("Ada");
135
+ await expect(asyncRender(renderer, drawing(THROWING_NAME))).rejects.toThrow();
136
+ });
137
+ it.runIf(inPage)("writes a story's args as the framework's code", () => {
138
+ const code = codeOf("Greeting", { name: "Ada" });
139
+ expect(code).toContain("Greeting");
140
+ expect(code).toContain("Ada");
141
+ });
142
+ it.runIf(inPage && docsOf !== undefined)("reads a component's docs in the catalogue's shape", () => {
143
+ expect(isComponentDocs(docsOf?.())).toBe(true);
144
+ }, DOCS_TIMEOUT);
145
+ });
146
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * A story's picture framed in the device its view names, as `forgebook shot` asks for it with a model in the view
3
+ * (`--view iphone-16`): the story pictured at the device's viewport, then drawn in the device (`forgebook/device-picture`).
4
+ *
5
+ * @module
6
+ */
7
+ import type { Page } from "@playwright/test";
8
+ import { type NamedDevice } from "forgebook/device-picture";
9
+ /**
10
+ * Pictures the story a page shows in the device, framed: the page must show it at the device's story area.
11
+ *
12
+ * @param page - the page, the story opened at its area's size (`deviceViewport`).
13
+ * @param device - the device.
14
+ * @param isEdgeToEdge - whether the story draws edge to edge.
15
+ * @param scheme - the story's scheme.
16
+ * @param file - where the picture goes.
17
+ */
18
+ export declare const framedShot: (page: Page, device: NamedDevice, isEdgeToEdge: boolean, scheme: "light" | "dark", file: string) => Promise<void>;
@@ -0,0 +1,29 @@
1
+ import { framedPicture } from "forgebook/device-picture";
2
+ /**
3
+ * Pictures the story a page shows in the device, framed: the page must show it at the device's story area.
4
+ *
5
+ * @param page - the page, the story opened at its area's size (`deviceViewport`).
6
+ * @param device - the device.
7
+ * @param isEdgeToEdge - whether the story draws edge to edge.
8
+ * @param scheme - the story's scheme.
9
+ * @param file - where the picture goes.
10
+ */
11
+ export const framedShot = async (page, device, isEdgeToEdge, scheme, file) => {
12
+ const picture = await page.screenshot({ animations: "disabled" });
13
+ // the screen around a story inside the safe areas takes its page's colour, as the shell's frame does
14
+ const background = await page.evaluate(() => {
15
+ const colour = (element) => getComputedStyle(element).backgroundColor;
16
+ const body = colour(document.body);
17
+ return body === "rgba(0, 0, 0, 0)" ? colour(document.documentElement) : body;
18
+ });
19
+ const framed = framedPicture({
20
+ shown: device,
21
+ isEdgeToEdge,
22
+ picture: picture.toString("base64"),
23
+ scheme,
24
+ background
25
+ });
26
+ await page.setViewportSize({ width: framed.width, height: framed.height });
27
+ await page.setContent(framed.html);
28
+ await page.screenshot({ path: file, omitBackground: true, animations: "disabled" });
29
+ };
@@ -0,0 +1,77 @@
1
+ import { type Page } from "@playwright/test";
2
+ import type { StoryDiffReport, StoryDiffRequest } from "forgebook/story-check";
3
+ /** How far a colour channel may move and stay the same, out of 255. */
4
+ export declare const TOLERANCE = 2;
5
+ /** What comparing two pictures says. */
6
+ export type PictureDifference = {
7
+ /** Their sizes differ, so no pixel can be compared. */
8
+ readonly isSizeChanged: boolean;
9
+ /** The pixels that changed. */
10
+ readonly changedPixels: number;
11
+ /** Their share of the picture, 0 to 100. */
12
+ readonly percent: number;
13
+ /** The smallest box that holds them; none when nothing changed. */
14
+ readonly region: {
15
+ x: number;
16
+ y: number;
17
+ width: number;
18
+ height: number;
19
+ } | undefined;
20
+ /** The picture with what changed marked, a PNG as a data URL; none when nothing can be marked. */
21
+ readonly marked: string | undefined;
22
+ };
23
+ /** A picture's pixels, four bytes each: red, green, blue, alpha. */
24
+ export type Pixels = {
25
+ /** Its width, in pixels. */
26
+ readonly width: number;
27
+ /** Its height, in pixels. */
28
+ readonly height: number;
29
+ /** Its bytes. */
30
+ readonly bytes: Uint8Array;
31
+ };
32
+ /**
33
+ * Compares two pictures' pixels.
34
+ *
35
+ * @param before - the stored picture.
36
+ * @param after - the new one.
37
+ * @param tolerance - how far a channel may move.
38
+ * @returns what changed, and the new picture's pixels with those marked.
39
+ */
40
+ export declare const comparePixels: (before: Pixels, after: Pixels, tolerance?: number) => Omit<PictureDifference, "marked"> & {
41
+ readonly marks: Uint8Array;
42
+ };
43
+ /**
44
+ * Compares two pictures, with the browser reading and writing the PNGs.
45
+ *
46
+ * @param page - any page.
47
+ * @param baseline - the stored picture, as a data URL.
48
+ * @param current - the new one, as a data URL.
49
+ */
50
+ export declare const comparePictures: (page: Page, baseline: string, current: string) => Promise<PictureDifference>;
51
+ /**
52
+ * The baselines of a component with no story left: what would otherwise pass without a word when a story is removed.
53
+ *
54
+ * @param files - the baselines folder's PNG files.
55
+ * @param component - the component's id.
56
+ * @param stories - its stories' ids.
57
+ * @returns the files.
58
+ */
59
+ export declare const removedBaselines: (files: readonly string[], component: string, stories: readonly string[]) => readonly string[];
60
+ /**
61
+ * The stored baseline a picture compares with: the one with its name, else the one named by the story's former id
62
+ * (`StoryCheckStory.formerId`), so the baselines stored before the ids followed Storybook's still count.
63
+ *
64
+ * @param files - the baselines folder's files.
65
+ * @param name - the picture's name.
66
+ * @param formerName - the name the picture had under the story's former id; none when the id did not change.
67
+ * @returns the baseline's name; the picture's own when neither is stored (a new baseline).
68
+ */
69
+ export declare const storedBaseline: (files: readonly string[], name: string, formerName: string | undefined) => string;
70
+ /**
71
+ * Compares the stories a request names with their baselines.
72
+ *
73
+ * @param request - the stories, the views, the baselines' folder and where the pictures go.
74
+ * @returns one result per story and view, and one per baseline left by a removed story.
75
+ * @throws when the pictures would go in the baselines' folder, and overwrite what they prove.
76
+ */
77
+ export declare const diffStories: (request: StoryDiffRequest) => Promise<StoryDiffReport>;
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Stories compared with their baselines, as `forgebook diff` asks: each picture taken again and read against the one
3
+ * `forgebook shot --update` stored.
4
+ *
5
+ * @remarks
6
+ * The pixels are compared in the browser the pictures come from, on a canvas: it is already there, and decoding PNG in
7
+ * Node would be a dependency for one comparison. A baseline only compares with pictures of the same rendering, so one
8
+ * taken on another system reads as changed or resized: take them where they are compared. A pixel counts as changed when a channel moves by more than `TOLERANCE`, which holds off rounding and no more; the answer
9
+ * gives the region and the percentage, and the decision about what matters stays with whoever reads it.
10
+ *
11
+ * @module
12
+ */
13
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { basename, join, resolve } from "node:path";
16
+ import { chromium } from "@playwright/test";
17
+ import { shotName, shotStories } from "./shotStories.js";
18
+ /** How far a colour channel may move and stay the same, out of 255. */
19
+ export const TOLERANCE = 2;
20
+ /** The colour that marks a changed pixel. */
21
+ const MARK = [255, 0, 80, 255];
22
+ /** How many bytes go to `String.fromCharCode` at once: a whole picture overflows the call's arguments. */
23
+ const CHUNK = 0x8000;
24
+ /** How many rows cross to the browser at once. */
25
+ const BAND_ROWS = 2000;
26
+ /** A PNG's bytes as a data URL. */
27
+ const dataUrl = (file) => `data:image/png;base64,${readFileSync(file).toString("base64")}`;
28
+ /** Whether a pixel moved by more than the tolerance in any channel. */
29
+ const hasMoved = (first, second, index, tolerance) => {
30
+ for (let channel = 0; channel < 4; channel += 1) {
31
+ if (Math.abs((first[index + channel] ?? 0) - (second[index + channel] ?? 0)) > tolerance)
32
+ return true;
33
+ }
34
+ return false;
35
+ };
36
+ /**
37
+ * Compares two pictures' pixels.
38
+ *
39
+ * @param before - the stored picture.
40
+ * @param after - the new one.
41
+ * @param tolerance - how far a channel may move.
42
+ * @returns what changed, and the new picture's pixels with those marked.
43
+ */
44
+ export const comparePixels = (before, after, tolerance = TOLERANCE) => {
45
+ const { width, height } = after;
46
+ const marks = Uint8Array.from(after.bytes);
47
+ if (before.width !== width || before.height !== height) {
48
+ return { isSizeChanged: true, changedPixels: 0, percent: 100, region: undefined, marks };
49
+ }
50
+ let changedPixels = 0;
51
+ let left = width;
52
+ let top = height;
53
+ let right = -1;
54
+ let bottom = -1;
55
+ for (let y = 0; y < height; y += 1) {
56
+ for (let x = 0; x < width; x += 1) {
57
+ const index = (y * width + x) * 4;
58
+ if (hasMoved(before.bytes, after.bytes, index, tolerance)) {
59
+ changedPixels += 1;
60
+ left = Math.min(left, x);
61
+ top = Math.min(top, y);
62
+ right = Math.max(right, x);
63
+ bottom = Math.max(bottom, y);
64
+ marks.set(MARK, index);
65
+ }
66
+ }
67
+ }
68
+ const region = changedPixels === 0 ? undefined : { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
69
+ return {
70
+ isSizeChanged: false,
71
+ changedPixels,
72
+ percent: (changedPixels / (width * height)) * 100,
73
+ region,
74
+ marks
75
+ };
76
+ };
77
+ /** A PNG's pixels, decoded by the browser and carried over in bands: one message cannot hold a tall picture. */
78
+ const pixelsOf = async (page, source) => {
79
+ const { width, height } = await page.evaluate(async (url) => {
80
+ const bitmap = await createImageBitmap(await (await fetch(url)).blob());
81
+ const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
82
+ canvas.getContext("2d", { willReadFrequently: true })?.drawImage(bitmap, 0, 0);
83
+ Object.assign(globalThis, { forgebookCanvas: canvas });
84
+ return { width: bitmap.width, height: bitmap.height };
85
+ }, source);
86
+ const bands = [];
87
+ for (let top = 0; top < height; top += BAND_ROWS) {
88
+ const rows = Math.min(BAND_ROWS, height - top);
89
+ const band = await page.evaluate(({ y, size, across, chunk }) => {
90
+ const canvas = Reflect.get(globalThis, "forgebookCanvas");
91
+ if (!(canvas instanceof OffscreenCanvas))
92
+ throw new Error("No picture to read");
93
+ const pixels = canvas.getContext("2d")?.getImageData(0, y, across, size).data;
94
+ if (pixels === undefined)
95
+ throw new Error("No canvas to compare on");
96
+ let text = "";
97
+ for (let start = 0; start < pixels.length; start += chunk) {
98
+ text += String.fromCharCode(...pixels.subarray(start, start + chunk));
99
+ }
100
+ return btoa(text);
101
+ }, { y: top, size: rows, across: width, chunk: CHUNK });
102
+ bands.push(Buffer.from(band, "base64"));
103
+ }
104
+ return { width, height, bytes: Buffer.concat(bands) };
105
+ };
106
+ /** Pixels as a PNG data URL, encoded by the browser, carried over in bands as they were read. */
107
+ const pngOf = async (page, pixels) => {
108
+ const { width, height, bytes } = pixels;
109
+ await page.evaluate(({ across, down }) => {
110
+ Object.assign(globalThis, { forgebookCanvas: new OffscreenCanvas(across, down) });
111
+ }, { across: width, down: height });
112
+ for (let top = 0; top < height; top += BAND_ROWS) {
113
+ const rows = Math.min(BAND_ROWS, height - top);
114
+ const band = bytes.subarray(top * width * 4, (top + rows) * width * 4);
115
+ await page.evaluate(({ y, across, size, data }) => {
116
+ const canvas = Reflect.get(globalThis, "forgebookCanvas");
117
+ if (!(canvas instanceof OffscreenCanvas))
118
+ throw new Error("No picture to write");
119
+ const part = Uint8ClampedArray.from(atob(data), (letter) => letter.charCodeAt(0));
120
+ canvas.getContext("2d")?.putImageData(new ImageData(part, across, size), 0, y);
121
+ }, { y: top, across: width, size: rows, data: Buffer.from(band).toString("base64") });
122
+ }
123
+ return page.evaluate(async (chunk) => {
124
+ const canvas = Reflect.get(globalThis, "forgebookCanvas");
125
+ if (!(canvas instanceof OffscreenCanvas))
126
+ throw new Error("No picture to write");
127
+ const png = new Uint8Array(await (await canvas.convertToBlob({ type: "image/png" })).arrayBuffer());
128
+ let text = "";
129
+ for (let start = 0; start < png.length; start += chunk) {
130
+ text += String.fromCharCode(...png.subarray(start, start + chunk));
131
+ }
132
+ return `data:image/png;base64,${btoa(text)}`;
133
+ }, CHUNK);
134
+ };
135
+ /**
136
+ * Compares two pictures, with the browser reading and writing the PNGs.
137
+ *
138
+ * @param page - any page.
139
+ * @param baseline - the stored picture, as a data URL.
140
+ * @param current - the new one, as a data URL.
141
+ */
142
+ export const comparePictures = async (page, baseline, current) => {
143
+ const after = await pixelsOf(page, current);
144
+ const { marks, ...difference } = comparePixels(await pixelsOf(page, baseline), after);
145
+ const marked = difference.changedPixels === 0 ? undefined : await pngOf(page, { ...after, bytes: marks });
146
+ return { ...difference, marked };
147
+ };
148
+ /**
149
+ * The baselines of a component with no story left: what would otherwise pass without a word when a story is removed.
150
+ *
151
+ * @param files - the baselines folder's PNG files.
152
+ * @param component - the component's id.
153
+ * @param stories - its stories' ids.
154
+ * @returns the files.
155
+ */
156
+ export const removedBaselines = (files, component, stories) => {
157
+ const prefixOf = (id) => `${shotName({ story: { id }, name: "" }).replace(/default\.png$/, "")}`;
158
+ const owned = prefixOf(component);
159
+ const kept = stories.map(prefixOf);
160
+ return files.filter((file) => file.startsWith(owned) && !file.endsWith(".diff.png") && !kept.some((prefix) => file.startsWith(prefix)));
161
+ };
162
+ /**
163
+ * The stored baseline a picture compares with: the one with its name, else the one named by the story's former id
164
+ * (`StoryCheckStory.formerId`), so the baselines stored before the ids followed Storybook's still count.
165
+ *
166
+ * @param files - the baselines folder's files.
167
+ * @param name - the picture's name.
168
+ * @param formerName - the name the picture had under the story's former id; none when the id did not change.
169
+ * @returns the baseline's name; the picture's own when neither is stored (a new baseline).
170
+ */
171
+ export const storedBaseline = (files, name, formerName) => !files.includes(name) && formerName !== undefined && files.includes(formerName) ? formerName : name;
172
+ /** One picture's result. Read before anything is written: the pictures must not touch what they are compared with. */
173
+ const diffOf = async (page, shot, folders) => {
174
+ const name = basename(shot.file);
175
+ const baseline = join(folders.baselines, folders.baselineName);
176
+ const current = join(folders.out, name);
177
+ copyFileSync(shot.file, current);
178
+ const base = { storyId: shot.storyId, view: shot.view, baseline, current };
179
+ if (shot.status !== undefined)
180
+ return { ...base, status: "broken", changedPixels: 0, percent: 100 };
181
+ if (!existsSync(baseline))
182
+ return { ...base, status: "new", changedPixels: 0, percent: 0 };
183
+ // most stories do not change, and equal files need no decoding
184
+ if (readFileSync(baseline).equals(readFileSync(shot.file))) {
185
+ return { ...base, status: "same", changedPixels: 0, percent: 0 };
186
+ }
187
+ const difference = await comparePictures(page, dataUrl(baseline), dataUrl(shot.file));
188
+ if (difference.isSizeChanged)
189
+ return { ...base, status: "resized", changedPixels: 0, percent: 100 };
190
+ if (difference.changedPixels === 0)
191
+ return { ...base, status: "same", changedPixels: 0, percent: 0 };
192
+ const diff = join(folders.out, name.replace(/\.png$/, ".diff.png"));
193
+ const [, data] = (difference.marked ?? "").split(",");
194
+ writeFileSync(diff, Buffer.from(data ?? "", "base64"));
195
+ return {
196
+ ...base,
197
+ status: "changed",
198
+ changedPixels: difference.changedPixels,
199
+ percent: difference.percent,
200
+ ...(difference.region === undefined ? {} : { region: difference.region }),
201
+ diff
202
+ };
203
+ };
204
+ /**
205
+ * Compares the stories a request names with their baselines.
206
+ *
207
+ * @param request - the stories, the views, the baselines' folder and where the pictures go.
208
+ * @returns one result per story and view, and one per baseline left by a removed story.
209
+ * @throws when the pictures would go in the baselines' folder, and overwrite what they prove.
210
+ */
211
+ export const diffStories = async (request) => {
212
+ if (resolve(request.outDir) === resolve(request.baselines)) {
213
+ throw new Error("The pictures cannot go in the baselines' folder: give --out another one");
214
+ }
215
+ const taken = mkdtempSync(join(tmpdir(), "forgebook-diff-"));
216
+ mkdirSync(request.outDir, { recursive: true });
217
+ const browser = await chromium.launch();
218
+ try {
219
+ const { shots } = await shotStories({ ...request, outDir: taken });
220
+ const page = await browser.newPage();
221
+ const results = [];
222
+ const stored = existsSync(request.baselines) ? readdirSync(request.baselines) : [];
223
+ const formerIds = new Map(request.stories.map((story) => [story.id, story.formerId]));
224
+ for (const shot of shots) {
225
+ const formerId = formerIds.get(shot.storyId);
226
+ const formerName = formerId === undefined ? undefined : shotName({ story: { id: formerId }, name: shot.view });
227
+ const baselineName = storedBaseline(stored, basename(shot.file), formerName);
228
+ results.push(await diffOf(page, shot, { baselines: request.baselines, out: request.outDir, baselineName }));
229
+ }
230
+ // a story's baseline may still carry its former id (`storedBaseline`): it is kept, not removed
231
+ const stories = request.stories.flatMap((story) => story.formerId === undefined ? [story.id] : [story.id, story.formerId]);
232
+ const owners = [request.component, request.formerComponent].flatMap((owner) => owner === undefined ? [] : [owner]);
233
+ const removed = [...new Set(owners.flatMap((owner) => removedBaselines(stored, owner, stories)))];
234
+ for (const file of removed) {
235
+ results.push({
236
+ storyId: file.replace(/\.png$/, ""),
237
+ view: "",
238
+ status: "removed",
239
+ changedPixels: 0,
240
+ percent: 100,
241
+ baseline: join(request.baselines, file),
242
+ current: ""
243
+ });
244
+ }
245
+ return { schema: 1, results };
246
+ }
247
+ finally {
248
+ await browser.close();
249
+ rmSync(taken, { recursive: true, force: true });
250
+ }
251
+ };
@@ -84,4 +84,3 @@ export declare const isOverlayChecked: (view: Partial<ViewState>) => boolean;
84
84
  * fixed element ends there.
85
85
  */
86
86
  export declare const findOverlaysPastFrame: (page: Page, sceneHeight?: number) => Promise<OverlayPastFrame[]>;
87
- //# sourceMappingURL=overlays.d.ts.map
@@ -100,4 +100,3 @@ export const findOverlaysPastFrame = async (page, sceneHeight = 0) => {
100
100
  await page.evaluate(twoFrames);
101
101
  return overlaysPastFrame(layout, grown, WINDOW_GROWTH);
102
102
  };
103
- //# sourceMappingURL=overlays.js.map
@@ -35,4 +35,3 @@ export declare const storyTestConfig: (server: StoryTestServer) => PlaywrightTes
35
35
  * @param server - the package's folder and ports.
36
36
  */
37
37
  export declare const visualTestConfig: (server: StoryTestServer) => PlaywrightTestConfig;
38
- //# sourceMappingURL=playwrightConfig.d.ts.map
@@ -31,9 +31,15 @@ export const frameServerCommand = (userAgent) => {
31
31
  };
32
32
  /** The workers to use. */
33
33
  const workersOf = ({ workers }) => workers !== undefined && workers > 0 ? workers : availableParallelism();
34
- /** The test instance of the frame page, started by Playwright and stopped after the run. */
34
+ /**
35
+ * The test instance of the frame page, started by Playwright and stopped after the run.
36
+ *
37
+ * @remarks
38
+ * The command runs in a shell (`sh`, or `cmd.exe` on Windows), so the config's path is in double quotes, which both
39
+ * read: a folder with a space (`C:\Users\Ada Lovelace`) would split it into two arguments.
40
+ */
35
41
  const webServer = ({ dir, port, vite }) => ({
36
- command: `${vite ?? frameServerCommand(process.env["npm_config_user_agent"])} --config ${dir}/vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`,
42
+ command: `${vite ?? frameServerCommand(process.env["npm_config_user_agent"])} --config "${dir}/vite.config.ts" --host 127.0.0.1 --port ${port} --strictPort`,
37
43
  url: `http://127.0.0.1:${port}/frame.html`,
38
44
  reuseExistingServer: false
39
45
  });
@@ -76,4 +82,3 @@ export const visualTestConfig = (server) => {
76
82
  }
77
83
  };
78
84
  };
79
- //# sourceMappingURL=playwrightConfig.js.map
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A model's judgement of what axe cannot see, as `forgebook check --semantic` asks for it: whether a heading says what its
3
+ * section holds, an image's text says what it shows, a label says what its control does.
4
+ *
5
+ * @remarks
6
+ * Off unless asked, and it sends the story's accessibility tree and picture to the address the user configured, which may
7
+ * be a third party: no model is bundled and no key is stored. The endpoint speaks the OpenAI chat format, the one most
8
+ * providers and local servers share. What comes back is a judgement, not a fact, so it is reported apart from the
9
+ * problems, and it does not fail a check.
10
+ *
11
+ * @module
12
+ */
13
+ import type { Page } from "@playwright/test";
14
+ import type { SemanticModel, StoryJudgement } from "forgebook/story-check";
15
+ /** What a story gives the model. */
16
+ export type StorySight = {
17
+ /** Its accessibility tree, as text. */
18
+ readonly tree: string;
19
+ /** Its picture, a PNG as base64. */
20
+ readonly picture: string;
21
+ };
22
+ /**
23
+ * The request a model is sent.
24
+ *
25
+ * @param model - the model's name.
26
+ * @param sight - what the story gives it.
27
+ * @returns the body, in the OpenAI chat format.
28
+ */
29
+ export declare const judgementRequest: (model: string, sight: StorySight) => unknown;
30
+ /**
31
+ * The judgements in a model's answer.
32
+ *
33
+ * @param body - the answer's body.
34
+ * @returns the findings, or why the answer cannot be read: a model that answers nothing usable must not read as a pass.
35
+ */
36
+ export declare const judgementsOf: (body: unknown) => readonly StoryJudgement[] | string;
37
+ /**
38
+ * Asks a model about a story.
39
+ *
40
+ * @param model - the endpoint, the model's name and its key.
41
+ * @param sight - what the story gives it.
42
+ * @returns the findings, or why there are none to give.
43
+ */
44
+ export declare const askModel: (model: SemanticModel, sight: StorySight) => Promise<readonly StoryJudgement[] | string>;
45
+ /**
46
+ * What a page gives the model.
47
+ *
48
+ * @param page - a page with its story drawn.
49
+ */
50
+ export declare const storySight: (page: Page) => Promise<StorySight>;