@zappie/verify 0.1.5 → 0.2.3

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 +65 -0
  2. package/README.md +43 -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 +55 -5
  10. package/dist/index.js +1475 -86
  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 +25 -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";
@@ -265,10 +269,69 @@ async function readStore(cwd) {
265
269
  }
266
270
 
267
271
  // src/runner.ts
268
- import { chromium, errors } from "@playwright/test";
272
+ import {
273
+ chromium,
274
+ errors
275
+ } from "@playwright/test";
269
276
  import { mkdir } from "fs/promises";
270
277
  import path3 from "path";
271
278
 
279
+ // src/cpu.ts
280
+ var REFERENCE_CPU_BENCHMARK = 26e3;
281
+ var REFERENCE_CPU_THROTTLING_RATE = 4;
282
+ var MIN_RATE = 1;
283
+ var MAX_RATE = 8;
284
+ var ROUNDS = 5;
285
+ var ROUND_MS = 100;
286
+ function cpuThrottlingRate(benchmark) {
287
+ if (!Number.isFinite(benchmark) || benchmark <= 0) {
288
+ return REFERENCE_CPU_THROTTLING_RATE;
289
+ }
290
+ const rate = REFERENCE_CPU_THROTTLING_RATE * benchmark / REFERENCE_CPU_BENCHMARK;
291
+ return Math.round(Math.min(Math.max(rate, MIN_RATE), MAX_RATE) * 10) / 10;
292
+ }
293
+ function benchmarkPage(page) {
294
+ return page.evaluate(
295
+ ({ rounds, roundMs }) => {
296
+ const work = () => {
297
+ let text = "";
298
+ for (let index = 0; index < 500; index++) {
299
+ text += String.fromCharCode(97 + index % 26);
300
+ }
301
+ const values = Array.from(
302
+ { length: 500 },
303
+ (_, index) => index * 7919 % 500
304
+ );
305
+ values.sort((a, b) => a - b);
306
+ return text.length + values[0];
307
+ };
308
+ const scores = [];
309
+ let sink = 0;
310
+ for (let round = 0; round < rounds; round++) {
311
+ const started = performance.now();
312
+ let iterations = 0;
313
+ while (performance.now() - started < roundMs) {
314
+ sink += work();
315
+ iterations++;
316
+ }
317
+ scores.push(iterations * 1e3 / (performance.now() - started));
318
+ }
319
+ scores.sort((a, b) => a - b);
320
+ window.__verifySink = sink;
321
+ return Math.round(scores[Math.floor(scores.length / 2)]);
322
+ },
323
+ { rounds: ROUNDS, roundMs: ROUND_MS }
324
+ );
325
+ }
326
+ async function benchmarkCpu(browser) {
327
+ const context = await browser.newContext();
328
+ try {
329
+ return await benchmarkPage(await context.newPage());
330
+ } finally {
331
+ await context.close();
332
+ }
333
+ }
334
+
272
335
  // src/checks/common.ts
