Setting the file. One moment.
Verify Interactions · Wix Headless Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page 28.10
Workflow
This file
Number 28.64
Position 64 of 89
Type JavaScript
Size 29 KB
Lines 598 scripts/ verify-interactions.mjs
JavaScript · 598 lines · 29 KB
]
||
args.url
||
args._[
0
];
9 const outputDir = path. resolve (args.out || args.output || "" );
10 if ( ! cloneUrl) throw new Error ( "Missing --clone-url for the running clone." );
11 if ( ! args.out && ! args.output) throw new Error ( "Missing --out for the generated project directory." );
12 const browserTooling = await resolveBrowserToolingContext ({ startDir: path. resolve (args[ "project-root" ] || process. cwd ()) });
13 const report = await verifyInteractionRuntime ({ outputDir, cloneUrl, browserTooling });
14 await writeJson (path. join ( docsDir (outputDir), "interaction-qa.json" ), report);
15 if (args.json) process.stdout. write ( `${ JSON . stringify ( report , null , 2 ) } \n ` );
16 else console. log ( `Interaction QA: ${ report . passedScenes }/${ report . sceneCount } scenes passed` );
17 if ( ! report.pass) process.exitCode = 1 ;
18 }
19
20 export async function verifyInteractionRuntime ({ outputDir , cloneUrl , browserTooling } = {}) {
21 const contract = await readJson (path. join ( docsDir (outputDir), "scene-contract.json" ));
22 let uiNormalization = null ;
23 try {
24 uiNormalization = await readJson (path. join ( docsDir (outputDir), "ui-normalization.json" ));
25 } catch (error) {
26 if (error?.code !== "ENOENT" ) throw error;
27 }
28 const toolingContext = browserTooling || await resolveBrowserToolingContext ({ startDir: process. cwd () });
29 const playwright = await loadPlaywrightFromContext (toolingContext);
30 const browser = await playwright.chromium. launch ({ headless: true });
31 const page = await browser. newPage ({ viewport: { width: 1440 , height: 1200 } });
32 try {
33 await page. goto (cloneUrl, { waitUntil: "domcontentloaded" , timeout: 45000 });
34 await page. waitForTimeout ( 800 );
35 const scenes = [];
36 for ( const scene of contract.scenes || []) scenes. push ( await verifyScene (page, scene));
37 const passedScenes = scenes. filter (( scene ) => scene.pass). length ;
38 const normalization = uiNormalization ? await verifyUiNormalization (page, uiNormalization) : null ;
39 const scenePass = scenes. length > 0 && passedScenes === scenes. length ;
40 return {
41 schemaVersion: 2 ,
42 generatedAt: new Date (). toISOString (),
43 cloneUrl,
44 sceneCount: scenes. length ,
45 passedScenes,
46 failedScenes: scenes. length - passedScenes,
47 pass: scenePass && ( ! normalization || normalization.pass),
48 scenes,
49 ... (normalization ? { normalization } : {}),
50 };
51 } finally {
52 await browser. close ();
53 }
54 }
55
56 async function verifyUiNormalization ( page , contract ) {
57 const globalChecks = [];
58 for ( const assertion of contract.globalAssertions || []) {
59 if (assertion.kind === "no-page-horizontal-overflow" ) {
60 const pixels = await page. evaluate (() => Math. max ( 0 , document.documentElement.scrollWidth - document.documentElement.clientWidth));
61 globalChecks. push (pixels <= assertion.maximumPixels
62 ? { ... assertion, pass: true , actualPixels: pixels }
63 : { ... assertion, pass: false , message: `Document overflows horizontally by ${ pixels }px.` });
64 } else if (assertion.kind === "reduced-motion-available" ) {
65 const present = await page. evaluate (() => Array. from (document.styleSheets). some (( sheet ) => {
66 try {
67 return Array. from (sheet.cssRules || []). some (( rule ) => String (rule.cssText || "" ). includes ( "prefers-reduced-motion" ));
68 } catch {
69 return false ;
70 }
71 }));
72 globalChecks. push (present
73 ? { ... assertion, pass: true }
74 : { ... assertion, pass: false , message: "No same-origin prefers-reduced-motion rule was found." });
75 }
76 }
77 const sections = [];
78 for ( const section of contract.sections || []) sections. push ( await verifyNormalizedSection (page, section));
79 return {
80 sectionCount: sections. length ,
81 passedSections: sections. filter (( section ) => section.pass). length ,
82 pass: globalChecks. every (( check ) => check.pass) && sections. every (( section ) => section.pass),
83 globalChecks,
84 sections,
85 };
86 }
87
88 async function verifyNormalizedSection ( page , section ) {
89 const selector = `[data-rp-section="${ escapeAttribute ( section . sectionId ) }"]` ;
90 const root = page. locator (selector). first ();
91 if ( !await root. count ()) {
92 return {
93 sectionId: section.sectionId,
94 pass: false ,
95 checks: [{ kind: "section-present" , pass: false , message: `Missing ${ selector }.` }],
96 };
97 }
98 const checks = [];
99 for ( const assertion of section.assertions || []) {
100 if (assertion.kind === "section-present" ) {
101 checks. push ({ ... assertion, pass: true });
102 continue ;
103 }
104 if (assertion.kind === "repeated-item-height-spread" ) {
105 const heights = await itemHeights (root);
106 const ratio = heightSpreadRatio (heights);
107 checks. push (ratio <= assertion.maximumRatio
108 ? { ... assertion, pass: true , actualRatio: round (ratio), heights }
109 : { ... assertion, pass: false , message: `Repeated item height spread ${ round ( ratio ) } exceeds ${ assertion . maximumRatio }.` , heights });
110 continue ;
111 }
112 if (assertion.kind === "activation-preserves-item-height" ) {
113 const before = await itemHeights (root);
114 let after = before;
115 const activators = root. locator ( "[data-rp-activate]" );
116 if ( await activators. count () > 1 ) {
117 await activators. nth ( 1 ). click ({ force: true });
118 await page. waitForTimeout ( 650 );
119 after = await itemHeights (root);
120 }
121 const ratio = maximumPairedHeightDelta (before, after);
122 checks. push (ratio <= assertion.maximumRatio
123 ? { ... assertion, pass: true , actualRatio: round (ratio) }
124 : { ... assertion, pass: false , message: `Activation changed item block size by ${ round ( ratio ) }; maximum is ${ assertion . maximumRatio }.` });
125 continue ;
126 }
127 if (assertion.kind === "hover-preserves-item-layout" ) {
128 const before = await itemRects (root);
129 const targets = root. locator ( "[data-rp-hover-target],[data-rp-item]" );
130 const count = await targets. count ();
131 if ( ! count) {
132 checks. push ({ ... assertion, pass: false , message: "No marked hover target exists." });
133 continue ;
134 }
135 let targetIndex = Math. max ( 0 , count - 1 );
136 for ( let index = 0 ; index < count; index += 1 ) {
137 const active = await targets. nth (index). getAttribute ( "data-rp-active" );
138 if (active !== "true" ) {
139 targetIndex = index;
140 break ;
141 }
142 }
143 await targets. nth (targetIndex). hover ({ force: true });
144 await page. waitForTimeout ( 280 );
145 const after = await itemRects (root);
146 const inlineRatio = maximumPairedRectDelta (before, after, "width" );
147 const blockRatio = maximumPairedRectDelta (before, after, "height" );
148 checks. push (inlineRatio <= assertion.maximumInlineRatio && blockRatio <= assertion.maximumBlockRatio
149 ? { ... assertion, pass: true , actualInlineRatio: round (inlineRatio), actualBlockRatio: round (blockRatio) }
150 : { ... assertion, pass: false , message: `Hover changed peer layout by inline=${ round ( inlineRatio ) }, block=${ round ( blockRatio ) }.` });
151 continue ;
152 }
153 if (assertion.kind === "eased-motion-present" ) {
154 const motion = await root. locator ( "[data-rp-motion]" ). evaluateAll (( nodes ) => nodes. map (( node ) => {
155 const style = getComputedStyle (node);
156 return { duration: style.transitionDuration, easing: style.transitionTimingFunction };
157 }));
158 const eased = motion. some (( item ) => item.duration. split ( "," ). some (( value ) => Number. parseFloat (value) > 0 )
159 && item.easing. split ( "," ). some (( value ) => ! / ^ \s * (?:linear | steps \( )/ i . test (value)));
160 checks. push (eased
161 ? { ... assertion, pass: true , motion }
162 : { ... assertion, pass: false , message: "No marked motion target has a non-zero eased transition." });
163 continue ;
164 }
165 checks. push ({ ... assertion, pass: false , message: `Unsupported UI normalization assertion: ${ assertion . kind }.` });
166 }
167 return { sectionId: section.sectionId, pass: checks. every (( check ) => check.pass), checks };
168 }
169
170 async function itemHeights ( root ) {
171 return root. locator ( "[data-rp-item]" ). evaluateAll (( items ) => items
172 . filter (( item ) => {
173 const style = getComputedStyle (item);
174 const rect = item. getBoundingClientRect ();
175 return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden" ;
176 })
177 . map (( item ) => Math. round (item. getBoundingClientRect ().height)));
178 }
179
180 async function itemRects ( root ) {
181 return root. locator ( "[data-rp-item]" ). evaluateAll (( items ) => items. map (( item ) => {
182 const rect = item. getBoundingClientRect ();
183 return { width: rect.width, height: rect.height };
184 }));
185 }
186
187 function heightSpreadRatio ( heights ) {
188 if (heights. length < 2 ) return 0 ;
189 const sorted = [ ... heights]. sort (( left , right ) => left - right);
190 const median = sorted[Math. floor (sorted. length / 2 )] || 1 ;
191 return (Math. max ( ... heights) - Math. min ( ... heights)) / median;
192 }
193
194 function maximumPairedHeightDelta ( before , after ) {
195 const length = Math. min (before. length , after. length );
196 let maximum = 0 ;
197 for ( let index = 0 ; index < length; index += 1 ) maximum = Math. max (maximum, Math. abs (after[index] - before[index]) / Math. max ( 1 , before[index]));
198 return maximum;
199 }
200
201 function maximumPairedRectDelta ( before , after , key ) {
202 const length = Math. min (before. length , after. length );
203 let maximum = 0 ;
204 for ( let index = 0 ; index < length; index += 1 ) maximum = Math. max (maximum, Math. abs (after[index][key] - before[index][key]) / Math. max ( 1 , before[index][key]));
205 return maximum;
206 }
207
208 async function verifyScene ( page , scene ) {
209 const selector = `[data-rp-scene="${ escapeAttribute ( scene . id ) }"]` ;
210 const root = page. locator (selector). first ();
211 const present = await root. count () > 0 ;
212 if ( ! present) {
213 return {
214 id: scene.id,
215 primitive: scene.implementation?.primitive || scene.implementation?.model || "unknown" ,
216 pass: false ,
217 checks: [{ kind: "scene-present" , pass: false , message: `Missing ${ selector }.` }],
218 };
219 }
220
221 await root. scrollIntoViewIfNeeded ();
222 await page. waitForTimeout ( 120 );
223 const before = await readSceneState (root);
224 const assertions = scene.implementation?.assertions || [];
225 const needsActivation = assertions. some (( item ) => [
226 "activation-changes-state" ,
227 "activation-changes-content" ,
228 "active-item-width-ratio" ,
229 "single-active-item" ,
230 "transition-settles" ,
231 ]. includes (item.kind));
232 let after = before;
233 let settleMs = 0 ;
234 let activationError = null ;
235 if (needsActivation) {
236 try {
237 const result = await activateAlternateState (page, root, assertions);
238 after = result.after;
239 settleMs = result.settleMs;
240 } catch (error) {
241 activationError = error.message;
242 }
243 }
244 let scrollFrames = [];
245 if (assertions. some (( item ) => item.kind. startsWith ( "scroll-" ) || item.kind === "visual-pins-during-scroll" )) {
246 scrollFrames = await sampleScrollScene (page, root);
247 }
248 let hoverBefore = null ;
249 let hoverAfter = null ;
250 let hoverError = null ;
251 if (assertions. some (( item ) => item.kind === "hover-changes-visual-state" )) {
252 try {
253 const hover = await probeHoverState (page, root);
254 hoverBefore = hover.before;
255 hoverAfter = hover.after;
256 } catch (error) {
257 hoverError = error.message;
258 }
259 }
260
261 const checks = assertions. map (( assertion ) => evaluateAssertion (assertion, {
262 before,
263 after,
264 settleMs,
265 scrollFrames,
266 activationError,
267 hoverBefore,
268 hoverAfter,
269 hoverError,
270 }));
271 if ( ! assertions. some (( item ) => item.kind === "scene-present" )) {
272 checks. unshift ({ kind: "scene-present" , pass: true });
273 }
274 return {
275 id: scene.id,
276 primitive: scene.implementation?.primitive || scene.implementation?.model || "unknown" ,
277 pass: checks. every (( check ) => check.pass),
278 settleMs,
279 checks,
280 };
281 }
282
283 async function readSceneState ( root ) {
284 return root. evaluate (( scene ) => {
285 const visible = ( node ) => {
286 const rect = node. getBoundingClientRect ();
287 const style = getComputedStyle (node);
288 return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0" ;
289 };
290 const clean = ( value ) => String (value || "" ). replace ( / \s + / g , " " ). trim ();
291 const items = Array. from (scene. querySelectorAll ( "[data-rp-item]" )). map (( node , index ) => {
292 const rect = node. getBoundingClientRect ();
293 const style = getComputedStyle (node);
294 return {
295 index,
296 active: node. getAttribute ( "data-rp-active" ) === "true" ,
297 text: clean (node.textContent). slice ( 0 , 160 ),
298 rect: { left: Math. round (rect.left), top: Math. round (rect.top), width: Math. round (rect.width), height: Math. round (rect.height) },
299 spacing: {
300 marginLeft: parseFloat (style.marginLeft) || 0 ,
301 marginRight: parseFloat (style.marginRight) || 0 ,
302 paddingLeft: parseFloat (style.paddingLeft) || 0 ,
303 paddingRight: parseFloat (style.paddingRight) || 0 ,
304 borderLeftWidth: parseFloat (style.borderLeftWidth) || 0 ,
305 borderRightWidth: parseFloat (style.borderRightWidth) || 0 ,
306 },
307 };
308 });
309 const track = scene. querySelector ( "[data-rp-track]" );
310 const viewport = scene. querySelector ( "[data-rp-viewport]" ) || track?.parentElement;
311 const panels = Array. from (scene. querySelectorAll ( "[data-rp-panel]" )). filter (visible). map (( node ) => clean (node.textContent). slice ( 0 , 600 ));
312 const mediaNodes = Array. from (scene. querySelectorAll ( "iframe,video" ));
313 const media = mediaNodes. map (( node ) => {
314 const rect = node. getBoundingClientRect ();
315 return {
316 tag: node.tagName. toLowerCase (),
317 src: node.currentSrc || node. getAttribute ( "src" ) || "" ,
318 autoplay: node.autoplay || /(?: ^| [?&] )autoplay=1(?:& |$ )/ . test (node. getAttribute ( "src" ) || "" ),
319 loop: node.loop || /(?: ^| [?&] )loop=1(?:& |$ )/ . test (node. getAttribute ( "src" ) || "" ),
320 muted: node.muted || /(?: ^| [?&] )muted=1(?:& |$ )/ . test (node. getAttribute ( "src" ) || "" ),
321 background: /(?: ^| [?&] )background=1(?:& |$ )/ . test (node. getAttribute ( "src" ) || "" ),
322 rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, width: rect.width, height: rect.height },
323 };
324 });
325 const fallbackLayers = Array. from (scene. querySelectorAll ( "*" )). slice ( 0 , 500 ). filter (visible). flatMap (( node ) => {
326 const sources = [];
327 if (node.tagName === "IMG" ) sources. push (node.currentSrc || node. getAttribute ( "src" ) || "" );
328 const background = getComputedStyle (node).backgroundImage || "" ;
329 for ( const match of background. matchAll ( /url \( ["'] ? ( [ ^ "')] + ) ["'] ? \) / g )) sources. push (match[ 1 ]);
330 return sources. filter (Boolean). map (( source ) => ({ source, rect: node. getBoundingClientRect () }));
331 });
332 const overlappingFallbackSources = fallbackLayers
333 . filter (( layer ) => mediaNodes. some (( mediaNode ) => overlapRatio (layer.rect, mediaNode. getBoundingClientRect ()) >= 0.8 ))
334 . map (( layer ) => layer.source);
335 return {
336 items,
337 activeIndexes: items. filter (( item ) => item.active). map (( item ) => item.index),
338 initialized: String (scene. getAttribute ( "data-rp-initialized" ) || "" ). split ( / \s + / ). filter (Boolean),
339 track: track ? {
340 clientWidth: viewport?.clientWidth || track.clientWidth,
341 scrollWidth: Math. max (track.scrollWidth, Math. round (track. getBoundingClientRect ().width), viewport?.scrollWidth || 0 ),
342 gap: parseFloat ( getComputedStyle (track).columnGap || getComputedStyle (track).gap) || 0 ,
343 } : null ,
344 panelText: panels. join ( " | " ),
345 phase: scene. getAttribute ( "data-rp-phase" ) || "" ,
346 media,
347 overlappingFallbackSources,
348 };
349
350 function overlapRatio ( left , right ) {
351 const width = Math. max ( 0 , Math. min (left.right, right.right) - Math. max (left.left, right.left));
352 const height = Math. max ( 0 , Math. min (left.bottom, right.bottom) - Math. max (left.top, right.top));
353 const overlap = width * height;
354 const smaller = Math. min (left.width * left.height, right.width * right.height);
355 return smaller > 0 ? overlap / smaller : 0 ;
356 }
357 });
358 }
359
360 async function probeHoverState ( page , root ) {
361 const targets = root. locator ( "[data-rp-hover-target],[data-rp-item],[data-rp-direction],[data-rp-activate]" );
362 if ( !await targets. count ()) throw new Error ( "No hoverable data-rp control exists in the scene." );
363 const target = targets. first ();
364 const before = await readHoverSnapshot (target);
365 await target. hover ({ force: true });
366 await page. waitForTimeout ( 220 );
367 const after = await readHoverSnapshot (target);
368 return { before, after };
369 }
370
371 async function readHoverSnapshot ( target ) {
372 return target. evaluate (( node ) => [node, ... Array. from (node. querySelectorAll ( "*" )). slice ( 0 , 16 )]. map (( part ) => {
373 const style = getComputedStyle (part);
374 const rect = part. getBoundingClientRect ();
375 return {
376 tag: part.tagName. toLowerCase (),
377 rect: { width: Math. round (rect.width), height: Math. round (rect.height) },
378 backgroundColor: style.backgroundColor,
379 color: style.color,
380 opacity: style.opacity,
381 transform: style.transform,
382 filter: style.filter,
383 boxShadow: style.boxShadow,
384 borderColor: style.borderColor,
385 };
386 }));
387 }
388
389 async function activateAlternateState ( page , root , assertions ) {
390 const activators = root. locator ( "[data-rp-activate]" );
391 const count = await activators. count ();
392 if ( ! count) throw new Error ( "No data-rp-activate target exists in the scene." );
393 const before = await readSceneState (root);
394 let targetIndex = count > 1 ? 1 : 0 ;
395 for ( let index = 0 ; index < count; index += 1 ) {
396 const active = await activators. nth (index). evaluate (( node ) => node. closest ( "[data-rp-item]" )?. getAttribute ( "data-rp-active" ) === "true" );
397 if ( ! active) {
398 targetIndex = index;
399 break ;
400 }
401 }
402 await activators. nth (targetIndex). click ({ force: true });
403 const maximumMs = Math. min ( 3000 , Math. max ( 500 , Number (assertions. find (( item ) => item.kind === "transition-settles" )?.maximumMs) || 1800 ));
404 const startedAt = Date. now ();
405 let lastSignature = "" ;
406 let stableCount = 0 ;
407 let after = before;
408 while (Date. now () - startedAt < maximumMs) {
409 await page. waitForTimeout ( 80 );
410 after = await readSceneState (root);
411 const signature = JSON . stringify ({ activeIndexes: after.activeIndexes, items: after.items. map (( item ) => item.rect), panelText: after.panelText });
412 if (signature === lastSignature) stableCount += 1 ;
413 else stableCount = 0 ;
414 lastSignature = signature;
415 if (stableCount >= 2 ) break ;
416 }
417 return { before, after, settleMs: Date. now () - startedAt };
418 }
419
420 async function sampleScrollScene ( page , root ) {
421 return root. evaluate ( async ( scene ) => {
422 const frames = [];
423 const start = window.scrollY + scene. getBoundingClientRect ().top;
424 const travel = Math. max ( 0 , scene. getBoundingClientRect ().height - window.innerHeight);
425 const samples = [
426 { progress: - 2 , scrollY: Math. max ( 0 , start - window.innerHeight * 2 ) },
427 { progress: - 1 , scrollY: Math. max ( 0 , start - window.innerHeight) },
428 { progress: - 0.5 , scrollY: Math. max ( 0 , start - window.innerHeight * 0.5 ) },
429 { progress: 0 , scrollY: start },
430 { progress: 0.5 , scrollY: start + travel * 0.5 },
431 { progress: 1 , scrollY: start + travel },
432 ];
433 for ( const sample of samples) {
434 window. scrollTo ( 0 , Math. round (sample.scrollY));
435 await new Promise (( resolve ) => setTimeout (resolve, 180 ));
436 const visual = scene. querySelector ( "[data-rp-visual]" );
437 const content = scene. querySelector ( "[data-rp-content]" );
438 const style = visual ? getComputedStyle (visual) : null ;
439 const rect = visual?. getBoundingClientRect ();
440 const contentStyle = content ? getComputedStyle (content) : null ;
441 const contentRect = content?. getBoundingClientRect ();
442 const curtainWidths = Array. from (scene. querySelectorAll ( "[data-rp-curtain]" )). map (( curtain ) => Math. round (curtain. getBoundingClientRect ().width));
443 frames. push ({
444 progress: sample.progress,
445 phase: scene. getAttribute ( "data-rp-phase" ) || "" ,
446 entryProgress: scene.style. getPropertyValue ( "--rp-entry-progress" ) || "" ,
447 scrollProgress: scene.style. getPropertyValue ( "--rp-scroll-progress" ) || "" ,
448 visual: visual ? { position: style.position, top: Math. round (rect.top), width: Math. round (rect.width), height: Math. round (rect.height), transform: style.transform } : null ,
449 content: content ? { width: Math. round (contentRect.width), height: Math. round (contentRect.height), transform: contentStyle.transform } : null ,
450 curtainWidths,
451 });
452 }
453 return frames;
454 });
455 }
456
457 function evaluateAssertion ( assertion , state ) {
458 const fail = ( message ) => ({ ... assertion, pass: false , message });
459 const pass = ( details ) => ({ ... assertion, pass: true , ... (details ? { details } : {}) });
460 if (assertion.kind === "scene-present" ) return pass ();
461 if (assertion.kind === "runtime-initialized" ) {
462 return state.after.initialized. includes (assertion.primitive)
463 ? pass ({ initialized: state.after.initialized })
464 : fail ( `Runtime primitive ${ assertion . primitive } did not initialize; found ${ state . after . initialized . join ( ", " ) || "none"}.` );
465 }
466 if (state.activationError && [ "activation-changes-state" , "activation-changes-content" , "active-item-width-ratio" , "transition-settles" ]. includes (assertion.kind)) {
467 return fail (state.activationError);
468 }
469 if (assertion.kind === "minimum-item-count" ) {
470 return state.after.items. length >= assertion.value ? pass ({ actual: state.after.items. length }) : fail ( `Expected at least ${ assertion . value } items; found ${ state . after . items . length }.` );
471 }
472 if (assertion.kind === "horizontal-overflow" ) {
473 const ratio = state.after.track?.clientWidth ? state.after.track.scrollWidth / state.after.track.clientWidth : 0 ;
474 return ratio >= assertion.minimumRatio ? pass ({ actualRatio: round (ratio) }) : fail ( `Expected overflow ratio >= ${ assertion . minimumRatio }; got ${ round ( ratio ) }.` );
475 }
476 if (assertion.kind === "single-active-item" ) {
477 return state.after.activeIndexes. length === assertion.value ? pass ({ activeIndexes: state.after.activeIndexes }) : fail ( `Expected ${ assertion . value } active item; found ${ state . after . activeIndexes . length }.` );
478 }
479 if (assertion.kind === "initial-active-item-count" ) {
480 return state.before.activeIndexes. length === assertion.value ? pass ({ activeIndexes: state.before.activeIndexes }) : fail ( `Expected ${ assertion . value } initially active item(s); found ${ state . before . activeIndexes . length }.` );
481 }
482 if (assertion.kind === "item-separation" ) {
483 const gaps = state.before.items. slice ( 1 ). map (( item , index ) => item.rect.left - (state.before.items[index].rect.left + state.before.items[index].rect.width));
484 const expected = assertion.observed || {};
485 const median = ( values ) => values. length ? values. sort (( left , right ) => left - right)[Math. floor (values. length / 2 )] : 0 ;
486 let observed = Number (expected.geometricGap) || 0 ;
487 let actual = median (gaps);
488 let metric = "geometric gap" ;
489 if (expected.mechanism === "track-gap" ) {
490 observed = Number (expected.trackGap) || 0 ;
491 actual = state.before.track?.gap || 0 ;
492 metric = "track gap" ;
493 } else if (expected.mechanism === "item-margin" ) {
494 observed = Number (expected.itemMargin) || 0 ;
495 actual = median (state.before.items. flatMap (( item ) => [item.spacing.marginLeft, item.spacing.marginRight]). filter (( value ) => value > 0 ));
496 metric = "item margin" ;
497 } else if (expected.mechanism === "divider" ) {
498 observed = Number (expected.dividerWidth) || 0 ;
499 actual = median (state.before.items. flatMap (( item ) => [item.spacing.borderLeftWidth, item.spacing.borderRightWidth]). filter (( value ) => value > 0 ));
500 metric = "divider" ;
501 } else if ( Number (expected.contentInset) > 0 ) {
502 observed = Number (expected.contentInset);
503 actual = median (state.before.items. flatMap (( item ) => [item.spacing.paddingLeft, item.spacing.paddingRight]). filter (( value ) => value > 0 ));
504 metric = "content inset" ;
505 }
506 return Math. abs (actual - observed) <= Math. max ( 2 , Math. abs (observed) * 0.2 ) ? pass ({ metric, actual: round (actual) }) : fail ( `Expected ${ metric } near ${ observed }px; got ${ round ( actual ) }px.` );
507 }
508 if (assertion.kind === "activation-changes-state" ) {
509 return JSON . stringify (state.before.activeIndexes) !== JSON . stringify (state.after.activeIndexes) ? pass () : fail ( "Activation did not change data-rp-active state." );
510 }
511 if (assertion.kind === "activation-changes-content" ) {
512 return state.before.panelText !== state.after.panelText ? pass () : fail ( "Activation did not change visible data-rp-panel content." );
513 }
514 if (assertion.kind === "active-item-width-ratio" ) {
515 const active = state.after.items. find (( item ) => item.active);
516 const inactiveWidths = state.after.items. filter (( item ) => ! item.active). map (( item ) => item.rect.width). filter (Boolean). sort (( left , right ) => left - right);
517 const collapsed = inactiveWidths[Math. floor (inactiveWidths. length / 2 )] || 0 ;
518 const ratio = collapsed ? active?.rect.width / collapsed : 0 ;
519 return ratio >= assertion.minimumRatio ? pass ({ actualRatio: round (ratio) }) : fail ( `Expected active width ratio >= ${ assertion . minimumRatio }; got ${ round ( ratio ) }.` );
520 }
521 if (assertion.kind === "transition-settles" ) {
522 return state.settleMs <= assertion.maximumMs ? pass ({ actualMs: state.settleMs }) : fail ( `Transition did not settle within ${ assertion . maximumMs }ms.` );
523 }
524 if (assertion.kind === "scroll-produces-distinct-phases" ) {
525 const signatures = new Set (state.scrollFrames. map (( frame ) => JSON . stringify ({ phase: frame.phase, entryProgress: frame.entryProgress, scrollProgress: frame.scrollProgress, visual: frame.visual })));
526 return signatures.size >= assertion.minimumPhaseCount ? pass ({ actualPhaseCount: signatures.size }) : fail ( `Expected ${ assertion . minimumPhaseCount } scroll phases; found ${ signatures . size }.` );
527 }
528 if (assertion.kind === "visual-pins-during-scroll" ) {
529 return state.scrollFrames. some (( frame ) => frame.visual?.position === "sticky" || frame.visual?.position === "fixed" ) ? pass () : fail ( "No sticky/fixed data-rp-visual phase was observed." );
530 }
531 if (assertion.kind === "scroll-visual-expansion" ) {
532 const widths = state.scrollFrames. map (( frame ) => frame.visual?.width || 0 ). filter (( width ) => width > 0 );
533 const widthRatio = widths. length ? Math. max ( ... widths) / Math. min ( ... widths) : 0 ;
534 const curtainTotals = state.scrollFrames. map (( frame ) => (frame.curtainWidths || []). reduce (( total , width ) => total + width, 0 ));
535 const curtainRatio = curtainTotals. length && widths. length
536 ? 1 + (Math. max ( ... curtainTotals) - Math. min ( ... curtainTotals)) / Math. max ( ... widths)
537 : 1 ;
538 const ratio = Math. max (widthRatio, curtainRatio);
539 return ratio >= assertion.minimumRatio ? pass ({ actualRatio: round (ratio) }) : fail ( `Expected scroll visual expansion ratio >= ${ assertion . minimumRatio }; got ${ round ( ratio ) }.` );
540 }
541 if (assertion.kind === "scroll-content-scale-bound" ) {
542 const widths = state.scrollFrames. map (( frame ) => frame.content?.width || 0 ). filter (( width ) => width > 0 );
543 const heights = state.scrollFrames. map (( frame ) => frame.content?.height || 0 ). filter (( height ) => height > 0 );
544 const ratio = Math. max (
545 widths. length ? Math. max ( ... widths) / Math. min ( ... widths) : 1 ,
546 heights. length ? Math. max ( ... heights) / Math. min ( ... heights) : 1 ,
547 );
548 return ratio <= assertion.maximumRatio ? pass ({ actualRatio: round (ratio) }) : fail ( `Expected content scale ratio <= ${ assertion . maximumRatio }; got ${ round ( ratio ) }.` );
549 }
550 if (assertion.kind === "exclusive-media-layer" ) {
551 const fallback = canonicalUrl (assertion.fallbackSource);
552 const overlaps = state.after.overlappingFallbackSources. filter (( source ) => canonicalUrl (source) === fallback);
553 return overlaps. length === 0
554 ? pass ()
555 : fail ( `Fallback layer overlaps active media: ${ overlaps . join ( ", " ) }.` );
556 }
557 if (assertion.kind === "hover-changes-visual-state" ) {
558 if (state.hoverError) return fail (state.hoverError);
559 return JSON . stringify (state.hoverBefore || []) !== JSON . stringify (state.hoverAfter || [])
560 ? pass ()
561 : fail ( "Hover did not change any marked control's visual state." );
562 }
563 if (assertion.kind === "media-source-present" ) {
564 const canonical = canonicalUrl (assertion.source);
565 const matched = state.after.media. find (( item ) => canonicalUrl (item.src) === canonical || item.src. includes (assertion.source));
566 if ( ! matched) return fail ( `Missing required media source ${ assertion . source }.` );
567 const mismatches = Object. entries (assertion.playback || {}). filter (([ key , value ]) => typeof value === "boolean" && Boolean (matched[key]) !== value);
568 return mismatches. length
569 ? fail ( `Media playback flags differ: ${ mismatches . map (([ key , value ]) => `${ key }=${ value }` ). join ( ", " ) }.` )
570 : pass ({ source: matched.src });
571 }
572 return fail ( `Unsupported interaction assertion: ${ assertion . kind }` );
573 }
574
575 function canonicalUrl ( value ) {
576 try {
577 const url = new URL (value);
578 url.hash = "" ;
579 return url. toString ();
580 } catch {
581 return String (value || "" );
582 }
583 }
584
585 function escapeAttribute ( value ) {
586 return String (value || "" ). replace ( / \\ / g , " \\\\ " ). replace ( /"/ g , ' \\ "' );
587 }
588
589 function round ( value ) {
590 return Math. round (( Number (value) || 0 ) * 1000 ) / 1000 ;
591 }
592
593 if ( import . meta .url === `file://${ process . argv [ 1 ] }` ) {
594 main (). catch (( error ) => {
595 console. error (error.stack || error.message);
596 process. exit ( 1 );
597 });
598 }