@zappie/verify 0.1.3 → 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 +45 -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-47RIF6JW.js +2807 -0
  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 +1133 -2386
  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-S5LELT7L.js +24 -0
  15. package/dist/quote-body-S5LELT7L.js.map +1 -0
  16. package/dist/{quote-client-6XJJHITL.js → quote-client-4RUY3TKQ.js} +84 -6
  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-3GCAICBQ.js → selected-menu-AQHWDELR.js} +9 -4
  21. package/dist/selected-menu-AQHWDELR.js.map +1 -0
  22. package/dist/{store-KIO5RCZO.js → store-OPM3OFLC.js} +20 -9
  23. package/dist/store-OPM3OFLC.js.map +1 -0
  24. package/package.json +2 -2
  25. package/schema/verify-report.v1.json +19 -1
  26. package/dist/category-page-UBHOB5IN.js.map +0 -1
  27. package/dist/chunk-WUWUDCYO.js +0 -485
  28. package/dist/chunk-WUWUDCYO.js.map +0 -1
  29. package/dist/debug-C3UDGUED.js.map +0 -1
  30. package/dist/quote-client-6XJJHITL.js.map +0 -1
  31. package/dist/search-YIHT6CXN.js.map +0 -1
  32. package/dist/selected-menu-3GCAICBQ.js.map +0 -1
  33. package/dist/store-KIO5RCZO.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,30 +1,25 @@
1
1
  import {
2
+ API_VERSION,
3
+ RestTransport,
2
4
  ZappieError,
3
- asError,
5
+ apiError,
6
+ createZappie,
4
7
  format,
5
- hours,
6
- isBoolean,
7
- isString,
8
- list,
9
- mapItem,
10
- mapMenu,
11
- mapMoney,
12
- money,
13
- multiplyMoney,
14
- opt,
15
- payloadKey,
16
- record,
17
- sumMoney,
18
- text,
19
- wireError
20
- } from "./chunk-WUWUDCYO.js";
8
+ previewItem,
9
+ requestWithRetry,
10
+ resolveSourceMenu,
11
+ stableErrorCode,
12
+ validateConfig,
13
+ validateLine
14
+ } from "./chunk-47RIF6JW.js";
21
15
 
22
16
  // src/error.ts
23
17
  var VerifyError = class extends Error {
24
- constructor(code, message, hint) {
18
+ constructor(code, message, hint, skip = false) {
25
19
  super(message);
26
20
  this.code = code;
27
21
  this.hint = hint;
22
+ this.skip = skip;
28
23
  this.name = "VerifyError";
29
24
  }
30
25
  };
@@ -33,6 +28,9 @@ function assertion(condition, code, message, hint) {
33
28
  throw new VerifyError(code, message, hint);
34
29
  }
35
30
  }
31
+ function skipCheck(code, message, hint) {
32
+ throw new VerifyError(code, message, hint, true);
33
+ }
36
34
 
37
35
  // src/local-preview.ts
38
36
  import { spawn } from "child_process";
@@ -291,6 +289,20 @@ async function debugValue(page, expression) {
291
289
  }
292
290
  var mobile = { width: 390, height: 844 };
293
291
  var desktop = { width: 1440, height: 900 };
292
+ async function applyPhoneProfile(page, rate) {
293
+ const cdp = await page.context().newCDPSession(page);
294
+ await cdp.send("Network.enable");
295
+ await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
296
+ await cdp.send("Network.emulateNetworkConditions", {
297
+ offline: false,
298
+ latency: 150,
299
+ downloadThroughput: 2e5,
300
+ uploadThroughput: 1e5,
301
+ connectionType: "cellular3g"
302
+ });
303
+ await cdp.send("Emulation.setCPUThrottlingRate", { rate });
304
+ return cdp;
305
+ }
294
306
 
295
307
  // src/checks/v01-routes.ts
296
308
  var v01 = {
@@ -360,2278 +372,277 @@ var v02 = {
360
372
  }
361
373
  };
362
374
 
363
- // ../sdk/src/cart/errors.ts
364
- var ZappieCartError = class extends ZappieError {
365
- constructor(issues) {
366
- super("cart_invalid", issues[0]?.message ?? "The cart needs attention");
367
- this.issues = issues;
368
- this.name = "ZappieCartError";
369
- }
370
- };
371
-
372
- // ../sdk/src/cart/price.ts
373
- function estimateUnitPrice(item, line) {
374
- const variant = line.variantId ? item.variants.find((v) => v.id === line.variantId) : void 0;
375
- if (line.variantId && !variant) {
376
- throw new ZappieError("invalid_request", "The selected variant is missing");
377
- }
378
- const extras = line.choices.flatMap((selected) => {
379
- const group = item.optionGroups.find((g) => g.id === selected.groupId);
380
- const choice = group?.choices.find((c) => c.id === selected.choiceId);
381
- if (!group || !choice) {
382
- throw new ZappieError(
383
- "invalid_request",
384
- "The selected option is missing"
385
- );
386
- }
387
- return group.isAttribute ? [] : [multiplyMoney(choice.price, Math.max(selected.quantity, 1))];
388
- });
389
- return sumMoney(extras, variant?.price ?? item.price);
390
- }
391
-
392
- // ../sdk/src/cart/source-menu.ts
393
- function resolveSourceMenu(menu, itemId, menuId) {
394
- const categories = menu.categories.filter((c) => c.itemIds.includes(itemId));
395
- const sources = menu.menus ?? [
396
- { id: menu.id, categoryIds: menu.categories.map((c) => c.id) }
397
- ];
398
- const placements = menu.items[itemId]?.placements;
399
- const candidates = sources.filter(
400
- (source) => (!placements || placements.some(
401
- (p) => p.menuId === source.id && source.categoryIds.includes(p.categoryId)
402
- )) && source.categoryIds.some((id) => categories.some((c) => c.id === id))
403
- );
404
- if (menuId !== void 0) {
405
- return typeof menuId === "string" && menuId.trim().length > 0 && menuId.length <= 256 && candidates.some((source) => source.id === menuId) ? menuId : void 0;
375
+ // src/fixtures.ts
376
+ async function installFault(page, fault) {
377
+ if (!fault) {
378
+ return;
406
379
  }
407
- return candidates.length === 1 ? candidates[0].id : void 0;
408
- }
409
-
410
- // ../sdk/src/cart/validate.ts
411
- function issue(code, message, fixes = [], groupId) {
412
- return {
413
- code,
414
- severity: "blocking",
415
- message,
416
- fixes,
417
- source: "local",
418
- ...groupId ? { groupId } : {}
419
- };
420
- }
421
- function effectiveGroups(item, input) {
422
- const selected = /* @__PURE__ */ new Map();
423
- for (const choice of input.choices ?? []) {
424
- const ids = selected.get(choice.groupId) ?? /* @__PURE__ */ new Set();
425
- ids.add(choice.choiceId);
426
- selected.set(choice.groupId, ids);
427
- }
428
- return item.optionGroups.map((group) => {
429
- let required = group.required;
430
- let { min, max } = group.selection;
431
- for (const rule of group.conditions ?? []) {
432
- const chosen = selected.get(rule.groupId) ?? /* @__PURE__ */ new Set();
433
- const matches = rule.count ? rule.count.op === "eq" ? chosen.size === rule.count.value : rule.count.op === "gte" ? chosen.size >= rule.count.value : chosen.size <= rule.count.value : chosen.size > 0 && rule.choiceIds.length > 0 && (rule.match === "all" ? rule.choiceIds.every((id) => chosen.has(id)) : rule.choiceIds.some((id) => chosen.has(id)));
434
- if (!matches) {
435
- continue;
436
- }
437
- required = rule.setRequired ?? required;
438
- min = rule.setMin ?? min;
439
- if (rule.setMax != null) {
440
- max = rule.setMax > 0 ? rule.setMax : void 0;
441
- }
442
- }
443
- return {
444
- ...group,
445
- required,
446
- selection: { min, ...max !== void 0 ? { max } : {} }
447
- };
448
- });
449
- }
450
- function normalizeInput(item, input) {
451
- const inactive = new Set(
452
- item.optionGroups.filter((g) => g.isAvailable === false).map((g) => g.id)
453
- );
454
- return {
455
- ...input,
456
- quantity: input.quantity ?? item.quantity.min,
457
- choices: (input.choices ?? []).filter((c) => !inactive.has(c.groupId)).map((c) => ({ ...c, quantity: c.quantity ?? 1 })),
458
- inputs: Object.fromEntries(
459
- Object.entries(input.inputs ?? {}).filter(([id]) => !inactive.has(id))
460
- )
461
- };
462
- }
463
- function resolveVariant(item, input) {
464
- const attrs = item.optionGroups.filter((g) => g.isAttribute);
465
- const matches = item.variants.filter(
466
- (variant) => (!input.variantId || variant.id === input.variantId) && attrs.every((group) => {
467
- const selected = input.choices?.filter((c) => c.groupId === group.id) ?? [];
468
- return selected.length === 1 && variant.attributeChoices[group.id] === selected[0].choiceId;
469
- })
470
- );
471
- return matches.length === 1 ? matches[0] : void 0;
472
- }
473
- function validInput(kind, value) {
474
- switch (kind) {
475
- case "number":
476
- return Number.isFinite(Number(value));
477
- case "date":
478
- return /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
479
- case "time":
480
- return /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/.test(value);
481
- case "toggle":
482
- return ["true", "false"].includes(value.toLowerCase());
483
- default:
484
- return true;
380
+ if (fault === "hydration-delay") {
381
+ await page.route(/\.(?:js|mjs)(?:\?|$)/, async (route) => {
382
+ await new Promise((resolve) => setTimeout(resolve, 3500));
383
+ await route.continue();
384
+ });
485
385
  }
486
- }
487
- function validateLine(item, raw, ctx) {
488
- if (!item || item.id !== raw.itemId || !Object.hasOwn(ctx.menu.items, raw.itemId)) {
489
- return [
490
- issue("item_unavailable", "This item is no longer on the menu", [
491
- { action: "remove_line" }
492
- ])
493
- ];
386
+ if (fault === "no-route-data" || fault === "no-prefetch") {
387
+ await page.route(
388
+ /\/item\/[^/]+\.data(?:\?|$)/,
389
+ (route) => route.request().headers()["sec-purpose"] === "prefetch" ? route.abort() : route.continue()
390
+ );
494
391
  }
495
- const issues = [];
496
- if (!resolveSourceMenu(ctx.menu, raw.itemId, raw.menuId)) {
497
- issues.push(
498
- issue(
499
- "item_unavailable",
500
- raw.menuId === void 0 ? `${item.name} is on more than one menu. Remove it and add it again from the menu you want.` : `${item.name} is no longer on the menu it was added from.`,
501
- [{ action: "remove_line" }]
502
- )
503
- );
504
- }
505
- if (!item.isAvailable) {
506
- issues.push(
507
- issue("item_unavailable", `${item.name} is unavailable`, [
508
- { action: "remove_line" }
509
- ])
510
- );
511
- }
512
- const input = normalizeInput(item, raw);
513
- const quantity = input.quantity;
514
- const { min, step } = item.quantity;
515
- const max = Math.min(item.quantity.max ?? 99, 99);
516
- if (!Number.isInteger(quantity) || quantity < min || quantity > max || quantity % step !== 0) {
517
- const lower = Math.ceil(min / step) * step;
518
- const upper = Math.floor(max / step) * step;
519
- const nearest = Math.max(
520
- lower,
521
- Math.min(
522
- upper,
523
- Math.round((Number.isFinite(quantity) ? quantity : min) / step) * step
524
- )
525
- );
526
- issues.push(
527
- issue(
528
- "quantity_limit",
529
- `Choose a valid quantity of ${item.name} (${min}\u2013${max}, step ${step})`,
530
- lower <= upper ? [{ action: "set_quantity", quantity: nearest }] : []
531
- )
532
- );
533
- }
534
- if ((input.note?.length ?? 0) > 280) {
535
- issues.push(issue("option_limit", "Notes must be at most 280 characters"));
536
- }
537
- for (const choice of input.choices ?? []) {
538
- if (!item.optionGroups.some((g) => g.id === choice.groupId)) {
539
- issues.push(
540
- issue(
541
- "option_unavailable",
542
- "This option group no longer exists",
543
- [{ action: "remove_line" }],
544
- choice.groupId
545
- )
546
- );
547
- }
392
+ if (fault === "no-prefetch") {
393
+ await page.route(/\/assets\/[^/]*item[^/]*\.js(?:\?|$)/, async (route) => {
394
+ await new Promise((resolve) => setTimeout(resolve, 1e4));
395
+ await route.continue();
396
+ });
548
397
  }
549
- for (const id of Object.keys(input.inputs ?? {})) {
550
- if (!item.optionGroups.some((g) => g.id === id)) {
551
- issues.push(
552
- issue(
553
- "option_unavailable",
554
- "This input group no longer exists",
555
- [{ action: "remove_line" }],
556
- id
557
- )
558
- );
559
- }
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
+ });
560
410
  }
561
- const groups = effectiveGroups(item, input);
562
- const missing = groups.filter(
563
- (g) => g.isAvailable !== false && g.required && !(input.choices ?? []).some((c) => c.groupId === g.id) && !(input.inputs?.[g.id] ?? "").trim()
564
- );
565
- if (missing.length) {
566
- return [
567
- ...issues,
568
- ...missing.map(
569
- (g) => issue(
570
- "option_required",
571
- `Choose ${g.name}`,
572
- [{ action: "choose_option", groupId: g.id }],
573
- g.id
574
- )
575
- )
576
- ];
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
+ });
577
428
  }
578
- for (const group of groups) {
579
- if (group.isAvailable === false) {
580
- continue;
581
- }
582
- const choices = input.choices?.filter((c) => c.groupId === group.id) ?? [];
583
- const text2 = input.inputs?.[group.id] ?? "";
584
- const value = text2.trim();
585
- const hybrid = group.isInformational && group.kind === "select";
586
- const isInput = group.kind !== "select" || hybrid && !group.choices.length;
587
- const fix = [{ action: "choose_option", groupId: group.id }];
588
- if (group.required && !choices.length && !value) {
589
- issues.push(
590
- issue("option_required", `Choose ${group.name}`, fix, group.id)
591
- );
592
- }
593
- if (value && (!isInput && !hybrid || choices.length > 0)) {
594
- issues.push(
595
- issue(
596
- "option_limit",
597
- `Choose either an option or a response for ${group.name}`,
598
- fix,
599
- group.id
600
- )
601
- );
602
- }
603
- if ((isInput || hybrid) && (text2.length > (group.input?.maxLength ?? 500) || value && !validInput(group.kind, value))) {
604
- issues.push(
605
- issue(
606
- "option_limit",
607
- `Enter a valid response for ${group.name}`,
608
- fix,
609
- group.id
610
- )
611
- );
612
- }
613
- const count = choices.reduce((sum, c) => sum + (c.quantity ?? 1), 0);
614
- if (choices.length && (isInput || count < group.selection.min || count > (group.selection.max ?? Infinity) || new Set(choices.map((c) => c.choiceId)).size !== choices.length)) {
615
- issues.push(
616
- issue(
617
- "option_limit",
618
- `Check the number of options for ${group.name}`,
619
- fix,
620
- group.id
621
- )
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 }
622
453
  );
623
- }
624
- for (const selected of choices) {
625
- const choice = group.choices.find((c) => c.id === selected.choiceId);
626
- if (!choice?.isAvailable) {
627
- issues.push(
628
- issue(
629
- "option_unavailable",
630
- `An option in ${group.name} is unavailable`,
631
- [
632
- { action: "choose_other_option", groupId: group.id },
633
- { action: "remove_line" }
634
- ],
635
- group.id
636
- )
637
- );
454
+ });
455
+ await page.route(
456
+ /\/assets\/cart-drawer-[^/]*\.js(?:\?|$)/,
457
+ async (route) => {
458
+ await tapped;
459
+ await route.continue().catch(() => {
460
+ });
638
461
  }
639
- const n = selected.quantity ?? 1;
640
- if (!Number.isInteger(n) || n < (choice?.quantity?.min ?? 1) || n > (choice?.quantity?.max ?? 99)) {
641
- issues.push(
642
- issue(
643
- "option_limit",
644
- `Check the option quantity for ${group.name}`,
645
- fix,
646
- group.id
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)
647
492
  )
648
493
  );
494
+ await route.fulfill({
495
+ response,
496
+ headers,
497
+ body: `await new Promise((resolve) => setTimeout(resolve, 2000));
498
+ ${await response.text()}`
499
+ });
649
500
  }
650
- }
651
- }
652
- const variant = resolveVariant(item, input);
653
- if (!variant?.isAvailable) {
654
- const groupId = item.optionGroups.find((g) => g.isAttribute)?.id;
655
- issues.push(
656
- issue(
657
- "option_limit",
658
- "Choose an available variant",
659
- groupId ? [{ action: "choose_option", groupId }] : [{ action: "remove_line" }],
660
- groupId
661
- )
662
501
  );
663
502
  }