273
336
  async function open(page, path4 = "/") {
274
337
  const response = await page.goto(path4, { waitUntil: "domcontentloaded" });
@@ -285,7 +348,9 @@ async function debugValue(page, expression) {
285
348
  }
286
349
  var mobile = { width: 390, height: 844 };
287
350
  var desktop = { width: 1440, height: 900 };
288
- async function applyPhoneProfile(page) {
351
+ async function applyPhoneProfile(page, fallbackRate) {
352
+ const benchmark = await benchmarkPage(page).catch(() => void 0);
353
+ const rate = benchmark === void 0 ? fallbackRate : cpuThrottlingRate(benchmark);
289
354
  const cdp = await page.context().newCDPSession(page);
290
355
  await cdp.send("Network.enable");
291
356
  await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
@@ -296,8 +361,144 @@ async function applyPhoneProfile(page) {
296
361
  uploadThroughput: 1e5,
297
362
  connectionType: "cellular3g"
298
363
  });
299
- await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
300
- return cdp;
364
+ await cdp.send("Emulation.setCPUThrottlingRate", { rate });
365
+ return { cdp, rate };
366
+ }
367
+ var ITEM_DIALOG = '[data-zappie-overlay="item"], [role="dialog"]';
368
+ async function itemOpener(page, itemId) {
369
+ const marked = `[data-zappie-item="${itemId}"]`;
370
+ await page.locator(marked).first().waitFor();
371
+ const own = page.locator(
372
+ [
373
+ `a[href]${marked}`,
374
+ `button${marked}`,
375
+ `[role="link"]${marked}`,
376
+ `[role="button"]${marked}`
377
+ ].join(", ")
378
+ ).filter({ visible: true }).first();
379
+ if (await own.count()) {
380
+ return { opener: own, focusTarget: own };
381
+ }
382
+ const card = page.locator(marked).filter({ visible: true }).first();
383
+ const inner = card.locator('a[href], [role="link"]').first();
384
+ if (await inner.count()) {
385
+ return { opener: inner, focusTarget: card };
386
+ }
387
+ const cell = card.locator("xpath=..");
388
+ return {
389
+ opener: cell.locator('a[href], [role="link"]').first(),
390
+ focusTarget: cell
391
+ };
392
+ }
393
+ async function openItem(page, itemId) {
394
+ const found = await itemOpener(page, itemId);
395
+ await found.opener.click();
396
+ await itemShown(page);
397
+ return found;
398
+ }
399
+ async function itemShown(page) {
400
+ await Promise.race([
401
+ page.waitForURL((url) => /\/item\//.test(url.pathname)),
402
+ page.locator(ITEM_DIALOG).filter({ visible: true }).first().waitFor()
403
+ ]);
404
+ }
405
+ async function closeItem(page) {
406
+ const dialog = page.locator(ITEM_DIALOG).filter({ visible: true }).last();
407
+ if (!await dialog.count()) {
408
+ await page.goBack();
409
+ return;
410
+ }
411
+ const close = dialog.getByRole("button", { name: /close/i }).filter({ visible: true }).first();
412
+ if (await close.count()) {
413
+ await close.click();
414
+ return;
415
+ }
416
+ const outside = page.getByRole("button", { name: /close/i }).filter({ visible: true }).last();
417
+ if (await outside.count()) {
418
+ await outside.click();
419
+ return;
420
+ }
421
+ await page.keyboard.press("Escape");
422
+ }
423
+ function itemOpenerSelector(itemId) {
424
+ const marked = itemId ? `[data-zappie-item="${itemId}"]` : "[data-zappie-item]";
425
+ return [
426
+ `a[href]${marked}`,
427
+ `button${marked}`,
428
+ `[role="link"]${marked}`,
429
+ `[role="button"]${marked}`,
430
+ `${marked} a[href]`
431
+ ].join(", ");
432
+ }
433
+ async function cartTriggerSelector(page) {
434
+ return await page.locator("[data-zappie-cart-trigger]").count() ? "[data-zappie-cart-trigger]" : '[data-zappie-overlay-trigger="bag"]';
435
+ }
436
+ async function cartTrigger(page) {
437
+ return page.locator(await cartTriggerSelector(page)).filter({ visible: true }).first();
438
+ }
439
+ async function tabTo(page, selector, presses = 60) {
440
+ for (let index = 0; index < presses; index++) {
441
+ await page.keyboard.press("Tab");
442
+ const inside = await page.evaluate(
443
+ (value) => Boolean(document.activeElement?.closest(value)),
444
+ selector
445
+ );
446
+ if (inside) {
447
+ return true;
448
+ }
449
+ }
450
+ return false;
451
+ }
452
+ async function signIn(page, site = page.url()) {
453
+ await page.context().addCookies([
454
+ {
455
+ name: "zappie_session",
456
+ value: "signed",
457
+ domain: new URL(site).hostname,
458
+ path: "/"
459
+ }
460
+ ]);
461
+ }
462
+ async function revealField(page, field, input, reach = async () => {
463
+ }) {
464
+ const site = new URL(page.url());
465
+ const opener = page.locator(`[data-zappie-field-open="${field}"]`).filter({ visible: true }).first();
466
+ const shown = input.filter({ visible: true }).first();
467
+ await shown.or(opener).first().waitFor();
468
+ if (await shown.isVisible()) {
469
+ return shown;
470
+ }
471
+ await opener.click();
472
+ if (await sentToSignIn(page, site, shown)) {
473
+ await signIn(page, site.href);
474
+ await open(page, site.pathname);
475
+ await reach();
476
+ await opener.click();
477
+ }
478
+ await shown.waitFor();
479
+ return shown;
480
+ }
481
+ async function sentToSignIn(page, site, shown) {
482
+ const deadline = Date.now() + 1e4;
483
+ while (Date.now() < deadline) {
484
+ const url = new URL(page.url());
485
+ if (url.origin !== site.origin || url.pathname === "/_zappie/login") {
486
+ return true;
487
+ }
488
+ if (await shown.isVisible().catch(() => false)) {
489
+ return false;
490
+ }
491
+ await page.waitForTimeout(100);
492
+ }
493
+ return false;
494
+ }
495
+ async function dialogConfirm(page, confirm) {
496
+ const marked = page.locator(confirm).filter({ visible: true }).first();
497
+ if (await marked.count()) {
498
+ return marked;
499
+ }
500
+ const button = page.locator('[role="dialog"]').filter({ visible: true }).last().locator('footer button:enabled, button[type="submit"]:enabled').filter({ visible: true }).last();
501
+ return await button.count() ? button : void 0;
301
502
  }
302
503
 
303
504
  // src/checks/v01-routes.ts
@@ -369,6 +570,7 @@ var v02 = {
369
570
  };
370
571
 
371
572
  // src/fixtures.ts
573
+ var preparedPages = /* @__PURE__ */ new WeakMap();
372
574
  async function installFault(page, fault) {
373
575
  if (!fault) {
374
576
  return;
@@ -391,6 +593,361 @@ async function installFault(page, fault) {
391
593
  await route.continue();
392
594
  });
393
595
  }
596
+ if (fault === "extra-h1") {
597
+ await page.addInitScript(() => {
598
+ new MutationObserver(() => {
599
+ const main = document.querySelector("main");
600
+ if (main && !document.querySelector("[data-verify-extra-h1]")) {
601
+ const heading = document.createElement("h1");
602
+ heading.setAttribute("data-verify-extra-h1", "");
603
+ heading.textContent = "Second heading";
604
+ main.append(heading);
605
+ }
606
+ }).observe(document, { childList: true, subtree: true });
607
+ });
608
+ }
609
+ if (fault === "card-link") {
610
+ await page.addInitScript(() => {
611
+ new MutationObserver(() => {
612
+ for (const node of document.querySelectorAll(
613
+ '[role="dialog"] [aria-label="Close item"]'
614
+ )) {
615
+ node.setAttribute("aria-label", "Close");
616
+ }
617
+ }).observe(document, {
618
+ childList: true,
619
+ subtree: true,
620
+ attributes: true,
621
+ attributeFilter: ["aria-label"]
622
+ });
623
+ });
624
+ }
625
+ if (fault === "no-add-marker") {
626
+ await page.addInitScript(() => {
627
+ new MutationObserver(() => {
628
+ for (const node of document.querySelectorAll(
629
+ '[data-zappie-action="add-to-cart"]'
630
+ )) {
631
+ node.removeAttribute("data-zappie-action");
632
+ }
633
+ }).observe(document, { childList: true, subtree: true });
634
+ });
635
+ }
636
+ if (fault === "add-enabled-invalid" || fault === "add-enabled-silent") {
637
+ await page.addInitScript((flags) => {
638
+ new MutationObserver(() => {
639
+ for (const original of document.querySelectorAll(
640
+ '[role="dialog"] button[data-zappie-action="add-to-cart"]:not([data-verify-proxied])'
641
+ )) {
642
+ original.setAttribute("data-verify-proxied", "");
643
+ original.removeAttribute("data-zappie-action");
644
+ original.style.display = "none";
645
+ const proxy = document.createElement("button");
646
+ proxy.type = "button";
647
+ proxy.textContent = "Add";
648
+ proxy.setAttribute("data-zappie-action", "add-to-cart");
649
+ proxy.setAttribute("data-verify-proxied", "");
650
+ proxy.addEventListener("click", () => {
651
+ if (!original.disabled) {
652
+ original.click();
653
+ return;
654
+ }
655
+ const group = original.closest('[role="dialog"]')?.querySelector(
656
+ '[data-zappie-option-group][aria-required="true"]:not(:has([data-state="checked"]))'
657
+ );
658
+ if (flags && group) {
659
+ group.setAttribute("aria-invalid", "true");
660
+ group.setAttribute("data-invalid", "");
661
+ const message = document.createElement("p");
662
+ message.textContent = "Choose one to continue";
663
+ group.append(message);
664
+ group.focus();
665
+ }
666
+ });
667
+ original.after(proxy);
668
+ }
669
+ }).observe(document, { childList: true, subtree: true });
670
+ }, fault === "add-enabled-invalid");
671
+ }
672
+ if (fault === "total-on-checkout" || fault === "total-missing") {
673
+ await page.addInitScript((onCheckout) => {
674
+ window.addEventListener(
675
+ "click",
676
+ (event) => {
677
+ const link = event.target?.closest("a[data-zappie-cart-checkout]");
678
+ if (!link) {
679
+ return;
680
+ }
681
+ event.preventDefault();
682
+ event.stopImmediatePropagation();
683
+ history.pushState(history.state, "", link.href);
684
+ window.dispatchEvent(
685
+ new PopStateEvent("popstate", { state: history.state })
686
+ );
687
+ },
688
+ { capture: true }
689
+ );
690
+ new MutationObserver(() => {
691
+ for (const node of document.querySelectorAll(
692
+ '[data-zappie-overlay="bag"] [data-zappie-total]'
693
+ )) {
694
+ sessionStorage.setItem("verify-total", node.textContent ?? "");
695
+ node.removeAttribute("data-zappie-total");
696
+ }
697
+ const text = sessionStorage.getItem("verify-total");
698
+ if (onCheckout && text && location.pathname === "/checkout" && document.querySelector("main") && !document.querySelector("[data-verify-total]")) {
699
+ const total = document.createElement("p");
700
+ total.setAttribute("data-verify-total", "");
701
+ total.setAttribute("data-zappie-total", "");
702
+ total.textContent = text;
703
+ document.querySelector("main").prepend(total);
704
+ }
705
+ }).observe(document, { childList: true, subtree: true });
706
+ }, fault === "total-on-checkout");
707
+ }
708
+ if (fault === "address-confirm" || fault === "address-not-stored") {
709
+ await page.addInitScript((confirms) => {
710
+ let release = false;
711
+ window.addEventListener(
712
+ "click",
713
+ (event) => {
714
+ const option = event.target?.closest(
715
+ '[role="option"]'
716
+ );
717
+ if (!option || release) {
718
+ return;
719
+ }
720
+ event.stopImmediatePropagation();
721
+ event.preventDefault();
722
+ if (!confirms) {
723
+ return;
724
+ }
725
+ const dialog = document.createElement("div");
726
+ dialog.setAttribute("role", "dialog");
727
+ dialog.setAttribute("aria-label", "Confirm address");
728
+ const footer = document.createElement("footer");
729
+ const button = document.createElement("button");
730
+ button.type = "button";
731
+ button.textContent = "Deliver here";
732
+ button.addEventListener("click", () => {
733
+ dialog.remove();
734
+ release = true;
735
+ option.click();
736
+ release = false;
737
+ });
738
+ footer.append(button);
739
+ dialog.append(footer);
740
+ document.body.append(dialog);
741
+ },
742
+ { capture: true }
743
+ );
744
+ }, fault === "address-confirm");
745
+ }
746
+ if (fault === "field-rows") {
747
+ await page.addInitScript(() => {
748
+ const fields = [
749
+ ["address", "[data-zappie-address-input]"],
750
+ ["phone", 'input[type="tel"]']
751
+ ];
752
+ new MutationObserver(() => {
753
+ for (const [field, selector] of fields) {
754
+ for (const input of document.querySelectorAll(
755
+ `${selector}:not([data-verify-row])`
756
+ )) {
757
+ input.setAttribute("data-verify-row", "");
758
+ input.hidden = true;
759
+ const row = document.createElement("button");
760
+ row.type = "button";
761
+ row.textContent = `Add your ${field}`;
762
+ row.setAttribute("data-zappie-field-open", field);
763
+ row.addEventListener("click", () => {
764
+ if (!document.cookie.includes("zappie_session=signed")) {
765
+ window.location.assign(
766
+ new URL(
767
+ `/_zappie/login?return=${encodeURIComponent(location.pathname)}`,
768
+ location.href
769
+ ).href
770
+ );
771
+ return;
772
+ }
773
+ row.remove();
774
+ input.hidden = false;
775
+ });
776
+ input.before(row);
777
+ }
778
+ }
779
+ }).observe(document, { childList: true, subtree: true });
780
+ });
781
+ }
782
+ if (fault === "overlay-markers") {
783
+ await page.addInitScript(() => {
784
+ new MutationObserver(() => {
785
+ for (const node of document.querySelectorAll(
786
+ "[data-zappie-cart-trigger]:not([data-zappie-overlay-trigger])"
787
+ )) {
788
+ node.setAttribute("data-zappie-overlay-trigger", "bag");
789
+ }
790
+ for (const node of document.querySelectorAll(
791
+ '[role="dialog"]:not([data-zappie-overlay])'
792
+ )) {
793
+ if (node.textContent?.includes("Your cart")) {
794
+ node.setAttribute("data-zappie-overlay", "bag");
795
+ }
796
+ }
797
+ }).observe(document, { childList: true, subtree: true });
798
+ });
799
+ }
800
+ if (fault === "bag-after-add" || fault === "bag-after-add-on-tap") {
801
+ await page.addInitScript(() => {
802
+ const sync = () => {
803
+ let count = 0;
804
+ try {
805
+ count = window.__zappie?.cart().count ?? 0;
806
+ } catch {
807
+ }
808
+ for (const node of document.querySelectorAll(
809
+ '[data-zappie-overlay-trigger="bag"]'
810
+ )) {
811
+ if (count === 0) {
812
+ node.style.setProperty("display", "none", "important");
813
+ } else {
814
+ node.style.removeProperty("display");
815
+ }
816
+ }
817
+ };
818
+ new MutationObserver(sync).observe(document, {
819
+ childList: true,
820
+ subtree: true
821
+ });
822
+ window.setInterval(sync, 50);
823
+ });
824
+ }
825
+ if (fault === "bag-on-tap" || fault === "bag-after-add-on-tap") {
826
+ let release = () => {
827
+ };
828
+ let tapped = Promise.resolve();
829
+ const reset2 = () => {
830
+ release();
831
+ tapped = new Promise((resolve) => {
832
+ release = resolve;
833
+ });
834
+ };
835
+ reset2();
836
+ page.on("framenavigated", (frame) => {
837
+ if (frame === page.mainFrame()) {
838
+ reset2();
839
+ }
840
+ });
841
+ await page.exposeFunction("__verifyTapped", () => release());
842
+ await page.addInitScript(() => {
843
+ document.addEventListener(
844
+ "click",
845
+ () => {
846
+ void window.__verifyTapped?.();
847
+ },
848
+ { capture: true }
849
+ );
850
+ });
851
+ await page.route(
852
+ /\/assets\/cart-drawer-[^/]*\.js(?:\?|$)/,
853
+ async (route) => {
854
+ await tapped;
855
+ await route.continue().catch(() => {
856
+ });
857
+ }
858
+ );
859
+ }
860
+ if (fault === "one-slow-open") {
861
+ const browser = page.context().browser();
862
+ const index = browser ? preparedPages.get(browser) ?? 0 : 0;
863
+ if (browser) {
864
+ preparedPages.set(browser, index + 1);
865
+ }
866
+ if (index > 0 && (index - 1) % 3 === 0) {
867
+ await page.addInitScript(() => {
868
+ document.addEventListener(
869
+ "click",
870
+ (event) => {
871
+ if (event.target instanceof Element && event.target.closest("[data-zappie-overlay-trigger]")) {
872
+ const until = performance.now() + 250;
873
+ while (performance.now() < until) {
874
+ }
875
+ }
876
+ },
877
+ { capture: true }
878
+ );
879
+ });
880
+ }
881
+ }
882
+ if (fault === "late-hydration") {
883
+ await page.route(
884
+ /\/assets\/entry\.client-[^/]*\.js(?:\?|$)/,
885
+ async (route) => {
886
+ const response = await route.fetch();
887
+ const headers = Object.fromEntries(
888
+ Object.entries(response.headers()).filter(
889
+ ([name]) => !["content-encoding", "content-length"].includes(name)
890
+ )
891
+ );
892
+ await route.fulfill({
893
+ response,
894
+ headers,
895
+ body: `await new Promise((resolve) => setTimeout(resolve, 2000));
896
+ ${await response.text()}`
897
+ });
898
+ }
899
+ );
900
+ }
901
+ if (fault === "late-idle") {
902
+ await page.addInitScript(() => {
903
+ const request = window.requestIdleCallback?.bind(window);
904
+ const cancel = window.cancelIdleCallback?.bind(window);
905
+ if (!request || !cancel) {
906
+ return;
907
+ }
908
+ const handles = /* @__PURE__ */ new Map();
909
+ let next = 1;
910
+ window.requestIdleCallback = (callback, options) => {
911
+ const handle = next++;
912
+ handles.set(
913
+ handle,
914
+ window.setTimeout(() => {
915
+ handles.set(handle, -request(callback, options));
916
+ }, 1500)
917
+ );
918
+ return handle;
919
+ };
920
+ window.cancelIdleCallback = (handle) => {
921
+ const scheduled = handles.get(handle);
922
+ if (scheduled === void 0) {
923
+ return;
924
+ }
925
+ if (scheduled < 0) {
926
+ cancel(-scheduled);
927
+ } else {
928
+ window.clearTimeout(scheduled);
929
+ }
930
+ handles.delete(handle);
931
+ };
932
+ });
933
+ }
934
+ if (fault === "no-overlay-markers") {
935
+ await page.addInitScript(() => {
936
+ new MutationObserver(() => {
937
+ for (const node of document.querySelectorAll(
938
+ "[data-zappie-overlay-trigger], [data-zappie-overlay]"
939
+ )) {
940
+ node.removeAttribute("data-zappie-overlay-trigger");
941
+ node.removeAttribute("data-zappie-overlay");
942
+ }
943
+ }).observe(document, {
944
+ childList: true,
945
+ subtree: true,
946
+ attributes: true,
947
+ attributeFilter: ["data-zappie-overlay-trigger", "data-zappie-overlay"]
948
+ });
949
+ });
950
+ }
394
951
  if (fault === "quick-add-via-item") {
395
952
  await page.addInitScript(() => {
396
953
  const removeQuickAdds = () => {
@@ -484,6 +1041,22 @@ async function applyPageFault(page, fault) {
484
1041
  }
485
1042
  });
486
1043
  }
1044
+ if (fault === "card-link") {
1045
+ await page.waitForFunction(() => {
1046
+ const link = document.querySelector("a[data-zappie-item]");
1047
+ return link !== null && Object.keys(link).some((key) => key.startsWith("__reactProps$"));
1048
+ });
1049
+ await page.locator("a[data-zappie-item]").evaluateAll((links) => {
1050
+ for (const link of links) {
1051
+ const id = link.getAttribute("data-zappie-item");
1052
+ const card = link.parentElement?.querySelector(
1053
+ `article[data-zappie-item="${id}"]`
1054
+ );
1055
+ link.textContent = card?.querySelector("h3")?.textContent ?? "Item";
1056
+ card?.removeAttribute("data-zappie-item");
1057
+ }
1058
+ });
1059
+ }
487
1060
  if (fault === "missing-marker") {
488
1061
  await page.locator("[data-zappie-item]").evaluateAll((nodes) => {
489
1062
  for (const node of nodes) {
@@ -719,11 +1292,11 @@ var v05 = {
719
1292
  "The test menu has no available item with required options",
720
1293
  "Keep one available configurable item in the test menu."
721
1294
  );
722
- const item = page.locator(`[data-zappie-item="${selected.id}"]`).first();
723
- await item.locator("xpath=..").getByRole("link", { name: "View details" }).click();
1295
+ await openItem(page, selected.id);
724
1296
  const dialog = page.getByRole("dialog");
725
1297
  await dialog.waitFor();
726
1298
  const required = dialog.locator('[data-zappie-option-group][aria-required="true"]').first();
1299
+ await required.waitFor({ timeout: 5e3 }).catch(() => void 0);
727
1300
  assertion(
728
1301
  await required.count(),
729
1302
  "required_group_missing",
@@ -732,23 +1305,41 @@ var v05 = {
732
1305
  );
733
1306
  const before = await debugValue(page, () => window.__zappie.cart().count);
734
1307
  const add = dialog.locator('[data-zappie-action="add-to-cart"]');
735
- assertion(
736
- await add.isDisabled(),
737
- "required_option_bypassed",
738
- "Add is enabled before the shopper chooses a required option",
739
- "Disable Add until the shopper completes every required option group."
740
- );
741
- for (let index = 0; index < 5 && await add.isDisabled(); index++) {
742
- const unsatisfied = dialog.locator(
743
- '[data-zappie-option-group][aria-required="true"]:not(:has([data-state="checked"]))'
744
- ).first();
1308
+ const unsatisfied = dialog.locator(
1309
+ '[data-zappie-option-group][aria-required="true"]:not(:has([data-state="checked"]))'
1310
+ );
1311
+ const blocked2 = await add.isDisabled();
1312
+ if (!blocked2) {
1313
+ const group = unsatisfied.first();
1314
+ const textBefore = (await group.innerText().catch(() => "")).trim();
1315
+ await add.click();
1316
+ await page.waitForTimeout(500);
1317
+ const added = await page.evaluate(
1318
+ (count) => window.__zappie.cart().count > count,
1319
+ before
1320
+ );
745
1321
  assertion(
746
- await unsatisfied.count(),
1322
+ !added && await dialog.isVisible(),
1323
+ "required_option_bypassed",
1324
+ "Add put the item in the cart before the shopper chose a required option",
1325
+ "Disable Add until the shopper completes every required option group, or keep the item open and flag the group when Add is tapped."
1326
+ );
1327
+ assertion(
1328
+ await group.evaluate(flagged, textBefore),
1329
+ "required_option_unflagged",
1330
+ "Tapping Add before a required choice did not flag the group",
1331
+ "Mark the group that needs a choice with aria-invalid or data-invalid and show a message saying why."
1332
+ );
1333
+ }
1334
+ for (let index = 0; index < 5 && (blocked2 ? await add.isDisabled() : await unsatisfied.count() > 0); index++) {
1335
+ const next = unsatisfied.first();
1336
+ assertion(
1337
+ await next.count(),
747
1338
  "required_option_unselectable",
748
1339
  "A required option group has no selectable choice",
749
1340
  "Keep an available choice in every required option group."
750
1341
  );
751
- await unsatisfied.locator('[data-zappie-option]:not([aria-disabled="true"])').first().click();
1342
+ await next.locator('[data-zappie-option]:not([aria-disabled="true"])').first().click();
752
1343
  }
753
1344
  assertion(
754
1345
  await add.isEnabled(),
@@ -776,6 +1367,22 @@ var v05 = {
776
1367
  );
777
1368
  }
778
1369
  };
1370
+ function flagged(group, textBefore) {
1371
+ const invalid = group.getAttribute("aria-invalid") === "true" || group.hasAttribute("data-invalid") || group.querySelector('[aria-invalid="true"], [data-invalid]') !== null;
1372
+ if (!invalid) {
1373
+ return false;
1374
+ }
1375
+ const shown = (element) => element instanceof HTMLElement && element.getClientRects().length > 0 && Boolean(element.innerText.trim());
1376
+ if (group instanceof HTMLElement && group.innerText.trim().length > textBefore.length) {
1377
+ return true;
1378
+ }
1379
+ const referenced = ["aria-describedby", "aria-errormessage"].flatMap((name) => group.getAttribute(name)?.split(/\s+/) ?? []).some((id) => shown(document.getElementById(id)));
1380
+ if (referenced) {
1381
+ return true;
1382
+ }
1383
+ const dialog = group.closest('[role="dialog"]') ?? document;
1384
+ return [...dialog.querySelectorAll('[role="alert"]')].some(shown);
1385
+ }
779
1386
 
780
1387
  // src/checks/v06-quote.ts
781
1388
  var v06 = {
@@ -787,9 +1394,20 @@ var v06 = {
787
1394
  if (new URL(page.url()).pathname !== "/") {
788
1395
  await open(page);
789
1396
  }
790
- const total = page.locator("[data-zappie-total]");
791
- if (!await total.isVisible()) {
792
- await page.locator("[data-zappie-cart-trigger]").first().click();
1397
+ const total = page.locator("[data-zappie-total]").filter({ visible: true });
1398
+ const bagOpen = page.locator('[data-zappie-overlay="bag"]').filter({ visible: true });
1399
+ if (!await total.count()) {
1400
+ if (!await bagOpen.count()) {
1401
+ await (await cartTrigger(page)).click();
1402
+ }
1403
+ await total.first().waitFor({ timeout: 2e3 }).catch(() => void 0);
1404
+ }
1405
+ if (!await total.count()) {
1406
+ const handover = page.locator('[data-zappie-overlay="bag"], [role="dialog"]').filter({ visible: true }).locator('[data-zappie-cart-checkout], a[href*="/checkout"]').filter({ visible: true }).first();
1407
+ if (await handover.count()) {
1408
+ await handover.click();
1409
+ await page.waitForURL((url) => url.pathname.endsWith("/checkout"));
1410
+ }
793
1411
  }
794
1412
  await page.waitForFunction(
795
1413
  () => window.__zappie.events.some((entry) => entry.name === "quote"),
@@ -803,11 +1421,21 @@ var v06 = {
803
1421
  "The cart did not produce a quote within 2 seconds",
804
1422
  "Open the cart through the SDK and render Totals so it requests a quote."
805
1423
  );
806
- const rendered = await total.textContent();
1424
+ let expected = format.money(quote.total);
1425
+ let rendered = null;
1426
+ for (let attempt = 0; attempt < 20; attempt++) {
1427
+ const latest = await page.evaluate(() => window.__zappie.quote());
1428
+ expected = format.money((latest ?? quote).total);
1429
+ rendered = await total.first().textContent({ timeout: 5e3 }).catch(() => null);
1430
+ if (rendered === null || rendered.includes(expected)) {
1431
+ break;
1432
+ }
1433
+ await page.waitForTimeout(250);
1434
+ }
807
1435
  assertion(
808
- rendered?.includes(format.money(quote.total)),
1436
+ rendered?.includes(expected),
809
1437
  "total_mismatch",
810
- `Rendered total does not match ${format.money(quote.total)}`,
1438
+ `Rendered total does not match ${expected}`,
811
1439
  "Render quote.total with the SDK Money formatter."
812
1440
  );
813
1441
  }
@@ -942,7 +1570,7 @@ var v07 = {
942
1570
  await window.__zappie.cart.inject({ lines: lines2 });
943
1571
  }, lines);
944
1572
  await page.reload({ waitUntil: "domcontentloaded" });
945
- await page.locator("[data-zappie-cart-trigger]").first().click();
1573
+ await (await cartTrigger(page)).click();
946
1574
  const codes = unavailable ? ["price_changed", "item_unavailable"] : ["price_changed"];
947
1575
  for (const code of codes) {
948
1576
  const issue = page.locator(`[data-zappie-issue="${code}"]`).first();
@@ -1265,14 +1893,28 @@ var v10 = {
1265
1893
  "Render a delivery control with data-zappie-fulfilment=delivery."
1266
1894
  );
1267
1895
  await delivery.click();
1268
- const input = page.locator("[data-zappie-address-input]");
1269
- await input.waitFor();
1896
+ const input = await revealField(
1897
+ page,
1898
+ "address",
1899
+ page.locator("[data-zappie-address-input]"),
1900
+ () => delivery.click()
1901
+ );
1270
1902
  await input.fill(ADDRESS);
1271
1903
  await page.getByText(ADDRESS, { exact: true }).last().click();
1272
- await page.waitForFunction(() => {
1273
- const fulfilment2 = window.__zappie.cart().fulfilment;
1274
- return fulfilment2?.type === "delivery" && Boolean(fulfilment2.address.placeId);
1275
- });
1904
+ if (!await addressStored(page, 3e3)) {
1905
+ const confirm = await dialogConfirm(
1906
+ page,
1907
+ "[data-zappie-address-confirm]"
1908
+ );
1909
+ assertion(
1910
+ confirm,
1911
+ "delivery_address_missing",
1912
+ "The selected address was not stored",
1913
+ "Store the resolved address on delivery fulfilment when the shopper picks it, or mark the control that confirms it with data-zappie-address-confirm."
1914
+ );
1915
+ await confirm.click();
1916
+ }
1917
+ await addressStored(page);
1276
1918
  const fulfilment = await debugValue(
1277
1919
  page,
1278
1920
  () => window.__zappie.cart().fulfilment
@@ -1316,10 +1958,154 @@ var v10 = {
1316
1958
  );
1317
1959
  }
1318
1960
  };
1961
+ async function addressStored(page, timeout) {
1962
+ return page.waitForFunction(
1963
+ () => {
1964
+ const fulfilment = window.__zappie.cart().fulfilment;
1965
+ return fulfilment?.type === "delivery" && Boolean(fulfilment.address.placeId);
1966
+ },
1967
+ void 0,
1968
+ timeout === void 0 ? {} : { timeout }
1969
+ ).then(
1970
+ () => true,
1971
+ (error) => {
1972
+ if (timeout === void 0) {
1973
+ throw error;
1974
+ }
1975
+ return false;
1976
+ }
1977
+ );
1978
+ }
1319
1979
 
1320
1980
  // src/checks/v13-accessibility.ts
1321
1981
  import AxeBuilder from "@axe-core/playwright";
1982
+
1983
+ // src/checks/semantics.ts
1984
+ var TOUCH_TARGET_PX = 24;
1985
+ async function semanticFindings(page, scope) {
1986
+ return page.evaluate(
1987
+ ({ scope: scope2, minimum }) => {
1988
+ const findings = [];
1989
+ const rendered = (element) => element.checkVisibility() && !element.closest('[aria-hidden="true"]');
1990
+ const describe = (element) => {
1991
+ const id = element.id ? `#${element.id}` : "";
1992
+ const text = (element.textContent ?? "").trim().replace(/\s+/g, " ");
1993
+ return `<${element.tagName.toLowerCase()}${id}>${text ? ` "${text.slice(0, 40)}"` : ""}`;
1994
+ };
1995
+ if (scope2.page) {
1996
+ const main = [
1997
+ ...document.querySelectorAll('main, [role="main"]')
1998
+ ].filter(rendered);
1999
+ if (main.length === 0) {
2000
+ findings.push({
2001
+ code: "main_landmark_missing",
2002
+ message: "The page has no main landmark"
2003
+ });
2004
+ }
2005
+ const headings = [
2006
+ ...document.querySelectorAll('h1, [role="heading"][aria-level="1"]')
2007
+ ].filter(
2008
+ (element) => rendered(element) && !element.closest('[role="dialog"], dialog')
2009
+ );
2010
+ if (headings.length !== 1) {
2011
+ findings.push({
2012
+ code: "h1_count",
2013
+ message: `The page has ${headings.length} h1 headings${headings.length > 1 ? `: ${headings.map(describe).join(", ")}` : ""}`
2014
+ });
2015
+ }
2016
+ }
2017
+ const named = (element) => {
2018
+ const label2 = element.getAttribute("aria-label")?.trim();
2019
+ if (label2) {
2020
+ return true;
2021
+ }
2022
+ const ids = element.getAttribute("aria-labelledby")?.split(/\s+/) ?? [];
2023
+ return ids.some(
2024
+ (id) => Boolean(document.getElementById(id)?.textContent?.trim())
2025
+ );
2026
+ };
2027
+ for (const dialog of document.querySelectorAll(
2028
+ '[role="dialog"], [role="alertdialog"], dialog[open]'
2029
+ )) {
2030
+ if (rendered(dialog) && !named(dialog)) {
2031
+ findings.push({
2032
+ code: "dialog_name_missing",
2033
+ message: `${describe(dialog)} has no accessible name`
2034
+ });
2035
+ }
2036
+ }
2037
+ for (const element of document.querySelectorAll("[tabindex]")) {
2038
+ if (Number(element.getAttribute("tabindex")) > 0) {
2039
+ findings.push({
2040
+ code: "positive_tabindex",
2041
+ message: `${describe(element)} has tabindex="${element.getAttribute("tabindex")}"`
2042
+ });
2043
+ }
2044
+ }
2045
+ if (scope2.phone) {
2046
+ const controls = document.querySelectorAll(
2047
+ '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"]'
2048
+ );
2049
+ const small = [];
2050
+ for (const element of controls) {
2051
+ if (!rendered(element) || element.matches(":disabled") || // Inline text links sit in a line of text; WCAG exempts them.
2052
+ element.matches("a") && getComputedStyle(element).display === "inline") {
2053
+ continue;
2054
+ }
2055
+ const box = element.getBoundingClientRect();
2056
+ const label2 = element.closest("label")?.getBoundingClientRect();
2057
+ if (box.width <= 2 || box.height <= 2) {
2058
+ if (!label2) {
2059
+ continue;
2060
+ }
2061
+ }
2062
+ const width = Math.max(box.width, label2?.width ?? 0);
2063
+ const height = Math.max(box.height, label2?.height ?? 0);
2064
+ if (width + 0.5 < minimum || height + 0.5 < minimum) {
2065
+ small.push(
2066
+ `${describe(element)} ${Math.round(width)}\xD7${Math.round(height)} px`
2067
+ );
2068
+ }
2069
+ }
2070
+ if (small.length > 0) {
2071
+ findings.push({
2072
+ code: "touch_target_too_small",
2073
+ message: `Controls under ${minimum}\xD7${minimum} px on a phone: ${small.slice(0, 8).join(", ")}${small.length > 8 ? ` and ${small.length - 8} more` : ""}`
2074
+ });
2075
+ }
2076
+ }
2077
+ return findings;
2078
+ },
2079
+ { scope, minimum: TOUCH_TARGET_PX }
2080
+ );
2081
+ }
2082
+ var semanticHints = {
2083
+ main_landmark_missing: "Wrap the page's main content in a <main> element.",
2084
+ h1_count: "Give each page exactly one h1 that names it.",
2085
+ dialog_name_missing: "Name every dialog with aria-labelledby pointing at its title, or with aria-label.",
2086
+ positive_tabindex: "Remove positive tabindex values. Use the DOM order, or tabindex 0 and -1.",
2087
+ touch_target_too_small: "Make every menu control at least 24\xD724 px on a phone, with padding or a larger hit area."
2088
+ };
2089
+
2090
+ // src/checks/v13-accessibility.ts
1322
2091
  async function severeViolations(page) {
2092
+ await page.evaluate(
2093
+ () => new Promise(
2094
+ (resolve) => requestAnimationFrame(
2095
+ () => requestAnimationFrame(() => requestAnimationFrame(resolve))
2096
+ )
2097
+ )
2098
+ );
2099
+ await page.evaluate(
2100
+ () => Promise.race([
2101
+ Promise.all(
2102
+ document.getAnimations().filter(
2103
+ (animation) => animation.effect?.getComputedTiming().iterations !== Infinity
2104
+ ).map((animation) => animation.finished.catch(() => void 0))
2105
+ ),
2106
+ new Promise((resolve) => setTimeout(resolve, 2e3))
2107
+ ])
2108
+ );
1323
2109
  const analysis = await new AxeBuilder({ page }).analyze();
1324
2110
  return analysis.violations.filter(
1325
2111
  (entry) => entry.impact === "critical" || entry.impact === "serious"
@@ -1343,15 +2129,47 @@ async function controlsAreReachable(page, selectors) {
1343
2129
  }
1344
2130
  return false;
1345
2131
  }
2132
+ async function holdsFocus(target2) {
2133
+ for (let attempt = 0; attempt < 20; attempt++) {
2134
+ const inside = await target2.evaluate((node) => node.contains(document.activeElement)).catch(() => false);
2135
+ if (inside) {
2136
+ return true;
2137
+ }
2138
+ await new Promise((resolve) => setTimeout(resolve, 50));
2139
+ }
2140
+ return false;
2141
+ }
2142
+ var ADD = '[data-zappie-action="add-to-cart"]';
2143
+ async function keyboardReachesAddAndCart(page, itemId, cartInColumn) {
2144
+ const cart = cartInColumn ? [] : [await cartTriggerSelector(page)];
2145
+ if (await page.locator(ADD).count() || !itemId) {
2146
+ return controlsAreReachable(page, [ADD, ...cart]);
2147
+ }
2148
+ const opener = itemOpenerSelector(itemId);
2149
+ if (cart.length && !await controlsAreReachable(page, cart)) {
2150
+ return false;
2151
+ }
2152
+ await open(page);
2153
+ if (!await tabTo(page, opener, 200)) {
2154
+ return false;
2155
+ }
2156
+ await page.keyboard.press("Enter");
2157
+ await itemShown(page);
2158
+ return tabTo(page, ADD);
2159
+ }
1346
2160
  var v13 = {
1347
2161
  id: "V13",
1348
2162
  title: "Accessibility",
1349
2163
  viewports: "both",
1350
- async run({ page, context, options, fault }, viewport) {
2164
+ async run({ page, options, fault }, viewport) {
1351
2165
  await page.setViewportSize(viewport);
1352
2166
  await open(page);
1353
2167
  await applyPageFault(page, fault);
1354
2168
  const violations = await severeViolations(page);
2169
+ const semantics = await semanticFindings(page, {
2170
+ page: true,
2171
+ phone: viewport.width < 768
2172
+ });
1355
2173
  const item = sortedItems(await readMenu(options)).find(
1356
2174
  (entry) => entry.isAvailable
1357
2175
  );
@@ -1361,11 +2179,27 @@ var v13 = {
1361
2179
  "No available item can be opened for accessibility checks",
1362
2180
  "Keep one available item in the test menu."
1363
2181
  );
1364
- await page.locator(`[data-zappie-item="${item.id}"]`).first().locator("xpath=..").getByRole("link", { name: "View details" }).click();
2182
+ const { focusTarget: card } = await openItem(page, item.id);
1365
2183
  violations.push(...await severeViolations(page));
1366
- await page.getByRole("button", { name: "Close item" }).click();
1367
- await page.locator("[data-zappie-cart-trigger]").first().click();
2184
+ semantics.push(
2185
+ ...await semanticFindings(page, { page: false, phone: false })
2186
+ );
2187
+ await closeItem(page);
2188
+ if (!await holdsFocus(card)) {
2189
+ semantics.push({
2190
+ code: "focus_not_returned",
2191
+ message: "Focus did not return to the item card after the item closed"
2192
+ });
2193
+ }
2194
+ const cart = await cartTrigger(page);
2195
+ const cartInColumn = viewport.width >= 768 && !await cart.isVisible();
2196
+ if (!cartInColumn) {
2197
+ await cart.click();
2198
+ }
1368
2199
  violations.push(...await severeViolations(page));
2200
+ semantics.push(
2201
+ ...await semanticFindings(page, { page: false, phone: false })
2202
+ );
1369
2203
  const message = violations.flatMap(
1370
2204
  (entry) => entry.nodes.map((node) => `${entry.id}: ${node.target.join(" ")}`)
1371
2205
  ).join(", ");
@@ -1375,15 +2209,29 @@ var v13 = {
1375
2209
  message || "Accessibility violation",
1376
2210
  "Fix every serious or critical axe violation in the menu, item dialog, cart and order page."
1377
2211
  );
1378
- await page.keyboard.press("Escape");
1379
- await context.addCookies([
1380
- {
1381
- name: "zappie_session",
1382
- value: "signed",
1383
- domain: new URL(page.url()).hostname,
1384
- path: "/"
1385
- }
1386
- ]);
2212
+ if (!cartInColumn) {
2213
+ await page.keyboard.press("Escape");
2214
+ }
2215
+ if (!cartInColumn && !await holdsFocus(cart)) {
2216
+ semantics.push({
2217
+ code: "focus_not_returned",
2218
+ message: "Focus did not return to the cart control after the cart closed"
2219
+ });
2220
+ }
2221
+ const [semantic] = semantics;
2222
+ assertion(
2223
+ !semantic,
2224
+ semantic?.code ?? "semantics",
2225
+ semantics.map(({ message: message2 }) => message2).join(". "),
2226
+ [
2227
+ ...new Set(
2228
+ semantics.map(
2229
+ ({ code }) => semanticHints[code] ?? "Return focus to the control that opened an overlay when it closes."
2230
+ )
2231
+ )
2232
+ ].join(" ")
2233
+ );
2234
+ await signIn(page);
1387
2235
  await open(page, "/orders/order_fixture");
1388
2236
  const order = await severeViolations(page);
1389
2237
  assertion(
@@ -1394,16 +2242,25 @@ var v13 = {
1394
2242
  );
1395
2243
  await open(page);
1396
2244
  assertion(
1397
- await controlsAreReachable(page, [
1398
- '[data-zappie-action="add-to-cart"]',
1399
- "[data-zappie-cart-trigger]"
1400
- ]),
2245
+ await keyboardReachesAddAndCart(
2246
+ page,
2247
+ simpleItem(await readMenu(options))?.id,
2248
+ cartInColumn
2249
+ ),
1401
2250
  "keyboard_marker_unreachable",
1402
2251
  "Keyboard navigation did not reach the Add and cart controls",
1403
2252
  "Keep the marked Add and cart controls in the tab order."
1404
2253
  );
1405
2254
  await open(page, "/checkout");
1406
- await page.locator('input[type="tel"]').fill("+2348000000001");
2255
+ const phone = await revealField(
2256
+ page,
2257
+ "phone",
2258
+ page.locator('input[type="tel"]')
2259
+ );
2260
+ await phone.fill("+2348000000001");
2261
+ if (await page.locator('[role="dialog"]').filter({ has: phone }).isVisible()) {
2262
+ await (await dialogConfirm(page, "[data-zappie-field-confirm]"))?.click();
2263
+ }
1407
2264
  await page.waitForFunction(
1408
2265
  () => !document.querySelector("[data-zappie-checkout]")?.disabled
1409
2266
  );
@@ -1421,9 +2278,9 @@ var v14 = {
1421
2278
  id: "V14",
1422
2279
  title: "Performance budget",
1423
2280
  viewports: "mobile",
1424
- async run({ page, metrics, fault }, viewport) {
2281
+ async run({ page, metrics, fault, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
1425
2282
  await page.setViewportSize(viewport);
1426
- const cdp = await applyPhoneProfile(page);
2283
+ const { cdp } = await applyPhoneProfile(page, cpuThrottlingRate2);
1427
2284
  await page.addInitScript(() => {
1428
2285
  const state = { lcp: 0, cls: 0 };
1429
2286
  Object.assign(window, { __zappiePerformance: state });
@@ -1451,24 +2308,27 @@ var v14 = {
1451
2308
  await page.waitForTimeout(500);
1452
2309
  const values = await page.evaluate(async () => {
1453
2310
  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 };
2311
+ const resources = performance.getEntriesByType("resource");
2312
+ const gzipped = async (entry) => {
2313
+ if (typeof CompressionStream === "undefined") {
2314
+ const timing = entry;
2315
+ return timing.encodedBodySize || timing.transferSize;
2316
+ }
2317
+ const response = await fetch(entry.name);
2318
+ const compressed = response.body?.pipeThrough(
2319
+ new CompressionStream("gzip")
2320
+ );
2321
+ return compressed ? (await new Response(compressed).arrayBuffer()).byteLength : 0;
2322
+ };
2323
+ const total = async (extension) => (await Promise.all(
2324
+ resources.filter(
2325
+ (entry) => new URL(entry.name).pathname.endsWith(extension)
2326
+ ).map(gzipped)
2327
+ )).reduce((sum, size) => sum + size, 0);
2328
+ return { ...observed, js: await total(".js"), css: await total(".css") };
1470
2329
  });
1471
2330
  metrics.menuJsKbGz = Math.round(values.js / 1024 * 10) / 10;
2331
+ metrics.menuCssKbGz = Math.round(values.css / 1024 * 10) / 10;
1472
2332
  metrics.lcpMs = Math.round(values.lcp);
1473
2333
  metrics.cls = Math.round(values.cls * 1e3) / 1e3;
1474
2334
  assertion(
@@ -1500,9 +2360,9 @@ var v15 = {
1500
2360
  id: "V15",
1501
2361
  title: "Tappable product cards",
1502
2362
  viewports: "mobile",
1503
- async run({ page, metrics }, viewport) {
2363
+ async run({ page, metrics, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
1504
2364
  await page.setViewportSize(viewport);
1505
- const cdp = await applyPhoneProfile(page);
2365
+ const { cdp } = await applyPhoneProfile(page, cpuThrottlingRate2);
1506
2366
  try {
1507
2367
  await page.goto("/", { waitUntil: "domcontentloaded" });
1508
2368
  await cdp.send("Network.setCacheDisabled", { cacheDisabled: false });
@@ -1617,6 +2477,446 @@ function itemRouteModule(itemPath) {
1617
2477
  return route ? new URL(route.module, location.href).pathname : void 0;
1618
2478
  }
1619
2479
 
2480
+ // src/checks/overlays.ts
2481
+ var OVERLAY_NAMES = ["bag", "branch", "search", "item"];
2482
+ var IDLE_FALLBACK_MS = 2e3;
2483
+ var NETWORK_QUIET_MS = 500;
2484
+ var SETTLE_ROUNDS = 3;
2485
+ var SETTLE_TIMEOUT_MS = 15e3;
2486
+ var triggerSelector = (name) => `[data-zappie-overlay-trigger="${name}"]`;
2487
+ var overlaySelector = (name) => `[data-zappie-overlay="${name}"]`;
2488
+ 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.';
2489
+ async function overlayTriggers(page) {
2490
+ const found = [];
2491
+ for (const name of OVERLAY_NAMES) {
2492
+ const trigger = page.locator(`${triggerSelector(name)}:visible`).first();
2493
+ if (await trigger.count() > 0) {
2494
+ found.push({ name, trigger });
2495
+ }
2496
+ }
2497
+ return found;
2498
+ }
2499
+ function skipWithoutTriggers(found) {
2500
+ if (found.length === 0) {
2501
+ skipCheck(
2502
+ "overlay_markers_missing",
2503
+ "The menu has no data-zappie-overlay-trigger controls",
2504
+ markersHint
2505
+ );
2506
+ }
2507
+ }
2508
+ function armOverlayTimer(page, name, timeoutMs) {
2509
+ return page.evaluate(
2510
+ ({ selector, timeout }) => {
2511
+ const state = {};
2512
+ Object.assign(window, { __zappieOverlayTimer: state });
2513
+ const visible = () => {
2514
+ const node = document.querySelector(selector);
2515
+ if (!(node instanceof HTMLElement)) {
2516
+ return false;
2517
+ }
2518
+ const box = node.getBoundingClientRect();
2519
+ return box.width > 0 && box.height > 0 && node.checkVisibility();
2520
+ };
2521
+ document.addEventListener(
2522
+ "click",
2523
+ (event) => {
2524
+ const started = event.timeStamp;
2525
+ const frame = () => {
2526
+ if (visible()) {
2527
+ requestAnimationFrame(() => {
2528
+ state.ms = performance.now() - started;
2529
+ });
2530
+ return;
2531
+ }
2532
+ if (performance.now() - started > timeout) {
2533
+ state.ms = -1;
2534
+ return;
2535
+ }
2536
+ requestAnimationFrame(frame);
2537
+ };
2538
+ requestAnimationFrame(frame);
2539
+ },
2540
+ { capture: true, once: true }
2541
+ );
2542
+ },
2543
+ { selector: overlaySelector(name), timeout: timeoutMs }
2544
+ );
2545
+ }
2546
+ async function readOverlayTimer(page) {
2547
+ const handle = await page.waitForFunction(
2548
+ () => window.__zappieOverlayTimer?.ms
2549
+ );
2550
+ const ms = await handle.jsonValue();
2551
+ return ms < 0 ? void 0 : ms;
2552
+ }
2553
+ async function overlayIsOpen(page, name) {
2554
+ return page.locator(`${overlaySelector(name)}:visible`).first().isVisible().catch(() => false);
2555
+ }
2556
+ function watchNetwork(page) {
2557
+ const inflight = /* @__PURE__ */ new Set();
2558
+ const activity = { at: Date.now(), requests: 0 };
2559
+ const started = (request) => {
2560
+ inflight.add(request);
2561
+ activity.at = Date.now();
2562
+ activity.requests++;
2563
+ };
2564
+ const ended = (request) => {
2565
+ inflight.delete(request);
2566
+ activity.at = Date.now();
2567
+ };
2568
+ page.on("request", started);
2569
+ page.on("requestfinished", ended);
2570
+ page.on("requestfailed", ended);
2571
+ return {
2572
+ get requests() {
2573
+ return activity.requests;
2574
+ },
2575
+ /** Resolves once no request has been in flight for 500 ms, or at the deadline. */
2576
+ async quiet(deadline) {
2577
+ for (; ; ) {
2578
+ const now = Date.now();
2579
+ if (now >= deadline) {
2580
+ return;
2581
+ }
2582
+ if (inflight.size === 0 && now - activity.at >= NETWORK_QUIET_MS) {
2583
+ return;
2584
+ }
2585
+ await new Promise((resolve) => setTimeout(resolve, 50));
2586
+ }
2587
+ },
2588
+ /** Starts the quiet window again from now. */
2589
+ restart() {
2590
+ activity.at = Date.now();
2591
+ },
2592
+ stop() {
2593
+ page.off("request", started);
2594
+ page.off("requestfinished", ended);
2595
+ page.off("requestfailed", ended);
2596
+ }
2597
+ };
2598
+ }
2599
+ function hydrated() {
2600
+ const nodes = [
2601
+ document.querySelector("[data-zappie-item]"),
2602
+ ...document.querySelectorAll("[data-zappie-overlay-trigger]")
2603
+ ];
2604
+ return nodes.every(
2605
+ (node) => node !== null && Object.keys(node).some(
2606
+ (key) => key.startsWith("__reactProps$") || key.startsWith("__reactFiber$")
2607
+ )
2608
+ );
2609
+ }
2610
+ async function openSettledMenu(page) {
2611
+ const network = watchNetwork(page);
2612
+ try {
2613
+ await page.goto("/", { waitUntil: "domcontentloaded" });
2614
+ await page.locator("[data-zappie-item]").first().waitFor();
2615
+ const deadline = Date.now() + SETTLE_TIMEOUT_MS;
2616
+ const remaining = () => Math.max(deadline - Date.now(), 1);
2617
+ await page.waitForFunction(hydrated, null, { timeout: remaining() }).catch(() => void 0);
2618
+ await page.waitForLoadState("load", { timeout: remaining() }).catch(() => void 0);
2619
+ network.restart();
2620
+ await network.quiet(deadline);
2621
+ for (let round = 0; round < SETTLE_ROUNDS; round++) {
2622
+ const before = network.requests;
2623
+ await page.evaluate(
2624
+ ({ fallback, remaining: remaining2 }) => new Promise((resolve) => {
2625
+ window.setTimeout(resolve, remaining2);
2626
+ const idle = (then) => {
2627
+ if (typeof window.requestIdleCallback === "function") {
2628
+ window.requestIdleCallback(() => then());
2629
+ } else {
2630
+ window.setTimeout(then, fallback);
2631
+ }
2632
+ };
2633
+ idle(() => idle(resolve));
2634
+ }),
2635
+ { fallback: IDLE_FALLBACK_MS, remaining: remaining() }
2636
+ );
2637
+ await network.quiet(deadline);
2638
+ if (network.requests === before || Date.now() >= deadline) {
2639
+ return;
2640
+ }
2641
+ }
2642
+ } finally {
2643
+ network.stop();
2644
+ }
2645
+ }
2646
+ async function revealBagTrigger(page, itemId) {
2647
+ const bag = page.locator(`${triggerSelector("bag")}:visible`).first();
2648
+ if (!itemId || await bag.count()) {
2649
+ return;
2650
+ }
2651
+ const before = await page.evaluate(() => window.__zappie?.cart().count ?? 0).catch(() => 0);
2652
+ const add = '[data-zappie-action="add-to-cart"]';
2653
+ const quick = page.locator(`[data-zappie-item="${itemId}"] ${add}`).filter({ visible: true }).first();
2654
+ if (await quick.count()) {
2655
+ await quick.click();
2656
+ } else {
2657
+ const opener = page.locator(itemOpenerSelector(itemId)).filter({ visible: true }).first();
2658
+ if (!await opener.count()) {
2659
+ return;
2660
+ }
2661
+ await opener.click();
2662
+ const inItem = page.locator(
2663
+ `[role="dialog"] [data-zappie-item] ${add}:visible, main [data-zappie-item] ${add}:visible`
2664
+ ).first();
2665
+ if (!await inItem.waitFor({ timeout: 5e3 }).then(
2666
+ () => true,
2667
+ () => false
2668
+ )) {
2669
+ return;
2670
+ }
2671
+ await inItem.click();
2672
+ }
2673
+ await page.waitForFunction(
2674
+ (count) => (window.__zappie?.cart().count ?? 0) > count,
2675
+ before,
2676
+ { timeout: 5e3 }
2677
+ ).catch(() => void 0);
2678
+ await openSettledMenu(page);
2679
+ await bag.waitFor({ timeout: 5e3 }).catch(() => void 0);
2680
+ }
2681
+
2682
+ // src/checks/v16-overlays-open-quickly.ts
2683
+ var OVERLAY_OPEN_BUDGET_MS = 100;
2684
+ var OVERLAY_OPEN_SAMPLES = 3;
2685
+ var OPEN_TIMEOUT_MS = 5e3;
2686
+ var median = (values) => {
2687
+ const sorted = [...values].sort((a, b) => a - b);
2688
+ return sorted[Math.floor(sorted.length / 2)];
2689
+ };
2690
+ async function firstOpen(fresh, viewport, name, cpuThrottlingRate2, itemId) {
2691
+ const { page } = fresh;
2692
+ try {
2693
+ await page.setViewportSize(viewport);
2694
+ const { cdp, rate } = await applyPhoneProfile(page, cpuThrottlingRate2);
2695
+ try {
2696
+ await openSettledMenu(page);
2697
+ if (name === "bag") {
2698
+ await revealBagTrigger(page, itemId);
2699
+ }
2700
+ const trigger = page.locator(`${triggerSelector(name)}:visible`).first();
2701
+ await trigger.evaluate(
2702
+ (node) => node.scrollIntoView({ block: "center", inline: "nearest" })
2703
+ );
2704
+ await armOverlayTimer(page, name, OPEN_TIMEOUT_MS);
2705
+ await trigger.click({ noWaitAfter: true });
2706
+ const ms = await readOverlayTimer(page);
2707
+ return ms === void 0 ? void 0 : { ms, rate };
2708
+ } finally {
2709
+ await cdp.detach().catch(() => void 0);
2710
+ }
2711
+ } finally {
2712
+ await fresh.close();
2713
+ }
2714
+ }
2715
+ var v16 = {
2716
+ id: "V16",
2717
+ title: "Overlays open quickly",
2718
+ viewports: "mobile",
2719
+ async run({ page, options, metrics, freshPage, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
2720
+ await page.setViewportSize(viewport);
2721
+ const itemId = simpleItem(await readMenu(options))?.id;
2722
+ await openSettledMenu(page);
2723
+ await revealBagTrigger(page, itemId);
2724
+ const names = (await overlayTriggers(page)).map(({ name }) => name);
2725
+ skipWithoutTriggers(names);
2726
+ const timings = {};
2727
+ const samples = {};
2728
+ const rates = {};
2729
+ const unopened = [];
2730
+ for (const name of names) {
2731
+ const opens = [];
2732
+ const opensRates = [];
2733
+ for (let sample = 0; sample < OVERLAY_OPEN_SAMPLES; sample++) {
2734
+ const open2 = await firstOpen(
2735
+ await freshPage(),
2736
+ viewport,
2737
+ name,
2738
+ cpuThrottlingRate2,
2739
+ itemId
2740
+ );
2741
+ if (open2 === void 0) {
2742
+ break;
2743
+ }
2744
+ opens.push(Math.round(open2.ms));
2745
+ opensRates.push(open2.rate);
2746
+ }
2747
+ if (opens.length < OVERLAY_OPEN_SAMPLES) {
2748
+ unopened.push(name);
2749
+ } else {
2750
+ samples[name] = opens;
2751
+ rates[name] = opensRates;
2752
+ timings[name] = median(opens);
2753
+ }
2754
+ }
2755
+ metrics.overlayOpenMs = timings;
2756
+ metrics.overlayOpenSamplesMs = samples;
2757
+ metrics.overlayOpenRates = rates;
2758
+ assertion(
2759
+ unopened.length === 0,
2760
+ "overlay_not_opened",
2761
+ `No data-zappie-overlay painted after tapping ${unopened.map((name) => `the ${name} trigger`).join(", ")}`,
2762
+ `Put data-zappie-overlay with the trigger's name on the role="dialog" element that the trigger opens.`
2763
+ );
2764
+ const slow = Object.entries(timings).filter(
2765
+ ([, ms]) => ms > OVERLAY_OPEN_BUDGET_MS
2766
+ );
2767
+ assertion(
2768
+ slow.length === 0,
2769
+ "overlay_open_too_slow",
2770
+ `Overlays took longer than ${OVERLAY_OPEN_BUDGET_MS} ms to paint, as the median of ${OVERLAY_OPEN_SAMPLES} first opens: ${slow.map(([name, ms]) => `${name} ${ms} ms (${samples[name].join(", ")} ms)`).join(", ")}`,
2771
+ "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."
2772
+ );
2773
+ }
2774
+ };
2775
+
2776
+ // src/checks/v17-overlay-focus.ts
2777
+ var focusInside = (page, name) => page.evaluate(
2778
+ (selector) => Boolean(document.activeElement?.closest(selector)) || // A native modal dialog keeps focus in its top layer even when the element itself is the dialog.
2779
+ document.activeElement === document.querySelector(selector),
2780
+ overlaySelector(name)
2781
+ );
2782
+ async function eventually(check, timeoutMs = 1e3) {
2783
+ const deadline = Date.now() + timeoutMs;
2784
+ for (; ; ) {
2785
+ if (await check()) {
2786
+ return true;
2787
+ }
2788
+ if (Date.now() > deadline) {
2789
+ return false;
2790
+ }
2791
+ await new Promise((resolve) => setTimeout(resolve, 50));
2792
+ }
2793
+ }
2794
+ var settlesInside = (page, name) => eventually(() => focusInside(page, name), 300);
2795
+ var scrollLocked = (page) => page.evaluate(() => {
2796
+ const root = document.scrollingElement ?? document.documentElement;
2797
+ if (root.scrollHeight <= window.innerHeight) {
2798
+ return true;
2799
+ }
2800
+ const held = (element) => {
2801
+ const style = getComputedStyle(element);
2802
+ return ["hidden", "clip"].includes(style.overflowY) || ["hidden", "clip"].includes(style.overflow) || style.position === "fixed";
2803
+ };
2804
+ return held(document.documentElement) || held(document.body);
2805
+ });
2806
+ async function checkOverlay(page, name, itemId) {
2807
+ await openSettledMenu(page);
2808
+ if (name === "bag") {
2809
+ await revealBagTrigger(page, itemId);
2810
+ }
2811
+ const trigger = page.locator(`${triggerSelector(name)}:visible`).first();
2812
+ await trigger.focus();
2813
+ await page.keyboard.press("Enter");
2814
+ if (!await eventually(() => overlayIsOpen(page, name), 5e3)) {
2815
+ return [
2816
+ {
2817
+ code: "overlay_not_opened",
2818
+ message: `Enter on the ${name} trigger did not open its data-zappie-overlay`,
2819
+ hint: "Open the overlay from the keyboard as well as by tap, and mark its dialog with data-zappie-overlay."
2820
+ }
2821
+ ];
2822
+ }
2823
+ const findings = [];
2824
+ if (!await eventually(() => focusInside(page, name))) {
2825
+ findings.push({
2826
+ code: "overlay_focus_not_moved",
2827
+ message: `Focus stayed outside the ${name} overlay after it opened`,
2828
+ hint: "Move focus into the overlay when it opens. Overlay from @zappie/foundations does this."
2829
+ });
2830
+ } else {
2831
+ const stops = await page.locator(overlaySelector(name)).first().evaluate(
2832
+ (node) => node.querySelectorAll(
2833
+ 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
2834
+ ).length
2835
+ );
2836
+ let escaped = false;
2837
+ for (let index = 0; index < Math.min(stops + 2, 30) && !escaped; index++) {
2838
+ await page.keyboard.press("Tab");
2839
+ escaped = !await settlesInside(page, name);
2840
+ }
2841
+ if (!escaped) {
2842
+ await page.keyboard.press("Shift+Tab");
2843
+ escaped = !await settlesInside(page, name);
2844
+ }
2845
+ if (escaped) {
2846
+ findings.push({
2847
+ code: "overlay_focus_escaped",
2848
+ message: `Tab moved focus out of the open ${name} overlay`,
2849
+ hint: "Keep Tab and Shift+Tab inside the overlay while it is open. Overlay from @zappie/foundations traps focus."
2850
+ });
2851
+ }
2852
+ }
2853
+ if (!await scrollLocked(page)) {
2854
+ findings.push({
2855
+ code: "overlay_scroll_unlocked",
2856
+ message: `The page behind the open ${name} overlay can still scroll`,
2857
+ hint: "Lock page scroll while the overlay is open. Overlay from @zappie/foundations does this."
2858
+ });
2859
+ }
2860
+ await page.keyboard.press("Escape");
2861
+ if (!await eventually(async () => !await overlayIsOpen(page, name))) {
2862
+ findings.push({
2863
+ code: "overlay_escape_ignored",
2864
+ message: `Escape did not close the ${name} overlay`,
2865
+ hint: "Close the overlay on Escape."
2866
+ });
2867
+ return findings;
2868
+ }
2869
+ const returned = await eventually(
2870
+ () => page.evaluate(
2871
+ (selector) => Boolean(document.activeElement?.closest(selector)),
2872
+ triggerSelector(name)
2873
+ )
2874
+ );
2875
+ if (!returned) {
2876
+ findings.push({
2877
+ code: "overlay_focus_not_returned",
2878
+ message: `Focus did not return to the ${name} trigger after the overlay closed`,
2879
+ hint: "Return focus to the control that opened the overlay when it closes. Overlay from @zappie/foundations does this."
2880
+ });
2881
+ }
2882
+ return findings;
2883
+ }
2884
+ var v17 = {
2885
+ id: "V17",
2886
+ title: "Overlays manage focus",
2887
+ viewports: "mobile",
2888
+ async run({ page, options, freshPage }, viewport) {
2889
+ await page.setViewportSize(viewport);
2890
+ const itemId = simpleItem(await readMenu(options))?.id;
2891
+ await page.goto("/", { waitUntil: "domcontentloaded" });
2892
+ await page.locator("[data-zappie-item]").first().waitFor();
2893
+ if (!await page.locator(`${triggerSelector("bag")}:visible`).count()) {
2894
+ await openSettledMenu(page);
2895
+ await revealBagTrigger(page, itemId);
2896
+ }
2897
+ const names = (await overlayTriggers(page)).map(({ name }) => name);
2898
+ skipWithoutTriggers(names);
2899
+ const findings = [];
2900
+ for (const name of names) {
2901
+ const fresh = await freshPage();
2902
+ try {
2903
+ await fresh.page.setViewportSize(viewport);
2904
+ findings.push(...await checkOverlay(fresh.page, name, itemId));
2905
+ } finally {
2906
+ await fresh.close();
2907
+ }
2908
+ }
2909
+ const [first] = findings;
2910
+ if (first) {
2911
+ throw new VerifyError(
2912
+ first.code,
2913
+ findings.map(({ message }) => message).join(". "),
2914
+ [...new Set(findings.map(({ hint }) => hint))].join(" ")
2915
+ );
2916
+ }
2917
+ }
2918
+ };
2919
+
1620
2920
  // src/checks/index.ts
1621
2921
  var checks = [
1622
2922
  v01,
@@ -1630,7 +2930,9 @@ var checks = [
1630
2930
  v10,
1631
2931
  v13,
1632
2932
  v14,
1633
- v15
2933
+ v15,
2934
+ v16,
2935
+ v17
1634
2936
  ];
1635
2937
  var blocked = [
1636
2938
  [
@@ -1762,7 +3064,7 @@ async function reset(options) {
1762
3064
  }
1763
3065
 
1764
3066
  // src/version.ts
1765
- var PACKAGE_VERSION = true ? "0.1.5" : "0.0.0-development";
3067
+ var PACKAGE_VERSION = true ? "0.2.3" : "0.0.0-development";
1766
3068
 
1767
3069
  // src/runner.ts
1768
3070
  var VERSION = PACKAGE_VERSION;
@@ -1811,7 +3113,7 @@ async function execute(check, run2) {
1811
3113
  message: failure instanceof Error ? failure.message : String(failure),
1812
3114
  hint: `Fix the failure in ${check.id} (${check.title.toLowerCase()}) and rerun verify.`
1813
3115
  };
1814
- if (error.code === "dependency_blocked") {
3116
+ if (error.code === "dependency_blocked" || failure instanceof VerifyError && failure.skip) {
1815
3117
  return {
1816
3118
  id: check.id,
1817
3119
  title: check.title,
@@ -1843,6 +3145,37 @@ async function execute(check, run2) {
1843
3145
  }
1844
3146
  }
1845
3147
  var label = (viewport) => `${viewport.width}x${viewport.height}`;
3148
+ async function openContext(browser, options) {
3149
+ const context = await browser.newContext(browserOptions(options));
3150
+ if (options.standInForPlatform) {
3151
+ await context.route(
3152
+ "**/_zappie/view",
3153
+ (route) => route.fulfill({ status: 204 })
3154
+ );
3155
+ await context.route(
3156
+ (url) => url.pathname === "/_zappie/session",
3157
+ async (route) => {
3158
+ const cookie = await route.request().headerValue("cookie") ?? "";
3159
+ const signedIn = cookie.split(";").some((part) => part.trim() === "zappie_session=signed");
3160
+ await route.fulfill({
3161
+ status: 200,
3162
+ contentType: "application/json",
3163
+ headers: { "cache-control": "no-store" },
3164
+ body: JSON.stringify({
3165
+ customer: signedIn ? { id: "customer_verify", given_name: "Test" } : null
3166
+ })
3167
+ });
3168
+ }
3169
+ );
3170
+ }
3171
+ return context;
3172
+ }
3173
+ async function preparePage(context, options, fault) {
3174
+ const page = await context.newPage();
3175
+ page.setDefaultTimeout(Math.min(options.timeoutMs ?? 2e4, 2e4));
3176
+ await installFault(page, fault);
3177
+ return page;
3178
+ }
1846
3179
  async function runVerify(options, fault) {
1847
3180
  if (!/^pk_test_.+/.test(options.key)) {
1848
3181
  throw new VerifyError(
@@ -1853,17 +3186,11 @@ async function runVerify(options, fault) {
1853
3186
  }
1854
3187
  const started = Date.now();
1855
3188
  const browser = await chromium.launch();
1856
- const context = await browser.newContext(browserOptions(options));
1857
- if (options.standInForPlatform) {
1858
- await context.route(
1859
- "**/_zappie/view",
1860
- (route) => route.fulfill({ status: 204 })
1861
- );
1862
- }
3189
+ const cpuBenchmark = await benchmarkCpu(browser).catch(() => void 0);
3190
+ const throttlingRate = cpuBenchmark === void 0 ? REFERENCE_CPU_THROTTLING_RATE : cpuThrottlingRate(cpuBenchmark);
3191
+ const context = await openContext(browser, options);
1863
3192
  await context.tracing.start({ screenshots: true, snapshots: true });
1864
- const page = await context.newPage();
1865
- page.setDefaultTimeout(Math.min(options.timeoutMs ?? 2e4, 2e4));
1866
- await installFault(page, fault);
3193
+ const page = await preparePage(context, options, fault);
1867
3194
  const metrics = {};
1868
3195
  const run2 = {
1869
3196
  options,
@@ -1871,7 +3198,15 @@ async function runVerify(options, fault) {
1871
3198
  context,
1872
3199
  page,
1873
3200
  metrics,
1874
- ...fault ? { fault } : {}
3201
+ cpuThrottlingRate: throttlingRate,
3202
+ ...fault ? { fault } : {},
3203
+ async freshPage() {
3204
+ const fresh = await openContext(browser, options);
3205
+ return {
3206
+ page: await preparePage(fresh, options, fault),
3207
+ close: () => fresh.close()
3208
+ };
3209
+ }
1875
3210
  };
1876
3211
  const results = [await reset(options)];
1877
3212
  try {
@@ -1899,7 +3234,9 @@ async function runVerify(options, fault) {
1899
3234
  runner: options.runner ?? "cli",
1900
3235
  verifyVersion: VERSION,
1901
3236
  sdkVersion,
1902
- browser: `chromium ${browser.version()}`
3237
+ browser: `chromium ${browser.version()}`,
3238
+ ...cpuBenchmark === void 0 ? {} : { cpuBenchmark },
3239
+ cpuThrottlingRate: throttlingRate
1903
3240
  },
1904
3241
  startedAt: new Date(started).toISOString(),
1905
3242
  durationMs: Date.now() - started,
@@ -1971,12 +3308,64 @@ function normalizeUrl(value) {
1971
3308
  }
1972
3309
  }
1973
3310
 
3311
+ // src/regression.ts
3312
+ var DEFAULT_REGRESSION_THRESHOLDS = {
3313
+ menuJsKbGz: { relative: 0.05, absolute: 2 },
3314
+ menuCssKbGz: { relative: 0.05, absolute: 1 },
3315
+ lcpMs: { relative: 0.1, absolute: 150 },
3316
+ cls: { relative: 0, absolute: 0.02 },
3317
+ productCardTappableMs: { relative: 0.1, absolute: 150 },
3318
+ productCardOpenMs: { relative: 0.15, absolute: 30 },
3319
+ overlayOpenMs: { relative: 0.15, absolute: 20 }
3320
+ };
3321
+ function flatten(metrics) {
3322
+ const values = /* @__PURE__ */ new Map();
3323
+ for (const [key, value] of Object.entries(metrics)) {
3324
+ if (key === "overlayOpenMs" && value && typeof value === "object") {
3325
+ for (const [name, ms] of Object.entries(value)) {
3326
+ if (Number.isFinite(ms)) {
3327
+ values.set(`overlayOpenMs.${name}`, {
3328
+ kind: "overlayOpenMs",
3329
+ value: ms
3330
+ });
3331
+ }
3332
+ }
3333
+ } else if (key in DEFAULT_REGRESSION_THRESHOLDS && typeof value === "number" && Number.isFinite(value)) {
3334
+ values.set(key, { kind: key, value });
3335
+ }
3336
+ }
3337
+ return values;
3338
+ }
3339
+ function findRegressions(baseline, head, thresholds = {}) {
3340
+ const limits = { ...DEFAULT_REGRESSION_THRESHOLDS, ...thresholds };
3341
+ const current = flatten(head.metrics);
3342
+ const regressions = [];
3343
+ for (const [metric, { kind, value: base }] of flatten(baseline.metrics)) {
3344
+ const { relative, absolute } = limits[kind];
3345
+ const allowed = base + Math.max(absolute, relative * base);
3346
+ const value = current.get(metric)?.value;
3347
+ if (value === void 0 || value > allowed) {
3348
+ regressions.push({
3349
+ metric,
3350
+ base,
3351
+ head: value,
3352
+ allowed: Math.round(allowed * 1e3) / 1e3
3353
+ });
3354
+ }
3355
+ }
3356
+ return regressions.sort(
3357
+ (left, right) => left.metric.localeCompare(right.metric)
3358
+ );
3359
+ }
3360
+
1974
3361
  // src/index.ts
1975
3362
  function verify(options) {
1976
3363
  return runVerify(options);
1977
3364
  }
1978
3365
  export {
3366
+ DEFAULT_REGRESSION_THRESHOLDS,
1979
3367
  VerifyError,
3368
+ findRegressions,
1980
3369
  printSummary,
1981
3370
  verify,
1982
3371
  verifyProject