@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.
- package/CHANGELOG.md +45 -0
- package/README.md +18 -1
- package/dist/{category-page-UBHOB5IN.js → category-page-I3HDIAJA.js} +7 -3
- package/dist/category-page-I3HDIAJA.js.map +1 -0
- package/dist/chunk-47RIF6JW.js +2807 -0
- package/dist/chunk-47RIF6JW.js.map +1 -0
- package/dist/{debug-C3UDGUED.js → debug-LOXPIQRS.js} +1 -1
- package/dist/debug-LOXPIQRS.js.map +1 -0
- package/dist/index.d.ts +55 -5
- package/dist/index.js +1133 -2386
- package/dist/index.js.map +1 -1
- package/dist/presentation-TKOXOILG.js +68 -0
- package/dist/presentation-TKOXOILG.js.map +1 -0
- package/dist/quote-body-S5LELT7L.js +24 -0
- package/dist/quote-body-S5LELT7L.js.map +1 -0
- package/dist/{quote-client-6XJJHITL.js → quote-client-4RUY3TKQ.js} +84 -6
- package/dist/quote-client-4RUY3TKQ.js.map +1 -0
- package/dist/{search-YIHT6CXN.js → search-OF62VKI3.js} +7 -2
- package/dist/search-OF62VKI3.js.map +1 -0
- package/dist/{selected-menu-3GCAICBQ.js → selected-menu-AQHWDELR.js} +9 -4
- package/dist/selected-menu-AQHWDELR.js.map +1 -0
- package/dist/{store-KIO5RCZO.js → store-OPM3OFLC.js} +20 -9
- package/dist/store-OPM3OFLC.js.map +1 -0
- package/package.json +2 -2
- package/schema/verify-report.v1.json +19 -1
- package/dist/category-page-UBHOB5IN.js.map +0 -1
- package/dist/chunk-WUWUDCYO.js +0 -485
- package/dist/chunk-WUWUDCYO.js.map +0 -1
- package/dist/debug-C3UDGUED.js.map +0 -1
- package/dist/quote-client-6XJJHITL.js.map +0 -1
- package/dist/search-YIHT6CXN.js.map +0 -1
- package/dist/selected-menu-3GCAICBQ.js.map +0 -1
- 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
|
-
|
|
5
|
+
apiError,
|
|
6
|
+
createZappie,
|
|
4
7
|
format,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
(
|
|
570
|
-
"
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
const
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
)
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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 (
|
|
790
|
-
|
|
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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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 (
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
|
|
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
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
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(
|
|
2708
|
-
return
|
|
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
|
|
768
|
+
const text = await page.locator(`[data-zappie-item="${item.id}"]`).first().textContent();
|
|
2758
769
|
assertion(
|
|
2759
|
-
priceMatches(
|
|
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
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
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.
|
|
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
|
|
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
|
|
2808
|
-
|
|
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
|
|
3062
|
-
await
|
|
1107
|
+
const issue = page.locator(`[data-zappie-issue="${code}"]`).first();
|
|
1108
|
+
await issue.waitFor({ state: "visible" });
|
|
3063
1109
|
assertion(
|
|
3064
|
-
await
|
|
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
|
|
3072
|
-
const fix =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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((
|
|
1745
|
+
new PerformanceObserver((list) => {
|
|
3621
1746
|
state.lcp = Math.max(
|
|
3622
1747
|
state.lcp,
|
|
3623
|
-
...
|
|
1748
|
+
...list.getEntries().map((entry) => entry.startTime)
|
|
3624
1749
|
);
|
|
3625
1750
|
}).observe({ type: "largest-contentful-paint", buffered: true });
|
|
3626
|
-
new PerformanceObserver((
|
|
3627
|
-
state.cls +=
|
|
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
|
|
3645
|
-
const
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
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.
|
|
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
|