664
- return issues;
665
- }
666
-
667
- // ../sdk/src/cart/reconcile.ts
668
- function lineInput(line) {
669
- return {
670
- itemId: line.itemId,
671
- ...line.menuId !== void 0 ? { menuId: line.menuId } : {},
672
- quantity: line.quantity,
673
- choices: line.choices.map((c) => ({ ...c })),
674
- inputs: { ...line.inputs },
675
- ...line.variantId ? { variantId: line.variantId } : {},
676
- ...line.note !== void 0 ? { note: line.note } : {}
677
- };
678
- }
679
- function freezeLine(line) {
680
- return Object.freeze({
681
- ...line,
682
- choices: Object.freeze(line.choices.map((c) => Object.freeze({ ...c }))),
683
- inputs: Object.freeze({ ...line.inputs })
684
- });
685
- }
686
- function reconcile(lines, menu, branch, currency, previous = []) {
687
- const issues = [];
688
- let priced = true;
689
- const result = lines.map((old) => {
690
- const item = Object.hasOwn(menu.items, old.itemId) ? menu.items[old.itemId] : void 0;
691
- const raw = {
692
- ...old,
693
- choices: old.choices?.map((c) => ({ ...c })),
694
- inputs: { ...old.inputs }
695
- };
696
- const input = item ? normalizeInput(item, raw) : raw;
697
- issues.push(
698
- ...validateLine(item, input, { menu, branch }).map((i) => ({
699
- ...i,
700
- lineId: old.id
701
- }))
702
- );
703
- const variantId = input.variantId ?? (item ? resolveVariant(item, input)?.id : void 0);
704
- let unitPrice = money(0, currency);
705
- let linePriced = true;
706
- try {
707
- if (!item) {
708
- throw new Error("Missing item");
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;
709
509
  }
710
- unitPrice = estimateUnitPrice(item, {
711
- choices: (input.choices ?? []).map((c) => ({
712
- ...c,
713
- quantity: c.quantity ?? 1
714
- })),
715
- ...variantId ? { variantId } : {}
716
- });
717
- } catch {
718
- priced = false;
719
- linePriced = false;
720
- }
721
- if (old.unitPrice && old.unitPrice.amountMinor !== unitPrice.amountMinor && item && linePriced) {
722
- issues.push({
723
- ...issue(
724
- "price_changed",
725
- `${item.name} is now ${format.money(unitPrice)}`,
726
- [{ action: "accept_price" }, { action: "remove_line" }]
727
- ),
728
- severity: "warning",
729
- lineId: old.id,
730
- data: { previous: old.unitPrice, current: unitPrice }
731
- });
732
- }
733
- return freezeLine({
734
- id: old.id,
735
- itemId: old.itemId,
736
- ...input.menuId !== void 0 ? { menuId: input.menuId } : {},
737
- name: item?.name ?? old.name,
738
- quantity: input.quantity ?? 1,
739
- choices: (input.choices ?? []).map((c) => ({
740
- ...c,
741
- quantity: c.quantity ?? 1
742
- })),
743
- inputs: input.inputs ?? {},
744
- ...variantId ? { variantId } : {},
745
- ...input.note !== void 0 ? { note: input.note } : {},
746
- unitPrice,
747
- lineSubtotal: multiplyMoney(unitPrice, input.quantity ?? 1),
748
- addedAt: old.addedAt
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
+ };
749
534
  });
750
- });
751
- for (const pending of previous) {
752
- if (pending.code === "price_changed" && result.some((l) => l.id === pending.lineId) && !issues.some(
753
- (i) => i.code === pending.code && i.lineId === pending.lineId
754
- )) {
755
- issues.push(pending);
756
- }
757
- }
758
- return { lines: Object.freeze(result), issues, priced };
759
- }
760
-
761
- // ../sdk/src/cart/storage.ts
762
- function storedMoney(value) {
763
- try {
764
- return value && money(value.amountMinor, value.currency);
765
- } catch {
766
- return void 0;
767
- }
768
- }
769
- function parseCart(raw) {
770
- const v = JSON.parse(raw);
771
- if (!v || v.v !== 1 || !Array.isArray(v.lines) || v.lines.length > 100 || !Number.isFinite(v.updatedAt) || typeof v.menuVersion !== "string" || v.promoCode !== null && typeof v.promoCode !== "string") {
772
- throw new Error("Invalid saved cart");
773
- }
774
- const ids = /* @__PURE__ */ new Set();
775
- for (const line of v.lines) {
776
- if (!line || typeof line.id !== "string" || ids.has(line.id) || typeof line.itemId !== "string" || typeof line.name !== "string" || !Number.isFinite(Date.parse(line.addedAt)) || !Number.isInteger(line.quantity) || line.quantity < 1 || line.quantity > 99 || line.note !== void 0 && (typeof line.note !== "string" || line.note.length > 280) || line.menuId !== void 0 && (typeof line.menuId !== "string" || !line.menuId.trim() || line.menuId.length > 256) || line.variantId !== void 0 && typeof line.variantId !== "string") {
777
- throw new Error("Invalid saved cart line");
778
- }
779
- ids.add(line.id);
780
- if (!Array.isArray(line.choices) || line.choices.length > 1e3 || line.choices.some(
781
- (c) => !c || typeof c.groupId !== "string" || typeof c.choiceId !== "string" || !Number.isInteger(c.quantity) || c.quantity < 1 || c.quantity > 99
782
- )) {
783
- throw new Error("Invalid saved choices");
784
- }
785
- if (!line.inputs || typeof line.inputs !== "object" || Array.isArray(line.inputs) || Object.values(line.inputs).some((t) => typeof t !== "string")) {
786
- throw new Error("Invalid saved inputs");
787
- }
788
535
  }
789
- if (v.fulfilment !== null && !validFulfilment(v.fulfilment)) {
790
- throw new Error("Invalid saved fulfilment");
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
+ });
791
552
  }
