@zappie/verify 0.1.5 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +18 -1
  3. package/dist/{category-page-UBHOB5IN.js → category-page-I3HDIAJA.js} +7 -3
  4. package/dist/category-page-I3HDIAJA.js.map +1 -0
  5. package/dist/{chunk-JYFSFOIS.js → chunk-47RIF6JW.js} +23 -11
  6. package/dist/chunk-47RIF6JW.js.map +1 -0
  7. package/dist/{debug-C3UDGUED.js → debug-LOXPIQRS.js} +1 -1
  8. package/dist/debug-LOXPIQRS.js.map +1 -0
  9. package/dist/index.d.ts +53 -5
  10. package/dist/index.js +842 -30
  11. package/dist/index.js.map +1 -1
  12. package/dist/presentation-TKOXOILG.js +68 -0
  13. package/dist/presentation-TKOXOILG.js.map +1 -0
  14. package/dist/{quote-body-NAFPZOGR.js → quote-body-S5LELT7L.js} +1 -1
  15. package/dist/quote-body-S5LELT7L.js.map +1 -0
  16. package/dist/{quote-client-3NC5KSBE.js → quote-client-4RUY3TKQ.js} +8 -3
  17. package/dist/quote-client-4RUY3TKQ.js.map +1 -0
  18. package/dist/{search-YIHT6CXN.js → search-OF62VKI3.js} +7 -2
  19. package/dist/search-OF62VKI3.js.map +1 -0
  20. package/dist/{selected-menu-5AIX7JHU.js → selected-menu-AQHWDELR.js} +8 -3
  21. package/dist/selected-menu-AQHWDELR.js.map +1 -0
  22. package/dist/{store-AOU2FRUX.js → store-OPM3OFLC.js} +1 -1
  23. package/dist/store-OPM3OFLC.js.map +1 -0
  24. package/package.json +2 -2
  25. package/schema/verify-report.v1.json +17 -1
  26. package/dist/category-page-UBHOB5IN.js.map +0 -1
  27. package/dist/chunk-JYFSFOIS.js.map +0 -1
  28. package/dist/debug-C3UDGUED.js.map +0 -1
  29. package/dist/quote-body-NAFPZOGR.js.map +0 -1
  30. package/dist/quote-client-3NC5KSBE.js.map +0 -1
  31. package/dist/search-YIHT6CXN.js.map +0 -1
  32. package/dist/selected-menu-5AIX7JHU.js.map +0 -1
  33. package/dist/store-AOU2FRUX.js.map +0 -1
package/dist/index.js CHANGED
@@ -11,14 +11,15 @@ import {
11
11
  stableErrorCode,
12
12
  validateConfig,
13
13
  validateLine
14
- } from "./chunk-JYFSFOIS.js";
14
+ } from "./chunk-47RIF6JW.js";
15
15
 
16
16
  // src/error.ts
17
17
  var VerifyError = class extends Error {
18
- constructor(code, message, hint) {
18
+ constructor(code, message, hint, skip = false) {
19
19
  super(message);
20
20
  this.code = code;
21
21
  this.hint = hint;
22
+ this.skip = skip;
22
23
  this.name = "VerifyError";
23
24
  }
24
25
  };
@@ -27,6 +28,9 @@ function assertion(condition, code, message, hint) {
27
28
  throw new VerifyError(code, message, hint);
28
29
  }
29
30
  }
31
+ function skipCheck(code, message, hint) {
32
+ throw new VerifyError(code, message, hint, true);
33
+ }
30
34
 
31
35
  // src/local-preview.ts
32
36
  import { spawn } from "child_process";
@@ -285,7 +289,7 @@ async function debugValue(page, expression) {
285
289
  }
286
290
  var mobile = { width: 390, height: 844 };
287
291
  var desktop = { width: 1440, height: 900 };
288
- async function applyPhoneProfile(page) {
292
+ async function applyPhoneProfile(page, rate) {
289
293
  const cdp = await page.context().newCDPSession(page);
290
294
  await cdp.send("Network.enable");
291
295
  await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
@@ -296,7 +300,7 @@ async function applyPhoneProfile(page) {
296
300
  uploadThroughput: 1e5,
297
301
  connectionType: "cellular3g"
298
302
  });
299
- await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
303
+ await cdp.send("Emulation.setCPUThrottlingRate", { rate });
300
304
  return cdp;
301
305
  }
302
306
 
@@ -391,6 +395,161 @@ async function installFault(page, fault) {
391
395
  await route.continue();
392
396
  });
393
397
  }
