@pygmalionjs/pygmalion 0.5.34 → 0.6.1

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.
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Annotates visible pseudo-state targets in the running capture page.
3
+ *
4
+ * The function is intentionally self-contained: Playwright serializes it into
5
+ * the browser realm. Keep its metadata schema and discovery behavior aligned
6
+ * with src/editor/automaticPseudoStates.ts.
7
+ */
8
+ export function annotateStoryboardAutomaticPseudoStates() {
9
+ const metadataAttribute = 'data-pygmalion-auto-pseudo';
10
+ const eventAttribute = 'data-pygmalion-pseudo-events';
11
+ const stateOrder = ['hover', 'focus-visible', 'active'];
12
+ const pseudoPattern =
13
+ /:(focus-visible|focus-within|hover|focus|active)(?![a-z0-9_-])/gi;
14
+ const focusableSelector = [
15
+ 'button:not(:disabled)',
16
+ 'a[href]',
17
+ 'input:not(:disabled)',
18
+ 'textarea:not(:disabled)',
19
+ 'select:not(:disabled)',
20
+ 'summary',
21
+ 'area[href]',
22
+ 'audio[controls]',
23
+ 'video[controls]',
24
+ 'iframe',
25
+ '[contenteditable]:not([contenteditable="false"])',
26
+ '[tabindex]:not(:disabled)',
27
+ ].join(',');
28
+ const hoverReactEventProps = [
29
+ 'onMouseEnter',
30
+ 'onMouseOver',
31
+ 'onMouseMove',
32
+ 'onPointerEnter',
33
+ 'onPointerOver',
34
+ 'onPointerMove',
35
+ ];
36
+ const focusReactEventProps = ['onFocus', 'onFocusCapture'];
37
+
38
+ const normalizeText = (value) =>
39
+ String(value ?? '')
40
+ .replace(/\s+/g, ' ')
41
+ .trim();
42
+ const compactLabel = (value) => {
43
+ const normalized = normalizeText(value);
44
+ return normalized.length <= 52
45
+ ? normalized
46
+ : `${normalized.slice(0, 49).trimEnd()}…`;
47
+ };
48
+ const attributeSelector = (name, value) => {
49
+ const escaped = String(value)
50
+ .replaceAll('\\', '\\\\')
51
+ .replaceAll('"', '\\"')
52
+ .replaceAll('\n', '\\a ')
53
+ .replaceAll('\r', '\\d ');
54
+ return `[${name}="${escaped}"]`;
55
+ };
56
+ const splitSelectorList = (selectorText) => {
57
+ const selectors = [];
58
+ let start = 0;
59
+ let round = 0;
60
+ let square = 0;
61
+ let quote = '';
62
+ for (let index = 0; index < selectorText.length; index += 1) {
63
+ const character = selectorText[index];
64
+ if (quote) {
65
+ if (character === '\\') index += 1;
66
+ else if (character === quote) quote = '';
67
+ continue;
68
+ }
69
+ if (character === '"' || character === "'") {
70
+ quote = character;
71
+ continue;
72
+ }
73
+ if (character === '(') round += 1;
74
+ else if (character === ')') round = Math.max(0, round - 1);
75
+ else if (character === '[') square += 1;
76
+ else if (character === ']') square = Math.max(0, square - 1);
77
+ else if (character === ',' && round === 0 && square === 0) {
78
+ selectors.push(selectorText.slice(start, index).trim());
79
+ start = index + 1;
80
+ }
81
+ }
82
+ selectors.push(selectorText.slice(start).trim());
83
+ return selectors.filter(Boolean);
84
+ };
85
+ const visibleElement = (element) => {
86
+ let style;
87
+ try {
88
+ style = getComputedStyle(element);
89
+ } catch {
90
+ return false;
91
+ }
92
+ if (
93
+ style.display === 'none' ||
94
+ style.visibility === 'hidden' ||
95
+ style.visibility === 'collapse'
96
+ ) {
97
+ return false;
98
+ }
99
+ const rect = element.getBoundingClientRect();
100
+ return rect.width > 0 && rect.height > 0;
101
+ };
102
+ const focusTarget = (element) => {
103
+ try {
104
+ if (element.matches(focusableSelector) && visibleElement(element)) {
105
+ return element;
106
+ }
107
+ return (
108
+ [...element.querySelectorAll(focusableSelector)].find(visibleElement) ??
109
+ null
110
+ );
111
+ } catch {
112
+ return null;
113
+ }
114
+ };
115
+ const normalizedNegatedPseudo = (selector) =>
116
+ selector.replace(
117
+ /:not\(\s*:(focus-visible|focus-within|hover|focus|active)\s*\)/gi,
118
+ (_match, state) => `:${state}`,
119
+ );
120
+ const compoundEndAfter = (selector, start) => {
121
+ let round = 0;
122
+ let square = 0;
123
+ let quote = '';
124
+ for (let index = 0; index < selector.length; index += 1) {
125
+ const character = selector[index];
126
+ if (quote) {
127
+ if (character === '\\') index += 1;
128
+ else if (character === quote) quote = '';
129
+ continue;
130
+ }
131
+ if (character === '"' || character === "'") {
132
+ quote = character;
133
+ continue;
134
+ }
135
+ if (character === '(') round += 1;
136
+ else if (character === ')') round = Math.max(0, round - 1);
137
+ else if (character === '[') square += 1;
138
+ else if (character === ']') square = Math.max(0, square - 1);
139
+ if (
140
+ index >= start &&
141
+ round === 0 &&
142
+ square === 0 &&
143
+ (character === '>' ||
144
+ character === '+' ||
145
+ character === '~' ||
146
+ /\s/.test(character))
147
+ ) {
148
+ return index;
149
+ }
150
+ }
151
+ return selector.length;
152
+ };
153
+ const pseudoSubjectSelector = (selectorText, pseudo) => {
154
+ const selector = normalizedNegatedPseudo(selectorText);
155
+ pseudoPattern.lastIndex = 0;
156
+ const matches = [...selector.matchAll(pseudoPattern)].filter((match) => {
157
+ const state = match[1].toLowerCase();
158
+ return pseudo === 'focus-visible'
159
+ ? state === 'focus' ||
160
+ state === 'focus-visible' ||
161
+ state === 'focus-within'
162
+ : state === pseudo;
163
+ });
164
+ pseudoPattern.lastIndex = 0;
165
+ const match = matches.at(-1);
166
+ if (!match || match.index == null) return null;
167
+ const end = compoundEndAfter(selector, match.index + match[0].length);
168
+ const prefix = selector
169
+ .slice(0, end)
170
+ .replace(pseudoPattern, '')
171
+ .replace(/::[a-z0-9_-]+(?:\([^)]*\))?/gi, '')
172
+ .replace(/:(?:is|where|not)\(\s*(?:,\s*)*\)/gi, '')
173
+ .trim();
174
+ pseudoPattern.lastIndex = 0;
175
+ return prefix || null;
176
+ };
177
+ const activeGroupingRule = (rule) => {
178
+ if (rule.type === CSSRule.MEDIA_RULE) {
179
+ return !rule.conditionText || matchMedia(rule.conditionText).matches;
180
+ }
181
+ if (rule.type === CSSRule.SUPPORTS_RULE) {
182
+ try {
183
+ return !rule.conditionText || CSS.supports(rule.conditionText);
184
+ } catch {
185
+ return true;
186
+ }
187
+ }
188
+ return true;
189
+ };
190
+ const found = new Map();
191
+ const add = (element, state) => {
192
+ if (!visibleElement(element)) return;
193
+ const states = found.get(element) ?? new Set();
194
+ states.add(state);
195
+ found.set(element, states);
196
+ };
197
+ const inspectRules = (rules) => {
198
+ for (const rule of [...rules]) {
199
+ const selectorText = rule.selectorText;
200
+ pseudoPattern.lastIndex = 0;
201
+ const carriesPseudo =
202
+ typeof selectorText === 'string' && pseudoPattern.test(selectorText);
203
+ pseudoPattern.lastIndex = 0;
204
+ if (carriesPseudo) {
205
+ for (const selector of splitSelectorList(selectorText)) {
206
+ const rawStates = new Set(
207
+ [...selector.matchAll(pseudoPattern)].map((match) =>
208
+ match[1].toLowerCase(),
209
+ ),
210
+ );
211
+ pseudoPattern.lastIndex = 0;
212
+ const states = [];
213
+ if (rawStates.has('hover')) states.push('hover');
214
+ if (
215
+ rawStates.has('focus') ||
216
+ rawStates.has('focus-visible') ||
217
+ rawStates.has('focus-within')
218
+ ) {
219
+ states.push('focus-visible');
220
+ }
221
+ if (rawStates.has('active')) states.push('active');
222
+ for (const state of states) {
223
+ const subjectSelector = pseudoSubjectSelector(selector, state);
224
+ if (!subjectSelector) continue;
225
+ let subjects;
226
+ try {
227
+ subjects = [...document.querySelectorAll(subjectSelector)];
228
+ } catch {
229
+ continue;
230
+ }
231
+ for (const subject of subjects) {
232
+ if (state === 'focus-visible') {
233
+ const target = focusTarget(subject);
234
+ if (target) add(target, state);
235
+ } else {
236
+ add(subject, state);
237
+ }
238
+ }
239
+ }
240
+ }
241
+ }
242
+ if (rule.cssRules && activeGroupingRule(rule)) {
243
+ inspectRules(rule.cssRules);
244
+ }
245
+ }
246
+ };
247
+ for (const sheet of [...document.styleSheets]) {
248
+ try {
249
+ inspectRules(sheet.cssRules);
250
+ } catch {
251
+ // Cross-origin stylesheets cannot be inspected.
252
+ }
253
+ }
254
+ for (const element of document.querySelectorAll('*')) {
255
+ if (!visibleElement(element)) continue;
256
+ const declared = new Set(
257
+ String(element.getAttribute(eventAttribute) ?? '')
258
+ .split(',')
259
+ .map((value) => value.trim()),
260
+ );
261
+ for (const key of Object.getOwnPropertyNames(element)) {
262
+ if (!key.startsWith('__reactProps$')) continue;
263
+ const props = element[key];
264
+ if (!props || typeof props !== 'object') continue;
265
+ if (hoverReactEventProps.some((name) => typeof props[name] === 'function')) {
266
+ declared.add('hover');
267
+ }
268
+ if (focusReactEventProps.some((name) => typeof props[name] === 'function')) {
269
+ declared.add('focus');
270
+ }
271
+ }
272
+ const states = found.get(element) ?? new Set();
273
+ if (declared.has('hover')) states.add('hover');
274
+ if (declared.has('focus') && focusTarget(element) === element) {
275
+ states.add('focus-visible');
276
+ }
277
+ if (states.size > 0) found.set(element, states);
278
+ }
279
+ const selectorForElement = (element) => {
280
+ for (const name of [
281
+ 'data-testid',
282
+ 'data-pygmalion-own-source',
283
+ 'data-pygmalion-source',
284
+ 'id',
285
+ ]) {
286
+ const value = element.getAttribute(name);
287
+ if (!value) continue;
288
+ const selector = attributeSelector(name, value);
289
+ try {
290
+ const matches = [...document.querySelectorAll(selector)];
291
+ const index = matches.indexOf(element);
292
+ if (index >= 0) return { selector, index };
293
+ } catch {
294
+ // Try the next identity source.
295
+ }
296
+ }
297
+ const segments = [];
298
+ let current = element;
299
+ while (current && current !== document.body) {
300
+ const parent = current.parentElement;
301
+ if (!parent) return null;
302
+ const tag = current.tagName.toLowerCase();
303
+ const siblings = [...parent.children].filter(
304
+ (candidate) => candidate.tagName === current.tagName,
305
+ );
306
+ segments.unshift(
307
+ `${tag}:nth-of-type(${siblings.indexOf(current) + 1})`,
308
+ );
309
+ current = parent;
310
+ }
311
+ return current && segments.length > 0
312
+ ? { selector: `body > ${segments.join(' > ')}`, index: 0 }
313
+ : null;
314
+ };
315
+ const targetLabel = (element) => {
316
+ const descendantLabel = element
317
+ .querySelector('[aria-label]')
318
+ ?.getAttribute('aria-label');
319
+ const sourceName = element
320
+ .getAttribute('data-pygmalion-source-style')
321
+ ?.split('#')
322
+ .at(-1);
323
+ return compactLabel(
324
+ element.getAttribute('aria-label') ||
325
+ element.getAttribute('title') ||
326
+ descendantLabel ||
327
+ element.getAttribute('data-testid') ||
328
+ normalizeText(element.textContent) ||
329
+ sourceName ||
330
+ element.getAttribute('role') ||
331
+ element.tagName.toLowerCase(),
332
+ );
333
+ };
334
+ const stableId = (value) => {
335
+ let hash = 0x811c9dc5;
336
+ for (let index = 0; index < value.length; index += 1) {
337
+ hash ^= value.charCodeAt(index);
338
+ hash = Math.imul(hash, 0x01000193);
339
+ }
340
+ return (hash >>> 0).toString(36);
341
+ };
342
+ const pending = [...found].flatMap(([element, states]) => {
343
+ const identity = selectorForElement(element);
344
+ if (!identity) return [];
345
+ return [
346
+ {
347
+ element,
348
+ identity,
349
+ baseLabel: targetLabel(element),
350
+ states: stateOrder.filter((state) => states.has(state)),
351
+ },
352
+ ];
353
+ });
354
+ const totals = new Map();
355
+ for (const entry of pending) {
356
+ totals.set(entry.baseLabel, (totals.get(entry.baseLabel) ?? 0) + 1);
357
+ }
358
+ const seen = new Map();
359
+ return pending.map(({ element, identity, baseLabel, states }) => {
360
+ const ordinal = (seen.get(baseLabel) ?? 0) + 1;
361
+ seen.set(baseLabel, ordinal);
362
+ const total = totals.get(baseLabel) ?? 1;
363
+ const label =
364
+ total > 1 ? `${baseLabel} · ${ordinal}/${total}` : baseLabel;
365
+ const key = `${identity.selector}\u0000${identity.index}`;
366
+ const target = {
367
+ id: `auto-pseudo:${stableId(key)}`,
368
+ label,
369
+ selector: identity.selector,
370
+ index: identity.index,
371
+ states,
372
+ };
373
+ element.setAttribute(metadataAttribute, JSON.stringify(target));
374
+ return target;
375
+ });
376
+ }
@@ -45,8 +45,20 @@ const INTERNAL_SOURCE_ATTRIBUTES = new Set([
45
45
  'data-pygmalion-slot-count',
46
46
  'data-pygmalion-own-source',
47
47
  'data-pygmalion-own-props',
48
+ 'data-pygmalion-pseudo-events',
48
49
  ]);
