@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.
|
|
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.
|
|
796
|
-
"@astryxdesign/cli": "0.7.2-canary.
|
|
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
|
-
'
|
|
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.
|
|
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
|
+
}
|