@astryxdesign/core 0.7.2-canary.91cfb53 → 0.7.2-canary.9b8b723

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.7.2-canary.91cfb53",
3
+ "version": "0.7.2-canary.9b8b723",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -792,8 +792,8 @@
792
792
  "react-dom": ">=19.0.0"
793
793
  },
794
794
  "devDependencies": {
795
- "@astryxdesign/a11y-spec": "0.7.2-canary.91cfb53",
796
- "@astryxdesign/cli": "0.7.2-canary.91cfb53",
795
+ "@astryxdesign/a11y-spec": "0.7.2-canary.9b8b723",
796
+ "@astryxdesign/cli": "0.7.2-canary.9b8b723",
797
797
  "@babel/cli": "^7.29.7",
798
798
  "@babel/core": "^7.29.7",
799
799
  "@babel/preset-react": "^7.29.7",
@@ -0,0 +1,292 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file CommandPaletteItemAudit.a11y.chromium.spec.ts
5
+ * @input Exact-head CommandPaletteItem stories across semantics, states, themes, direction, and viewport sizes
6
+ * @output Screenshots and fail-closed sensor receipts for the CommandPaletteItem whole-component audit
7
+ * @position Component-owned Night Watch browser evidence; CI runs it only for a CommandPaletteItem audit pull request
8
+ */
9
+
10
+ import {createHash} from 'node:crypto';
11
+ import {execFileSync} from 'node:child_process';
12
+ import * as fs from 'node:fs';
13
+ import * as path from 'node:path';
14
+ import {expect, test, type Page} from '@playwright/test';
15
+ import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
16
+ import {
17
+ DEFAULT_STORYBOOK_DIR,
18
+ serveStorybook,
19
+ type StaticServer,
20
+ } from '@astryxdesign/a11y-spec/storybook';
21
+
22
+ const OUTPUT = path.resolve('command-palette-item-audit-evidence');
23
+ const WIDE = {width: 1024, height: 768};
24
+ const NARROW = {width: 320, height: 720};
25
+
26
+ type Mode = 'light' | 'dark';
27
+ type Direction = 'ltr' | 'rtl';
28
+ type Theme = 'neutral' | 'butter';
29
+ type Story = 'states' | 'combined-and-long';
30
+ type State = 'rest' | 'hover';
31
+
32
+ type AuditCase = {
33
+ key: string;
34
+ story: Story;
35
+ mode: Mode;
36
+ direction: Direction;
37
+ theme: Theme;
38
+ state: State;
39
+ viewport: {width: number; height: number};
40
+ };
41
+
42
+ type AuditReceipt = {
43
+ passed: boolean;
44
+ expected: Record<string, unknown>;
45
+ };
46
+
47
+ const cases: AuditCase[] = [];
48
+ function addPair(
49
+ key: string,
50
+ options: Partial<Omit<AuditCase, 'key' | 'mode'>> = {},
51
+ ) {
52
+ for (const mode of ['light', 'dark'] as const) {
53
+ cases.push({
54
+ key: `${key}-${mode}`,
55
+ story: options.story ?? 'states',
56
+ mode,
57
+ direction: options.direction ?? 'ltr',
58
+ theme: options.theme ?? 'neutral',
59
+ state: options.state ?? 'rest',
60
+ viewport: options.viewport ?? WIDE,
61
+ });
62
+ }
63
+ }
64
+
65
+ addPair('states-rest');
66
+ addPair('states-hover', {state: 'hover'});
67
+ addPair('states-custom-theme', {theme: 'butter'});
68
+ addPair('states-rtl', {direction: 'rtl'});
69
+ addPair('states-narrow', {viewport: NARROW});
70
+ addPair('combined-rest', {story: 'combined-and-long'});
71
+ addPair('combined-custom-theme', {
72
+ story: 'combined-and-long',
73
+ theme: 'butter',
74
+ });
75
+ addPair('combined-narrow', {
76
+ story: 'combined-and-long',
77
+ viewport: NARROW,
78
+ });
79
+
80
+ test.describe.configure({mode: 'serial', retries: 0});
81
+ let server: StaticServer;
82
+ let checkoutSha: string;
83
+ let storybookSha: string;
84
+
85
+ function storyId(item: AuditCase) {
86
+ return `core-commandpaletteitem--${item.story}`;
87
+ }
88
+
89
+ function storyUrl(item: AuditCase) {
90
+ return `${server.origin}/iframe.html?id=${storyId(item)}&viewMode=story&globals=colorMode:${item.mode};astryxTheme:${item.theme};direction:${item.direction}`;
91
+ }
92
+
93
+ async function settle(page: Page) {
94
+ await holdMotionStill(page);
95
+ await page.evaluate(async () => document.fonts.ready);
96
+ await page.waitForTimeout(40);
97
+ }
98
+
99
+ test.beforeAll(async () => {
100
+ fs.rmSync(OUTPUT, {recursive: true, force: true});
101
+ fs.mkdirSync(OUTPUT, {recursive: true});
102
+ checkoutSha = execFileSync('git', ['rev-parse', 'HEAD'], {
103
+ encoding: 'utf8',
104
+ }).trim();
105
+ const expected = process.env.ASTRYX_HEAD_SHA;
106
+ if (expected && expected !== checkoutSha) {
107
+ throw new Error(
108
+ `checkout ${checkoutSha} does not match expected head ${expected}`,
109
+ );
110
+ }
111
+ server = await serveStorybook(
112
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
113
+ );
114
+ const response = await fetch(`${server.origin}/astryx-build-sha.txt`);
115
+ if (!response.ok) {
116
+ throw new Error(`Storybook build has no SHA stamp (${response.status})`);
117
+ }
118
+ storybookSha = (await response.text()).trim();
119
+ if (storybookSha !== checkoutSha) {
120
+ throw new Error(
121
+ `Storybook ${storybookSha} does not match checkout ${checkoutSha}`,
122
+ );
123
+ }
124
+ });
125
+
126
+ test.afterAll(async () => {
127
+ await server?.close();
128
+ });
129
+
130
+ for (const item of cases) {
131
+ test(item.key, async ({browser}) => {
132
+ const context = await browser.newContext({
133
+ viewport: item.viewport,
134
+ colorScheme: item.mode,
135
+ reducedMotion: 'reduce',
136
+ });
137
+ const page = await context.newPage();
138
+ const pageErrors: string[] = [];
139
+ page.on('pageerror', error => pageErrors.push(String(error)));
140
+ await page.goto(storyUrl(item), {waitUntil: 'load'});
141
+ await page.locator('#storybook-root').waitFor();
142
+
143
+ const listName =
144
+ item.story === 'states'
145
+ ? 'Command item states'
146
+ : 'Combined and long command item states';
147
+ const list = page.getByRole('listbox', {name: listName});
148
+ const options = list.getByRole('option');
149
+ const expectedCount = item.story === 'states' ? 4 : 2;
150
+ const expectedDisabled = item.story === 'states' ? 1 : 0;
151
+ await expect(list).toBeVisible();
152
+ await expect(options).toHaveCount(expectedCount);
153
+ await expect(
154
+ list.locator('[role="option"][aria-selected="true"]'),
155
+ ).toHaveCount(1);
156
+ await expect(
157
+ list.locator('[role="option"][aria-disabled="true"]'),
158
+ ).toHaveCount(expectedDisabled);
159
+ await expect(list.locator('button, a, [tabindex]')).toHaveCount(0);
160
+
161
+ if (item.state === 'hover') {
162
+ await options.first().hover();
163
+ }
164
+ await settle(page);
165
+
166
+ const observed = await page.evaluate(() => ({
167
+ theme: document
168
+ .querySelector('[data-astryx-theme]')
169
+ ?.getAttribute('data-astryx-theme'),
170
+ mode: document.documentElement.getAttribute('data-theme'),
171
+ direction: getComputedStyle(
172
+ document.querySelector('.astryx-command-palette-item') as HTMLElement,
173
+ ).direction,
174
+ viewport: {width: innerWidth, height: innerHeight},
175
+ reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
176
+ horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
177
+ fonts: document.fonts.status,
178
+ }));
179
+ const optionStates = await options.evaluateAll(nodes =>
180
+ nodes.map(node => {
181
+ const style = getComputedStyle(node);
182
+ const box = node.getBoundingClientRect();
183
+ return {
184
+ name: node.textContent?.replace(/\s+/g, ' ').trim(),
185
+ selected: node.getAttribute('aria-selected'),
186
+ disabled: node.getAttribute('aria-disabled'),
187
+ backgroundColor: style.backgroundColor,
188
+ color: style.color,
189
+ opacity: style.opacity,
190
+ geometry: {
191
+ x: box.x,
192
+ y: box.y,
193
+ width: box.width,
194
+ height: box.height,
195
+ },
196
+ };
197
+ }),
198
+ );
199
+ const semanticsPass =
200
+ optionStates.length === expectedCount &&
201
+ optionStates.filter(option => option.selected === 'true').length === 1 &&
202
+ optionStates.filter(option => option.disabled === 'true').length ===
203
+ expectedDisabled &&
204
+ optionStates.every(
205
+ option => option.geometry.width > 0 && option.geometry.height > 0,
206
+ );
207
+
208
+ const png = await page.screenshot({animations: 'disabled', fullPage: true});
209
+ const image = `${item.key}.png`;
210
+ const receipt = {
211
+ schemaVersion: 1,
212
+ expected: {
213
+ build: checkoutSha,
214
+ storyId: storyId(item),
215
+ mode: item.mode,
216
+ direction: item.direction,
217
+ theme: item.theme,
218
+ state: item.state,
219
+ viewport: item.viewport,
220
+ optionCount: expectedCount,
221
+ disabledCount: expectedDisabled,
222
+ selectedCount: 1,
223
+ },
224
+ observed: {
225
+ build: storybookSha,
226
+ ...observed,
227
+ listRole: await list.getAttribute('role'),
228
+ listName: await list.getAttribute('aria-label'),
229
+ optionStates,
230
+ pageErrors,
231
+ },
232
+ image: {
233
+ file: image,
234
+ sha256: createHash('sha256').update(png).digest('hex'),
235
+ width: png.readUInt32BE(16),
236
+ height: png.readUInt32BE(20),
237
+ },
238
+ checks: {semanticsPass},
239
+ passed:
240
+ storybookSha === checkoutSha &&
241
+ pageErrors.length === 0 &&
242
+ observed.theme === item.theme &&
243
+ observed.mode === item.mode &&
244
+ observed.direction === item.direction &&
245
+ observed.viewport.width === item.viewport.width &&
246
+ observed.viewport.height === item.viewport.height &&
247
+ observed.reducedMotion &&
248
+ !observed.horizontalOverflow &&
249
+ observed.fonts === 'loaded' &&
250
+ semanticsPass,
251
+ };
252
+ fs.writeFileSync(path.join(OUTPUT, image), png);
253
+ fs.writeFileSync(
254
+ path.join(OUTPUT, `${item.key}.sensors.json`),
255
+ `${JSON.stringify(receipt, null, 2)}\n`,
256
+ );
257
+
258
+ await context.close();
259
+ });
260
+ }
261
+
262
+ test('manifest is complete', async () => {
263
+ const receiptFiles = fs
264
+ .readdirSync(OUTPUT)
265
+ .filter(file => file.endsWith('.sensors.json'));
266
+ const receipts = receiptFiles.map(
267
+ (file): AuditReceipt =>
268
+ JSON.parse(
269
+ fs.readFileSync(path.join(OUTPUT, file), 'utf8'),
270
+ ) as AuditReceipt,
271
+ );
272
+ const manifest = {
273
+ schemaVersion: 1,
274
+ component: 'core/CommandPaletteItem',
275
+ build: checkoutSha,
276
+ frames: cases.map(item => ({
277
+ key: item.key,
278
+ image: `${item.key}.png`,
279
+ receipt: `${item.key}.sensors.json`,
280
+ })),
281
+ };
282
+ fs.writeFileSync(
283
+ path.join(OUTPUT, 'manifest.json'),
284
+ `${JSON.stringify(manifest, null, 2)}\n`,
285
+ );
286
+ expect(receiptFiles).toHaveLength(cases.length);
287
+ expect(
288
+ receipts
289
+ .filter(receipt => !receipt.passed)
290
+ .map(receipt => receipt.expected),
291
+ ).toEqual([]);
292
+ });
@@ -96,14 +96,13 @@ export const docs = {
96
96
  name: 'items',
97
97
  type: 'ContextMenuOption[]',
98
98
  description:
99
- 'Array of menu entries. Each entry is one of: an action item `{label, onClick?, icon?, isDisabled?, variant?, items?}` (nested `items` open a flyout in popover presentation and drill into a new view in bottom-sheet presentation; variant `"destructive"` renders it in the error color), a divider `{type: "divider"}`, or a section `{type: "section", title?, items: [...action items]}`.',
100
- required: true,
99
+ 'Data-driven menu entries. Pass exactly one of `items` or `menuContent`. Each entry is one of: an action item `{label, onClick?, icon?, isDisabled?, variant?, items?}` (nested `items` open a flyout in popover presentation and drill into a new view in bottom-sheet presentation; variant `"destructive"` renders it in the error color), a divider `{type: "divider"}`, or a section `{type: "section", title?, items: [...action items]}`.',
101
100
  },
102
101
  {
103
102
  name: 'menuContent',
104
103
  type: 'ReactNode',
105
104
  description:
106
- 'Custom JSX menu content for compound mode. Use instead of items for dynamic or stateful menus.',
105
+ 'Custom JSX menu content for compound mode. Pass exactly one of `menuContent` or `items`.',
107
106
  },
108
107
  {
109
108
  name: 'menuWidth',
@@ -0,0 +1,438 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file ContextMenuAudit.a11y.chromium.spec.ts
5
+ * @input Exact-head ContextMenu stories across pointer/touch presentation, state, theme, direction, and viewport
6
+ * @output Screenshots and fail-closed sensor receipts for the ContextMenu whole-component audit
7
+ * @position Component-owned Night Watch browser evidence; CI runs it only for a ContextMenu audit pull request
8
+ */
9
+
10
+ import {createHash} from 'node:crypto';
11
+ import {execFileSync} from 'node:child_process';
12
+ import * as fs from 'node:fs';
13
+ import * as path from 'node:path';
14
+ import {expect, test, type Locator, type Page} from '@playwright/test';
15
+ import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
16
+ import {
17
+ DEFAULT_STORYBOOK_DIR,
18
+ serveStorybook,
19
+ type StaticServer,
20
+ } from '@astryxdesign/a11y-spec/storybook';
21
+
22
+ const OUTPUT = path.resolve('context-menu-audit-evidence');
23
+ const WIDE = {width: 1024, height: 768};
24
+ const NARROW = {width: 320, height: 720};
25
+
26
+ type Mode = 'light' | 'dark';
27
+ type Direction = 'ltr' | 'rtl';
28
+ type Theme = 'neutral' | 'butter';
29
+ type Surface = 'menu' | 'dialog';
30
+ type AuditCase = {
31
+ key: string;
32
+ storyId: string;
33
+ mode: Mode;
34
+ direction: Direction;
35
+ theme: Theme;
36
+ viewport: {width: number; height: number};
37
+ surface: Surface;
38
+ triggerText?: string;
39
+ menuName?: string;
40
+ expectedState?: 'default' | 'disabled' | 'sections' | 'selectable';
41
+ };
42
+
43
+ const cases: AuditCase[] = [
44
+ {
45
+ key: 'pointer-neutral-light',
46
+ storyId: 'core-contextmenu--pointer-presentation',
47
+ mode: 'light',
48
+ direction: 'ltr',
49
+ theme: 'neutral',
50
+ viewport: WIDE,
51
+ surface: 'menu',
52
+ menuName: 'Document actions',
53
+ },
54
+ {
55
+ key: 'pointer-neutral-dark',
56
+ storyId: 'core-contextmenu--pointer-presentation',
57
+ mode: 'dark',
58
+ direction: 'ltr',
59
+ theme: 'neutral',
60
+ viewport: WIDE,
61
+ surface: 'menu',
62
+ menuName: 'Document actions',
63
+ },
64
+ {
65
+ key: 'pointer-butter-light',
66
+ storyId: 'core-contextmenu--pointer-presentation',
67
+ mode: 'light',
68
+ direction: 'ltr',
69
+ theme: 'butter',
70
+ viewport: WIDE,
71
+ surface: 'menu',
72
+ menuName: 'Document actions',
73
+ },
74
+ {
75
+ key: 'pointer-butter-dark',
76
+ storyId: 'core-contextmenu--pointer-presentation',
77
+ mode: 'dark',
78
+ direction: 'ltr',
79
+ theme: 'butter',
80
+ viewport: WIDE,
81
+ surface: 'menu',
82
+ menuName: 'Document actions',
83
+ },
84
+ {
85
+ key: 'pointer-rtl-light',
86
+ storyId: 'core-contextmenu--pointer-presentation',
87
+ mode: 'light',
88
+ direction: 'rtl',
89
+ theme: 'neutral',
90
+ viewport: WIDE,
91
+ surface: 'menu',
92
+ menuName: 'Document actions',
93
+ },
94
+ {
95
+ key: 'pointer-rtl-dark',
96
+ storyId: 'core-contextmenu--pointer-presentation',
97
+ mode: 'dark',
98
+ direction: 'rtl',
99
+ theme: 'neutral',
100
+ viewport: WIDE,
101
+ surface: 'menu',
102
+ menuName: 'Document actions',
103
+ },
104
+ {
105
+ key: 'pointer-narrow-light',
106
+ storyId: 'core-contextmenu--pointer-presentation',
107
+ mode: 'light',
108
+ direction: 'ltr',
109
+ theme: 'neutral',
110
+ viewport: NARROW,
111
+ surface: 'menu',
112
+ menuName: 'Document actions',
113
+ },
114
+ {
115
+ key: 'pointer-narrow-dark',
116
+ storyId: 'core-contextmenu--pointer-presentation',
117
+ mode: 'dark',
118
+ direction: 'ltr',
119
+ theme: 'neutral',
120
+ viewport: NARROW,
121
+ surface: 'menu',
122
+ menuName: 'Document actions',
123
+ },
124
+ {
125
+ key: 'disabled-item-light',
126
+ storyId: 'core-contextmenu--with-disabled-items',
127
+ mode: 'light',
128
+ direction: 'ltr',
129
+ theme: 'neutral',
130
+ viewport: WIDE,
131
+ surface: 'menu',
132
+ triggerText: 'Right-click this area',
133
+ expectedState: 'disabled',
134
+ },
135
+ {
136
+ key: 'sections-dark',
137
+ storyId: 'core-contextmenu--with-sections',
138
+ mode: 'dark',
139
+ direction: 'ltr',
140
+ theme: 'neutral',
141
+ viewport: WIDE,
142
+ surface: 'menu',
143
+ triggerText: 'Right-click for grouped actions',
144
+ expectedState: 'sections',
145
+ },
146
+ {
147
+ key: 'selectable-light',
148
+ storyId: 'core-contextmenu--with-selectable-items',
149
+ mode: 'light',
150
+ direction: 'ltr',
151
+ theme: 'neutral',
152
+ viewport: WIDE,
153
+ surface: 'menu',
154
+ triggerText: 'Right-click for selectable items',
155
+ expectedState: 'selectable',
156
+ },
157
+ {
158
+ key: 'selectable-dark',
159
+ storyId: 'core-contextmenu--with-selectable-items',
160
+ mode: 'dark',
161
+ direction: 'ltr',
162
+ theme: 'neutral',
163
+ viewport: WIDE,
164
+ surface: 'menu',
165
+ triggerText: 'Right-click for selectable items',
166
+ expectedState: 'selectable',
167
+ },
168
+ {
169
+ key: 'sheet-light',
170
+ storyId: 'core-contextmenu--bottom-sheet-presentation',
171
+ mode: 'light',
172
+ direction: 'ltr',
173
+ theme: 'neutral',
174
+ viewport: NARROW,
175
+ surface: 'dialog',
176
+ },
177
+ {
178
+ key: 'sheet-dark',
179
+ storyId: 'core-contextmenu--bottom-sheet-presentation',
180
+ mode: 'dark',
181
+ direction: 'ltr',
182
+ theme: 'neutral',
183
+ viewport: NARROW,
184
+ surface: 'dialog',
185
+ },
186
+ {
187
+ key: 'sheet-rtl',
188
+ storyId: 'core-contextmenu--bottom-sheet-presentation',
189
+ mode: 'light',
190
+ direction: 'rtl',
191
+ theme: 'neutral',
192
+ viewport: NARROW,
193
+ surface: 'dialog',
194
+ },
195
+ {
196
+ key: 'inline-light',
197
+ storyId: 'core-contextmenu--inline-trigger',
198
+ mode: 'light',
199
+ direction: 'ltr',
200
+ theme: 'neutral',
201
+ viewport: WIDE,
202
+ surface: 'menu',
203
+ triggerText: 'T123',
204
+ menuName: 'Task actions',
205
+ },
206
+ {
207
+ key: 'inline-dark',
208
+ storyId: 'core-contextmenu--inline-trigger',
209
+ mode: 'dark',
210
+ direction: 'ltr',
211
+ theme: 'neutral',
212
+ viewport: WIDE,
213
+ surface: 'menu',
214
+ triggerText: 'T123',
215
+ menuName: 'Task actions',
216
+ },
217
+ {
218
+ key: 'scroll-container-light',
219
+ storyId: 'core-contextmenu--in-scroll-container',
220
+ mode: 'light',
221
+ direction: 'ltr',
222
+ theme: 'neutral',
223
+ viewport: WIDE,
224
+ surface: 'menu',
225
+ triggerText: 'Right-click this area',
226
+ },
227
+ ];
228
+
229
+ test.describe.configure({mode: 'serial', retries: 0});
230
+ let server: StaticServer;
231
+ let checkoutSha: string;
232
+ let storybookSha: string;
233
+
234
+ function storyUrl(item: AuditCase) {
235
+ return `${server.origin}/iframe.html?id=${item.storyId}&viewMode=story&globals=colorMode:${item.mode};astryxTheme:${item.theme};direction:${item.direction}`;
236
+ }
237
+
238
+ async function settle(page: Page) {
239
+ await holdMotionStill(page);
240
+ await page.evaluate(async () => document.fonts.ready);
241
+ await page.waitForTimeout(40);
242
+ }
243
+
244
+ async function openSurface(
245
+ page: Page,
246
+ item: AuditCase,
247
+ ): Promise<{surface: Locator; pointer: {x: number; y: number}}> {
248
+ const trigger =
249
+ item.storyId === 'core-contextmenu--pointer-presentation'
250
+ ? page.getByTestId('pointer-context-menu')
251
+ : item.storyId === 'core-contextmenu--bottom-sheet-presentation'
252
+ ? page.getByTestId('bottom-sheet-context-menu')
253
+ : item.triggerText
254
+ ? page.getByText(item.triggerText, {exact: true}).first()
255
+ : null;
256
+ if (trigger === null) {
257
+ throw new Error(`No trigger configured for ${item.key}`);
258
+ }
259
+ await trigger.scrollIntoViewIfNeeded();
260
+ const triggerBox = await trigger.boundingBox();
261
+ if (triggerBox === null) {
262
+ throw new Error(`Trigger has no rendered box for ${item.key}`);
263
+ }
264
+ const pointer = {
265
+ x: triggerBox.x + Math.min(40, triggerBox.width / 2),
266
+ y: triggerBox.y + Math.min(40, triggerBox.height / 2),
267
+ };
268
+ await page.mouse.click(pointer.x, pointer.y, {button: 'right'});
269
+ const surface =
270
+ item.surface === 'dialog'
271
+ ? page.getByRole('dialog').last()
272
+ : item.menuName
273
+ ? page.getByRole('menu', {name: item.menuName}).last()
274
+ : page.getByRole('menu').last();
275
+ await expect(surface).toBeVisible();
276
+ return {surface, pointer};
277
+ }
278
+
279
+ test.beforeAll(async () => {
280
+ fs.rmSync(OUTPUT, {recursive: true, force: true});
281
+ fs.mkdirSync(OUTPUT, {recursive: true});
282
+ checkoutSha = execFileSync('git', ['rev-parse', 'HEAD'], {
283
+ encoding: 'utf8',
284
+ }).trim();
285
+ const expected = process.env.ASTRYX_HEAD_SHA;
286
+ if (expected && expected !== checkoutSha) {
287
+ throw new Error(
288
+ `checkout ${checkoutSha} does not match expected head ${expected}`,
289
+ );
290
+ }
291
+ server = await serveStorybook(
292
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
293
+ );
294
+ const response = await fetch(`${server.origin}/astryx-build-sha.txt`);
295
+ if (!response.ok) {
296
+ throw new Error(`Storybook build has no SHA stamp (${response.status})`);
297
+ }
298
+ storybookSha = (await response.text()).trim();
299
+ if (storybookSha !== checkoutSha) {
300
+ throw new Error(
301
+ `Storybook ${storybookSha} does not match checkout ${checkoutSha}`,
302
+ );
303
+ }
304
+ });
305
+
306
+ test.afterAll(async () => {
307
+ await server?.close();
308
+ });
309
+
310
+ for (const item of cases) {
311
+ test(item.key, async ({browser}) => {
312
+ const context = await browser.newContext({
313
+ viewport: item.viewport,
314
+ colorScheme: item.mode,
315
+ reducedMotion: 'reduce',
316
+ });
317
+ const page = await context.newPage();
318
+ const pageErrors: string[] = [];
319
+ page.on('pageerror', error => pageErrors.push(String(error)));
320
+ await page.goto(storyUrl(item), {waitUntil: 'load'});
321
+ await page.locator('#storybook-root').waitFor();
322
+ const {surface, pointer} = await openSurface(page, item);
323
+ await settle(page);
324
+
325
+ const menuItems = page.locator(
326
+ '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]',
327
+ );
328
+ if (item.surface === 'menu') {
329
+ await expect(menuItems.first()).toBeVisible();
330
+ } else {
331
+ await expect(surface.getByRole('button').first()).toBeVisible();
332
+ }
333
+ if (item.expectedState === 'disabled') {
334
+ await expect(
335
+ page.locator('[role="menuitem"][aria-disabled="true"]'),
336
+ ).toHaveCount(1);
337
+ }
338
+ if (item.expectedState === 'sections') {
339
+ await expect(page.getByRole('group')).toHaveCount(2);
340
+ }
341
+ if (item.expectedState === 'selectable') {
342
+ await expect(page.getByRole('menuitemcheckbox')).toHaveCount(2);
343
+ await expect(page.getByRole('menuitemradio')).toHaveCount(3);
344
+ await expect(
345
+ page.locator('[role="menuitemradio"][aria-checked="true"]'),
346
+ ).toHaveCount(1);
347
+ }
348
+
349
+ const observed = await surface.evaluate(element => {
350
+ const box = element.getBoundingClientRect();
351
+ return {
352
+ theme: document
353
+ .querySelector('[data-astryx-theme]')
354
+ ?.getAttribute('data-astryx-theme'),
355
+ mode: document.documentElement.getAttribute('data-theme'),
356
+ direction: getComputedStyle(element).direction,
357
+ viewport: {width: innerWidth, height: innerHeight},
358
+ reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
359
+ horizontalOverflow:
360
+ document.documentElement.scrollWidth > innerWidth + 1,
361
+ fonts: document.fonts.status,
362
+ surfaceRole:
363
+ element.getAttribute('role') ??
364
+ (element instanceof HTMLDialogElement ? 'dialog' : null),
365
+ surfaceName:
366
+ element.getAttribute('aria-label') ??
367
+ element.getAttribute('aria-labelledby'),
368
+ geometry: {x: box.x, y: box.y, width: box.width, height: box.height},
369
+ };
370
+ });
371
+ const geometryPass =
372
+ observed.geometry !== null &&
373
+ observed.geometry.width > 0 &&
374
+ observed.geometry.height > 0;
375
+ let pointerPlacementPass = true;
376
+ if (item.storyId === 'core-contextmenu--pointer-presentation') {
377
+ const surfaceBox = await surface.boundingBox();
378
+ pointerPlacementPass =
379
+ surfaceBox !== null &&
380
+ Math.abs(
381
+ (item.direction === 'rtl'
382
+ ? surfaceBox.x + surfaceBox.width
383
+ : surfaceBox.x) - pointer.x,
384
+ ) <= 24 &&
385
+ Math.abs(surfaceBox.y - pointer.y) <= 24;
386
+ }
387
+
388
+ const png = await page.screenshot({animations: 'disabled', fullPage: true});
389
+ const image = `${item.key}.png`;
390
+ const receipt = {
391
+ schemaVersion: 1,
392
+ expected: {
393
+ build: checkoutSha,
394
+ storyId: item.storyId,
395
+ mode: item.mode,
396
+ direction: item.direction,
397
+ theme: item.theme,
398
+ viewport: item.viewport,
399
+ surface: item.surface,
400
+ state: item.expectedState ?? 'default',
401
+ },
402
+ observed: {
403
+ build: storybookSha,
404
+ ...observed,
405
+ menuItemCount: await menuItems.count(),
406
+ pageErrors,
407
+ },
408
+ image: {
409
+ file: image,
410
+ sha256: createHash('sha256').update(png).digest('hex'),
411
+ width: png.readUInt32BE(16),
412
+ height: png.readUInt32BE(20),
413
+ },
414
+ checks: {geometryPass, pointerPlacementPass},
415
+ passed:
416
+ storybookSha === checkoutSha &&
417
+ pageErrors.length === 0 &&
418
+ observed.theme === item.theme &&
419
+ observed.mode === item.mode &&
420
+ observed.direction === item.direction &&
421
+ observed.viewport.width === item.viewport.width &&
422
+ observed.viewport.height === item.viewport.height &&
423
+ observed.reducedMotion &&
424
+ !observed.horizontalOverflow &&
425
+ observed.fonts === 'loaded' &&
426
+ observed.surfaceRole === item.surface &&
427
+ geometryPass &&
428
+ pointerPlacementPass,
429
+ };
430
+ fs.writeFileSync(path.join(OUTPUT, image), png);
431
+ fs.writeFileSync(
432
+ path.join(OUTPUT, `${item.key}.sensors.json`),
433
+ `${JSON.stringify(receipt, null, 2)}\n`,
434
+ );
435
+ expect(receipt.passed).toBe(true);
436
+ await context.close();
437
+ });
438
+ }