398
+ if (fault === "extra-h1") {
399
+ await page.addInitScript(() => {
400
+ new MutationObserver(() => {
401
+ const main = document.querySelector("main");
402
+ if (main && !document.querySelector("[data-verify-extra-h1]")) {
403
+ const heading = document.createElement("h1");
404
+ heading.setAttribute("data-verify-extra-h1", "");
405
+ heading.textContent = "Second heading";
406
+ main.append(heading);
407
+ }
408
+ }).observe(document, { childList: true, subtree: true });
409
+ });
410
+ }
411
+ if (fault === "overlay-markers") {
412
+ await page.addInitScript(() => {
413
+ new MutationObserver(() => {
414
+ for (const node of document.querySelectorAll(
415
+ "[data-zappie-cart-trigger]:not([data-zappie-overlay-trigger])"
416
+ )) {
417
+ node.setAttribute("data-zappie-overlay-trigger", "bag");
418
+ }
419
+ for (const node of document.querySelectorAll(
420
+ '[role="dialog"]:not([data-zappie-overlay])'
421
+ )) {
422
+ if (node.textContent?.includes("Your cart")) {
423
+ node.setAttribute("data-zappie-overlay", "bag");
424
+ }
425
+ }
426
+ }).observe(document, { childList: true, subtree: true });
427
+ });
428
+ }
429
+ if (fault === "bag-on-tap") {
430
+ let release = () => {
431
+ };
432
+ let tapped = Promise.resolve();
433
+ const reset2 = () => {
434
+ release();
435
+ tapped = new Promise((resolve) => {
436
+ release = resolve;
437
+ });
438
+ };
439
+ reset2();
440
+ page.on("framenavigated", (frame) => {
441
+ if (frame === page.mainFrame()) {
442
+ reset2();
443
+ }
444
+ });
445
+ await page.exposeFunction("__verifyTapped", () => release());
446
+ await page.addInitScript(() => {
447
+ document.addEventListener(
448
+ "click",
449
+ () => {
450
+ void window.__verifyTapped?.();
451
+ },
452
+ { capture: true }
453
+ );
454
+ });
455
+ await page.route(
456
+ /\/assets\/cart-drawer-[^/]*\.js(?:\?|$)/,
457
+ async (route) => {
458
+ await tapped;
459
+ await route.continue().catch(() => {
460
+ });
461
+ }
462
+ );
463
+ }
464
+ if (fault === "one-slow-open") {
465
+ await page.addInitScript(() => {
466
+ document.addEventListener(
467
+ "click",
468
+ (event) => {
469
+ if (!(event.target instanceof Element) || !event.target.closest("[data-zappie-overlay-trigger]")) {
470
+ return;
471
+ }
472
+ const taps = Number(sessionStorage.getItem("__verifyTaps") ?? 0);
473
+ sessionStorage.setItem("__verifyTaps", String(taps + 1));
474
+ if (taps % 3 === 0) {
475
+ const until = performance.now() + 250;
476
+ while (performance.now() < until) {
477
+ }
478
+ }
479
+ },
480
+ { capture: true }
481
+ );
482
+ });
483
+ }
484
+ if (fault === "late-hydration") {
485
+ await page.route(
486
+ /\/assets\/entry\.client-[^/]*\.js(?:\?|$)/,
487
+ async (route) => {
488
+ const response = await route.fetch();
489
+ const headers = Object.fromEntries(
490
+ Object.entries(response.headers()).filter(
491
+ ([name]) => !["content-encoding", "content-length"].includes(name)
492
+ )
493
+ );
494
+ await route.fulfill({
495
+ response,
496
+ headers,
497
+ body: `await new Promise((resolve) => setTimeout(resolve, 2000));
498
+ ${await response.text()}`
499
+ });
500
+ }
501
+ );
502
+ }
503
+ if (fault === "late-idle") {
504
+ await page.addInitScript(() => {
505
+ const request = window.requestIdleCallback?.bind(window);
506
+ const cancel = window.cancelIdleCallback?.bind(window);
507
+ if (!request || !cancel) {
508
+ return;
509
+ }
510
+ const handles = /* @__PURE__ */ new Map();
511
+ let next = 1;
512
+ window.requestIdleCallback = (callback, options) => {
513
+ const handle = next++;
514
+ handles.set(
515
+ handle,
516
+ window.setTimeout(() => {
517
+ handles.set(handle, -request(callback, options));
518
+ }, 1500)
519
+ );
520
+ return handle;
521
+ };
522
+ window.cancelIdleCallback = (handle) => {
523
+ const scheduled = handles.get(handle);
524
+ if (scheduled === void 0) {
525
+ return;
526
+ }
527
+ if (scheduled < 0) {
528
+ cancel(-scheduled);
529
+ } else {
530
+ window.clearTimeout(scheduled);
531
+ }
532
+ handles.delete(handle);
533
+ };
534
+ });
535
+ }
536
+ if (fault === "no-overlay-markers") {
537
+ await page.addInitScript(() => {
538
+ new MutationObserver(() => {
539
+ for (const node of document.querySelectorAll(
540
+ "[data-zappie-overlay-trigger], [data-zappie-overlay]"
541
+ )) {
542
+ node.removeAttribute("data-zappie-overlay-trigger");
543
+ node.removeAttribute("data-zappie-overlay");
544
+ }
545
+ }).observe(document, {
546
+ childList: true,
547
+ subtree: true,
548
+ attributes: true,
549
+ attributeFilter: ["data-zappie-overlay-trigger", "data-zappie-overlay"]
550
+ });
551
+ });
552
+ }
394
553
  if (fault === "quick-add-via-item") {
395
554
  await page.addInitScript(() => {
396
555
  const removeQuickAdds = () => {
@@ -1319,6 +1478,115 @@ var v10 = {
1319
1478
 
1320
1479
  // src/checks/v13-accessibility.ts
1321
1480
  import AxeBuilder from "@axe-core/playwright";
1481
+
1482
+ // src/checks/semantics.ts
1483
+ var TOUCH_TARGET_PX = 44;
1484
+ async function semanticFindings(page, scope) {
1485
+ return page.evaluate(
1486
+ ({ scope: scope2, minimum }) => {
1487
+ const findings = [];
1488
+ const rendered = (element) => element.checkVisibility() && !element.closest('[aria-hidden="true"]');
1489
+ const describe = (element) => {
1490
+ const id = element.id ? `#${element.id}` : "";
1491
+ const text = (element.textContent ?? "").trim().replace(/\s+/g, " ");
1492
+ return `<${element.tagName.toLowerCase()}${id}>${text ? ` "${text.slice(0, 40)}"` : ""}`;
1493
+ };
1494
+ if (scope2.page) {
1495
+ const main = [
1496
+ ...document.querySelectorAll('main, [role="main"]')
1497
+ ].filter(rendered);
1498
+ if (main.length === 0) {
1499
+ findings.push({
1500
+ code: "main_landmark_missing",
1501
+ message: "The page has no main landmark"
1502
+ });
1503
+ }
1504
+ const headings = [
1505
+ ...document.querySelectorAll('h1, [role="heading"][aria-level="1"]')
1506
+ ].filter(
1507
+ (element) => rendered(element) && !element.closest('[role="dialog"], dialog')
1508
+ );
1509
+ if (headings.length !== 1) {
1510
+ findings.push({
1511
+ code: "h1_count",
1512
+ message: `The page has ${headings.length} h1 headings${headings.length > 1 ? `: ${headings.map(describe).join(", ")}` : ""}`
1513
+ });
1514
+ }
1515
+ }
1516
+ const named = (element) => {
1517
+ const label2 = element.getAttribute("aria-label")?.trim();
1518
+ if (label2) {
1519
+ return true;
1520
+ }
1521
+ const ids = element.getAttribute("aria-labelledby")?.split(/\s+/) ?? [];
1522
+ return ids.some(
1523
+ (id) => Boolean(document.getElementById(id)?.textContent?.trim())
1524
+ );
1525
+ };
1526
+ for (const dialog of document.querySelectorAll(
1527
+ '[role="dialog"], [role="alertdialog"], dialog[open]'
1528
+ )) {
1529
+ if (rendered(dialog) && !named(dialog)) {
1530
+ findings.push({
1531
+ code: "dialog_name_missing",
1532
+ message: `${describe(dialog)} has no accessible name`
1533
+ });
1534
+ }
1535
+ }
1536
+ for (const element of document.querySelectorAll("[tabindex]")) {
1537
+ if (Number(element.getAttribute("tabindex")) > 0) {
1538
+ findings.push({
1539
+ code: "positive_tabindex",
1540
+ message: `${describe(element)} has tabindex="${element.getAttribute("tabindex")}"`
1541
+ });
1542
+ }
1543
+ }
1544
+ if (scope2.phone) {
1545
+ const controls = document.querySelectorAll(
1546
+ 'button, a[href], input:not([type="hidden"]), select, textarea, summary, [role="button"], [role="link"], [role="tab"], [role="checkbox"], [role="radio"], [role="switch"], [role="menuitem"], [role="option"]'
1547
+ );
1548
+ const small = [];
1549
+ for (const element of controls) {
1550
+ if (!rendered(element) || element.matches(":disabled") || // Inline text links sit in a line of text; WCAG exempts them.
1551
+ element.matches("a") && getComputedStyle(element).display === "inline") {
1552
+ continue;
1553
+ }
1554
+ const box = element.getBoundingClientRect();
1555
+ const label2 = element.closest("label")?.getBoundingClientRect();
1556
+ if (box.width <= 2 || box.height <= 2) {
1557
+ if (!label2) {
1558
+ continue;
1559
+ }
1560
+ }
1561
+ const width = Math.max(box.width, label2?.width ?? 0);
1562
+ const height = Math.max(box.height, label2?.height ?? 0);
1563
+ if (width + 0.5 < minimum || height + 0.5 < minimum) {
1564
+ small.push(
1565
+ `${describe(element)} ${Math.round(width)}\xD7${Math.round(height)} px`
1566
+ );
1567
+ }
1568
+ }
1569
+ if (small.length > 0) {
1570
+ findings.push({
1571
+ code: "touch_target_too_small",
1572
+ message: `Controls under ${minimum}\xD7${minimum} px on a phone: ${small.slice(0, 8).join(", ")}${small.length > 8 ? ` and ${small.length - 8} more` : ""}`
1573
+ });
1574
+ }
1575
+ }
1576
+ return findings;
1577
+ },
1578
+ { scope, minimum: TOUCH_TARGET_PX }
1579
+ );
1580
+ }
1581
+ var semanticHints = {
1582
+ main_landmark_missing: "Wrap the page's main content in a <main> element.",
1583
+ h1_count: "Give each page exactly one h1 that names it.",
1584
+ dialog_name_missing: "Name every dialog with aria-labelledby pointing at its title, or with aria-label.",
1585
+ positive_tabindex: "Remove positive tabindex values. Use the DOM order, or tabindex 0 and -1.",
1586
+ touch_target_too_small: "Make every menu control at least 44\xD744 px on a phone, with padding or a larger hit area."
1587
+ };
1588
+
1589
+ // src/checks/v13-accessibility.ts
1322
1590
  async function severeViolations(page) {
1323
1591
  const analysis = await new AxeBuilder({ page }).analyze();
1324
1592
  return analysis.violations.filter(
@@ -1343,6 +1611,16 @@ async function controlsAreReachable(page, selectors) {
1343
1611
  }
1344
1612
  return false;
1345
1613
  }
1614
+ async function holdsFocus(target2) {
1615
+ for (let attempt = 0; attempt < 20; attempt++) {
1616
+ const inside = await target2.evaluate((node) => node.contains(document.activeElement)).catch(() => false);
1617
+ if (inside) {
1618
+ return true;
1619
+ }
1620
+ await new Promise((resolve) => setTimeout(resolve, 50));
1621
+ }
1622
+ return false;
1623
+ }
1346
1624
  var v13 = {
1347
1625
  id: "V13",
1348
1626
  title: "Accessibility",
@@ -1352,6 +1630,10 @@ var v13 = {
1352
1630
  await open(page);
1353
1631
  await applyPageFault(page, fault);
1354
1632
  const violations = await severeViolations(page);
1633
+ const semantics = await semanticFindings(page, {
1634
+ page: true,
1635
+ phone: viewport.width < 768
1636
+ });
1355
1637
  const item = sortedItems(await readMenu(options)).find(
1356
1638
  (entry) => entry.isAvailable
1357
1639
  );
@@ -1361,11 +1643,25 @@ var v13 = {
1361
1643
  "No available item can be opened for accessibility checks",
1362
1644
  "Keep one available item in the test menu."
1363
1645
  );
1364
- await page.locator(`[data-zappie-item="${item.id}"]`).first().locator("xpath=..").getByRole("link", { name: "View details" }).click();
1646
+ const card = page.locator(`[data-zappie-item="${item.id}"]`).first().locator("xpath=..");
1647
+ await card.getByRole("link", { name: "View details" }).click();
1365
1648
  violations.push(...await severeViolations(page));
1649
+ semantics.push(
1650
+ ...await semanticFindings(page, { page: false, phone: false })
1651
+ );
1366
1652
  await page.getByRole("button", { name: "Close item" }).click();
1367
- await page.locator("[data-zappie-cart-trigger]").first().click();
1653
+ if (!await holdsFocus(card)) {
1654
+ semantics.push({
1655
+ code: "focus_not_returned",
1656
+ message: "Focus did not return to the item card after the item closed"
1657
+ });
1658
+ }
1659
+ const cartTrigger = page.locator("[data-zappie-cart-trigger]").first();
1660
+ await cartTrigger.click();
1368
1661
  violations.push(...await severeViolations(page));
1662
+ semantics.push(
1663
+ ...await semanticFindings(page, { page: false, phone: false })
1664
+ );
1369
1665
  const message = violations.flatMap(
1370
1666
  (entry) => entry.nodes.map((node) => `${entry.id}: ${node.target.join(" ")}`)
1371
1667
  ).join(", ");
@@ -1376,6 +1672,25 @@ var v13 = {
1376
1672
  "Fix every serious or critical axe violation in the menu, item dialog, cart and order page."
1377
1673
  );
1378
1674
  await page.keyboard.press("Escape");
1675
+ if (!await holdsFocus(cartTrigger)) {
1676
+ semantics.push({
1677
+ code: "focus_not_returned",
1678
+ message: "Focus did not return to the cart control after the cart closed"
1679
+ });
1680
+ }
1681
+ const [semantic] = semantics;
1682
+ assertion(
1683
+ !semantic,
1684
+ semantic?.code ?? "semantics",
1685
+ semantics.map(({ message: message2 }) => message2).join(". "),
1686
+ [
1687
+ ...new Set(
1688
+ semantics.map(
1689
+ ({ code }) => semanticHints[code] ?? "Return focus to the control that opened an overlay when it closes."
1690
+ )
1691
+ )
1692
+ ].join(" ")
1693
+ );
1379
1694
  await context.addCookies([
1380
1695
  {
1381
1696
  name: "zappie_session",
@@ -1421,9 +1736,9 @@ var v14 = {
1421
1736
  id: "V14",
1422
1737
  title: "Performance budget",
1423
1738
  viewports: "mobile",
1424
- async run({ page, metrics, fault }, viewport) {
1739
+ async run({ page, metrics, fault, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
1425
1740
  await page.setViewportSize(viewport);
1426
- const cdp = await applyPhoneProfile(page);
1741
+ const cdp = await applyPhoneProfile(page, cpuThrottlingRate2);
1427
1742
  await page.addInitScript(() => {
1428
1743
  const state = { lcp: 0, cls: 0 };
1429
1744
  Object.assign(window, { __zappiePerformance: state });
@@ -1451,24 +1766,27 @@ var v14 = {
1451
1766
  await page.waitForTimeout(500);
1452
1767
  const values = await page.evaluate(async () => {
1453
1768
  const observed = window.__zappiePerformance ?? { lcp: 0, cls: 0 };
1454
- const scripts = performance.getEntriesByType("resource").filter((entry) => entry.name.endsWith(".js"));
1455
- const sizes = await Promise.all(
1456
- scripts.map(async (entry) => {
1457
- if (typeof CompressionStream === "undefined") {
1458
- const timing = entry;
1459
- return timing.encodedBodySize || timing.transferSize;
1460
- }
1461
- const response = await fetch(entry.name);
1462
- const compressed = response.body?.pipeThrough(
1463
- new CompressionStream("gzip")
1464
- );
1465
- return compressed ? (await new Response(compressed).arrayBuffer()).byteLength : 0;
1466
- })
1467
- );
1468
- const js = sizes.reduce((sum, size) => sum + size, 0);
1469
- return { ...observed, js };
1769
+ const resources = performance.getEntriesByType("resource");
1770
+ const gzipped = async (entry) => {
1771
+ if (typeof CompressionStream === "undefined") {
1772
+ const timing = entry;
1773
+ return timing.encodedBodySize || timing.transferSize;
1774
+ }
1775
+ const response = await fetch(entry.name);
1776
+ const compressed = response.body?.pipeThrough(
1777
+ new CompressionStream("gzip")
1778
+ );
1779
+ return compressed ? (await new Response(compressed).arrayBuffer()).byteLength : 0;
1780
+ };
1781
+ const total = async (extension) => (await Promise.all(
1782
+ resources.filter(
1783
+ (entry) => new URL(entry.name).pathname.endsWith(extension)
1784
+ ).map(gzipped)
1785
+ )).reduce((sum, size) => sum + size, 0);
1786
+ return { ...observed, js: await total(".js"), css: await total(".css") };
1470
1787
  });
1471
1788
  metrics.menuJsKbGz = Math.round(values.js / 1024 * 10) / 10;
1789
+ metrics.menuCssKbGz = Math.round(values.css / 1024 * 10) / 10;
1472
1790
  metrics.lcpMs = Math.round(values.lcp);
1473
1791
  metrics.cls = Math.round(values.cls * 1e3) / 1e3;
1474
1792
  assertion(
@@ -1500,9 +1818,9 @@ var v15 = {
1500
1818
  id: "V15",
1501
1819
  title: "Tappable product cards",
1502
1820
  viewports: "mobile",
1503
- async run({ page, metrics }, viewport) {
1821
+ async run({ page, metrics, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
1504
1822
  await page.setViewportSize(viewport);
1505
- const cdp = await applyPhoneProfile(page);
1823
+ const cdp = await applyPhoneProfile(page, cpuThrottlingRate2);
1506
1824
  try {
1507
1825
  await page.goto("/", { waitUntil: "domcontentloaded" });
1508
1826
  await cdp.send("Network.setCacheDisabled", { cacheDisabled: false });
@@ -1617,6 +1935,372 @@ function itemRouteModule(itemPath) {
1617
1935
  return route ? new URL(route.module, location.href).pathname : void 0;
1618
1936
  }
1619
1937
 
1938
+ // src/checks/overlays.ts
1939
+ var OVERLAY_NAMES = ["bag", "branch", "search", "item"];
1940
+ var IDLE_FALLBACK_MS = 2e3;
1941
+ var NETWORK_QUIET_MS = 500;
1942
+ var SETTLE_ROUNDS = 3;
1943
+ var SETTLE_TIMEOUT_MS = 15e3;
1944
+ var triggerSelector = (name) => `[data-zappie-overlay-trigger="${name}"]`;
1945
+ var overlaySelector = (name) => `[data-zappie-overlay="${name}"]`;
1946
+ var markersHint = 'Add data-zappie-overlay-trigger="bag", "branch", "search" or "item" to each control that opens that overlay, and data-zappie-overlay with the same name on its role="dialog" element. Until the menu has them, this check is skipped.';
1947
+ async function overlayTriggers(page) {
1948
+ const found = [];
1949
+ for (const name of OVERLAY_NAMES) {
1950
+ const trigger = page.locator(`${triggerSelector(name)}:visible`).first();
1951
+ if (await trigger.count() > 0) {
1952
+ found.push({ name, trigger });
1953
+ }
1954
+ }
1955
+ return found;
1956
+ }
1957
+ function skipWithoutTriggers(found) {
1958
+ if (found.length === 0) {
1959
+ skipCheck(
1960
+ "overlay_markers_missing",
1961
+ "The menu has no data-zappie-overlay-trigger controls",
1962
+ markersHint
1963
+ );
1964
+ }
1965
+ }
1966
+ function armOverlayTimer(page, name, timeoutMs) {
1967
+ return page.evaluate(
1968
+ ({ selector, timeout }) => {
1969
+ const state = {};
1970
+ Object.assign(window, { __zappieOverlayTimer: state });
1971
+ const visible = () => {
1972
+ const node = document.querySelector(selector);
1973
+ if (!(node instanceof HTMLElement)) {
1974
+ return false;
1975
+ }
1976
+ const box = node.getBoundingClientRect();
1977
+ return box.width > 0 && box.height > 0 && node.checkVisibility();
1978
+ };
1979
+ document.addEventListener(
1980
+ "click",
1981
+ (event) => {
1982
+ const started = event.timeStamp;
1983
+ const frame = () => {
1984
+ if (visible()) {
1985
+ requestAnimationFrame(() => {
1986
+ state.ms = performance.now() - started;
1987
+ });
1988
+ return;
1989
+ }
1990
+ if (performance.now() - started > timeout) {
1991
+ state.ms = -1;
1992
+ return;
1993
+ }
1994
+ requestAnimationFrame(frame);
1995
+ };
1996
+ requestAnimationFrame(frame);
1997
+ },
1998
+ { capture: true, once: true }
1999
+ );
2000
+ },
2001
+ { selector: overlaySelector(name), timeout: timeoutMs }
2002
+ );
2003
+ }
2004
+ async function readOverlayTimer(page) {
2005
+ const handle = await page.waitForFunction(
2006
+ () => window.__zappieOverlayTimer?.ms
2007
+ );
2008
+ const ms = await handle.jsonValue();
2009
+ return ms < 0 ? void 0 : ms;
2010
+ }
2011
+ async function overlayIsOpen(page, name) {
2012
+ return page.locator(`${overlaySelector(name)}:visible`).first().isVisible().catch(() => false);
2013
+ }
2014
+ function watchNetwork(page) {
2015
+ const inflight = /* @__PURE__ */ new Set();
2016
+ const activity = { at: Date.now(), requests: 0 };
2017
+ const started = (request) => {
2018
+ inflight.add(request);
2019
+ activity.at = Date.now();
2020
+ activity.requests++;
2021
+ };
2022
+ const ended = (request) => {
2023
+ inflight.delete(request);
2024
+ activity.at = Date.now();
2025
+ };
2026
+ page.on("request", started);
2027
+ page.on("requestfinished", ended);
2028
+ page.on("requestfailed", ended);
2029
+ return {
2030
+ get requests() {
2031
+ return activity.requests;
2032
+ },
2033
+ /** Resolves once no request has been in flight for 500 ms, or at the deadline. */
2034
+ async quiet(deadline) {
2035
+ for (; ; ) {
2036
+ const now = Date.now();
2037
+ if (now >= deadline) {
2038
+ return;
2039
+ }
2040
+ if (inflight.size === 0 && now - activity.at >= NETWORK_QUIET_MS) {
2041
+ return;
2042
+ }
2043
+ await new Promise((resolve) => setTimeout(resolve, 50));
2044
+ }
2045
+ },
2046
+ /** Starts the quiet window again from now. */
2047
+ restart() {
2048
+ activity.at = Date.now();
2049
+ },
2050
+ stop() {
2051
+ page.off("request", started);
2052
+ page.off("requestfinished", ended);
2053
+ page.off("requestfailed", ended);
2054
+ }
2055
+ };
2056
+ }
2057
+ function hydrated() {
2058
+ const nodes = [
2059
+ document.querySelector("[data-zappie-item]"),
2060
+ ...document.querySelectorAll("[data-zappie-overlay-trigger]")
2061
+ ];
2062
+ return nodes.every(
2063
+ (node) => node !== null && Object.keys(node).some(
2064
+ (key) => key.startsWith("__reactProps$") || key.startsWith("__reactFiber$")
2065
+ )
2066
+ );
2067
+ }
2068
+ async function openSettledMenu(page) {
2069
+ const network = watchNetwork(page);
2070
+ try {
2071
+ await page.goto("/", { waitUntil: "domcontentloaded" });
2072
+ await page.locator("[data-zappie-item]").first().waitFor();
2073
+ const deadline = Date.now() + SETTLE_TIMEOUT_MS;
2074
+ const remaining = () => Math.max(deadline - Date.now(), 1);
2075
+ await page.waitForFunction(hydrated, null, { timeout: remaining() }).catch(() => void 0);
2076
+ await page.waitForLoadState("load", { timeout: remaining() }).catch(() => void 0);
2077
+ network.restart();
2078
+ await network.quiet(deadline);
2079
+ for (let round = 0; round < SETTLE_ROUNDS; round++) {
2080
+ const before = network.requests;
2081
+ await page.evaluate(
2082
+ ({ fallback, remaining: remaining2 }) => new Promise((resolve) => {
2083
+ window.setTimeout(resolve, remaining2);
2084
+ const idle = (then) => {
2085
+ if (typeof window.requestIdleCallback === "function") {
2086
+ window.requestIdleCallback(() => then());
2087
+ } else {
2088
+ window.setTimeout(then, fallback);
2089
+ }
2090
+ };
2091
+ idle(() => idle(resolve));
2092
+ }),
2093
+ { fallback: IDLE_FALLBACK_MS, remaining: remaining() }
2094
+ );
2095
+ await network.quiet(deadline);
2096
+ if (network.requests === before || Date.now() >= deadline) {
2097
+ return;
2098
+ }
2099
+ }
2100
+ } finally {
2101
+ network.stop();
2102
+ }
2103
+ }
2104
+
2105
+ // src/checks/v16-overlays-open-quickly.ts
2106
+ var OVERLAY_OPEN_BUDGET_MS = 100;
2107
+ var OVERLAY_OPEN_SAMPLES = 3;
2108
+ var OPEN_TIMEOUT_MS = 5e3;
2109
+ var median = (values) => {
2110
+ const sorted = [...values].sort((a, b) => a - b);
2111
+ return sorted[Math.floor(sorted.length / 2)];
2112
+ };
2113
+ var v16 = {
2114
+ id: "V16",
2115
+ title: "Overlays open quickly",
2116
+ viewports: "mobile",
2117
+ async run({ page, metrics, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
2118
+ await page.setViewportSize(viewport);
2119
+ const cdp = await applyPhoneProfile(page, cpuThrottlingRate2);
2120
+ try {
2121
+ await openSettledMenu(page);
2122
+ const names = (await overlayTriggers(page)).map(({ name }) => name);
2123
+ skipWithoutTriggers(names);
2124
+ await cdp.send("Network.setCacheDisabled", { cacheDisabled: false });
2125
+ const timings = {};
2126
+ const samples = {};
2127
+ const unopened = [];
2128
+ for (const name of names) {
2129
+ const opens = [];
2130
+ for (let sample = 0; sample < OVERLAY_OPEN_SAMPLES; sample++) {
2131
+ await openSettledMenu(page);
2132
+ const trigger = page.locator(`${triggerSelector(name)}:visible`).first();
2133
+ await trigger.evaluate(
2134
+ (node) => node.scrollIntoView({ block: "center", inline: "nearest" })
2135
+ );
2136
+ await armOverlayTimer(page, name, OPEN_TIMEOUT_MS);
2137
+ await trigger.click({ noWaitAfter: true });
2138
+ const ms = await readOverlayTimer(page);
2139
+ if (ms === void 0) {
2140
+ break;
2141
+ }
2142
+ opens.push(Math.round(ms));
2143
+ }
2144
+ if (opens.length < OVERLAY_OPEN_SAMPLES) {
2145
+ unopened.push(name);
2146
+ } else {
2147
+ samples[name] = opens;
2148
+ timings[name] = median(opens);
2149
+ }
2150
+ }
2151
+ metrics.overlayOpenMs = timings;
2152
+ metrics.overlayOpenSamplesMs = samples;
2153
+ assertion(
2154
+ unopened.length === 0,
2155
+ "overlay_not_opened",
2156
+ `No data-zappie-overlay painted after tapping ${unopened.map((name) => `the ${name} trigger`).join(", ")}`,
2157
+ `Put data-zappie-overlay with the trigger's name on the role="dialog" element that the trigger opens.`
2158
+ );
2159
+ const slow = Object.entries(timings).filter(
2160
+ ([, ms]) => ms > OVERLAY_OPEN_BUDGET_MS
2161
+ );
2162
+ assertion(
2163
+ slow.length === 0,
2164
+ "overlay_open_too_slow",
2165
+ `Overlays took longer than ${OVERLAY_OPEN_BUDGET_MS} ms to paint, as the median of ${OVERLAY_OPEN_SAMPLES} opens: ${slow.map(([name, ms]) => `${name} ${ms} ms (${samples[name].join(", ")} ms)`).join(", ")}`,
2166
+ "Load overlay code with deferred() from @zappie/foundations, spread useIntentPreload on the trigger, and keep work out of the first render so the overlay paints within 100 ms of the tap."
2167
+ );
2168
+ } finally {
2169
+ await cdp.detach();
2170
+ }
2171
+ }
2172
+ };
2173
+
2174
+ // src/checks/v17-overlay-focus.ts
2175
+ var focusInside = (page, name) => page.evaluate(
2176
+ (selector) => Boolean(document.activeElement?.closest(selector)) || // A native modal dialog keeps focus in its top layer even when the element itself is the dialog.
2177
+ document.activeElement === document.querySelector(selector),
2178
+ overlaySelector(name)
2179
+ );
2180
+ async function eventually(check, timeoutMs = 1e3) {
2181
+ const deadline = Date.now() + timeoutMs;
2182
+ for (; ; ) {
2183
+ if (await check()) {
2184
+ return true;
2185
+ }
2186
+ if (Date.now() > deadline) {
2187
+ return false;
2188
+ }
2189
+ await new Promise((resolve) => setTimeout(resolve, 50));
2190
+ }
2191
+ }
2192
+ var settlesInside = (page, name) => eventually(() => focusInside(page, name), 300);
2193
+ var scrollLocked = (page) => page.evaluate(() => {
2194
+ const root = document.scrollingElement ?? document.documentElement;
2195
+ if (root.scrollHeight <= window.innerHeight) {
2196
+ return true;
2197
+ }
2198
+ const held = (element) => {
2199
+ const style = getComputedStyle(element);
2200
+ return ["hidden", "clip"].includes(style.overflowY) || ["hidden", "clip"].includes(style.overflow) || style.position === "fixed";
2201
+ };
2202
+ return held(document.documentElement) || held(document.body);
2203
+ });
2204
+ async function checkOverlay(page, name) {
2205
+ await openSettledMenu(page);
2206
+ const trigger = page.locator(`${triggerSelector(name)}:visible`).first();
2207
+ await trigger.focus();
2208
+ await page.keyboard.press("Enter");
2209
+ if (!await eventually(() => overlayIsOpen(page, name), 5e3)) {
2210
+ return [
2211
+ {
2212
+ code: "overlay_not_opened",
2213
+ message: `Enter on the ${name} trigger did not open its data-zappie-overlay`,
2214
+ hint: "Open the overlay from the keyboard as well as by tap, and mark its dialog with data-zappie-overlay."
2215
+ }
2216
+ ];
2217
+ }
2218
+ const findings = [];
2219
+ if (!await eventually(() => focusInside(page, name))) {
2220
+ findings.push({
2221
+ code: "overlay_focus_not_moved",
2222
+ message: `Focus stayed outside the ${name} overlay after it opened`,
2223
+ hint: "Move focus into the overlay when it opens. Overlay from @zappie/foundations does this."
2224
+ });
2225
+ } else {
2226
+ const stops = await page.locator(overlaySelector(name)).first().evaluate(
2227
+ (node) => node.querySelectorAll(
2228
+ 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
2229
+ ).length
2230
+ );
2231
+ let escaped = false;
2232
+ for (let index = 0; index < Math.min(stops + 2, 30) && !escaped; index++) {
2233
+ await page.keyboard.press("Tab");
2234
+ escaped = !await settlesInside(page, name);
2235
+ }
2236
+ if (!escaped) {
2237
+ await page.keyboard.press("Shift+Tab");
2238
+ escaped = !await settlesInside(page, name);
2239
+ }
2240
+ if (escaped) {
2241
+ findings.push({
2242
+ code: "overlay_focus_escaped",
2243
+ message: `Tab moved focus out of the open ${name} overlay`,
2244
+ hint: "Keep Tab and Shift+Tab inside the overlay while it is open. Overlay from @zappie/foundations traps focus."
2245
+ });
2246
+ }
2247
+ }
2248
+ if (!await scrollLocked(page)) {
2249
+ findings.push({
2250
+ code: "overlay_scroll_unlocked",
2251
+ message: `The page behind the open ${name} overlay can still scroll`,
2252
+ hint: "Lock page scroll while the overlay is open. Overlay from @zappie/foundations does this."
2253
+ });
2254
+ }
2255
+ await page.keyboard.press("Escape");
2256
+ if (!await eventually(async () => !await overlayIsOpen(page, name))) {
2257
+ findings.push({
2258
+ code: "overlay_escape_ignored",
2259
+ message: `Escape did not close the ${name} overlay`,
2260
+ hint: "Close the overlay on Escape."
2261
+ });
2262
+ return findings;
2263
+ }
2264
+ const returned = await eventually(
2265
+ () => page.evaluate(
2266
+ (selector) => Boolean(document.activeElement?.closest(selector)),
2267
+ triggerSelector(name)
2268
+ )
2269
+ );
2270
+ if (!returned) {
2271
+ findings.push({
2272
+ code: "overlay_focus_not_returned",
2273
+ message: `Focus did not return to the ${name} trigger after the overlay closed`,
2274
+ hint: "Return focus to the control that opened the overlay when it closes. Overlay from @zappie/foundations does this."
2275
+ });
2276
+ }
2277
+ return findings;
2278
+ }
2279
+ var v17 = {
2280
+ id: "V17",
2281
+ title: "Overlays manage focus",
2282
+ viewports: "mobile",
2283
+ async run({ page }, viewport) {
2284
+ await page.setViewportSize(viewport);
2285
+ await page.goto("/", { waitUntil: "domcontentloaded" });
2286
+ await page.locator("[data-zappie-item]").first().waitFor();
2287
+ const names = (await overlayTriggers(page)).map(({ name }) => name);
2288
+ skipWithoutTriggers(names);
2289
+ const findings = [];
2290
+ for (const name of names) {
2291
+ findings.push(...await checkOverlay(page, name));
2292
+ }
2293
+ const [first] = findings;
2294
+ if (first) {
2295
+ throw new VerifyError(
2296
+ first.code,
2297
+ findings.map(({ message }) => message).join(". "),
2298
+ [...new Set(findings.map(({ hint }) => hint))].join(" ")
2299
+ );
2300
+ }
2301
+ }
2302
+ };
2303
+
1620
2304
  // src/checks/index.ts
1621
2305
  var checks = [
1622
2306
  v01,
@@ -1630,7 +2314,9 @@ var checks = [
1630
2314
  v10,
1631
2315
  v13,
1632
2316
  v14,
1633
- v15
2317
+ v15,
2318
+ v16,
2319
+ v17
1634
2320
  ];
1635
2321
  var blocked = [
1636
2322
  [
@@ -1676,6 +2362,60 @@ function skippedChecks(suite) {
1676
2362
  ];
1677
2363
  }
1678
2364
 
2365
+ // src/cpu.ts
2366
+ var REFERENCE_CPU_BENCHMARK = 26e3;
2367
+ var REFERENCE_CPU_THROTTLING_RATE = 4;
2368
+ var MIN_RATE = 1;
2369
+ var MAX_RATE = 8;
2370
+ var ROUNDS = 5;
2371
+ var ROUND_MS = 100;
2372
+ function cpuThrottlingRate(benchmark) {
2373
+ if (!Number.isFinite(benchmark) || benchmark <= 0) {
2374
+ return REFERENCE_CPU_THROTTLING_RATE;
2375
+ }
2376
+ const rate = REFERENCE_CPU_THROTTLING_RATE * benchmark / REFERENCE_CPU_BENCHMARK;
2377
+ return Math.round(Math.min(Math.max(rate, MIN_RATE), MAX_RATE) * 10) / 10;
2378
+ }
2379
+ async function benchmarkCpu(browser) {
2380
+ const context = await browser.newContext();
2381
+ try {
2382
+ const page = await context.newPage();
2383
+ return await page.evaluate(
2384
+ ({ rounds, roundMs }) => {
2385
+ const work = () => {
2386
+ let text = "";
2387
+ for (let index = 0; index < 500; index++) {
2388
+ text += String.fromCharCode(97 + index % 26);
2389
+ }
2390
+ const values = Array.from(
2391
+ { length: 500 },
2392
+ (_, index) => index * 7919 % 500
2393
+ );
2394
+ values.sort((a, b) => a - b);
2395
+ return text.length + values[0];
2396
+ };
2397
+ const scores = [];
2398
+ let sink = 0;
2399
+ for (let round = 0; round < rounds; round++) {
2400
+ const started = performance.now();
2401
+ let iterations = 0;
2402
+ while (performance.now() - started < roundMs) {
2403
+ sink += work();
2404
+ iterations++;
2405
+ }
2406
+ scores.push(iterations * 1e3 / (performance.now() - started));
2407
+ }
2408
+ scores.sort((a, b) => a - b);
2409
+ window.__verifySink = sink;
2410
+ return Math.round(scores[Math.floor(scores.length / 2)]);
2411
+ },
2412
+ { rounds: ROUNDS, roundMs: ROUND_MS }
2413
+ );
2414
+ } finally {
2415
+ await context.close();
2416
+ }
2417
+ }
2418
+
1679
2419
  // src/report.ts
1680
2420
  import { createPrivateKey, randomUUID, sign } from "crypto";
1681
2421
  var REPORT_SCHEMA = "https://registry.zappie.ai/v1/schema/verify-report.json";
@@ -1762,7 +2502,7 @@ async function reset(options) {
1762
2502
  }
1763
2503
 
1764
2504
  // src/version.ts
1765
- var PACKAGE_VERSION = true ? "0.1.5" : "0.0.0-development";
2505
+ var PACKAGE_VERSION = true ? "0.2.2" : "0.0.0-development";
1766
2506
 
1767
2507
  // src/runner.ts
1768
2508
  var VERSION = PACKAGE_VERSION;
@@ -1811,7 +2551,7 @@ async function execute(check, run2) {
1811
2551
  message: failure instanceof Error ? failure.message : String(failure),
1812
2552
  hint: `Fix the failure in ${check.id} (${check.title.toLowerCase()}) and rerun verify.`
1813
2553
  };
1814
- if (error.code === "dependency_blocked") {
2554
+ if (error.code === "dependency_blocked" || failure instanceof VerifyError && failure.skip) {
1815
2555
  return {
1816
2556
  id: check.id,
1817
2557
  title: check.title,
@@ -1853,12 +2593,29 @@ async function runVerify(options, fault) {
1853
2593
  }
1854
2594
  const started = Date.now();
1855
2595
  const browser = await chromium.launch();
2596
+ const cpuBenchmark = await benchmarkCpu(browser).catch(() => void 0);
2597
+ const throttlingRate = cpuBenchmark === void 0 ? REFERENCE_CPU_THROTTLING_RATE : cpuThrottlingRate(cpuBenchmark);
1856
2598
  const context = await browser.newContext(browserOptions(options));
1857
2599
  if (options.standInForPlatform) {
1858
2600
  await context.route(
1859
2601
  "**/_zappie/view",
1860
2602
  (route) => route.fulfill({ status: 204 })
1861
2603
  );
2604
+ await context.route(
2605
+ (url) => url.pathname === "/_zappie/session",
2606
+ async (route) => {
2607
+ const cookie = await route.request().headerValue("cookie") ?? "";
2608
+ const signedIn = cookie.split(";").some((part) => part.trim() === "zappie_session=signed");
2609
+ await route.fulfill({
2610
+ status: 200,
2611
+ contentType: "application/json",
2612
+ headers: { "cache-control": "no-store" },
2613
+ body: JSON.stringify({
2614
+ customer: signedIn ? { id: "customer_verify", given_name: "Test" } : null
2615
+ })
2616
+ });
2617
+ }
2618
+ );
1862
2619
  }
1863
2620
  await context.tracing.start({ screenshots: true, snapshots: true });
1864
2621
  const page = await context.newPage();
@@ -1871,6 +2628,7 @@ async function runVerify(options, fault) {
1871
2628
  context,
1872
2629
  page,
1873
2630
  metrics,
2631
+ cpuThrottlingRate: throttlingRate,
1874
2632
  ...fault ? { fault } : {}
1875
2633
  };
1876
2634
  const results = [await reset(options)];
@@ -1899,7 +2657,9 @@ async function runVerify(options, fault) {
1899
2657
  runner: options.runner ?? "cli",
1900
2658
  verifyVersion: VERSION,
1901
2659
  sdkVersion,
1902
- browser: `chromium ${browser.version()}`
2660
+ browser: `chromium ${browser.version()}`,
2661
+ ...cpuBenchmark === void 0 ? {} : { cpuBenchmark },
2662
+ cpuThrottlingRate: throttlingRate
1903
2663
  },
1904
2664
  startedAt: new Date(started).toISOString(),
1905
2665
  durationMs: Date.now() - started,
@@ -1971,12 +2731,64 @@ function normalizeUrl(value) {
1971
2731
  }
1972
2732
  }
1973
2733
 
2734
+ // src/regression.ts
2735
+ var DEFAULT_REGRESSION_THRESHOLDS = {
2736
+ menuJsKbGz: { relative: 0.05, absolute: 2 },
2737
+ menuCssKbGz: { relative: 0.05, absolute: 1 },
2738
+ lcpMs: { relative: 0.1, absolute: 150 },
2739
+ cls: { relative: 0, absolute: 0.02 },
2740
+ productCardTappableMs: { relative: 0.1, absolute: 150 },
2741
+ productCardOpenMs: { relative: 0.15, absolute: 30 },
2742
+ overlayOpenMs: { relative: 0.15, absolute: 20 }
2743
+ };
2744
+ function flatten(metrics) {
2745
+ const values = /* @__PURE__ */ new Map();
2746
+ for (const [key, value] of Object.entries(metrics)) {
2747
+ if (key === "overlayOpenMs" && value && typeof value === "object") {
2748
+ for (const [name, ms] of Object.entries(value)) {
2749
+ if (Number.isFinite(ms)) {
2750
+ values.set(`overlayOpenMs.${name}`, {
2751
+ kind: "overlayOpenMs",
2752
+ value: ms
2753
+ });
2754
+ }
2755
+ }
2756
+ } else if (key in DEFAULT_REGRESSION_THRESHOLDS && typeof value === "number" && Number.isFinite(value)) {
2757
+ values.set(key, { kind: key, value });
2758
+ }
2759
+ }
2760
+ return values;
2761
+ }
2762
+ function findRegressions(baseline, head, thresholds = {}) {
2763
+ const limits = { ...DEFAULT_REGRESSION_THRESHOLDS, ...thresholds };
2764
+ const current = flatten(head.metrics);
2765
+ const regressions = [];
2766
+ for (const [metric, { kind, value: base }] of flatten(baseline.metrics)) {
2767
+ const { relative, absolute } = limits[kind];
2768
+ const allowed = base + Math.max(absolute, relative * base);
2769
+ const value = current.get(metric)?.value;
2770
+ if (value === void 0 || value > allowed) {
2771
+ regressions.push({
2772
+ metric,
2773
+ base,
2774
+ head: value,
2775
+ allowed: Math.round(allowed * 1e3) / 1e3
2776
+ });
2777
+ }
2778
+ }
2779
+ return regressions.sort(
2780
+ (left, right) => left.metric.localeCompare(right.metric)
2781
+ );
2782
+ }
2783
+
1974
2784
  // src/index.ts
1975
2785
  function verify(options) {
1976
2786
  return runVerify(options);
1977
2787
  }
1978
2788
  export {
2789
+ DEFAULT_REGRESSION_THRESHOLDS,
1979
2790
  VerifyError,
2791
+ findRegressions,
1980
2792
  printSummary,
1981
2793
  verify,
1982
2794
  verifyProject