@forgebook/testing 0.1.9 → 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 (92) hide show
  1. package/README.md +25 -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 -19
  15. package/dist/src/browserTests.js +21 -4
  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 +9 -5
  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 +12 -4
  44. package/dist/src/storyTestPlan.d.ts +0 -1
  45. package/dist/src/storyTestPlan.js +0 -1
  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/storyTestPlan.d.ts.map +0 -1
  86. package/dist/src/storyTestPlan.js.map +0 -1
  87. package/dist/src/testViteConfig.d.ts.map +0 -1
  88. package/dist/src/testViteConfig.js.map +0 -1
  89. package/dist/src/touchWindow.d.ts.map +0 -1
  90. package/dist/src/touchWindow.js.map +0 -1
  91. package/dist/src/visualBrowser.d.ts.map +0 -1
  92. package/dist/src/visualBrowser.js.map +0 -1
package/README.md CHANGED
@@ -107,6 +107,9 @@ import { storyTestViteConfig } from "@forgebook/testing/vite";
107
107
  export default storyTestViteConfig(import.meta.dirname, react());
108
108
  ```
109
109
 
110
+ The config holds the framework's own Vite plugins (React's, or Solid's that compiles the stories' JSX), as the catalogue
111
+ does: don't add them again.
112
+
110
113
  Then `npx playwright install chromium` once, and `playwright test -c test/stories/playwright.config.ts` starts the
111
114
  frame page and runs one test per story and variant (light, dark, both with more contrast, a narrow width, and the
112
115
  structure view), starting Vite through the package manager that runs them (`vite` in `testServer.ts` names
@@ -166,3 +169,25 @@ translation catalogue), its HTML checked.
166
169
  | `forgebook-shot` | Opens one story of a running catalogue and reports it with a picture |
167
170
 
168
171
  More: [Forgebook's README](https://github.com/ensiria/forgebook/blob/develop/packages/forgebook/README.md).
172
+
173
+ ## Writing a framework adapter
174
+
175
+ A framework is an adapter package: a renderer, a code writer and a docs reader. `@forgebook/testing/conformance` is what
176
+ every adapter passes, in its own Vitest with a simulated page, so a framework is done when this is green:
177
+
178
+ ```ts
179
+ import { describeAdapter } from "@forgebook/testing/conformance";
180
+
181
+ describeAdapter({ name: "Solid", renderer, drawing, decorator, codeOf, docsOf });
182
+ ```
183
+
184
+ `drawing(name)` gives a story with a button showing `Hello <name> <clicks>`, its clicks in its own state, which throws
185
+ for `THROWING_NAME`. `decorator(label)` draws a story inside a `section` with that `data-decorator`. A renderer's
186
+ `renderToString` may return a promise. The frame takes only the drawing half, a `FrameRenderer` of `forgebook/renderer`:
187
+ a framework's frame page imports a module without the server render, so the server renderer stays out of a static
188
+ catalogue and of a first start's pre-bundle (`reactFrameRenderer` in `@forgebook/react`). A framework whose page and
189
+ server builds differ runs the suite twice, with `half: "page"` in a simulated page and `half: "server"` in Node: Svelte
190
+ compiles a component apart for each, and Solid's `solid-js/web` is its browser build or its server build, never both.
191
+ `test/conformance.test.tsx` in `@forgebook/react` and `@forgebook/preact`, `test/conformance.test.ts` in
192
+ `@forgebook/vue`, `@forgebook/web-components`, `@forgebook/html` and `@forgebook/angular`, and the two `conformance`
193
+ tests of `@forgebook/svelte` and of `@forgebook/solid` are complete examples.
@@ -1,3 +1,2 @@
1
1
  #!/usr/bin/env node
2
2
  export {};
3
- //# sourceMappingURL=forgebook-heights.d.mts.map
@@ -121,4 +121,3 @@ await defineScript({
121
121
  }
122
122
  }
123
123
  }).run();
124
- //# sourceMappingURL=forgebook-heights.mjs.map
@@ -81,4 +81,3 @@ export type MeasureResult = {
81
81
  */
82
82
  export declare const measureStories: (request: MeasureRequest) => Promise<MeasureResult>;
83
83
  export {};
84
- //# sourceMappingURL=measureHeights.d.ts.map
@@ -12,6 +12,7 @@
12
12
  * @module
13
13
  */
14
14
  import { chromium } from "@playwright/test";
15
+ import { codeLiteral } from "forgebook/code-literal";
15
16
  import { STATUS_ATTRIBUTE } from "forgebook/frame-status";
16
17
  import { STORY_IDS_ELEMENT } from "forgebook/story-ids-element";
17
18
  import { frameAddressOf } from "forgebook/view-state";
@@ -115,7 +116,7 @@ const measureStory = async (page, address, storyId, widths, timeout) => {
115
116
  await page.setViewportSize({ width: first, height: VIEWPORT_HEIGHT });
116
117
  await page.goto(new URL(frameAddressOf("frame.html", storyId, ""), address).href);
117
118
  const status = await page
118
- .waitForFunction(`document.documentElement.getAttribute(${JSON.stringify(STATUS_ATTRIBUTE)})`, undefined, {
119
+ .waitForFunction(`document.documentElement.getAttribute(${codeLiteral(STATUS_ATTRIBUTE)})`, undefined, {
119
120
  timeout
120
121
  })
121
122
  .then(async (handle) => String(await handle.jsonValue()))
@@ -177,4 +178,3 @@ export const measureStories = async (request) => {
177
178
  await browser.close();
178
179
  }
179
180
  };
180
- //# sourceMappingURL=measureHeights.js.map
@@ -24,4 +24,3 @@ export type HeightsReport = {
24
24
  /** The ones that failed, left out of the file. */
25
25
  readonly failures: readonly HeightFailure[];
26
26
  };
27
- //# sourceMappingURL=measureHeights.types.d.ts.map
@@ -4,4 +4,3 @@
4
4
  *
5
5
  * @module
6
6
  */
7
- //# sourceMappingURL=measureHeights.types.js.map
@@ -1,3 +1,2 @@
1
1
  #!/usr/bin/env node
2
2
  export {};
3
- //# sourceMappingURL=forgebook-shot.d.mts.map
@@ -107,4 +107,3 @@ await defineScript({
107
107
  return shot;
108
108
  }
109
109
  }).run();
110
- //# sourceMappingURL=forgebook-shot.mjs.map
@@ -31,4 +31,3 @@ export declare const shotAddress: (catalogue: string, story: string, view: strin
31
31
  * @returns the report; a frame that never answers is `timeout`, not an exception.
32
32
  */
33
33
  export declare const takeShot: (request: ShotRequest) => Promise<StoryShot>;
34
- //# sourceMappingURL=storyShot.d.ts.map
@@ -11,6 +11,7 @@
11
11
  import { AxeBuilder } from "@axe-core/playwright";
12
12
  import { chromium } from "@playwright/test";
13
13
  import { DEFAULT_A11Y_TAGS } from "forgebook/catalogue";
14
+ import { codeLiteral } from "forgebook/code-literal";
14
15
  import { STATUS_ATTRIBUTE } from "forgebook/frame-status";
15
16
  import { frameAddressOf } from "forgebook/view-state";
16
17
  /**
@@ -44,7 +45,7 @@ export const takeShot = async (request) => {
44
45
  await page.goto(address);
45
46
  // a text expression: the command runs on Node, where the page's own globals are not typed
46
47
  const status = await page
47
- .waitForFunction(`document.documentElement.getAttribute(${JSON.stringify(STATUS_ATTRIBUTE)})`, undefined, {
48
+ .waitForFunction(`document.documentElement.getAttribute(${codeLiteral(STATUS_ATTRIBUTE)})`, undefined, {
48
49
  timeout: request.timeout
49
50
  })
50
51
  .then(async (handle) => ((await handle.jsonValue()) === "ready" ? "ready" : "error"))
@@ -80,4 +81,3 @@ export const takeShot = async (request) => {
80
81
  await browser.close();
81
82
  }
82
83
  };
83
- //# sourceMappingURL=storyShot.js.map
@@ -29,4 +29,3 @@ export type StoryShot = {
29
29
  /** The accessibility rules the story breaks, when asked (`--a11y`). */
30
30
  readonly violations?: readonly Violation[];
31
31
  };
32
- //# sourceMappingURL=storyShot.types.d.ts.map
@@ -1 +0,0 @@
1
- //# sourceMappingURL=storyShot.types.js.map
@@ -1,21 +1,3 @@
1
- /**
2
- * One Playwright test per story and variant, on the catalogue's frame pages: the address the catalogue shows is the
3
- * address the tests open.
4
- *
5
- * @remarks
6
- * Each test opens the frame for a story in a variant (the theme, the contrast, the style sheets), waits for the
7
- * frame to say the story is shown (`data-forgebook-status`), its story clock held at its start, and fails on an error in the console or the page, or on
8
- * an axe violation of WCAG 2.2 A and AA, on an element that spills out of its parent (`forgebook/spills`: what overlaps a
9
- * neighbour when the story is narrower than it was drawn for), on an overlay its play leaves open past the frame the
10
- * catalogue would give it (`overlays.ts`: the story then declares `parameters.height`), or on the source's own
11
- * `check`. The variants include a
12
- * narrow frame, as wide as the catalogue's stage beside the props panel. With `screenshot` it compares the frame with its reference image instead,
13
- * for the visual tests, which run in the Playwright container so fonts and rendering are the same everywhere.
14
- * A story about logic (`parameters.logic`) is tested once, in the default view, without the layout checks, and never
15
- * pictured (`storyTestPlan.ts`).
16
- *
17
- * @module
18
- */
19
1
  import { type Page } from "@playwright/test";
20
2
  import { type CatalogueLists } from "forgebook/catalogue";
21
3
  import { type ViewSwitch } from "forgebook/switches";
@@ -70,4 +52,3 @@ export type StoryBrowserTestsOptions = StoryFilesOptions & {
70
52
  * the source's own.
71
53
  */
72
54
  export declare const storyBrowserTests: (options: StoryBrowserTestsOptions) => void;
73
- //# sourceMappingURL=browserTests.d.ts.map
@@ -16,11 +16,12 @@
16
16
  *
17
17
  * @module
18
18
  */
19
+ import { existsSync } from "node:fs";
19
20
  import { expect, test } from "@playwright/test";
20
21
  import { catalogueSettings } from "forgebook/catalogue";
21
22
  import { STATUS_ATTRIBUTE } from "forgebook/frame-status";
22
23
  import { heldClockAddress } from "forgebook/story-clock";
23
- import { storyTree, testedStoryLocations } from "forgebook/story-tree";
24
+ import { formerStoryId, storyTree, testedStoryLocations } from "forgebook/story-tree";
24
25
  import { DEFAULT_SWITCHES, switchWithRole } from "forgebook/switches";
25
26
  import { defaultView, frameAddress } from "forgebook/view-state";
26
27
  import { isOverlayChecked } from "./overlays.js";
@@ -81,6 +82,21 @@ const expectChecksPass = async (page, planned, a11y, check) => {
81
82
  await check?.(page);
82
83
  expect(messages(await overlayProblems(page, isOverlayChecked(planned.variant.view), planned.story.height))).toEqual([]);
83
84
  };
85
+ /**
86
+ * A story's screenshot name in a variant: by its id, or by the id an earlier rule gave it while only a screenshot of
87
+ * that name is stored, so the screenshots taken before the ids followed Storybook's keep comparing.
88
+ *
89
+ * @param id - the story's id.
90
+ * @param formerId - the id the former rule gave it.
91
+ * @param variant - the variant's name.
92
+ */
93
+ const screenshotName = (id, formerId, variant) => {
94
+ const named = (storyId) => `${storyId.replace(":", "--")}--${variant}.png`;
95
+ const isStored = (name) => existsSync(test.info().snapshotPath(name, { kind: "screenshot" }));
96
+ return named(formerId) !== named(id) && !isStored(named(id)) && isStored(named(formerId))
97
+ ? named(formerId)
98
+ : named(id);
99
+ };
84
100
  /**
85
101
  * Declares the tests, one `describe` per component and one test per story and variant.
86
102
  *
@@ -92,7 +108,8 @@ export const storyBrowserTests = (options) => {
92
108
  const variants = options.variants ?? storyVariants(switches);
93
109
  const catalogue = catalogueSettings("", switches, options.lists);
94
110
  const defaults = defaultView(catalogue);
95
- const tree = storyTree([indexedSource(options)]);
111
+ const source = indexedSource(options);
112
+ const tree = storyTree([source]);
96
113
  const groups = Map.groupBy(testedStoryLocations(tree), (location) => location.component.title);
97
114
  for (const [title, locations] of groups) {
98
115
  const planned = plannedStoryTests(locations.map((location) => location.story), variants, screenshot);
@@ -116,7 +133,8 @@ export const storyBrowserTests = (options) => {
116
133
  await page.goto(heldClockAddress(frameAddress(frameUrl, story.id, view, switches)));
117
134
  await expect(page.locator("html")).toHaveAttribute(STATUS_ATTRIBUTE, "ready");
118
135
  if (screenshot) {
119
- await expect(page).toHaveScreenshot(`${story.id.replace(":", "--")}--${variant.name}.png`, {
136
+ const name = screenshotName(story.id, formerStoryId(source.id, title, story.exportName), variant.name);
137
+ await expect(page).toHaveScreenshot(name, {
120
138
  fullPage: true,
121
139
  animations: "disabled"
122
140
  });
@@ -129,4 +147,3 @@ export const storyBrowserTests = (options) => {
129
147
  });
130
148
  }
131
149
  };
132
- //# sourceMappingURL=browserTests.js.map
@@ -0,0 +1,16 @@
1
+ import { type CatalogueLists } from "forgebook/catalogue";
2
+ import type { StoryCheckReport } from "forgebook/story-check";
3
+ /** What a page check is asked. */
4
+ export type PageCheckRequest = {
5
+ /** The pages' addresses. */
6
+ readonly addresses: readonly string[];
7
+ /** The catalogue's lists: its accessibility rules. */
8
+ readonly lists: CatalogueLists;
9
+ };
10
+ /**
11
+ * Checks pages, on one browser.
12
+ *
13
+ * @param request - the addresses and the catalogue's settings.
14
+ * @returns one result per page, in the request's order.
15
+ */
16
+ export declare const checkPages: ({ addresses, lists }: PageCheckRequest) => Promise<StoryCheckReport>;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * A real page of an application checked as a story is: accessibility, what spills, and its errors.
3
+ *
4
+ * @remarks
5
+ * An agent verifies the screen it built, not only the stories behind it, so `forgebook check-page` opens any address.
6
+ * Loaded from the project only when it runs, like `checkStories.ts`. No frame says this page is ready, so it counts as
7
+ * ready when the network goes quiet, or after `QUIET_MS` for a page that keeps a connection open, which is checked as it
8
+ * stands then. The report has a story check's shape, the address standing for the story's id.
9
+ *
10
+ * @module
11
+ */
12
+ import { chromium } from "@playwright/test";
13
+ import { catalogueSettings } from "forgebook/catalogue";
14
+ import { axeProblems, errorProblems, focusProblems, spillProblems, watchedErrors } from "./storyCheck.js";
15
+ /** How long a page gets for its network to go quiet, in milliseconds. */
16
+ const QUIET_MS = 10_000;
17
+ /** One page, checked. */
18
+ const checkedPage = async (address, open, a11y) => {
19
+ const page = await open();
20
+ const errors = watchedErrors(page);
21
+ const result = (problems) => ({
22
+ storyId: address,
23
+ view: "",
24
+ frame: address,
25
+ problems
26
+ });
27
+ try {
28
+ const response = await page.goto(address);
29
+ await page.waitForLoadState("networkidle", { timeout: QUIET_MS }).catch(() => undefined);
30
+ if (response !== null && !response.ok()) {
31
+ return result([{ kind: "frame", message: `It answered ${response.status()}` }]);
32
+ }
33
+ return result([
34
+ ...(await axeProblems(page, a11y)),
35
+ ...(await spillProblems(page, "body")),
36
+ ...(await focusProblems(page)),
37
+ ...errorProblems(errors)
38
+ ]);
39
+ }
40
+ catch (error) {
41
+ return result([{ kind: "frame", message: error instanceof Error ? error.message : String(error) }]);
42
+ }
43
+ finally {
44
+ await page.close();
45
+ }
46
+ };
47
+ /**
48
+ * Checks pages, on one browser.
49
+ *
50
+ * @param request - the addresses and the catalogue's settings.
51
+ * @returns one result per page, in the request's order.
52
+ */
53
+ export const checkPages = async ({ addresses, lists }) => {
54
+ const { a11y } = catalogueSettings("", [], lists);
55
+ const browser = await chromium.launch();
56
+ try {
57
+ // from a context of its own: axe opens pages of its own, which a bare `browser.newPage()` cannot share
58
+ const context = await browser.newContext();
59
+ const results = [];
60
+ for (const address of addresses) {
61
+ results.push(await checkedPage(address, () => context.newPage(), a11y));
62
+ }
63
+ return { schema: 1, results };
64
+ }
65
+ finally {
66
+ await browser.close();
67
+ }
68
+ };
@@ -6,4 +6,3 @@ import type { StoryCheckReport, StoryCheckRequest } from "forgebook/story-check"
6
6
  * @returns one result per story and view, in the request's order.
7
7
  */
8
8
  export declare const checkStories: (request: StoryCheckRequest) => Promise<StoryCheckReport>;
9
- //# sourceMappingURL=checkStories.d.ts.map
@@ -1,6 +1,7 @@
1
1
  import { catalogueSettings } from "forgebook/catalogue";
2
2
  import { isOverlayChecked } from "./overlays.js";
3
- import { axeProblems, errorProblems, overlayProblems, spillProblems, watchedErrors } from "./storyCheck.js";
3
+ import { askModel, storySight } from "./semanticJudge.js";
4
+ import { axeProblems, errorProblems, focusProblems, overlayProblems, spillProblems, watchedErrors } from "./storyCheck.js";
4
5
  import { eachStoryPage, openedStory, READY_MS } from "./storyPages.js";
5
6
  import { hasLookChecks } from "./storyTestPlan.js";
6
7
  /**
@@ -10,7 +11,7 @@ import { hasLookChecks } from "./storyTestPlan.js";
10
11
  * @param storyPage - the story and the view.
11
12
  * @param a11y - the catalogue's accessibility settings.
12
13
  */
13
- const checkedPage = async (page, storyPage, a11y) => {
14
+ const checkedPage = async (page, storyPage, a11y, semantic) => {
14
15
  const errors = watchedErrors(page);
15
16
  const status = await openedStory(page, storyPage);
16
17
  const result = (problems) => ({
@@ -34,7 +35,11 @@ const checkedPage = async (page, storyPage, a11y) => {
34
35
  ...(await overlayProblems(page, isOverlayChecked(storyPage.view), storyPage.story.height))
35
36
  ]
36
37
  : [];
37
- return result([...(await axeProblems(page, a11y)), ...lookProblems, ...errorProblems(errors)]);
38
+ const axe = await axeProblems(page, a11y);
39
+ const checked = result([...axe, ...lookProblems, ...(await focusProblems(page)), ...errorProblems(errors)]);
40
+ return semantic === undefined
41
+ ? checked
42
+ : { ...checked, judgements: await askModel(semantic, await storySight(page)) };
38
43
  };
39
44
  /**
40
45
  * Checks the stories a request names, in its views, on one browser.
@@ -44,7 +49,6 @@ const checkedPage = async (page, storyPage, a11y) => {
44
49
  */
45
50
  export const checkStories = async (request) => {
46
51
  const { a11y } = catalogueSettings("", request.switches, request.lists);
47
- const results = await eachStoryPage(request, (page, storyPage) => checkedPage(page, storyPage, a11y));
52
+ const results = await eachStoryPage(request, (page, storyPage) => checkedPage(page, storyPage, a11y, request.semantic));
48
53
  return { schema: 1, results };
49
54
  };
50
- //# sourceMappingURL=checkStories.js.map
@@ -0,0 +1,46 @@
1
+ import type { StoryCodeWriter, StoryDrawing, StoryRenderer } from "forgebook/renderer";
2
+ import type { LoadedDecorator } from "forgebook/story-module";
3
+ /** A half of the suite: what a story does in a page, or on a server. */
4
+ export type AdapterHalf = "page" | "server";
5
+ /** The name a subject's story throws for. */
6
+ export declare const THROWING_NAME = "boom";
7
+ /** What an adapter hands the suite. */
8
+ export type AdapterSubject = {
9
+ /** The framework's name, in the tests' titles. */
10
+ readonly name: string;
11
+ /** Its renderer. */
12
+ readonly renderer: StoryRenderer;
13
+ /**
14
+ * A drawing of a story with a button that shows `Hello <name> <clicks>`, counting its clicks in its own state, and that
15
+ * throws while drawn when the name is `THROWING_NAME`.
16
+ *
17
+ * @param name - the name, as the story's args carry it.
18
+ */
19
+ readonly drawing: (name: string) => StoryDrawing;
20
+ /**
21
+ * A decorator that draws the story inside a `section` with this `data-decorator` value, written as the framework
22
+ * writes one.
23
+ *
24
+ * @param label - the value.
25
+ */
26
+ readonly decorator: (label: string) => LoadedDecorator;
27
+ /** The code writer the shell uses. */
28
+ readonly codeOf: StoryCodeWriter;
29
+ /**
30
+ * The framework's docs of a story file's component, as `extractStoryComponent` reads them. None skips the check, for an
31
+ * adapter with no documented types yet.
32
+ */
33
+ readonly docsOf?: () => unknown;
34
+ /**
35
+ * The half of the suite this run checks, for a framework whose components are compiled apart for a page and for a
36
+ * server: its package runs the suite once in a simulated page (`page`, with the code writer and the docs) and once in
37
+ * Node (`server`). Both halves when none.
38
+ */
39
+ readonly half?: AdapterHalf;
40
+ };
41
+ /**
42
+ * The suite of an adapter.
43
+ *
44
+ * @param subject - the adapter's subject.
45
+ */
46
+ export declare const describeAdapter: (subject: AdapterSubject) => void;
@@ -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
+ };