792
- return {
793
- v: 1,
794
- lines: v.lines.map((l) => {
795
- const unitPrice = storedMoney(l.unitPrice);
796
- return {
797
- id: l.id,
798
- itemId: l.itemId,
799
- ...l.menuId !== void 0 ? { menuId: l.menuId } : {},
800
- name: l.name,
801
- addedAt: l.addedAt,
802
- ...unitPrice ? { unitPrice } : {},
803
- quantity: l.quantity,
804
- choices: l.choices,
805
- inputs: l.inputs,
806
- ...l.variantId ? { variantId: l.variantId } : {},
807
- ...l.note !== void 0 ? { note: l.note } : {}
553
+ if (fault === "quick-add-via-item") {
554
+ await page.addInitScript(() => {
555
+ const removeQuickAdds = () => {
556
+ if (location.pathname !== "/") {
557
+ return;
558
+ }
559
+ for (const node of document.querySelectorAll(
560
+ '[data-zappie-item] [data-zappie-action="add-to-cart"]'
561
+ )) {
562
+ node.remove();
563
+ }
808
564
  };
809
- }),
810
- fulfilment: v.fulfilment,
811
- promoCode: v.promoCode,
812
- menuVersion: v.menuVersion,
813
- updatedAt: v.updatedAt
814
- };
815
- }
816
- function validFulfilment(f) {
817
- if (!f || !["pickup", "delivery", "dine_in"].includes(f.type) || typeof f.branchId !== "string") {
818
- return false;
819
- }
820
- if ("scheduledFor" in f && f.scheduledFor !== void 0 && !Number.isFinite(Date.parse(f.scheduledFor))) {
821
- return false;
565
+ new MutationObserver(removeQuickAdds).observe(document, {
566
+ childList: true,
567
+ subtree: true
568
+ });
569
+ });
822
570
  }
823
- if (f.type === "delivery") {
824
- const a = f.address;
825
- if (!a || typeof a.line1 !== "string" || !a.line1.trim()) {
826
- return false;
827
- }
828
- if ([a.line2, a.city, a.placeId, a.instructions].some(
829
- (s) => s !== void 0 && typeof s !== "string"
830
- )) {
831
- return false;
832
- }
833
- if (a.coordinates && (!Number.isFinite(a.coordinates.lat) || !Number.isFinite(a.coordinates.lng) || Math.abs(a.coordinates.lat) > 90 || Math.abs(a.coordinates.lng) > 180)) {
834
- return false;
835
- }
571
+ if (fault === "no-sold-out") {
572
+ await page.addInitScript(() => {
573
+ let debug;
574
+ Object.defineProperty(window, "__zappie", {
575
+ configurable: true,
576
+ get: () => debug,
577
+ set(value) {
578
+ debug = value;
579
+ if (value) {
580
+ const catalogue = value.catalogue;
581
+ value.catalogue = async () => {
582
+ const result = await catalogue();
583
+ return {
584
+ ...result,
585
+ menu: {
586
+ ...result.menu,
587
+ items: Object.fromEntries(
588
+ Object.entries(result.menu.items).filter(
589
+ ([, item]) => item.isAvailable
590
+ )
591
+ )
592
+ }
593
+ };
594
+ };
595
+ }
596
+ }
597
+ });
598
+ });
836
599
  }
837
- return f.type !== "dine_in" || f.table === void 0 || typeof f.table === "string";
838
- }
839
- function createCartStorage(provided, warn) {
840
- let storage;
841
- let initialized = false;
842
- let failed = false;
843
- let warned = false;
844
- const memory = /* @__PURE__ */ new Map();
845
- const warning = (message) => {
846
- if (!warned) {
847
- warned = true;
848
- warn(message);
849
- }
850
- };
851
- const access = (fn, fallback) => {
852
- try {
853
- if (!initialized) {
854
- initialized = true;
855
- storage = provided ?? (typeof window === "undefined" ? void 0 : window.localStorage);
856
- }
857
- if (storage && !failed) {
858
- return fn(storage);
859
- }
860
- } catch {
861
- failed = true;
862
- warning("Cart storage unavailable; using memory for this client");
863
- }
864
- return fallback();
865
- };
866
- return {
867
- warning,
868
- read(key) {
869
- return access(
870
- (s) => {
871
- const value = s.getItem(key);
872
- if (value !== null) {
873
- memory.set(key, value);
874
- } else {
875
- memory.delete(key);
600
+ if (fault === "add-stuck") {
601
+ await page.addInitScript(() => {
602
+ document.addEventListener(
603
+ "click",
604
+ (event) => {
605
+ const target2 = event.target;
606
+ if (target2?.closest('[data-zappie-action="add-to-cart"]')) {
607
+ event.stopImmediatePropagation();
608
+ event.preventDefault();
876
609
  }
877
- return value;
878
610
  },
879
- () => memory.get(key) ?? null
880
- );
881
- },
882
- write(key, value) {
883
- memory.set(key, value);
884
- access(
885
- (s) => s.setItem(key, value),
886
- () => {
887
- }
611
+ true
888
612
  );
889
- },
890
- remove(key) {
891
- memory.delete(key);
892
- access(
893
- (s) => s.removeItem(key),
894
- () => {
613
+ });
614
+ }
615
+ if (fault === "missing-issue-fix") {
616
+ await page.addInitScript(() => {
617
+ new MutationObserver(() => {
618
+ for (const node of document.querySelectorAll(
619
+ "[data-zappie-issue-fix]"
620
+ )) {
621
+ node.removeAttribute("data-zappie-issue-fix");
895
622
  }
896
- );
897
- },
898
- accepts(area) {
899
- return !failed && (!area || area === storage);
900
- },
901
- sweep(prefix, current, ttl) {
902
- access(
903
- (s) => {
904
- const keys = Array.from(
905
- { length: s.length ?? 0 },
906
- (_, index) => s.key?.(index)
907
- );
908
- for (const key of keys) {
909
- if (!key || key === current || !key.startsWith(prefix)) {
910
- continue;
911
- }
912
- try {
913
- const raw = s.getItem(key);
914
- if (raw && Date.now() - parseCart(raw).updatedAt > ttl) {
915
- s.removeItem(key);
916
- }
917
- } catch {
918
- s.removeItem(key);
919
- }
920
- }
921
- },
922
- () => {
923
- }
924
- );
925
- }
926
- };
927
- }
928
-
929
- // ../sdk/src/cart/wire.ts
930
- function wireLine(line) {
931
- return {
932
- line_id: line.id,
933
- item_id: line.itemId,
934
- ...line.menuId !== void 0 ? { menu_id: line.menuId } : {},
935
- quantity: line.quantity,
936
- ...line.variantId ? { variant_id: line.variantId } : {},
937
- options: line.choices.map((c) => ({
938
- group_id: c.groupId,
939
- choice_id: c.choiceId,
940
- quantity: c.quantity
941
- })),
942
- inputs: { ...line.inputs },
943
- ...line.note !== void 0 ? { note: line.note } : {}
944
- };
945
- }
946
- function wireFulfilment(f) {
947
- return {
948
- type: f.type,
949
- ..."scheduledFor" in f && f.scheduledFor ? { scheduled_for: f.scheduledFor } : {},
950
- ...f.type === "dine_in" && f.table ? { table: f.table } : {},
951
- ...f.type === "delivery" ? {
952
- address: {
953
- line1: f.address.line1,
954
- ...f.address.line2 ? { line2: f.address.line2 } : {},
955
- ...f.address.city ? { city: f.address.city } : {},
956
- ...f.address.placeId ? { place_id: f.address.placeId } : {},
957
- ...f.address.instructions ? { instructions: f.address.instructions } : {},
958
- ...f.address.coordinates ? {
959
- lat: f.address.coordinates.lat,
960
- lng: f.address.coordinates.lng
961
- } : {}
962
- }
963
- } : {}
964
- };
965
- }
966
-
967
- // ../sdk/src/cart/index.ts
968
- var EMPTY_CART = Object.freeze({
969
- lines: Object.freeze([]),
970
- count: 0,
971
- subtotal: null,
972
- fulfilment: null,
973
- promoCode: null,
974
- state: { status: "ready" },
975
- issues: Object.freeze([]),
976
- isHydrated: false
977
- });
978
- function deepFreeze(value) {
979
- if (value && typeof value === "object" && !Object.isFrozen(value)) {
980
- Object.freeze(value);
981
- for (const child of Object.values(value)) {
982
- deepFreeze(child);
983
- }
984
- }
985
- return value;
986
- }
987
- function identity(input) {
988
- return JSON.stringify([
989
- input.itemId,
990
- input.menuId,
991
- input.variantId,
992
- input.note ?? "",
993
- [...input.choices ?? []].sort(
994
- (a, b) => a.groupId.localeCompare(b.groupId) || a.choiceId.localeCompare(b.choiceId)
995
- ),
996
- Object.entries(input.inputs ?? {}).sort(([a], [b]) => a.localeCompare(b))
997
- ]);
998
- }
999
- function createCart(deps) {
1000
- const { config, menu: menus, store, events } = deps;
1001
- const ttl = config.cart?.ttl ?? 7 * 24 * 60 * 60 * 1e3;
1002
- if (!Number.isFinite(ttl) || ttl <= 0) {
1003
- throw new ZappieError(
1004
- "invalid_request",
1005
- "Cart ttl must be positive milliseconds"
1006
- );
1007
- }
1008
- let snapshot = EMPTY_CART;
1009
- let menu;
1010
- let branch;
1011
- let selected = config.branch;
1012
- let updatedAt = 0;
1013
- let timer;
1014
- let revision = 0;
1015
- let quoteRevision = 0;
1016
- let moveSequence = 0;
1017
- let refreshSequence = 0;
1018
- let mounted = 0;
1019
- let detach;
1020
- let hydration;
1021
- let lifecycle = 0;
1022
- let legacy;
1023
- const listeners = /* @__PURE__ */ new Set();
1024
- const prefix = `zappie:cart:v1:${config.store}:`;
1025
- const scope = config.cart?.storageScope;
1026
- const keyFor = (id) => `${prefix}${id}${scope ? `:${scope}` : ""}`;
1027
- const key = () => keyFor(branch.id);
1028
- const storage = createCartStorage(
1029
- config.storage,
1030
- (message) => events.emit("warning", { message })
1031
- );
1032
- const readSaved = (id) => {
1033
- let raw = storage.read(keyFor(id));
1034
- if (raw === null && scope) {
1035
- raw = storage.read(prefix + id);
1036
- if (raw !== null) {
1037
- storage.remove(prefix + id);
1038
- storage.write(keyFor(id), raw);
1039
- }
1040
- }
1041
- return raw;
1042
- };
1043
- let quotes;
1044
- let loadingQuotes;
1045
- const quoteClient = () => loadingQuotes ??= import("./quote-client-6XJJHITL.js").then(
1046
- ({ createQuoteClient }) => quotes = createQuoteClient(deps.transport, deps.context),
1047
- (error) => {
1048
- loadingQuotes = void 0;
1049
- throw error;
1050
- }
1051
- );
1052
- const publish = (patch) => {
1053
- snapshot = deepFreeze({
1054
- ...snapshot,
1055
- ...patch,
1056
- branchId: branch?.id
1057
- });
1058
- for (const fn of [...listeners]) {
1059
- try {
1060
- fn();
1061
- } catch (error) {
1062
- events.emit("error", { error: asError(error) });
1063
- }
1064
- }
1065
- };
1066
- const attention = (issues, quote) => issues.some((i) => i.severity !== "info") ? { status: "needs_attention", issues, ...quote ? { quote } : {} } : quote ? { status: "quoted", quote } : { status: "ready" };
1067
- const cancel = () => {
1068
- revision++;
1069
- quoteRevision++;
1070
- clearTimeout(timer);
1071
- quotes?.cancel();
1072
- };
1073
- const persist = () => {
1074
- const value = {
1075
- v: 1,
1076
- lines: snapshot.lines.map((line) => ({
1077
- ...lineInput(line),
1078
- id: line.id,
1079
- name: line.name,
1080
- addedAt: line.addedAt,
1081
- unitPrice: line.unitPrice
1082
- })),
1083
- fulfilment: snapshot.fulfilment,
1084
- promoCode: snapshot.promoCode,
1085
- menuVersion: menu.version,
1086
- updatedAt
1087
- };
1088
- storage.write(key(), JSON.stringify(value));
1089
- };
1090
- const schedule = () => {
1091
- clearTimeout(timer);
1092
- if (snapshot.lines.length) {
1093
- timer = setTimeout(() => {
1094
- void cart.quote().catch(() => {
1095
- });
1096
- }, 300);
1097
- }
1098
- };
1099
- const recompute = (lines, pending = snapshot.issues.filter((i) => i.source === "local"), patch = {}) => {
1100
- const currency = store.peek().currency;
1101
- const result = reconcile(lines, menu, branch, currency, pending);
1102
- const subtotal = result.priced ? sumMoney(
1103
- result.lines.map((l) => l.lineSubtotal),
1104
- money(0, currency)
1105
- ) : null;
1106
- publish({
1107
- ...patch,
1108
- lines: result.lines,
1109
- count: result.lines.reduce((n, l) => n + l.quantity, 0),
1110
- subtotal,
1111
- issues: result.issues,
1112
- state: attention(result.issues)
1113
- });
1114
- for (const i of result.issues) {
1115
- events.emit("cart_issue", { code: i.code });
1116
- }
1117
- };
1118
- const changed = (lines = snapshot.lines, patch = {}) => {
1119
- cancel();
1120
- recompute(lines, void 0, patch);
1121
- updatedAt = Date.now();
1122
- persist();
1123
- schedule();
1124
- };
1125
- const choose = (value) => {
1126
- const found = store.peek()?.branches.find((b) => b.id === value.branchId);
1127
- if (!found) {
1128
- throw new ZappieError(
1129
- "branch_not_found",
1130
- "Load the storefront branch before using its cart"
1131
- );
1132
- }
1133
- menu = value;
1134
- branch = found;
1135
- selected = found.id;
1136
- };
1137
- const load = (raw, writeBack = true) => {
1138
- cancel();
1139
- let saved;
1140
- try {
1141
- saved = raw ? parseCart(raw) : void 0;
1142
- if (saved && Date.now() - saved.updatedAt > ttl) {
1143
- saved = void 0;
1144
- storage.remove(key());
1145
- events.emit("cart_expired", { branchId: branch.id });
1146
- }
1147
- } catch {
1148
- storage.remove(key());
1149
- storage.warning("Invalid saved cart discarded");
1150
- }
1151
- updatedAt = saved?.updatedAt ?? Date.now();
1152
- const idle = Date.now() - updatedAt;
1153
- let fulfilment = saved?.fulfilment ?? null;
1154
- if (fulfilment?.branchId !== branch.id || idle > 864e5 && fulfilment.type === "delivery") {
1155
- fulfilment = null;
1156
- }
1157
- if (fulfilment && "scheduledFor" in fulfilment && fulfilment.scheduledFor && Date.parse(fulfilment.scheduledFor) < Date.now()) {
1158
- fulfilment = { ...fulfilment };
1159
- delete fulfilment.scheduledFor;
1160
- }
1161
- const lines = (saved?.lines ?? []).map((line) => ({
1162
- ...line,
1163
- ...idle > 864e5 ? { inputs: {} } : {}
1164
- }));
1165
- const result = reconcile(lines, menu, branch, store.peek().currency);
1166
- recompute(result.lines, result.issues, {
1167
- fulfilment,
1168
- promoCode: saved?.promoCode ?? null,
1169
- isHydrated: true
1170
- });
1171
- if (raw !== null && writeBack) {
1172
- persist();
1173
- }
1174
- storage.sweep(prefix, key(), ttl);
1175
- };
1176
- const ensure = () => {
1177
- const id = branch?.id ?? store.peek()?.branches.find(
1178
- (b) => b.id === selected || b.slug === selected || !selected && b.isDefault
1179
- )?.id ?? (!selected ? store.peek()?.branches[0]?.id : void 0);
1180
- if (snapshot.isHydrated) {
1181
- return;
1182
- }
1183
- const loaded = menu ?? (id ? deps.workingSet.peek(id, []) : void 0);
1184
- if (!loaded || !store.peek()) {
1185
- throw new ZappieError(
1186
- "invalid_request",
1187
- "Load the store and menu before editing the cart"
1188
- );
1189
- }
1190
- choose(loaded);
1191
- const raw = readSaved(branch.id);
1192
- let migrated = [];
1193
- if (raw === null && legacy) {
1194
- try {
1195
- migrated = legacy(storage.read);
1196
- } catch {
1197
- storage.warning("Invalid legacy cart discarded");
1198
- }
1199
- }
1200
- load(raw);
1201
- if (raw === null && legacy) {
1202
- persist();
1203
- }
1204
- for (const input of migrated.slice(0, 100)) {
1205
- try {
1206
- cart.add(input.itemId, input);
1207
- } catch {
1208
- events.emit("cart_issue", { code: "item_unavailable" });
1209
- }
1210
- }
1211
- };
1212
- const refresh = async () => {
1213
- const id = branch?.id;
1214
- const seq = moveSequence;
1215
- const request = ++refreshSequence;
1216
- const rev = revision;
1217
- if (!id || !menu) {
1218
- return;
1219
- }
1220
- if (!snapshot.lines.length) {
1221
- return;
1222
- }
1223
- if (menu.completeness !== "partial") {
1224
- const version = await deps.transport.getMenuVersion({
1225
- ...deps.context(),
1226
- branchId: id
1227
- });
1228
- if (version.version === menu.version) {
1229
- return;
1230
- }
1231
- }
1232
- const value = await deps.workingSet.read(
1233
- id,
1234
- snapshot.lines.map((l) => l.itemId),
1235
- true
1236
- );
1237
- if (seq !== moveSequence || request !== refreshSequence || rev !== revision || JSON.stringify(value) === JSON.stringify(menu)) {
1238
- return;
1239
- }
1240
- menu = value;
1241
- cancel();
1242
- recompute(snapshot.lines);
1243
- persist();
1244
- schedule();
1245
- };
1246
- const current = (itemId) => {
1247
- const candidate = deps.workingSet.peek(branch.id, [
1248
- ...snapshot.lines.map((l) => l.itemId),
1249
- itemId
1250
- ]);
1251
- if (candidate.items[itemId]) {
1252
- menu = candidate;
1253
- }
1254
- };
1255
- const validated = (raw, id, addedAt) => {
1256
- current(raw.itemId);
1257
- const item = Object.hasOwn(menu.items, raw.itemId) ? menu.items[raw.itemId] : void 0;
1258
- const problems = validateLine(item, raw, { menu, branch });
1259
- if (problems.length || !item) {
1260
- throw new ZappieCartError(problems);
1261
- }
1262
- const input = normalizeInput(item, raw);
1263
- const variant = resolveVariant(item, input);
1264
- const choices = (input.choices ?? []).map((c) => ({
1265
- ...c,
1266
- quantity: c.quantity ?? 1
1267
- }));
1268
- const unitPrice = estimateUnitPrice(item, {
1269
- choices,
1270
- variantId: variant.id
1271
- });
1272
- return freezeLine({
1273
- id: id ?? `line_${Array.from(globalThis.crypto.getRandomValues(new Uint8Array(10)), (byte) => "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-"[byte & 63]).join("")}`,
1274
- itemId: item.id,
1275
- ...input.menuId !== void 0 ? { menuId: input.menuId } : {},
1276
- name: item.name,
1277
- quantity: input.quantity,
1278
- choices,
1279
- inputs: input.inputs ?? {},
1280
- variantId: variant.id,
1281
- ...input.note !== void 0 ? { note: input.note } : {},
1282
- unitPrice,
1283
- lineSubtotal: multiplyMoney(unitPrice, input.quantity),
1284
- addedAt: addedAt ?? (/* @__PURE__ */ new Date()).toISOString()
1285
- });
1286
- };
1287
- const offMenu = events.on("menu:updated", ({ branchId, version }) => {
1288
- if (branch?.id === branchId) {
1289
- const value = menus.peek(branchId);
1290
- if (value && value.completeness !== "partial" && value.version === version && value.version !== menu?.version) {
1291
- refreshSequence++;
1292
- menu = value;
1293
- cancel();
1294
- recompute(snapshot.lines);
1295
- persist();
1296
- schedule();
1297
- }
1298
- }
1299
- });
1300
- const cart = {
1301
- get branchId() {
1302
- return snapshot.branchId;
1303
- },
1304
- get lines() {
1305
- return snapshot.lines;
1306
- },
1307
- get count() {
1308
- return snapshot.count;
1309
- },
1310
- get subtotal() {
1311
- return snapshot.subtotal;
1312
- },
1313
- get fulfilment() {
1314
- return snapshot.fulfilment;
1315
- },
1316
- get promoCode() {
1317
- return snapshot.promoCode;
1318
- },
1319
- get state() {
1320
- return snapshot.state;
1321
- },
1322
- get issues() {
1323
- return snapshot.issues;
1324
- },
1325
- get isHydrated() {
1326
- return snapshot.isHydrated;
1327
- },
1328
- getSnapshot: () => snapshot,
1329
- subscribe(fn) {
1330
- listeners.add(fn);
1331
- return () => {
1332
- listeners.delete(fn);
1333
- };
1334
- },
1335
- async hydrate() {
1336
- if (snapshot.isHydrated) {
1337
- return refresh();
1338
- }
1339
- const life = lifecycle;
1340
- hydration ??= (async () => {
1341
- const value = await store.get();
1342
- const target2 = value.branches.find(
1343
- (b) => b.id === selected || b.slug === selected
1344
- ) ?? (!selected ? value.branches.find((b) => b.isDefault) ?? value.branches[0] : void 0);
1345
- if (!target2) {
1346
- throw new ZappieError(
1347
- "branch_not_found",
1348
- "Cart branch is unavailable"
1349
- );
1350
- }
1351
- let inputs = snapshot.lines;
1352
- if (!snapshot.isHydrated) {
1353
- const raw = readSaved(target2.id);
1354
- try {
1355
- const saved = raw ? parseCart(raw) : void 0;
1356
- inputs = saved && Date.now() - saved.updatedAt <= ttl ? saved.lines : raw === null ? legacy?.(storage.read) ?? [] : [];
1357
- } catch {
1358
- inputs = [];
1359
- }
1360
- }
1361
- const loaded = await deps.workingSet.read(
1362
- target2.id,
1363
- inputs.map((l) => l.itemId)
1364
- );
1365
- if (life !== lifecycle) {
1366
- return;
1367
- }
1368
- choose(loaded);
1369
- ensure();
1370
- if (loaded.completeness !== "partial") {
1371
- await refresh();
1372
- }
1373
- })().finally(() => {
1374
- hydration = void 0;
1375
- });
1376
- return hydration;
1377
- },
1378
- mount() {
1379
- mounted++;
1380
- if (!detach && typeof window !== "undefined") {
1381
- const onStorage = (event) => {
1382
- if (branch && event.key === key() && storage.accepts(event.storageArea)) {
1383
- const raw = event.newValue;
1384
- let ids;
1385
- try {
1386
- ids = raw ? parseCart(raw).lines.map((l) => l.itemId) : [];
1387
- } catch {
1388
- load(raw, false);
1389
- return;
1390
- }
1391
- const cached = deps.workingSet.peek(branch.id, ids);
1392
- if (cached.completeness !== "partial" || ids.every((id) => cached.items[id])) {
1393
- menu = cached;
1394
- load(raw, false);
1395
- schedule();
1396
- return;
1397
- }
1398
- const rev = revision;
1399
- const request = ++refreshSequence;
1400
- const life = lifecycle;
1401
- void deps.workingSet.read(branch.id, ids).then((value) => {
1402
- if (life === lifecycle && rev === revision && request === refreshSequence) {
1403
- menu = value;
1404
- load(raw, false);
1405
- schedule();
1406
- }
1407
- }).catch(
1408
- (error) => events.emit("error", { error: asError(error) })
1409
- );
1410
- }
1411
- };
1412
- const onFocus = () => {
1413
- void refresh().catch(
1414
- (error) => events.emit("error", { error: asError(error) })
1415
- );
1416
- };
1417
- window.addEventListener("storage", onStorage);
1418
- window.addEventListener("focus", onFocus);
1419
- detach = () => {
1420
- window.removeEventListener("storage", onStorage);
1421
- window.removeEventListener("focus", onFocus);
1422
- };
1423
- }
1424
- if (mounted === 1) {
1425
- void cart.hydrate().catch(
1426
- (error) => events.emit("error", { error: asError(error) })
1427
- );
1428
- }
1429
- let active = true;
1430
- return () => {
1431
- if (active) {
1432
- active = false;
1433
- mounted--;
1434
- if (mounted === 0) {
1435
- detach?.();
1436
- detach = void 0;
1437
- cancel();
1438
- }
1439
- }
1440
- };
1441
- },
1442
- dispose() {
1443
- lifecycle++;
1444
- moveSequence++;
1445
- offMenu();
1446
- cancel();
1447
- detach?.();
1448
- detach = void 0;
1449
- listeners.clear();
1450
- },
1451
- validate(input) {
1452
- ensure();
1453
- current(input.itemId);
1454
- const problems = validateLine(
1455
- Object.hasOwn(menu.items, input.itemId) ? menu.items[input.itemId] : void 0,
1456
- input,
1457
- { menu, branch }
1458
- );
1459
- return problems.length ? { ok: false, issues: problems } : { ok: true };
1460
- },
1461
- add(item, input = {}) {
1462
- ensure();
1463
- let line = validated({
1464
- ...input,
1465
- itemId: typeof item === "string" ? item : item.id
1466
- });
1467
- const existing = snapshot.lines.find(
1468
- (l) => identity(lineInput(l)) === identity(lineInput(line))
1469
- );
1470
- if (existing) {
1471
- line = validated(
1472
- { ...lineInput(line), quantity: existing.quantity + line.quantity },
1473
- existing.id,
1474
- existing.addedAt
1475
- );
1476
- } else if (snapshot.lines.length >= 100) {
1477
- throw new ZappieCartError([
1478
- issue("quantity_limit", "A cart can contain at most 100 lines")
1479
- ]);
1480
- }
1481
- changed(
1482
- existing ? snapshot.lines.map((l) => l.id === existing.id ? line : l) : [...snapshot.lines, line]
1483
- );
1484
- events.emit("add_to_cart", {
1485
- itemId: line.itemId,
1486
- quantity: line.quantity - (existing?.quantity ?? 0),
1487
- value: multiplyMoney(
1488
- line.unitPrice,
1489
- line.quantity - (existing?.quantity ?? 0)
1490
- )
1491
- });
1492
- return line;
1493
- },
1494
- update(lineId, patch) {
1495
- ensure();
1496
- const old = snapshot.lines.find((l) => l.id === lineId);
1497
- if (!old) {
1498
- throw new ZappieCartError([
1499
- issue("item_not_found", "Cart line not found")
1500
- ]);
1501
- }
1502
- const input = { ...lineInput(old), ...patch };
1503
- if (patch.choices && !patch.variantId) {
1504
- delete input.variantId;
1505
- }
1506
- let line = validated(input, old.id, old.addedAt);
1507
- const duplicate = snapshot.lines.find(
1508
- (l) => l.id !== lineId && identity(lineInput(l)) === identity(lineInput(line))
1509
- );
1510
- if (duplicate) {
1511
- line = validated(
1512
- { ...lineInput(line), quantity: line.quantity + duplicate.quantity },
1513
- old.id,
1514
- old.addedAt
1515
- );
1516
- }
1517
- changed(
1518
- snapshot.lines.filter((l) => l.id !== duplicate?.id).map((l) => l.id === lineId ? line : l)
1519
- );
1520
- events.emit("update_cart_line", {
1521
- lineId,
1522
- itemId: line.itemId,
1523
- quantity: line.quantity
1524
- });
1525
- return line;
1526
- },
1527
- remove(lineId) {
1528
- ensure();
1529
- const line = snapshot.lines.find((l) => l.id === lineId);
1530
- changed(snapshot.lines.filter((l) => l.id !== lineId));
1531
- if (line) {
1532
- events.emit("remove_from_cart", {
1533
- itemId: line.itemId,
1534
- quantity: line.quantity,
1535
- value: line.lineSubtotal
1536
- });
1537
- }
1538
- },
1539
- clear() {
1540
- ensure();
1541
- changed([], { fulfilment: null, promoCode: null });
1542
- },
1543
- setFulfilment(value) {
1544
- ensure();
1545
- if (!validFulfilment(value) || value.branchId !== branch.id) {
1546
- throw new ZappieCartError([
1547
- issue(
1548
- "fulfilment_unavailable",
1549
- "Select fulfilment for the cart's branch"
1550
- )
1551
- ]);
1552
- }
1553
- changed(snapshot.lines, { fulfilment: structuredClone(value) });
1554
- },
1555
- setPromoCode(code) {
1556
- ensure();
1557
- changed(snapshot.lines, { promoCode: code?.trim() || null });
1558
- },
1559
- async moveTo(branchId) {
1560
- ensure();
1561
- const seq = ++moveSequence;
1562
- cancel();
1563
- publish({ state: attention(snapshot.issues) });
1564
- const value = await deps.workingSet.read(
1565
- branchId,
1566
- snapshot.lines.map((l) => l.itemId)
1567
- );
1568
- if (seq !== moveSequence) {
1569
- return;
1570
- }
1571
- const oldKey = key();
1572
- const oldLines = snapshot.lines;
1573
- choose(value);
1574
- const kept = oldLines.filter(
1575
- (line) => !validateLine(value.items[line.itemId], lineInput(line), {
1576
- menu: value,
1577
- branch
1578
- }).length
1579
- );
1580
- changed(kept, { fulfilment: null, promoCode: null });
1581
- const dropped = oldLines.filter((line) => !kept.includes(line)).map((line) => ({
1582
- ...issue(
1583
- "item_unavailable",
1584
- `${line.name} is unavailable at this branch`
1585
- ),
1586
- severity: "warning",
1587
- lineId: line.id
1588
- }));
1589
- const issues = [...snapshot.issues, ...dropped];
1590
- publish({ issues, state: attention(issues) });
1591
- if (oldKey !== key()) {
1592
- storage.remove(oldKey);
1593
- }
1594
- },
1595
- async quote(opts = {}) {
1596
- ensure();
1597
- clearTimeout(timer);
1598
- if (opts.force) {
1599
- events.emit("view_cart", {});
1600
- }
1601
- const body = {
1602
- branch_id: branch.id,
1603
- lines: snapshot.lines.map(wireLine),
1604
- ...snapshot.fulfilment ? { fulfilment: wireFulfilment(snapshot.fulfilment) } : {},
1605
- ...snapshot.promoCode ? { promo_code: snapshot.promoCode } : {},
1606
- menu_version: menu.version
1607
- };
1608
- const seq = revision;
1609
- const attempt = ++quoteRevision;
1610
- publish({ state: { status: "quoting" } });
1611
- try {
1612
- const client = await quoteClient();
1613
- return await client.get(body, opts.force ?? false, (quote) => {
1614
- if (seq !== revision) {
1615
- return;
1616
- }
1617
- const local = snapshot.issues.filter((i) => i.source === "local");
1618
- const remote = quote.issues.filter(
1619
- (q) => !local.some(
1620
- (l) => l.code === q.code && l.lineId === q.lineId && l.groupId === q.groupId
1621
- )
1622
- );
1623
- const issues = [...local, ...remote];
1624
- publish({ issues, state: attention(issues, quote) });
1625
- for (const i of remote) {
1626
- events.emit("cart_issue", { code: i.code });
1627
- }
1628
- if (quote.menuVersion !== menu.version || quote.issues.some((i) => i.code === "menu_version_stale")) {
1629
- void refresh().catch(
1630
- (error) => events.emit("error", { error: asError(error) })
1631
- );
1632
- }
1633
- });
1634
- } catch (error) {
1635
- if (seq === revision && attempt === quoteRevision) {
1636
- const local = snapshot.issues.filter((i) => i.source === "local");
1637
- publish({ issues: local, state: attention(local) });
1638
- events.emit("quote_failed", { code: asError(error).code });
1639
- }
1640
- throw error instanceof Error && error.name === "AbortError" ? error : asError(error);
1641
- }
1642
- },
1643
- applyFix(problem, fix = problem.fixes[0]) {
1644
- if (!snapshot.issues.includes(problem) || !fix || !problem.fixes.some((f) => JSON.stringify(f) === JSON.stringify(fix))) {
1645
- throw new ZappieError(
1646
- "invalid_request",
1647
- "Choose a current cart issue and one of its fixes"
1648
- );
1649
- }
1650
- switch (fix.action) {
1651
- case "remove_line": {
1652
- if (problem.lineId) {
1653
- cart.remove(problem.lineId);
1654
- }
1655
- return;
1656
- }
1657
- case "remove_promo":
1658
- cart.setPromoCode(null);
1659
- return;
1660
- case "set_quantity": {
1661
- if (problem.lineId) {
1662
- cart.update(problem.lineId, { quantity: fix.quantity });
1663
- }
1664
- return;
1665
- }
1666
- case "accept_price": {
1667
- quotes?.accept(problem);
1668
- const issues = snapshot.issues.filter((i) => i !== problem);
1669
- const quote = "quote" in snapshot.state ? snapshot.state.quote : void 0;
1670
- const accepted = quote ? {
1671
- ...quote,
1672
- issues: quote.issues.filter(
1673
- (i) => !(i.code === problem.code && i.lineId === problem.lineId)
1674
- )
1675
- } : void 0;
1676
- publish({ issues, state: attention(issues, accepted) });
1677
- return;
1678
- }
1679
- default:
1680
- throw new ZappieError(
1681
- "invalid_request",
1682
- `${fix.action} needs the shopper's selection; use update or setFulfilment`
1683
- );
1684
- }
1685
- },
1686
- toCheckoutPayload() {
1687
- ensure();
1688
- const blocking = snapshot.issues.filter((i) => i.severity === "blocking");
1689
- if (!snapshot.lines.length || !snapshot.fulfilment) {
1690
- blocking.push(
1691
- issue(
1692
- "fulfilment_unavailable",
1693
- "Add items and select fulfilment before checkout"
1694
- )
1695
- );
1696
- }
1697
- if (blocking.length) {
1698
- throw new ZappieCartError(blocking);
1699
- }
1700
- return {
1701
- lines: snapshot.lines.map(wireLine),
1702
- fulfilment: structuredClone(snapshot.fulfilment),
1703
- ...snapshot.promoCode ? { promo_code: snapshot.promoCode } : {},
1704
- menu_version: menu.version
1705
- };
1706
- }
1707
- };
1708
- return {
1709
- cart: Object.freeze(cart),
1710
- cartCatalogue: () => menu,
1711
- setLegacyReader(reader) {
1712
- legacy = reader;
1713
- }
1714
- };
1715
- }
1716
-
1717
- // ../sdk/src/cart/working-set.ts
1718
- function createWorkingSet(deps) {
1719
- const compose = (branchId, ids, metadata) => {
1720
- const source = metadata ?? deps.menu.peek(branchId);
1721
- const items = Object.fromEntries(
1722
- [...new Set(ids)].flatMap((id) => {
1723
- const item = deps.peekItem(id, branchId) ?? source?.items[id];
1724
- return item ? [[id, item]] : [];
1725
- })
1726
- );
1727
- return {
1728
- id: `branch:${branchId}`,
1729
- name: "Cart working set",
1730
- branchId,
1731
- version: "",
1732
- ...source,
1733
- kind: "working-set",
1734
- completeness: "partial",
1735
- items,
1736
- categories: (source?.categories ?? []).map((category) => ({
1737
- ...category,
1738
- itemIds: Object.values(items).filter(
1739
- (item) => item.placements ? item.placements.some(
1740
- (p) => p.categoryId === category.id && (source?.menus ?? [
1741
- { id: source?.id, categoryIds: [category.id] }
1742
- ]).some(
1743
- (m) => m.id === p.menuId && m.categoryIds.includes(category.id)
1744
- )
1745
- ) : category.itemIds.includes(item.id)
1746
- ).map((item) => item.id)
1747
- }))
1748
- };
1749
- };
1750
- const peek = (branchId, ids) => {
1751
- const value = deps.menu.peek(branchId);
1752
- return value && value.completeness !== "partial" ? value : compose(branchId, ids);
1753
- };
1754
- return {
1755
- peek,
1756
- async read(branchId, ids, fresh = false) {
1757
- const cached = peek(branchId, ids);
1758
- if (!ids.length || !fresh && cached.completeness !== "partial") {
1759
- return cached;
1760
- }
1761
- const response = fresh ? await deps.transport.getMenu({
1762
- ...deps.context(),
1763
- branchId,
1764
- metadataOnly: true
1765
- }) : void 0;
1766
- const metadata = response && "menu" in response ? response.menu : await deps.menu.get({ branch: branchId, metadataOnly: true });
1767
- if (metadata.branchId !== branchId) {
1768
- throw new ZappieError(
1769
- "server_error",
1770
- "Cart metadata belongs to another branch"
1771
- );
1772
- }
1773
- deps.menu.hydrate(metadata);
1774
- if (metadata.completeness !== "partial") {
1775
- return metadata;
1776
- }
1777
- await Promise.all(
1778
- [...new Set(ids)].map(async (id) => {
1779
- try {
1780
- await deps.readItem(id, branchId, fresh);
1781
- } catch (error) {
1782
- if (error.code !== "item_not_found") {
1783
- throw error;
1784
- }
1785
- }
1786
- })
1787
- );
1788
- return compose(branchId, ids, metadata);
1789
- }
1790
- };
1791
- }
1792
-
1793
- // ../sdk/src/config.ts
1794
- var API_VERSION = "2026-10-01";
1795
- function validateConfig(config, secret = false) {
1796
- if (!config.store?.trim()) {
1797
- throw new ZappieError("invalid_request", "A storefront slug is required");
1798
- }
1799
- if (secret && typeof window !== "undefined") {
1800
- throw new ZappieError(
1801
- "invalid_request",
1802
- "Secret keys cannot be used in a browser"
1803
- );
1804
- }
1805
- const pattern = secret ? /^sk_(test|live)_.+$/ : /^pk_(test|live)_.+$/;
1806
- const match = pattern.exec(config.key);
1807
- if (!match) {
1808
- throw new ZappieError(
1809
- "invalid_request",
1810
- secret ? "A secret sk_test_ or sk_live_ key is required" : "Use a publishable pk_test_ or pk_live_ key; secret keys are never accepted by createZappie"
1811
- );
1812
- }
1813
- return match[1];
1814
- }
1815
-
1816
- // ../sdk/src/internal/abort.ts
1817
- function abortReason(signal2) {
1818
- return signal2?.reason ?? new DOMException("The request was aborted", "AbortError");
1819
- }
1820
- function throwIfAborted(signal2) {
1821
- if (signal2?.aborted) {
1822
- throw abortReason(signal2);
1823
- }
1824
- }
1825
- function delay(ms, signal2) {
1826
- return new Promise((resolve, reject) => {
1827
- throwIfAborted(signal2);
1828
- const abort = () => {
1829
- clearTimeout(timer);
1830
- reject(abortReason(signal2));
1831
- };
1832
- const timer = setTimeout(() => {
1833
- signal2?.removeEventListener("abort", abort);
1834
- resolve();
1835
- }, ms);
1836
- signal2?.addEventListener("abort", abort, { once: true });
1837
- });
1838
- }
1839
-
1840
- // ../sdk/src/internal/cache.ts
1841
- var REFRESH_BACKOFF_MS = 1e4;
1842
- var EMPTY = Object.freeze({
1843
- fetchedAt: 0,
1844
- loading: false
1845
- });
1846
- var Cache = class {
1847
- constructor(onError) {
1848
- this.onError = onError;
1849
- this.entries = /* @__PURE__ */ new Map();
1850
- this.listeners = /* @__PURE__ */ new Set();
1851
- this.flights = /* @__PURE__ */ new Map();
1852
- this.subscribe = (fn) => {
1853
- this.listeners.add(fn);
1854
- return () => {
1855
- this.listeners.delete(fn);
1856
- };
1857
- };
1858
- }
1859
- snapshot(key) {
1860
- return this.entries.get(key) ?? EMPTY;
1861
- }
1862
- notify() {
1863
- for (const fn of [...this.listeners]) {
1864
- fn();
1865
- }
1866
- }
1867
- set(key, value, etag) {
1868
- this.entries.set(key, {
1869
- value,
1870
- fetchedAt: Date.now(),
1871
- loading: false,
1872
- ...etag !== void 0 ? { etag } : {}
1873
- });
1874
- this.notify();
1875
- }
1876
- stale(key) {
1877
- const entry = this.snapshot(key);
1878
- return entry.value !== void 0 && Date.now() - entry.fetchedAt >= 6e4;
1879
- }
1880
- async get(key, loader, signal2) {
1881
- throwIfAborted(signal2);
1882
- const entry = this.snapshot(key);
1883
- const age = Date.now() - entry.fetchedAt;
1884
- if (entry.value !== void 0 && age < 6e4) {
1885
- return entry.value;
1886
- }
1887
- const background = entry.value !== void 0 && age < 6e5;
1888
- if (background && entry.failedAt !== void 0 && Date.now() - entry.failedAt < REFRESH_BACKOFF_MS) {
1889
- return entry.value;
1890
- }
1891
- let flight = this.flights.get(key);
1892
- if (!flight) {
1893
- const controller = new AbortController();
1894
- flight = {
1895
- controller,
1896
- users: 0,
1897
- keepAlive: background,
1898
- promise: Promise.resolve()
1899
- };
1900
- const current2 = flight;
1901
- this.flights.set(key, current2);
1902
- const loading = { ...entry, loading: true };
1903
- this.entries.set(key, loading);
1904
- this.notify();
1905
- current2.promise = Promise.resolve().then(() => {
1906
- throwIfAborted(controller.signal);
1907
- return loader(controller.signal, entry);
1908
- }).then((result) => {
1909
- if (this.entries.get(key) === loading && !controller.signal.aborted) {
1910
- this.set(key, result.value, result.etag);
1911
- result.onStored?.();
1912
- }
1913
- return result.value;
1914
- }).catch((error) => {
1915
- if (this.entries.get(key) === loading) {
1916
- this.entries.set(
1917
- key,
1918
- controller.signal.aborted ? { ...entry, loading: false } : {
1919
- ...entry,
1920
- loading: false,
1921
- error: asError(error),
1922
- failedAt: Date.now()
1923
- }
1924
- );
1925
- this.notify();
1926
- if (!controller.signal.aborted) {
1927
- this.onError(asError(error));
1928
- }
1929
- }
1930
- throw controller.signal.aborted ? abortReason(controller.signal) : asError(error);
1931
- }).finally(() => {
1932
- if (this.flights.get(key) === current2) {
1933
- this.flights.delete(key);
1934
- }
1935
- });
1936
- }
1937
- if (background) {
1938
- flight.keepAlive = true;
1939
- void flight.promise.catch(() => {
1940
- });
1941
- return entry.value;
1942
- }
1943
- const current = flight;
1944
- current.users++;
1945
- return new Promise((resolve, reject) => {
1946
- let done = false;
1947
- const finish = (callback) => {
1948
- if (done) {
1949
- return;
1950
- }
1951
- done = true;
1952
- signal2?.removeEventListener("abort", abort);
1953
- current.users--;
1954
- callback();
1955
- };
1956
- const abort = () => {
1957
- finish(() => reject(abortReason(signal2)));
1958
- if (current.users === 0 && !current.keepAlive) {
1959
- current.controller.abort(abortReason(signal2));
1960
- if (this.flights.get(key) === current) {
1961
- this.flights.delete(key);
623
+ }).observe(document, { childList: true, subtree: true });
624
+ });
625
+ }
626
+ if (fault === "wrong-total") {
627
+ await page.addInitScript(() => {
628
+ new MutationObserver(() => {
629
+ for (const node of document.querySelectorAll("[data-zappie-total]")) {
630
+ if (node.textContent !== "Total \u20A61") {
631
+ node.textContent = "Total \u20A61";
1962
632
  }
1963
633
  }
1964
- };
1965
- signal2?.addEventListener("abort", abort, { once: true });
1966
- current.promise.then(
1967
- (value) => finish(() => resolve(value)),
1968
- (error) => finish(() => reject(error))
1969
- );
1970
- if (signal2?.aborted) {
1971
- abort();
1972
- }
634
+ }).observe(document, { childList: true, subtree: true });
1973
635
  });
1974
636
  }
1975
- };
1976
-
1977
- // ../sdk/src/internal/emitter.ts
1978
- function createEmitter() {
1979
- const listeners = /* @__PURE__ */ new Map();
1980
- return {
1981
- on(event, fn) {
1982
- const set = listeners.get(event) ?? /* @__PURE__ */ new Set();
1983
- listeners.set(event, set);
1984
- set.add(fn);
1985
- return () => {
1986
- set.delete(fn);
1987
- };
1988
- },
1989
- emit(event, payload) {
1990
- for (const fn of [...listeners.get(event) ?? []]) {
1991
- try {
1992
- fn(payload);
1993
- } catch (error) {
1994
- console.error("Zappie event listener failed", error);
1995
- }
1996
- }
1997
- }
1998
- };
1999
637
  }
2000
-
2001
- // ../sdk/src/transport/rest.ts
2002
- var MAX_RETRY_WAIT_MS = 5e3;
2003
- var RestTransport = class {
2004
- constructor(opts = {}) {
2005
- const url = new URL(opts.baseUrl ?? "https://api.zappie.ai/storefront/v1");
2006
- if (url.username || url.password || url.search || url.hash || url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) {
2007
- throw new ZappieError(
2008
- "invalid_request",
2009
- "REST baseUrl requires HTTPS, or HTTP on localhost"
2010
- );
2011
- }
2012
- this.baseUrl = url.href.replace(/\/$/, "");
2013
- this.fetcher = opts.fetch ?? ((input, init) => globalThis.fetch(input, init));
2014
- }
2015
- async request(path4, ctx, etag, body) {
2016
- if (typeof window !== "undefined" && ctx.key.startsWith("sk_")) {
2017
- throw new ZappieError(
2018
- "invalid_request",
2019
- "Secret keys cannot be sent from a browser"
2020
- );
2021
- }
2022
- for (let attempt = 0; ; attempt++) {
2023
- throwIfAborted(ctx.signal);
2024
- let response;
2025
- try {
2026
- response = await this.fetcher(this.baseUrl + path4, {
2027
- method: body ? "POST" : "GET",
2028
- ...body ? { body: JSON.stringify(body) } : {},
2029
- // Workers reject redirect: "error"; a redirect is still refused below
2030
- // as a non-OK response (a 3xx, or opaqueredirect in browsers).
2031
- redirect: "manual",
2032
- headers: {
2033
- ...body ? { "Content-Type": "application/json" } : {},
2034
- ...ctx.idempotencyKey ? { "Idempotency-Key": ctx.idempotencyKey } : {},
2035
- Authorization: `Bearer ${ctx.key}`,
2036
- "Zappie-Version": ctx.version,
2037
- ...etag ? { "If-None-Match": etag } : {}
2038
- },
2039
- ...ctx.signal ? { signal: ctx.signal } : {}
2040
- });
2041
- } catch (error) {
2042
- throwIfAborted(ctx.signal);
2043
- if (attempt >= 2) {
2044
- throw new ZappieError(
2045
- "network_error",
2046
- "Could not reach the storefront service",
2047
- { cause: error }
2048
- );
2049
- }
2050
- await delay(100 * 2 ** attempt + Math.random() * 100, ctx.signal);
2051
- continue;
2052
- }
2053
- if (response.ok || response.status === 304) {
2054
- return response;
2055
- }
2056
- const retry = response.headers.get("Retry-After");
2057
- const seconds = retry !== null ? Number(retry) : NaN;
2058
- const wait2 = retry !== null ? Number.isFinite(seconds) ? Math.max(0, seconds * 1e3) : Math.max(0, Date.parse(retry) - Date.now()) : NaN;
2059
- const tooLong = Number.isFinite(wait2) && wait2 > MAX_RETRY_WAIT_MS;
2060
- if (attempt < 2 && !tooLong && (response.status === 429 || response.status >= 500)) {
2061
- await response.body?.cancel();
2062
- await delay(
2063
- Number.isFinite(wait2) ? wait2 : 100 * 2 ** attempt + Math.random() * 100,
2064
- ctx.signal
2065
- );
2066
- continue;
2067
- }
2068
- const errorBody = await response.json().catch(() => ({}));
2069
- throw wireError(
2070
- errorBody,
2071
- response.status,
2072
- response.headers.get("Zappie-Request-Id") ?? void 0
2073
- );
2074
- }
2075
- }
2076
- async json(response) {
2077
- try {
2078
- return await response.json();
2079
- } catch (cause) {
2080
- if (cause instanceof Error && cause.name === "AbortError") {
2081
- throw cause;
2082
- }
2083
- throw new ZappieError(
2084
- "server_error",
2085
- "The storefront service returned invalid JSON",
2086
- { status: response.status, cause }
2087
- );
2088
- }
2089
- }
2090
- async quote(ctx, body) {
2091
- const request = {
2092
- ...ctx,
2093
- idempotencyKey: ctx.idempotencyKey ?? await payloadKey(JSON.stringify(body))
2094
- };
2095
- const raw = await this.json(
2096
- await this.request("/quote", request, void 0, body)
2097
- );
2098
- return raw;
2099
- }
2100
- async getStore(ctx) {
2101
- const mapper = import("./store-KIO5RCZO.js").catch((cause) => {
2102
- throw new ZappieError(
2103
- "server_error",
2104
- "Could not load the storefront store mapper",
2105
- { cause }
2106
- );
2107
- });
2108
- const response = this.request("/store", ctx).then(
2109
- (value) => this.json(value)
2110
- );
2111
- const [{ mapStore }, raw] = await Promise.all([mapper, response]);
2112
- return mapStore(
2113
- raw,
2114
- record,
2115
- text,
2116
- list,
2117
- hours,
2118
- mapMoney,
2119
- money,
2120
- opt,
2121
- isString,
2122
- isBoolean
2123
- );
2124
- }
2125
- async getMenu(ctx) {
2126
- let cursor;
2127
- const seen = /* @__PURE__ */ new Set();
2128
- let output;
2129
- let etag;
2130
- do {
2131
- const query = new URLSearchParams({
2132
- branch: ctx.branchId,
2133
- ...ctx.metadataOnly ? { metadata_only: "true" } : {},
2134
- ...cursor ? { cursor } : {}
2135
- });
2136
- const response = await this.request(
2137
- `/menus?${query}`,
2138
- ctx,
2139
- cursor ? void 0 : ctx.ifNoneMatch
2140
- );
2141
- if (response.status === 304) {
2142
- if (cursor) {
2143
- throw new ZappieError(
2144
- "server_error",
2145
- "Unexpected 304 on a continuation page"
2146
- );
2147
- }
2148
- return {
2149
- notModified: true,
2150
- ...response.headers.get("ETag") ? { etag: response.headers.get("ETag") } : {}
2151
- };
2152
- }
2153
- const raw = record(await this.json(response));
2154
- const page = mapMenu(raw, ctx.branchId);
2155
- if (ctx.metadataOnly) {
2156
- if (Object.keys(page.items).length || raw.next_cursor) {
2157
- throw new ZappieError("server_error", "Invalid metadata response");
2158
- }
2159
- return { menu: { ...page, completeness: "partial" } };
2160
- }
2161
- if (!output) {
2162
- output = page;
2163
- etag = response.headers.get("ETag") ?? void 0;
2164
- } else {
2165
- if (page.version !== output.version) {
2166
- throw new ZappieError(
2167
- "server_error",
2168
- "Menu changed while fetching pages; retry the read"
2169
- );
2170
- }
2171
- Object.assign(output.items, page.items);
2172
- for (const category of page.categories) {
2173
- const existing = output.categories.find((c) => c.id === category.id);
2174
- if (existing) {
2175
- existing.itemIds = [
2176
- .../* @__PURE__ */ new Set([...existing.itemIds, ...category.itemIds])
2177
- ];
2178
- } else {
2179
- output.categories.push(category);
2180
- }
2181
- }
2182
- for (const section of page.menus ?? []) {
2183
- const existing = output.menus?.find((m) => m.id === section.id);
2184
- if (existing) {
2185
- existing.categoryIds = [
2186
- .../* @__PURE__ */ new Set([...existing.categoryIds, ...section.categoryIds])
2187
- ];
2188
- } else {
2189
- output.menus?.push(section);
2190
- }
2191
- }
2192
- }
2193
- cursor = typeof raw.next_cursor === "string" && raw.next_cursor ? raw.next_cursor : void 0;
2194
- if (cursor) {
2195
- if (seen.has(cursor)) {
2196
- throw new ZappieError(
2197
- "server_error",
2198
- "Repeated menu pagination cursor"
2199
- );
2200
- }
2201
- seen.add(cursor);
2202
- }
2203
- } while (cursor);
2204
- if (output.menus && output.menus.length > 1) {
2205
- output.id = `branch:${ctx.branchId}`;
2206
- output.name = "Catalogue";
2207
- delete output.schedule;
2208
- }
2209
- for (const category of output.categories) {
2210
- for (const id of category.itemIds) {
2211
- if (!Object.prototype.hasOwnProperty.call(output.items, id)) {
2212
- throw new ZappieError(
2213
- "server_error",
2214
- "Category refers to a missing item"
2215
- );
2216
- }
2217
- }
2218
- }
2219
- if (Object.keys(output.items).length > 2e3) {
2220
- console.warn("Zappie menu contains more than 2,000 items");
2221
- }
2222
- return { menu: output, ...etag ? { etag } : {} };
2223
- }
2224
- async getSelectedMenu(ctx) {
2225
- return (await import("./selected-menu-3GCAICBQ.js")).selectedMenu(
2226
- ctx,
2227
- (path4) => this.request(path4, ctx, ctx.ifNoneMatch),
2228
- (response) => this.json(response),
2229
- mapMenu,
2230
- () => new ZappieError("server_error", "Invalid selected menu")
2231
- );
2232
- }
2233
- async search(ctx) {
2234
- return (await import("./search-YIHT6CXN.js")).searchPage(
2235
- ctx,
2236
- async (path4) => this.json(await this.request(path4, ctx)),
2237
- mapMenu,
2238
- () => new ZappieError("server_error", "Invalid search page")
2239
- );
2240
- }
2241
- async getCategoryPage(ctx) {
2242
- const query = new URLSearchParams({
2243
- branch: ctx.branchId,
2244
- ...ctx.cursor ? { cursor: ctx.cursor } : {}
2245
- });
2246
- const raw = await this.json(
2247
- await this.request(
2248
- `/menus/${encodeURIComponent(ctx.menuId)}/categories/${encodeURIComponent(ctx.categoryId)}/items?${query}`,
2249
- ctx
2250
- )
2251
- );
2252
- return (await import("./category-page-UBHOB5IN.js")).categoryPage(
2253
- record(raw),
2254
- ctx,
2255
- mapMenu,
2256
- () => new ZappieError("server_error", "Invalid category page")
2257
- );
2258
- }
2259
- async getMenuVersion(ctx) {
2260
- const value = record(
2261
- await this.json(
2262
- await this.request(
2263
- `/menus/version?${new URLSearchParams({ branch: ctx.branchId })}`,
2264
- ctx
2265
- )
2266
- )
2267
- );
2268
- if (typeof value.menu_version !== "string" || typeof value.availability_hash !== "string") {
2269
- throw new ZappieError("server_error", "Invalid menu version response");
2270
- }
2271
- return {
2272
- version: value.menu_version,
2273
- availabilityHash: value.availability_hash
2274
- };
2275
- }
2276
- async getItem(ctx) {
2277
- const item = mapItem(
2278
- await this.json(
2279
- await this.request(
2280
- `/items/${encodeURIComponent(ctx.itemId)}?${new URLSearchParams({ branch: ctx.branchId })}`,
2281
- ctx
2282
- )
2283
- )
2284
- );
2285
- if (item.id !== ctx.itemId) {
2286
- throw new ZappieError(
2287
- "server_error",
2288
- "Item response identity does not match the request"
2289
- );
2290
- }
2291
- return item;
2292
- }
2293
- };
2294
-
2295
- // ../sdk/src/client.ts
2296
- function buildClient(input, secret = false) {
2297
- const mode = validateConfig(input, secret);
2298
- const config = Object.freeze({
2299
- ...input,
2300
- version: input.version ?? API_VERSION,
2301
- telemetry: input.telemetry ?? true
2302
- });
2303
- const transport = input.transport ?? new RestTransport({
2304
- ...input.baseUrl ? { baseUrl: input.baseUrl } : {},
2305
- ...input.fetch ? { fetch: input.fetch } : {}
2306
- });
2307
- const events = createEmitter();
2308
- const cache = new Cache((error) => events.emit("error", { error }));
2309
- const ctx = (signal2) => ({
2310
- key: config.key,
2311
- version: config.version,
2312
- ...signal2 ? { signal: signal2 } : {}
2313
- });
2314
- const store = {
2315
- get: (opts = {}) => cache.get(
2316
- "store",
2317
- async (signal2) => ({ value: await transport.getStore(ctx(signal2)) }),
2318
- opts.signal
2319
- ),
2320
- peek: () => cache.snapshot("store").value
2321
- };
2322
- const resolveBranch = (value, requested) => {
2323
- const selected = requested ?? config.branch;
2324
- const branch = selected ? value.branches.find((b) => b.id === selected || b.slug === selected) : value.branches.find((b) => b.isDefault) ?? value.branches[0];
2325
- if (!branch) {
2326
- throw new ZappieError(
2327
- "branch_not_found",
2328
- "No matching storefront branch",
2329
- { param: "branch" }
2330
- );
2331
- }
2332
- return branch;
2333
- };
2334
- let hydratedBranch;
2335
- const branchKey = (requested) => {
2336
- const value = store.peek();
2337
- if (value) {
2338
- return resolveBranch(value, requested).id;
2339
- }
2340
- const selected = requested ?? config.branch;
2341
- if (hydratedBranch && (selected === void 0 || selected === config.branch || selected === hydratedBranch)) {
2342
- return hydratedBranch;
2343
- }
2344
- return selected ?? "default";
2345
- };
2346
- const menuKey = (requested) => `menu:${branchKey(requested)}`;
2347
- const menu = {
2348
- async search(query, opts = {}) {
2349
- if (!transport.search) {
2350
- throw new ZappieError(
2351
- "invalid_request",
2352
- "Transport does not support search"
2353
- );
2354
- }
2355
- const branch = resolveBranch(await store.get(opts), opts.branch);
2356
- return transport.search({
2357
- ...ctx(opts.signal),
2358
- branchId: branch.id,
2359
- query,
2360
- ...opts.cursor ? { cursor: opts.cursor } : {}
2361
- });
2362
- },
2363
- async get(opts = {}) {
2364
- const branch = resolveBranch(await store.get(opts), opts.branch);
2365
- return cache.get(
2366
- `menu:${branch.id}${opts.metadataOnly ? ":partial" : ""}`,
2367
- async (signal2, entry) => {
2368
- const response = await transport.getMenu({
2369
- ...ctx(signal2),
2370
- branchId: branch.id,
2371
- ...opts.metadataOnly ? { metadataOnly: true } : {},
2372
- ...entry.etag ? { ifNoneMatch: entry.etag } : {}
2373
- });
2374
- if ("notModified" in response) {
2375
- if (!entry.value) {
2376
- throw new ZappieError(
2377
- "server_error",
2378
- "Received 304 without a cached menu"
2379
- );
2380
- }
2381
- return {
2382
- value: entry.value,
2383
- ...response.etag ?? entry.etag ? { etag: response.etag ?? entry.etag } : {}
2384
- };
2385
- }
2386
- if (!opts.metadataOnly && response.menu.completeness === "partial") {
2387
- throw new ZappieError(
2388
- "server_error",
2389
- "Partial catalogue cannot satisfy a complete read"
2390
- );
2391
- }
2392
- if (response.menu.branchId !== branch.id) {
2393
- throw new ZappieError(
2394
- "server_error",
2395
- "Menu belongs to a different branch"
2396
- );
2397
- }
2398
- return {
2399
- value: response.menu,
2400
- etag: response.etag ?? response.menu.version,
2401
- onStored: () => {
2402
- if (entry.value && entry.value.version !== response.menu.version) {
2403
- events.emit("menu:updated", {
2404
- branchId: branch.id,
2405
- version: response.menu.version
2406
- });
2407
- }
2408
- }
2409
- };
2410
- },
2411
- opts.signal
2412
- );
2413
- },
2414
- peek: (branch) => cache.snapshot(menuKey(branch)).value ?? cache.snapshot(`${menuKey(branch)}:partial`).value,
2415
- async page(menuId, categoryId, opts = {}) {
2416
- if (!transport.getCategoryPage) {
2417
- throw new ZappieError(
2418
- "invalid_request",
2419
- "Transport does not support category pages"
2420
- );
2421
- }
2422
- const branch = resolveBranch(await store.get(opts), opts.branch);
2423
- return transport.getCategoryPage({
2424
- ...ctx(opts.signal),
2425
- branchId: branch.id,
2426
- menuId,
2427
- categoryId,
2428
- ...opts.cursor ? { cursor: opts.cursor } : {}
2429
- });
2430
- },
2431
- async version(opts = {}) {
2432
- const branch = resolveBranch(await store.get(opts), opts.branch);
2433
- return transport.getMenuVersion({
2434
- ...ctx(opts.signal),
2435
- branchId: branch.id
2436
- });
2437
- },
2438
- async item(id, opts = {}) {
2439
- const branch = resolveBranch(await store.get(opts), opts.branch);
2440
- if (!menu.peek(branch.id) || menu.peek(branch.id)?.completeness === "partial") {
2441
- await menu.get({ ...opts, metadataOnly: true });
2442
- }
2443
- return readItem(id, branch.id, false, opts.signal);
2444
- },
2445
- hydrate(value) {
2446
- hydratedBranch ??= value.branchId;
2447
- const previous = cache.snapshot(`menu:${value.branchId}`).value;
2448
- cache.set(
2449
- `menu:${value.branchId}${value.completeness === "partial" ? ":partial" : ""}`,
2450
- value,
2451
- value.version
2452
- );
2453
- if (previous && previous.version !== value.version) {
2454
- events.emit("menu:updated", {
2455
- branchId: value.branchId,
2456
- version: value.version
2457
- });
638
+ async function applyPageFault(page, fault) {
639
+ if (fault === "quick-add-via-item") {
640
+ await page.locator('[data-zappie-item] [data-zappie-action="add-to-cart"]').evaluateAll((nodes) => {
641
+ for (const node of nodes) {
642
+ node.remove();
2458
643
  }
2459
- }
2460
- };
2461
- const itemValues = /* @__PURE__ */ new Map();
2462
- const peekItem = (id, branch) => itemValues.get(`${branch}:${id}`);
2463
- const readItem = async (id, branch, fresh = false, signal2) => {
2464
- const complete = cache.snapshot(`menu:${branch}`).value;
2465
- if (!fresh && complete && Object.hasOwn(complete.items, id)) {
2466
- return complete.items[id];
2467
- }
2468
- const load = (requestSignal = signal2) => transport.getItem({
2469
- ...ctx(requestSignal),
2470
- branchId: branch,
2471
- itemId: id
2472
644
  });
2473
- try {
2474
- const value = fresh ? await load() : await cache.get(
2475
- `item:${branch}:${id}`,
2476
- async (requestSignal) => ({ value: await load(requestSignal) }),
2477
- signal2
2478
- );
2479
- if (fresh) {
2480
- cache.set(`item:${branch}:${id}`, value);
2481
- }
2482
- itemValues.set(`${branch}:${id}`, value);
2483
- return value;
2484
- } catch (error) {
2485
- if (error.code === "item_not_found") {
2486
- itemValues.delete(`${branch}:${id}`);
2487
- cache.set(`item:${branch}:${id}`, void 0);
2488
- }
2489
- throw error;
2490
- }
2491
- };
2492
- const workingSet = createWorkingSet({
2493
- menu,
2494
- store,
2495
- transport,
2496
- context: ctx,
2497
- peekItem,
2498
- readItem
2499
- });
2500
- const { cart, setLegacyReader, cartCatalogue } = createCart({
2501
- workingSet,
2502
- config,
2503
- menu,
2504
- store,
2505
- transport,
2506
- context: ctx,
2507
- events
2508
- });
2509
- return Object.freeze({
2510
- cart,
2511
- config,
2512
- mode,
2513
- store,
2514
- menu,
2515
- format,
2516
- on: events.on,
2517
- _internal: {
2518
- cartCatalogue,
2519
- setLegacyReader,
2520
- transport,
2521
- cache,
2522
- emit: events.emit,
2523
- menuKey,
2524
- hydrateStore: (value) => cache.set("store", value)
2525
- }
2526
- });
2527
- }
2528
- function createZappie(config) {
2529
- const client = buildClient(config);
2530
- if (client.mode === "test" && typeof window !== "undefined") {
2531
- void import("./debug-C3UDGUED.js").then(({ installDebug }) => installDebug(client));
2532
- }
2533
- return client;
2534
- }
2535
-
2536
- // ../sdk/src/cart/preview.ts
2537
- function previewQuantity(value) {
2538
- return Number.isInteger(value) && value >= 0 && value <= 99 ? value : 0;
2539
- }
2540
- function previewItem(item, raw) {
2541
- const input = normalizeInput(item, raw);
2542
- const groups = effectiveGroups(item, input).filter(
2543
- (group) => group.isAvailable !== false
2544
- );
2545
- const variantId = resolveVariant(item, input)?.id;
2546
- const choices = (input.choices ?? []).filter(
2547
- ({ groupId, choiceId }) => groups.some(
2548
- (group) => group.id === groupId && group.choices.some((choice) => choice.id === choiceId)
2549
- )
2550
- ).map((selection) => ({
2551
- ...selection,
2552
- quantity: previewQuantity(selection.quantity)
2553
- }));
2554
- const unitPrice = estimateUnitPrice(item, {
2555
- choices,
2556
- ...variantId ? { variantId } : {}
2557
- });
2558
- const total = multiplyMoney(unitPrice, previewQuantity(input.quantity));
2559
- return { groups, unitPrice, total };
2560
- }
2561
-
2562
- // src/fixtures.ts
2563
- async function installFault(page, fault) {
2564
- if (!fault) {
2565
- return;
2566
645
  }
2567
- if (fault === "no-sold-out") {
2568
- await page.addInitScript(() => {
2569
- let debug;
2570
- Object.defineProperty(window, "__zappie", {
2571
- configurable: true,
2572
- get: () => debug,
2573
- set(value) {
2574
- debug = value;
2575
- if (value) {
2576
- const catalogue = value.catalogue;
2577
- value.catalogue = async () => {
2578
- const result = await catalogue();
2579
- return {
2580
- ...result,
2581
- menu: {
2582
- ...result.menu,
2583
- items: Object.fromEntries(
2584
- Object.entries(result.menu.items).filter(
2585
- ([, item]) => item.isAvailable
2586
- )
2587
- )
2588
- }
2589
- };
2590
- };
2591
- }
2592
- }
2593
- });
2594
- });
2595
- }
2596
- if (fault === "add-stuck") {
2597
- await page.addInitScript(() => {
2598
- document.addEventListener(
2599
- "click",
2600
- (event) => {
2601
- const target2 = event.target;
2602
- if (target2?.closest('[data-zappie-action="add-to-cart"]')) {
2603
- event.stopImmediatePropagation();
2604
- event.preventDefault();
2605
- }
2606
- },
2607
- true
2608
- );
2609
- });
2610
- }
2611
- if (fault === "missing-issue-fix") {
2612
- await page.addInitScript(() => {
2613
- new MutationObserver(() => {
2614
- for (const node of document.querySelectorAll(
2615
- "[data-zappie-issue-fix]"
2616
- )) {
2617
- node.removeAttribute("data-zappie-issue-fix");
2618
- }
2619
- }).observe(document, { childList: true, subtree: true });
2620
- });
2621
- }
2622
- if (fault === "wrong-total") {
2623
- await page.addInitScript(() => {
2624
- new MutationObserver(() => {
2625
- for (const node of document.querySelectorAll("[data-zappie-total]")) {
2626
- if (node.textContent !== "Total \u20A61") {
2627
- node.textContent = "Total \u20A61";
2628
- }
2629
- }
2630
- }).observe(document, { childList: true, subtree: true });
2631
- });
2632
- }
2633
- }
2634
- async function applyPageFault(page, fault) {
2635
646
  if (fault === "missing-marker") {
2636
647
  await page.locator("[data-zappie-item]").evaluateAll((nodes) => {
2637
648
  for (const node of nodes) {
@@ -2704,8 +715,8 @@ function menuCoverage(items, shownIds) {
2704
715
  passes: available.length === 0 || shown / available.length >= 0.95
2705
716
  };
2706
717
  }
2707
- function priceMatches(text2, formatted) {
2708
- return text2?.includes(formatted) ?? false;
718
+ function priceMatches(text, formatted) {
719
+ return text?.includes(formatted) ?? false;
2709
720
  }
2710
721
 
2711
722
  // src/checks/v03-menu.ts
@@ -2754,9 +765,9 @@ var v03 = {
2754
765
  }
2755
766
  }
2756
767
  for (const item of available.slice(0, 2)) {
2757
- const text2 = await page.locator(`[data-zappie-item="${item.id}"]`).first().textContent();
768
+ const text = await page.locator(`[data-zappie-item="${item.id}"]`).first().textContent();
2758
769
  assertion(
2759
- priceMatches(text2, format.money(item.price)),
770
+ priceMatches(text, format.money(item.price)),
2760
771
  "price_mismatch",
2761
772
  `${item.name} does not show ${format.money(item.price)}`,
2762
773
  "Render prices with format.money from @zappie/sdk."
@@ -2775,6 +786,14 @@ var v04 = {
2775
786
  await open(page);
2776
787
  await applyPageFault(page, fault);
2777
788
  const before = await debugValue(page, () => window.__zappie.cart().count);
789
+ await page.evaluate(() => {
790
+ window.__zappieV04Events = [];
791
+ window.addEventListener("zappie:event", (event2) => {
792
+ window.__zappieV04Events.push(
793
+ event2.detail
794
+ );
795
+ });
796
+ });
2778
797
  const item = simpleItem(await readMenu(options));
2779
798
  assertion(
2780
799
  item,
@@ -2782,15 +801,43 @@ var v04 = {
2782
801
  "The test menu has no available item without required options",
2783
802
  "Keep one available item without required options in the test menu."
2784
803
  );
2785
- const simple = page.locator(`[data-zappie-item="${item.id}"]`).locator('[data-zappie-action="add-to-cart"]');
2786
- assertion(
2787
- await simple.count(),
2788
- "simple_item_missing",
2789
- "The item has no quick-add button",
2790
- "Render an available item without required options and mark its add button."
2791
- );
804
+ let openedItem = false;
805
+ let simple = page.locator(`[data-zappie-item="${item.id}"]`).locator('[data-zappie-action="add-to-cart"]');
806
+ if (!await simple.count()) {
807
+ const itemLink = page.locator(
808
+ `[data-zappie-item="${item.id}"] a[href*="/item/"], a[data-zappie-item="${item.id}"], a[href*="/item/${item.id}"]`
809
+ ).first();
810
+ assertion(
811
+ await itemLink.count(),
812
+ "simple_item_missing",
813
+ "The item has no quick-add button or item link",
814
+ "Render a marked quick-add button, or link the product card to an item page with one."
815
+ );
816
+ await itemLink.click();
817
+ openedItem = true;
818
+ simple = page.locator(
819
+ '[role="dialog"] [data-zappie-item] [data-zappie-action="add-to-cart"]:visible, main [data-zappie-item] [data-zappie-action="add-to-cart"]:visible'
820
+ ).first();
821
+ await simple.waitFor({ state: "visible", timeout: 5e3 }).catch(() => {
822
+ });
823
+ assertion(
824
+ await simple.count(),
825
+ "simple_item_missing",
826
+ "The item page has no add button",
827
+ 'Mark the item page add button with data-zappie-action="add-to-cart".'
828
+ );
829
+ }
2792
830
  await simple.click();
2793
- const updated = await page.evaluate(
831
+ const updated = openedItem ? await page.waitForFunction(
832
+ () => window.__zappieV04Events.some(
833
+ (entry) => entry.name === "add_to_cart"
834
+ ),
835
+ void 0,
836
+ { timeout: 3e3 }
837
+ ).then(
838
+ () => true,
839
+ () => false
840
+ ) : await page.evaluate(
2794
841
  (count) => new Promise((resolve) => {
2795
842
  requestAnimationFrame(() => {
2796
843
  resolve(window.__zappie.cart().count > count);
@@ -2801,12 +848,11 @@ var v04 = {
2801
848
  assertion(
2802
849
  updated,
2803
850
  "cart_not_updated",
2804
- "The cart count did not change within one frame of Add",
2805
- "Connect the marked Add control to client.cart.add and update its count immediately."
851
+ openedItem ? "The item page did not add the item to the cart" : "The cart count did not change within one frame of Add",
852
+ "Connect the marked Add control to client.cart.add and update the cart immediately."
2806
853
  );
2807
- const event = await debugValue(
2808
- page,
2809
- () => window.__zappie.events.some((entry) => entry.name === "add_to_cart")
854
+ const event = await page.evaluate(
855
+ () => window.__zappieV04Events.some((entry) => entry.name === "add_to_cart")
2810
856
  );
2811
857
  assertion(
2812
858
  event,
@@ -3058,18 +1104,18 @@ var v07 = {
3058
1104
  await page.locator("[data-zappie-cart-trigger]").first().click();
3059
1105
  const codes = unavailable ? ["price_changed", "item_unavailable"] : ["price_changed"];
3060
1106
  for (const code of codes) {
3061
- const issue2 = page.locator(`[data-zappie-issue="${code}"]`).first();
3062
- await issue2.waitFor({ state: "visible" });
1107
+ const issue = page.locator(`[data-zappie-issue="${code}"]`).first();
1108
+ await issue.waitFor({ state: "visible" });
3063
1109
  assertion(
3064
- await issue2.isVisible(),
1110
+ await issue.isVisible(),
3065
1111
  "quote_issue_missing",
3066
1112
  `The cart does not show ${code}`,
3067
1113
  "Render each quote issue with data-zappie-issue and its fixes with data-zappie-issue-fix."
3068
1114
  );
3069
1115
  }
3070
1116
  for (const code of codes) {
3071
- const issue2 = page.locator(`[data-zappie-issue="${code}"]`).first();
3072
- const fix = issue2.locator("[data-zappie-issue-fix]").first();
1117
+ const issue = page.locator(`[data-zappie-issue="${code}"]`).first();
1118
+ const fix = issue.locator("[data-zappie-issue-fix]").first();
3073
1119
  assertion(
3074
1120
  await fix.count(),
3075
1121
  "quote_fix_missing",
@@ -3077,7 +1123,7 @@ var v07 = {
3077
1123
  "Connect each marked issue fix to the SDK cart applyFix action."
3078
1124
  );
3079
1125
  await fix.click();
3080
- await issue2.waitFor({ state: "hidden" });
1126
+ await issue.waitFor({ state: "hidden" });
3081
1127
  }
3082
1128
  await open(page, "/checkout");
3083
1129
  await page.waitForFunction(() => {
@@ -3142,73 +1188,6 @@ var v09 = {
3142
1188
  }
3143
1189
  };
3144
1190
 
3145
- // ../sdk/src/internal/customer-http.ts
3146
- function stableErrorCode(status) {
3147
- if (status === 400 || status === 422) {
3148
- return "invalid_request";
3149
- }
3150
- if (status === 401) {
3151
- return "unauthorized";
3152
- }
3153
- if (status === 404) {
3154
- return "not_found";
3155
- }
3156
- if (status === 429) {
3157
- return "rate_limited";
3158
- }
3159
- return "server_error";
3160
- }
3161
- async function apiError(response, fallback) {
3162
- const body = await response.json().catch(() => ({}));
3163
- const envelope = body && typeof body === "object" ? body : {};
3164
- const value = "error" in envelope && envelope.error && typeof envelope.error === "object" ? envelope.error : {};
3165
- return {
3166
- apiCode: "code" in value && typeof value.code === "string" ? value.code : "server_error",
3167
- message: "message" in value && typeof value.message === "string" ? value.message : fallback,
3168
- ..."param" in value && typeof value.param === "string" ? { param: value.param } : {},
3169
- ..."issues" in value ? { issues: value.issues } : {}
3170
- };
3171
- }
3172
- function wait(ms, signal2) {
3173
- signal2?.throwIfAborted();
3174
- return new Promise((resolve, reject) => {
3175
- const done = () => {
3176
- signal2?.removeEventListener("abort", aborted2);
3177
- resolve();
3178
- };
3179
- const timer = setTimeout(done, ms);
3180
- const aborted2 = () => {
3181
- clearTimeout(timer);
3182
- signal2?.removeEventListener("abort", aborted2);
3183
- reject(signal2?.reason ?? new DOMException("Aborted", "AbortError"));
3184
- };
3185
- signal2?.addEventListener("abort", aborted2, { once: true });
3186
- });
3187
- }
3188
- async function requestWithRetry(fetcher, input, init, message) {
3189
- for (let attempt = 0; attempt < 2; attempt++) {
3190
- try {
3191
- return await fetcher(input, {
3192
- ...init,
3193
- credentials: "same-origin",
3194
- headers: { Accept: "application/json", ...init.headers }
3195
- });
3196
- } catch (cause) {
3197
- if (init.signal?.aborted) {
3198
- throw init.signal.reason ?? cause;
3199
- }
3200
- if (attempt === 1) {
3201
- throw new ZappieError("network_error", message, {
3202
- cause,
3203
- retryable: true
3204
- });
3205
- }
3206
- await wait(400, init.signal ?? void 0);
3207
- }
3208
- }
3209
- throw new ZappieError("network_error", message, { retryable: true });
3210
- }
3211
-
3212
1191
  // ../sdk/src/places/error.ts
3213
1192
  var PlacesError = class extends ZappieError {
3214
1193
  constructor(status, apiCode, message, param, issues) {
@@ -3499,6 +1478,115 @@ var v10 = {
3499
1478
 
3500
1479
  // src/checks/v13-accessibility.ts
3501
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
3502
1590
  async function severeViolations(page) {
3503
1591
  const analysis = await new AxeBuilder({ page }).analyze();
3504
1592
  return analysis.violations.filter(
@@ -3523,6 +1611,16 @@ async function controlsAreReachable(page, selectors) {
3523
1611
  }
3524
1612
  return false;
3525
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
+ }
3526
1624
  var v13 = {
3527
1625
  id: "V13",
3528
1626
  title: "Accessibility",
@@ -3532,6 +1630,10 @@ var v13 = {
3532
1630
  await open(page);
3533
1631
  await applyPageFault(page, fault);
3534
1632
  const violations = await severeViolations(page);
1633
+ const semantics = await semanticFindings(page, {
1634
+ page: true,
1635
+ phone: viewport.width < 768
1636
+ });
3535
1637
  const item = sortedItems(await readMenu(options)).find(
3536
1638
  (entry) => entry.isAvailable
3537
1639
  );
@@ -3541,11 +1643,25 @@ var v13 = {
3541
1643
  "No available item can be opened for accessibility checks",
3542
1644
  "Keep one available item in the test menu."
3543
1645
  );
3544
- 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();
3545
1648
  violations.push(...await severeViolations(page));
1649
+ semantics.push(
1650
+ ...await semanticFindings(page, { page: false, phone: false })
1651
+ );
3546
1652
  await page.getByRole("button", { name: "Close item" }).click();
3547
- 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();
3548
1661
  violations.push(...await severeViolations(page));
1662
+ semantics.push(
1663
+ ...await semanticFindings(page, { page: false, phone: false })
1664
+ );
3549
1665
  const message = violations.flatMap(
3550
1666
  (entry) => entry.nodes.map((node) => `${entry.id}: ${node.target.join(" ")}`)
3551
1667
  ).join(", ");
@@ -3556,6 +1672,25 @@ var v13 = {
3556
1672
  "Fix every serious or critical axe violation in the menu, item dialog, cart and order page."
3557
1673
  );
3558
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
+ );
3559
1694
  await context.addCookies([
3560
1695
  {
3561
1696
  name: "zappie_session",
@@ -3601,30 +1736,20 @@ var v14 = {
3601
1736
  id: "V14",
3602
1737
  title: "Performance budget",
3603
1738
  viewports: "mobile",
3604
- async run({ page, metrics, fault }, viewport) {
1739
+ async run({ page, metrics, fault, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
3605
1740
  await page.setViewportSize(viewport);
3606
- const cdp = await page.context().newCDPSession(page);
3607
- await cdp.send("Network.enable");
3608
- await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
3609
- await cdp.send("Network.emulateNetworkConditions", {
3610
- offline: false,
3611
- latency: 150,
3612
- downloadThroughput: 2e5,
3613
- uploadThroughput: 1e5,
3614
- connectionType: "cellular3g"
3615
- });
3616
- await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
1741
+ const cdp = await applyPhoneProfile(page, cpuThrottlingRate2);
3617
1742
  await page.addInitScript(() => {
3618
1743
  const state = { lcp: 0, cls: 0 };
3619
1744
  Object.assign(window, { __zappiePerformance: state });
3620
- new PerformanceObserver((list2) => {
1745
+ new PerformanceObserver((list) => {
3621
1746
  state.lcp = Math.max(
3622
1747
  state.lcp,
3623
- ...list2.getEntries().map((entry) => entry.startTime)
1748
+ ...list.getEntries().map((entry) => entry.startTime)
3624
1749
  );
3625
1750
  }).observe({ type: "largest-contentful-paint", buffered: true });
3626
- new PerformanceObserver((list2) => {
3627
- state.cls += list2.getEntries().filter(
1751
+ new PerformanceObserver((list) => {
1752
+ state.cls += list.getEntries().filter(
3628
1753
  (entry) => !entry.hadRecentInput
3629
1754
  ).reduce(
3630
1755
  (sum, entry) => sum + (entry.value ?? 0),
@@ -3641,24 +1766,27 @@ var v14 = {
3641
1766
  await page.waitForTimeout(500);
3642
1767
  const values = await page.evaluate(async () => {
3643
1768
  const observed = window.__zappiePerformance ?? { lcp: 0, cls: 0 };
3644
- const scripts = performance.getEntriesByType("resource").filter((entry) => entry.name.endsWith(".js"));
3645
- const sizes = await Promise.all(
3646
- scripts.map(async (entry) => {
3647
- if (typeof CompressionStream === "undefined") {
3648
- const timing = entry;
3649
- return timing.encodedBodySize || timing.transferSize;
3650
- }
3651
- const response = await fetch(entry.name);
3652
- const compressed = response.body?.pipeThrough(
3653
- new CompressionStream("gzip")
3654
- );
3655
- return compressed ? (await new Response(compressed).arrayBuffer()).byteLength : 0;
3656
- })
3657
- );
3658
- const js = sizes.reduce((sum, size) => sum + size, 0);
3659
- 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") };
3660
1787
  });
3661
1788
  metrics.menuJsKbGz = Math.round(values.js / 1024 * 10) / 10;
1789
+ metrics.menuCssKbGz = Math.round(values.css / 1024 * 10) / 10;
3662
1790
  metrics.lcpMs = Math.round(values.lcp);
3663
1791
  metrics.cls = Math.round(values.cls * 1e3) / 1e3;
3664
1792
  assertion(
@@ -3683,6 +1811,496 @@ var v14 = {
3683
1811
  }
3684
1812
  };
3685
1813
 
1814
+ // src/checks/v15-tappable-product-cards.ts
1815
+ var TAPPABLE_BUDGET_MS = 3e3;
1816
+ var OPEN_BUDGET_MS = 300;
1817
+ var v15 = {
1818
+ id: "V15",
1819
+ title: "Tappable product cards",
1820
+ viewports: "mobile",
1821
+ async run({ page, metrics, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
1822
+ await page.setViewportSize(viewport);
1823
+ const cdp = await applyPhoneProfile(page, cpuThrottlingRate2);
1824
+ try {
1825
+ await page.goto("/", { waitUntil: "domcontentloaded" });
1826
+ await cdp.send("Network.setCacheDisabled", { cacheDisabled: false });
1827
+ const link = page.locator('a[href*="/item/"]').first();
1828
+ await link.waitFor();
1829
+ const href = await link.getAttribute("href");
1830
+ assertion(
1831
+ href !== null,
1832
+ "product_card_link_missing",
1833
+ "The first product card has no destination",
1834
+ "Link the product card to its item page."
1835
+ );
1836
+ const itemPath = new URL(href, page.url()).pathname;
1837
+ await page.waitForFunction(() => "__reactRouterManifest" in window, null, {
1838
+ timeout: 8e3
1839
+ }).catch(() => void 0);
1840
+ const itemModule = await page.evaluate(itemRouteModule, itemPath);
1841
+ const prefetched = page.waitForResponse(
1842
+ (response) => {
1843
+ const path4 = new URL(response.url()).pathname;
1844
+ if (path4 === itemModule) {
1845
+ return true;
1846
+ }
1847
+ return path4 === `${itemPath}.data` && response.request().headers()["sec-purpose"] === "prefetch";
1848
+ },
1849
+ { timeout: 8e3 }
1850
+ ).then(async (response) => {
1851
+ await response.finished();
1852
+ return true;
1853
+ }).catch(() => false);
1854
+ await link.evaluate(
1855
+ (node) => node.scrollIntoView({ block: "center", inline: "nearest" })
1856
+ );
1857
+ await page.waitForFunction(() => {
1858
+ const candidate = document.querySelector('a[href*="/item/"]');
1859
+ return candidate !== null && Object.keys(candidate).some(
1860
+ (key) => key.startsWith("__reactProps$") || key.startsWith("__reactFiber$")
1861
+ );
1862
+ });
1863
+ const tappableMs = await page.evaluate(() => performance.now());
1864
+ metrics.productCardTappableMs = Math.round(tappableMs);
1865
+ assertion(
1866
+ tappableMs <= TAPPABLE_BUDGET_MS,
1867
+ "product_card_tappable_too_late",
1868
+ `The first product card became tappable in ${Math.round(tappableMs)} ms`,
1869
+ "Reduce critical scripts, fonts and high-priority images until product cards become tappable within 3 seconds."
1870
+ );
1871
+ assertion(
1872
+ await prefetched,
1873
+ "product_card_prefetch_missing",
1874
+ "The first product card did not prefetch its item page",
1875
+ 'Prefetch item links with prefetch="viewport".'
1876
+ );
1877
+ await page.waitForLoadState("networkidle");
1878
+ await page.evaluate(() => {
1879
+ document.addEventListener(
1880
+ "click",
1881
+ (event) => {
1882
+ const target2 = event.target;
1883
+ if (target2?.closest('a[href*="/item/"]')) {
1884
+ window.__zappieTapAt = performance.now();
1885
+ }
1886
+ },
1887
+ { capture: true, once: true }
1888
+ );
1889
+ });
1890
+ await link.click({ noWaitAfter: true });
1891
+ await Promise.race([
1892
+ page.waitForURL((url) => /\/item\//.test(url.pathname)),
1893
+ page.locator('[role="dialog"] [data-zappie-item]').waitFor({ state: "visible" })
1894
+ ]);
1895
+ const openMs = await page.evaluate(
1896
+ () => performance.now() - (window.__zappieTapAt ?? 0)
1897
+ );
1898
+ metrics.productCardOpenMs = Math.round(openMs);
1899
+ assertion(
1900
+ openMs <= OPEN_BUDGET_MS,
1901
+ "product_card_open_too_slow",
1902
+ `The first product card opened in ${Math.round(openMs)} ms`,
1903
+ 'Set routeDiscovery to initial and prefetch item links with prefetch="viewport" so the item surface opens within 300 ms.'
1904
+ );
1905
+ } finally {
1906
+ await cdp.detach();
1907
+ }
1908
+ }
1909
+ };
1910
+ function itemRouteModule(itemPath) {
1911
+ const manifest = window.__reactRouterManifest;
1912
+ if (!manifest) {
1913
+ return void 0;
1914
+ }
1915
+ const segments = (path4) => path4.split("/").filter(Boolean);
1916
+ const fullPath = (route2) => {
1917
+ const parts = [];
1918
+ for (let current = route2; current; current = current.parentId ? manifest.routes[current.parentId] : void 0) {
1919
+ if (current.path) {
1920
+ parts.unshift(current.path);
1921
+ }
1922
+ }
1923
+ return segments(parts.join("/"));
1924
+ };
1925
+ const wanted = segments(itemPath);
1926
+ const route = Object.values(manifest.routes).find((entry) => {
1927
+ if (!entry.path) {
1928
+ return false;
1929
+ }
1930
+ const pattern = fullPath(entry);
1931
+ return pattern.length === wanted.length && pattern.every(
1932
+ (part, index) => part.startsWith(":") || part === wanted[index]
1933
+ );
1934
+ });
1935
+ return route ? new URL(route.module, location.href).pathname : void 0;
1936
+ }
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
+
3686
2304
  // src/checks/index.ts
3687
2305
  var checks = [
3688
2306
  v01,
@@ -3695,7 +2313,10 @@ var checks = [
3695
2313
  v09,
3696
2314
  v10,
3697
2315
  v13,
3698
- v14
2316
+ v14,
2317
+ v15,
2318
+ v16,
2319
+ v17
3699
2320
  ];
3700
2321
  var blocked = [
3701
2322
  [
@@ -3741,6 +2362,60 @@ function skippedChecks(suite) {
3741
2362
  ];
3742
2363
  }
3743
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
+
3744
2419
  // src/report.ts
3745
2420
  import { createPrivateKey, randomUUID, sign } from "crypto";
3746
2421
  var REPORT_SCHEMA = "https://registry.zappie.ai/v1/schema/verify-report.json";
@@ -3827,7 +2502,7 @@ async function reset(options) {
3827
2502
  }
3828
2503
 
3829
2504
  // src/version.ts
3830
- var PACKAGE_VERSION = true ? "0.1.3" : "0.0.0-development";
2505
+ var PACKAGE_VERSION = true ? "0.2.2" : "0.0.0-development";
3831
2506
 
3832
2507
  // src/runner.ts
3833
2508
  var VERSION = PACKAGE_VERSION;
@@ -3876,7 +2551,7 @@ async function execute(check, run2) {
3876
2551
  message: failure instanceof Error ? failure.message : String(failure),
3877
2552
  hint: `Fix the failure in ${check.id} (${check.title.toLowerCase()}) and rerun verify.`
3878
2553
  };
3879
- if (error.code === "dependency_blocked") {
2554
+ if (error.code === "dependency_blocked" || failure instanceof VerifyError && failure.skip) {
3880
2555
  return {
3881
2556
  id: check.id,
3882
2557
  title: check.title,
@@ -3918,12 +2593,29 @@ async function runVerify(options, fault) {
3918
2593
  }
3919
2594
  const started = Date.now();
3920
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);
3921
2598
  const context = await browser.newContext(browserOptions(options));
3922
2599
  if (options.standInForPlatform) {
3923
2600
  await context.route(
3924
2601
  "**/_zappie/view",
3925
2602
  (route) => route.fulfill({ status: 204 })
3926
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
+ );
3927
2619
  }
3928
2620
  await context.tracing.start({ screenshots: true, snapshots: true });
3929
2621
  const page = await context.newPage();
@@ -3936,6 +2628,7 @@ async function runVerify(options, fault) {
3936
2628
  context,
3937
2629
  page,
3938
2630
  metrics,
2631
+ cpuThrottlingRate: throttlingRate,
3939
2632
  ...fault ? { fault } : {}
3940
2633
  };
3941
2634
  const results = [await reset(options)];
@@ -3964,7 +2657,9 @@ async function runVerify(options, fault) {
3964
2657
  runner: options.runner ?? "cli",
3965
2658
  verifyVersion: VERSION,
3966
2659
  sdkVersion,
3967
- browser: `chromium ${browser.version()}`
2660
+ browser: `chromium ${browser.version()}`,
2661
+ ...cpuBenchmark === void 0 ? {} : { cpuBenchmark },
2662
+ cpuThrottlingRate: throttlingRate
3968
2663
  },
3969
2664
  startedAt: new Date(started).toISOString(),
3970
2665
  durationMs: Date.now() - started,
@@ -4036,12 +2731,64 @@ function normalizeUrl(value) {
4036
2731
  }
4037
2732
  }
4038
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
+
4039
2784
  // src/index.ts
4040
2785
  function verify(options) {
4041
2786
  return runVerify(options);
4042
2787
  }
4043
2788
  export {
2789
+ DEFAULT_REGRESSION_THRESHOLDS,
4044
2790
  VerifyError,
2791
+ findRegressions,
4045
2792
  printSummary,
4046
2793
  verify,
4047
2794
  verifyProject