49
50
 
51
+ const HOVER_EVENT_ATTRIBUTES = new Set([
52
+ 'onMouseEnter',
53
+ 'onMouseOver',
54
+ 'onMouseMove',
55
+ 'onPointerEnter',
56
+ 'onPointerOver',
57
+ 'onPointerMove',
58
+ ]);
59
+
60
+ const FOCUS_EVENT_ATTRIBUTES = new Set(['onFocus']);
61
+
50
62
  function primitiveJsxAttributeValue(attribute, sourceFile) {
51
63
  if (!attribute.initializer) return true;
52
64
  if (ts.isStringLiteral(attribute.initializer)) return attribute.initializer.text;
@@ -144,6 +156,31 @@ function instrumentJsxInto(code, componentFile, magic) {
144
156
  : 0;
145
157
  injected.push(` data-pygmalion-slot-count="${slotCount}"`);
146
158
  }
159
+ if (
160
+ /^[a-z]/.test(elementName) &&
161
+ !authoredAttributes.has('data-pygmalion-pseudo-events')
162
+ ) {
163
+ const pseudoEvents = [];
164
+ if (
165
+ [...authoredAttributes].some((name) =>
166
+ HOVER_EVENT_ATTRIBUTES.has(name),
167
+ )
168
+ ) {
169
+ pseudoEvents.push('hover');
170
+ }
171
+ if (
172
+ [...authoredAttributes].some((name) =>
173
+ FOCUS_EVENT_ATTRIBUTES.has(name),
174
+ )
175
+ ) {
176
+ pseudoEvents.push('focus');
177
+ }
178
+ if (pseudoEvents.length > 0) {
179
+ injected.push(
180
+ ` data-pygmalion-pseudo-events="${pseudoEvents.join(',')}"`,
181
+ );
182
+ }
183
+ }
147
184
  if (injected.length > 0) {
148
185
  // Insert as first properties. Afterwards, if there are spread/explicit properties, they follow React's general property priority.
149
186
  magic.appendLeft(node.tagName.getEnd(), injected.join(''));
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { annotateStoryboardAutomaticPseudoStates } from './automatic-pseudo-runtime.mjs';
2
3
 
3
4
  const DEFAULT_VIEWPORT = Object.freeze({ width: 1280, height: 800 });
4
5
  const QA_FAILURE_STAGES = new Set(['interaction', 'assertion']);
@@ -381,6 +382,13 @@ export async function runStoryboardInteraction(page, interaction) {
381
382
  await target.click({ timeout: timeoutMs });
382
383
  } else if (interaction.action === 'focus') {
383
384
  await target.focus({ timeout: timeoutMs });
385
+ } else if (interaction.action === 'hover') {
386
+ await target.hover({ timeout: timeoutMs });
387
+ } else if (interaction.action === 'focus-visible') {
388
+ await target.focus({ timeout: timeoutMs });
389
+ } else if (interaction.action === 'active') {
390
+ await target.hover({ timeout: timeoutMs });
391
+ await page.mouse.down();
384
392
  } else if (interaction.action === 'fill') {
385
393
  await target.fill(interaction.value ?? '', { timeout: timeoutMs });
386
394
  } else if (interaction.action === 'check') {
@@ -1245,6 +1253,13 @@ async function collectStableEvidence(
1245
1253
  } catch (error) {
1246
1254
  errors.push(new StoryboardCaptureStageError('stabilize', error));
1247
1255
  }
1256
+ if (includePreviewSnapshot) {
1257
+ try {
1258
+ await page.evaluate(annotateStoryboardAutomaticPseudoStates);
1259
+ } catch (error) {
1260
+ errors.push(new StoryboardCaptureStageError('serialize', error));
1261
+ }
1262
+ }
1248
1263
  if (includeDomTree) {
1249
1264
  try {
1250
1265
  evidence.domTree = await page.evaluate(collectStoryboardDomTree);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pygmalionjs/pygmalion",
3
- "version": "0.5.34",
3
+ "version": "0.6.1",
4
4
  "description": "Code-backed DOM design sandbox and visual QA editor",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
@@ -32,7 +32,9 @@
32
32
  },
33
33
  "files": [
34
34
  "dist-lib",
35
+ "docs/screen-state-contract.md",
35
36
  "node/component-branches.mjs",
37
+ "node/automatic-pseudo-runtime.mjs",
36
38
  "node/design-session.mjs",
37
39
  "node/dev-mirror.mjs",
38
40
  "node/dev-view.vite.mjs",