@zappie/verify 0.2.2 → 0.2.4
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 +49 -0
- package/README.md +26 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +800 -195
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/schema/verify-report.v1.json +8 -0
package/dist/index.js
CHANGED
|
@@ -269,10 +269,69 @@ async function readStore(cwd) {
|
|
|
269
269
|
}
|
|
270
270
|
|
|
271
271
|
// src/runner.ts
|
|
272
|
-
import {
|
|
272
|
+
import {
|
|
273
|
+
chromium,
|
|
274
|
+
errors
|
|
275
|
+
} from "@playwright/test";
|
|
273
276
|
import { mkdir } from "fs/promises";
|
|
274
277
|
import path3 from "path";
|
|
275
278
|
|
|
279
|
+
// src/cpu.ts
|
|
280
|
+
var REFERENCE_CPU_BENCHMARK = 26e3;
|
|
281
|
+
var REFERENCE_CPU_THROTTLING_RATE = 4;
|
|
282
|
+
var MIN_RATE = 1;
|
|
283
|
+
var MAX_RATE = 8;
|
|
284
|
+
var ROUNDS = 5;
|
|
285
|
+
var ROUND_MS = 100;
|
|
286
|
+
function cpuThrottlingRate(benchmark) {
|
|
287
|
+
if (!Number.isFinite(benchmark) || benchmark <= 0) {
|
|
288
|
+
return REFERENCE_CPU_THROTTLING_RATE;
|
|
289
|
+
}
|
|
290
|
+
const rate = REFERENCE_CPU_THROTTLING_RATE * benchmark / REFERENCE_CPU_BENCHMARK;
|
|
291
|
+
return Math.round(Math.min(Math.max(rate, MIN_RATE), MAX_RATE) * 10) / 10;
|
|
292
|
+
}
|
|
293
|
+
function benchmarkPage(page) {
|
|
294
|
+
return page.evaluate(
|
|
295
|
+
({ rounds, roundMs }) => {
|
|
296
|
+
const work = () => {
|
|
297
|
+
let text = "";
|
|
298
|
+
for (let index = 0; index < 500; index++) {
|
|
299
|
+
text += String.fromCharCode(97 + index % 26);
|
|
300
|
+
}
|
|
301
|
+
const values = Array.from(
|
|
302
|
+
{ length: 500 },
|
|
303
|
+
(_, index) => index * 7919 % 500
|
|
304
|
+
);
|
|
305
|
+
values.sort((a, b) => a - b);
|
|
306
|
+
return text.length + values[0];
|
|
307
|
+
};
|
|
308
|
+
const scores = [];
|
|
309
|
+
let sink = 0;
|
|
310
|
+
for (let round = 0; round < rounds; round++) {
|
|
311
|
+
const started = performance.now();
|
|
312
|
+
let iterations = 0;
|
|
313
|
+
while (performance.now() - started < roundMs) {
|
|
314
|
+
sink += work();
|
|
315
|
+
iterations++;
|
|
316
|
+
}
|
|
317
|
+
scores.push(iterations * 1e3 / (performance.now() - started));
|
|
318
|
+
}
|
|
319
|
+
scores.sort((a, b) => a - b);
|
|
320
|
+
window.__verifySink = sink;
|
|
321
|
+
return Math.round(scores[Math.floor(scores.length / 2)]);
|
|
322
|
+
},
|
|
323
|
+
{ rounds: ROUNDS, roundMs: ROUND_MS }
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
async function benchmarkCpu(browser) {
|
|
327
|
+
const context = await browser.newContext();
|
|
328
|
+
try {
|
|
329
|
+
return await benchmarkPage(await context.newPage());
|
|
330
|
+
} finally {
|
|
331
|
+
await context.close();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
276
335
|
// src/checks/common.ts
|
|
277
336
|
async function open(page, path4 = "/") {
|
|
278
337
|
const response = await page.goto(path4, { waitUntil: "domcontentloaded" });
|
|
@@ -289,7 +348,10 @@ async function debugValue(page, expression) {
|
|
|
289
348
|
}
|
|
290
349
|
var mobile = { width: 390, height: 844 };
|
|
291
350
|
var desktop = { width: 1440, height: 900 };
|
|
292
|
-
async function applyPhoneProfile(page,
|
|
351
|
+
async function applyPhoneProfile(page, fallbackRate) {
|
|
352
|
+
await page.goto("about:blank");
|
|
353
|
+
const benchmark = await benchmarkPage(page).catch(() => void 0);
|
|
354
|
+
const rate = benchmark === void 0 ? fallbackRate : cpuThrottlingRate(benchmark);
|
|
293
355
|
const cdp = await page.context().newCDPSession(page);
|
|
294
356
|
await cdp.send("Network.enable");
|
|
295
357
|
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
|
|
@@ -301,7 +363,143 @@ async function applyPhoneProfile(page, rate) {
|
|
|
301
363
|
connectionType: "cellular3g"
|
|
302
364
|
});
|
|
303
365
|
await cdp.send("Emulation.setCPUThrottlingRate", { rate });
|
|
304
|
-
return cdp;
|
|
366
|
+
return { cdp, rate };
|
|
367
|
+
}
|
|
368
|
+
var ITEM_DIALOG = '[data-zappie-overlay="item"], [role="dialog"]';
|
|
369
|
+
async function itemOpener(page, itemId) {
|
|
370
|
+
const marked = `[data-zappie-item="${itemId}"]`;
|
|
371
|
+
await page.locator(marked).first().waitFor();
|
|
372
|
+
const own = page.locator(
|
|
373
|
+
[
|
|
374
|
+
`a[href]${marked}`,
|
|
375
|
+
`button${marked}`,
|
|
376
|
+
`[role="link"]${marked}`,
|
|
377
|
+
`[role="button"]${marked}`
|
|
378
|
+
].join(", ")
|
|
379
|
+
).filter({ visible: true }).first();
|
|
380
|
+
if (await own.count()) {
|
|
381
|
+
return { opener: own, focusTarget: own };
|
|
382
|
+
}
|
|
383
|
+
const card = page.locator(marked).filter({ visible: true }).first();
|
|
384
|
+
const inner = card.locator('a[href], [role="link"]').first();
|
|
385
|
+
if (await inner.count()) {
|
|
386
|
+
return { opener: inner, focusTarget: card };
|
|
387
|
+
}
|
|
388
|
+
const cell = card.locator("xpath=..");
|
|
389
|
+
return {
|
|
390
|
+
opener: cell.locator('a[href], [role="link"]').first(),
|
|
391
|
+
focusTarget: cell
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
async function openItem(page, itemId) {
|
|
395
|
+
const found = await itemOpener(page, itemId);
|
|
396
|
+
await found.opener.click();
|
|
397
|
+
await itemShown(page);
|
|
398
|
+
return found;
|
|
399
|
+
}
|
|
400
|
+
async function itemShown(page) {
|
|
401
|
+
await Promise.race([
|
|
402
|
+
page.waitForURL((url) => /\/item\//.test(url.pathname)),
|
|
403
|
+
page.locator(ITEM_DIALOG).filter({ visible: true }).first().waitFor()
|
|
404
|
+
]);
|
|
405
|
+
}
|
|
406
|
+
async function closeItem(page) {
|
|
407
|
+
const dialog = page.locator(ITEM_DIALOG).filter({ visible: true }).last();
|
|
408
|
+
if (!await dialog.count()) {
|
|
409
|
+
await page.goBack();
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const close = dialog.getByRole("button", { name: /close/i }).filter({ visible: true }).first();
|
|
413
|
+
if (await close.count()) {
|
|
414
|
+
await close.click();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const outside = page.getByRole("button", { name: /close/i }).filter({ visible: true }).last();
|
|
418
|
+
if (await outside.count()) {
|
|
419
|
+
await outside.click();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
await page.keyboard.press("Escape");
|
|
423
|
+
}
|
|
424
|
+
function itemOpenerSelector(itemId) {
|
|
425
|
+
const marked = itemId ? `[data-zappie-item="${itemId}"]` : "[data-zappie-item]";
|
|
426
|
+
return [
|
|
427
|
+
`a[href]${marked}`,
|
|
428
|
+
`button${marked}`,
|
|
429
|
+
`[role="link"]${marked}`,
|
|
430
|
+
`[role="button"]${marked}`,
|
|
431
|
+
`${marked} a[href]`
|
|
432
|
+
].join(", ");
|
|
433
|
+
}
|
|
434
|
+
async function cartTriggerSelector(page) {
|
|
435
|
+
return await page.locator("[data-zappie-cart-trigger]").count() ? "[data-zappie-cart-trigger]" : '[data-zappie-overlay-trigger="bag"]';
|
|
436
|
+
}
|
|
437
|
+
async function cartTrigger(page) {
|
|
438
|
+
return page.locator(await cartTriggerSelector(page)).filter({ visible: true }).first();
|
|
439
|
+
}
|
|
440
|
+
async function tabTo(page, selector, presses = 60) {
|
|
441
|
+
for (let index = 0; index < presses; index++) {
|
|
442
|
+
await page.keyboard.press("Tab");
|
|
443
|
+
const inside = await page.evaluate(
|
|
444
|
+
(value) => Boolean(document.activeElement?.closest(value)),
|
|
445
|
+
selector
|
|
446
|
+
);
|
|
447
|
+
if (inside) {
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
async function signIn(page, site = page.url()) {
|
|
454
|
+
await page.context().addCookies([
|
|
455
|
+
{
|
|
456
|
+
name: "zappie_session",
|
|
457
|
+
value: "signed",
|
|
458
|
+
domain: new URL(site).hostname,
|
|
459
|
+
path: "/"
|
|
460
|
+
}
|
|
461
|
+
]);
|
|
462
|
+
}
|
|
463
|
+
async function revealField(page, field, input, reach = async () => {
|
|
464
|
+
}) {
|
|
465
|
+
const site = new URL(page.url());
|
|
466
|
+
const opener = page.locator(`[data-zappie-field-open="${field}"]`).filter({ visible: true }).first();
|
|
467
|
+
const shown = input.filter({ visible: true }).first();
|
|
468
|
+
await shown.or(opener).first().waitFor();
|
|
469
|
+
if (await shown.isVisible()) {
|
|
470
|
+
return shown;
|
|
471
|
+
}
|
|
472
|
+
await opener.click();
|
|
473
|
+
if (await sentToSignIn(page, site, shown)) {
|
|
474
|
+
await signIn(page, site.href);
|
|
475
|
+
await open(page, site.pathname);
|
|
476
|
+
await reach();
|
|
477
|
+
await opener.click();
|
|
478
|
+
}
|
|
479
|
+
await shown.waitFor();
|
|
480
|
+
return shown;
|
|
481
|
+
}
|
|
482
|
+
async function sentToSignIn(page, site, shown) {
|
|
483
|
+
const deadline = Date.now() + 1e4;
|
|
484
|
+
while (Date.now() < deadline) {
|
|
485
|
+
const url = new URL(page.url());
|
|
486
|
+
if (url.origin !== site.origin || url.pathname === "/_zappie/login") {
|
|
487
|
+
return true;
|
|
488
|
+
}
|
|
489
|
+
if (await shown.isVisible().catch(() => false)) {
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
await page.waitForTimeout(100);
|
|
493
|
+
}
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
async function dialogConfirm(page, confirm) {
|
|
497
|
+
const marked = page.locator(confirm).filter({ visible: true }).first();
|
|
498
|
+
if (await marked.count()) {
|
|
499
|
+
return marked;
|
|
500
|
+
}
|
|
501
|
+
const button = page.locator('[role="dialog"]').filter({ visible: true }).last().locator('footer button:enabled, button[type="submit"]:enabled').filter({ visible: true }).last();
|
|
502
|
+
return await button.count() ? button : void 0;
|
|
305
503
|
}
|
|
306
504
|
|
|
307
505
|
// src/checks/v01-routes.ts
|
|
@@ -373,6 +571,7 @@ var v02 = {
|
|
|
373
571
|
};
|
|
374
572
|
|
|
375
573
|
// src/fixtures.ts
|
|
574
|
+
var preparedPages = /* @__PURE__ */ new WeakMap();
|
|
376
575
|
async function installFault(page, fault) {
|
|
377
576
|
if (!fault) {
|
|
378
577
|
return;
|
|
@@ -408,6 +607,179 @@ async function installFault(page, fault) {
|
|
|
408
607
|
}).observe(document, { childList: true, subtree: true });
|
|
409
608
|
});
|
|
410
609
|
}
|
|
610
|
+
if (fault === "card-link") {
|
|
611
|
+
await page.addInitScript(() => {
|
|
612
|
+
new MutationObserver(() => {
|
|
613
|
+
for (const node of document.querySelectorAll(
|
|
614
|
+
'[role="dialog"] [aria-label="Close item"]'
|
|
615
|
+
)) {
|
|
616
|
+
node.setAttribute("aria-label", "Close");
|
|
617
|
+
}
|
|
618
|
+
}).observe(document, {
|
|
619
|
+
childList: true,
|
|
620
|
+
subtree: true,
|
|
621
|
+
attributes: true,
|
|
622
|
+
attributeFilter: ["aria-label"]
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
if (fault === "no-add-marker") {
|
|
627
|
+
await page.addInitScript(() => {
|
|
628
|
+
new MutationObserver(() => {
|
|
629
|
+
for (const node of document.querySelectorAll(
|
|
630
|
+
'[data-zappie-action="add-to-cart"]'
|
|
631
|
+
)) {
|
|
632
|
+
node.removeAttribute("data-zappie-action");
|
|
633
|
+
}
|
|
634
|
+
}).observe(document, { childList: true, subtree: true });
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
if (fault === "add-enabled-invalid" || fault === "add-enabled-silent") {
|
|
638
|
+
await page.addInitScript((flags) => {
|
|
639
|
+
new MutationObserver(() => {
|
|
640
|
+
for (const original of document.querySelectorAll(
|
|
641
|
+
'[role="dialog"] button[data-zappie-action="add-to-cart"]:not([data-verify-proxied])'
|
|
642
|
+
)) {
|
|
643
|
+
original.setAttribute("data-verify-proxied", "");
|
|
644
|
+
original.removeAttribute("data-zappie-action");
|
|
645
|
+
original.style.display = "none";
|
|
646
|
+
const proxy = document.createElement("button");
|
|
647
|
+
proxy.type = "button";
|
|
648
|
+
proxy.textContent = "Add";
|
|
649
|
+
proxy.setAttribute("data-zappie-action", "add-to-cart");
|
|
650
|
+
proxy.setAttribute("data-verify-proxied", "");
|
|
651
|
+
proxy.addEventListener("click", () => {
|
|
652
|
+
if (!original.disabled) {
|
|
653
|
+
original.click();
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const group = original.closest('[role="dialog"]')?.querySelector(
|
|
657
|
+
'[data-zappie-option-group][aria-required="true"]:not(:has([data-state="checked"]))'
|
|
658
|
+
);
|
|
659
|
+
if (flags && group) {
|
|
660
|
+
group.setAttribute("aria-invalid", "true");
|
|
661
|
+
group.setAttribute("data-invalid", "");
|
|
662
|
+
const message = document.createElement("p");
|
|
663
|
+
message.textContent = "Choose one to continue";
|
|
664
|
+
group.append(message);
|
|
665
|
+
group.focus();
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
original.after(proxy);
|
|
669
|
+
}
|
|
670
|
+
}).observe(document, { childList: true, subtree: true });
|
|
671
|
+
}, fault === "add-enabled-invalid");
|
|
672
|
+
}
|
|
673
|
+
if (fault === "total-on-checkout" || fault === "total-missing") {
|
|
674
|
+
await page.addInitScript((onCheckout) => {
|
|
675
|
+
window.addEventListener(
|
|
676
|
+
"click",
|
|
677
|
+
(event) => {
|
|
678
|
+
const link = event.target?.closest("a[data-zappie-cart-checkout]");
|
|
679
|
+
if (!link) {
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
event.preventDefault();
|
|
683
|
+
event.stopImmediatePropagation();
|
|
684
|
+
history.pushState(history.state, "", link.href);
|
|
685
|
+
window.dispatchEvent(
|
|
686
|
+
new PopStateEvent("popstate", { state: history.state })
|
|
687
|
+
);
|
|
688
|
+
},
|
|
689
|
+
{ capture: true }
|
|
690
|
+
);
|
|
691
|
+
new MutationObserver(() => {
|
|
692
|
+
for (const node of document.querySelectorAll(
|
|
693
|
+
'[data-zappie-overlay="bag"] [data-zappie-total]'
|
|
694
|
+
)) {
|
|
695
|
+
sessionStorage.setItem("verify-total", node.textContent ?? "");
|
|
696
|
+
node.removeAttribute("data-zappie-total");
|
|
697
|
+
}
|
|
698
|
+
const text = sessionStorage.getItem("verify-total");
|
|
699
|
+
if (onCheckout && text && location.pathname === "/checkout" && document.querySelector("main") && !document.querySelector("[data-verify-total]")) {
|
|
700
|
+
const total = document.createElement("p");
|
|
701
|
+
total.setAttribute("data-verify-total", "");
|
|
702
|
+
total.setAttribute("data-zappie-total", "");
|
|
703
|
+
total.textContent = text;
|
|
704
|
+
document.querySelector("main").prepend(total);
|
|
705
|
+
}
|
|
706
|
+
}).observe(document, { childList: true, subtree: true });
|
|
707
|
+
}, fault === "total-on-checkout");
|
|
708
|
+
}
|
|
709
|
+
if (fault === "address-confirm" || fault === "address-not-stored") {
|
|
710
|
+
await page.addInitScript((confirms) => {
|
|
711
|
+
let release = false;
|
|
712
|
+
window.addEventListener(
|
|
713
|
+
"click",
|
|
714
|
+
(event) => {
|
|
715
|
+
const option = event.target?.closest(
|
|
716
|
+
'[role="option"]'
|
|
717
|
+
);
|
|
718
|
+
if (!option || release) {
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
event.stopImmediatePropagation();
|
|
722
|
+
event.preventDefault();
|
|
723
|
+
if (!confirms) {
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
const dialog = document.createElement("div");
|
|
727
|
+
dialog.setAttribute("role", "dialog");
|
|
728
|
+
dialog.setAttribute("aria-label", "Confirm address");
|
|
729
|
+
const footer = document.createElement("footer");
|
|
730
|
+
const button = document.createElement("button");
|
|
731
|
+
button.type = "button";
|
|
732
|
+
button.textContent = "Deliver here";
|
|
733
|
+
button.addEventListener("click", () => {
|
|
734
|
+
dialog.remove();
|
|
735
|
+
release = true;
|
|
736
|
+
option.click();
|
|
737
|
+
release = false;
|
|
738
|
+
});
|
|
739
|
+
footer.append(button);
|
|
740
|
+
dialog.append(footer);
|
|
741
|
+
document.body.append(dialog);
|
|
742
|
+
},
|
|
743
|
+
{ capture: true }
|
|
744
|
+
);
|
|
745
|
+
}, fault === "address-confirm");
|
|
746
|
+
}
|
|
747
|
+
if (fault === "field-rows") {
|
|
748
|
+
await page.addInitScript(() => {
|
|
749
|
+
const fields = [
|
|
750
|
+
["address", "[data-zappie-address-input]"],
|
|
751
|
+
["phone", 'input[type="tel"]']
|
|
752
|
+
];
|
|
753
|
+
new MutationObserver(() => {
|
|
754
|
+
for (const [field, selector] of fields) {
|
|
755
|
+
for (const input of document.querySelectorAll(
|
|
756
|
+
`${selector}:not([data-verify-row])`
|
|
757
|
+
)) {
|
|
758
|
+
input.setAttribute("data-verify-row", "");
|
|
759
|
+
input.hidden = true;
|
|
760
|
+
const row = document.createElement("button");
|
|
761
|
+
row.type = "button";
|
|
762
|
+
row.textContent = `Add your ${field}`;
|
|
763
|
+
row.setAttribute("data-zappie-field-open", field);
|
|
764
|
+
row.addEventListener("click", () => {
|
|
765
|
+
if (!document.cookie.includes("zappie_session=signed")) {
|
|
766
|
+
window.location.assign(
|
|
767
|
+
new URL(
|
|
768
|
+
`/_zappie/login?return=${encodeURIComponent(location.pathname)}`,
|
|
769
|
+
location.href
|
|
770
|
+
).href
|
|
771
|
+
);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
row.remove();
|
|
775
|
+
input.hidden = false;
|
|
776
|
+
});
|
|
777
|
+
input.before(row);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}).observe(document, { childList: true, subtree: true });
|
|
781
|
+
});
|
|
782
|
+
}
|
|
411
783
|
if (fault === "overlay-markers") {
|
|
412
784
|
await page.addInitScript(() => {
|
|
413
785
|
new MutationObserver(() => {
|
|
@@ -426,7 +798,32 @@ async function installFault(page, fault) {
|
|
|
426
798
|
}).observe(document, { childList: true, subtree: true });
|
|
427
799
|
});
|
|
428
800
|
}
|
|
429
|
-
if (fault === "bag-on-tap") {
|
|
801
|
+
if (fault === "bag-after-add" || fault === "bag-after-add-on-tap") {
|
|
802
|
+
await page.addInitScript(() => {
|
|
803
|
+
const sync = () => {
|
|
804
|
+
let count = 0;
|
|
805
|
+
try {
|
|
806
|
+
count = window.__zappie?.cart().count ?? 0;
|
|
807
|
+
} catch {
|
|
808
|
+
}
|
|
809
|
+
for (const node of document.querySelectorAll(
|
|
810
|
+
'[data-zappie-overlay-trigger="bag"]'
|
|
811
|
+
)) {
|
|
812
|
+
if (count === 0) {
|
|
813
|
+
node.style.setProperty("display", "none", "important");
|
|
814
|
+
} else {
|
|
815
|
+
node.style.removeProperty("display");
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
new MutationObserver(sync).observe(document, {
|
|
820
|
+
childList: true,
|
|
821
|
+
subtree: true
|
|
822
|
+
});
|
|
823
|
+
window.setInterval(sync, 50);
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
if (fault === "bag-on-tap" || fault === "bag-after-add-on-tap") {
|
|
430
827
|
let release = () => {
|
|
431
828
|
};
|
|
432
829
|
let tapped = Promise.resolve();
|
|
@@ -462,24 +859,26 @@ async function installFault(page, fault) {
|
|
|
462
859
|
);
|
|
463
860
|
}
|
|
464
861
|
if (fault === "one-slow-open") {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
862
|
+
const browser = page.context().browser();
|
|
863
|
+
const index = browser ? preparedPages.get(browser) ?? 0 : 0;
|
|
864
|
+
if (browser) {
|
|
865
|
+
preparedPages.set(browser, index + 1);
|
|
866
|
+
}
|
|
867
|
+
if (index > 0 && (index - 1) % 3 === 0) {
|
|
868
|
+
await page.addInitScript(() => {
|
|
869
|
+
document.addEventListener(
|
|
870
|
+
"click",
|
|
871
|
+
(event) => {
|
|
872
|
+
if (event.target instanceof Element && event.target.closest("[data-zappie-overlay-trigger]")) {
|
|
873
|
+
const until = performance.now() + 250;
|
|
874
|
+
while (performance.now() < until) {
|
|
875
|
+
}
|
|
477
876
|
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
);
|
|
482
|
-
}
|
|
877
|
+
},
|
|
878
|
+
{ capture: true }
|
|
879
|
+
);
|
|
880
|
+
});
|
|
881
|
+
}
|
|
483
882
|
}
|
|
484
883
|
if (fault === "late-hydration") {
|
|
485
884
|
await page.route(
|
|
@@ -643,6 +1042,22 @@ async function applyPageFault(page, fault) {
|
|
|
643
1042
|
}
|
|
644
1043
|
});
|
|
645
1044
|
}
|
|
1045
|
+
if (fault === "card-link") {
|
|
1046
|
+
await page.waitForFunction(() => {
|
|
1047
|
+
const link = document.querySelector("a[data-zappie-item]");
|
|
1048
|
+
return link !== null && Object.keys(link).some((key) => key.startsWith("__reactProps$"));
|
|
1049
|
+
});
|
|
1050
|
+
await page.locator("a[data-zappie-item]").evaluateAll((links) => {
|
|
1051
|
+
for (const link of links) {
|
|
1052
|
+
const id = link.getAttribute("data-zappie-item");
|
|
1053
|
+
const card = link.parentElement?.querySelector(
|
|
1054
|
+
`article[data-zappie-item="${id}"]`
|
|
1055
|
+
);
|
|
1056
|
+
link.textContent = card?.querySelector("h3")?.textContent ?? "Item";
|
|
1057
|
+
card?.removeAttribute("data-zappie-item");
|
|
1058
|
+
}
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
646
1061
|
if (fault === "missing-marker") {
|
|
647
1062
|
await page.locator("[data-zappie-item]").evaluateAll((nodes) => {
|
|
648
1063
|
for (const node of nodes) {
|
|
@@ -878,11 +1293,11 @@ var v05 = {
|
|
|
878
1293
|
"The test menu has no available item with required options",
|
|
879
1294
|
"Keep one available configurable item in the test menu."
|
|
880
1295
|
);
|
|
881
|
-
|
|
882
|
-
await item.locator("xpath=..").getByRole("link", { name: "View details" }).click();
|
|
1296
|
+
await openItem(page, selected.id);
|
|
883
1297
|
const dialog = page.getByRole("dialog");
|
|
884
1298
|
await dialog.waitFor();
|
|
885
1299
|
const required = dialog.locator('[data-zappie-option-group][aria-required="true"]').first();
|
|
1300
|
+
await required.waitFor({ timeout: 5e3 }).catch(() => void 0);
|
|
886
1301
|
assertion(
|
|
887
1302
|
await required.count(),
|
|
888
1303
|
"required_group_missing",
|
|
@@ -891,23 +1306,41 @@ var v05 = {
|
|
|
891
1306
|
);
|
|
892
1307
|
const before = await debugValue(page, () => window.__zappie.cart().count);
|
|
893
1308
|
const add = dialog.locator('[data-zappie-action="add-to-cart"]');
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
1309
|
+
const unsatisfied = dialog.locator(
|
|
1310
|
+
'[data-zappie-option-group][aria-required="true"]:not(:has([data-state="checked"]))'
|
|
1311
|
+
);
|
|
1312
|
+
const blocked2 = await add.isDisabled();
|
|
1313
|
+
if (!blocked2) {
|
|
1314
|
+
const group = unsatisfied.first();
|
|
1315
|
+
const textBefore = (await group.innerText().catch(() => "")).trim();
|
|
1316
|
+
await add.click();
|
|
1317
|
+
await page.waitForTimeout(500);
|
|
1318
|
+
const added = await page.evaluate(
|
|
1319
|
+
(count) => window.__zappie.cart().count > count,
|
|
1320
|
+
before
|
|
1321
|
+
);
|
|
904
1322
|
assertion(
|
|
905
|
-
await
|
|
1323
|
+
!added && await dialog.isVisible(),
|
|
1324
|
+
"required_option_bypassed",
|
|
1325
|
+
"Add put the item in the cart before the shopper chose a required option",
|
|
1326
|
+
"Disable Add until the shopper completes every required option group, or keep the item open and flag the group when Add is tapped."
|
|
1327
|
+
);
|
|
1328
|
+
assertion(
|
|
1329
|
+
await group.evaluate(flagged, textBefore),
|
|
1330
|
+
"required_option_unflagged",
|
|
1331
|
+
"Tapping Add before a required choice did not flag the group",
|
|
1332
|
+
"Mark the group that needs a choice with aria-invalid or data-invalid and show a message saying why."
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
for (let index = 0; index < 5 && (blocked2 ? await add.isDisabled() : await unsatisfied.count() > 0); index++) {
|
|
1336
|
+
const next = unsatisfied.first();
|
|
1337
|
+
assertion(
|
|
1338
|
+
await next.count(),
|
|
906
1339
|
"required_option_unselectable",
|
|
907
1340
|
"A required option group has no selectable choice",
|
|
908
1341
|
"Keep an available choice in every required option group."
|
|
909
1342
|
);
|
|
910
|
-
await
|
|
1343
|
+
await next.locator('[data-zappie-option]:not([aria-disabled="true"])').first().click();
|
|
911
1344
|
}
|
|
912
1345
|
assertion(
|
|
913
1346
|
await add.isEnabled(),
|
|
@@ -935,6 +1368,22 @@ var v05 = {
|
|
|
935
1368
|
);
|
|
936
1369
|
}
|
|
937
1370
|
};
|
|
1371
|
+
function flagged(group, textBefore) {
|
|
1372
|
+
const invalid = group.getAttribute("aria-invalid") === "true" || group.hasAttribute("data-invalid") || group.querySelector('[aria-invalid="true"], [data-invalid]') !== null;
|
|
1373
|
+
if (!invalid) {
|
|
1374
|
+
return false;
|
|
1375
|
+
}
|
|
1376
|
+
const shown = (element) => element instanceof HTMLElement && element.getClientRects().length > 0 && Boolean(element.innerText.trim());
|
|
1377
|
+
if (group instanceof HTMLElement && group.innerText.trim().length > textBefore.length) {
|
|
1378
|
+
return true;
|
|
1379
|
+
}
|
|
1380
|
+
const referenced = ["aria-describedby", "aria-errormessage"].flatMap((name) => group.getAttribute(name)?.split(/\s+/) ?? []).some((id) => shown(document.getElementById(id)));
|
|
1381
|
+
if (referenced) {
|
|
1382
|
+
return true;
|
|
1383
|
+
}
|
|
1384
|
+
const dialog = group.closest('[role="dialog"]') ?? document;
|
|
1385
|
+
return [...dialog.querySelectorAll('[role="alert"]')].some(shown);
|
|
1386
|
+
}
|
|
938
1387
|
|
|
939
1388
|
// src/checks/v06-quote.ts
|
|
940
1389
|
var v06 = {
|
|
@@ -946,9 +1395,20 @@ var v06 = {
|
|
|
946
1395
|
if (new URL(page.url()).pathname !== "/") {
|
|
947
1396
|
await open(page);
|
|
948
1397
|
}
|
|
949
|
-
const total = page.locator("[data-zappie-total]");
|
|
950
|
-
|
|
951
|
-
|
|
1398
|
+
const total = page.locator("[data-zappie-total]").filter({ visible: true });
|
|
1399
|
+
const bagOpen = page.locator('[data-zappie-overlay="bag"]').filter({ visible: true });
|
|
1400
|
+
if (!await total.count()) {
|
|
1401
|
+
if (!await bagOpen.count()) {
|
|
1402
|
+
await (await cartTrigger(page)).click();
|
|
1403
|
+
}
|
|
1404
|
+
await total.first().waitFor({ timeout: 2e3 }).catch(() => void 0);
|
|
1405
|
+
}
|
|
1406
|
+
if (!await total.count()) {
|
|
1407
|
+
const handover = page.locator('[data-zappie-overlay="bag"], [role="dialog"]').filter({ visible: true }).locator('[data-zappie-cart-checkout], a[href*="/checkout"]').filter({ visible: true }).first();
|
|
1408
|
+
if (await handover.count()) {
|
|
1409
|
+
await handover.click();
|
|
1410
|
+
await page.waitForURL((url) => url.pathname.endsWith("/checkout"));
|
|
1411
|
+
}
|
|
952
1412
|
}
|
|
953
1413
|
await page.waitForFunction(
|
|
954
1414
|
() => window.__zappie.events.some((entry) => entry.name === "quote"),
|
|
@@ -962,11 +1422,21 @@ var v06 = {
|
|
|
962
1422
|
"The cart did not produce a quote within 2 seconds",
|
|
963
1423
|
"Open the cart through the SDK and render Totals so it requests a quote."
|
|
964
1424
|
);
|
|
965
|
-
|
|
1425
|
+
let expected = format.money(quote.total);
|
|
1426
|
+
let rendered = null;
|
|
1427
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
1428
|
+
const latest = await page.evaluate(() => window.__zappie.quote());
|
|
1429
|
+
expected = format.money((latest ?? quote).total);
|
|
1430
|
+
rendered = await total.first().textContent({ timeout: 5e3 }).catch(() => null);
|
|
1431
|
+
if (rendered === null || rendered.includes(expected)) {
|
|
1432
|
+
break;
|
|
1433
|
+
}
|
|
1434
|
+
await page.waitForTimeout(250);
|
|
1435
|
+
}
|
|
966
1436
|
assertion(
|
|
967
|
-
rendered?.includes(
|
|
1437
|
+
rendered?.includes(expected),
|
|
968
1438
|
"total_mismatch",
|
|
969
|
-
`Rendered total does not match ${
|
|
1439
|
+
`Rendered total does not match ${expected}`,
|
|
970
1440
|
"Render quote.total with the SDK Money formatter."
|
|
971
1441
|
);
|
|
972
1442
|
}
|
|
@@ -1101,7 +1571,7 @@ var v07 = {
|
|
|
1101
1571
|
await window.__zappie.cart.inject({ lines: lines2 });
|
|
1102
1572
|
}, lines);
|
|
1103
1573
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
1104
|
-
await
|
|
1574
|
+
await (await cartTrigger(page)).click();
|
|
1105
1575
|
const codes = unavailable ? ["price_changed", "item_unavailable"] : ["price_changed"];
|
|
1106
1576
|
for (const code of codes) {
|
|
1107
1577
|
const issue = page.locator(`[data-zappie-issue="${code}"]`).first();
|
|
@@ -1424,14 +1894,28 @@ var v10 = {
|
|
|
1424
1894
|
"Render a delivery control with data-zappie-fulfilment=delivery."
|
|
1425
1895
|
);
|
|
1426
1896
|
await delivery.click();
|
|
1427
|
-
const input =
|
|
1428
|
-
|
|
1897
|
+
const input = await revealField(
|
|
1898
|
+
page,
|
|
1899
|
+
"address",
|
|
1900
|
+
page.locator("[data-zappie-address-input]"),
|
|
1901
|
+
() => delivery.click()
|
|
1902
|
+
);
|
|
1429
1903
|
await input.fill(ADDRESS);
|
|
1430
1904
|
await page.getByText(ADDRESS, { exact: true }).last().click();
|
|
1431
|
-
await page
|
|
1432
|
-
const
|
|
1433
|
-
|
|
1434
|
-
|
|
1905
|
+
if (!await addressStored(page, 3e3)) {
|
|
1906
|
+
const confirm = await dialogConfirm(
|
|
1907
|
+
page,
|
|
1908
|
+
"[data-zappie-address-confirm]"
|
|
1909
|
+
);
|
|
1910
|
+
assertion(
|
|
1911
|
+
confirm,
|
|
1912
|
+
"delivery_address_missing",
|
|
1913
|
+
"The selected address was not stored",
|
|
1914
|
+
"Store the resolved address on delivery fulfilment when the shopper picks it, or mark the control that confirms it with data-zappie-address-confirm."
|
|
1915
|
+
);
|
|
1916
|
+
await confirm.click();
|
|
1917
|
+
}
|
|
1918
|
+
await addressStored(page);
|
|
1435
1919
|
const fulfilment = await debugValue(
|
|
1436
1920
|
page,
|
|
1437
1921
|
() => window.__zappie.cart().fulfilment
|
|
@@ -1475,12 +1959,30 @@ var v10 = {
|
|
|
1475
1959
|
);
|
|
1476
1960
|
}
|
|
1477
1961
|
};
|
|
1962
|
+
async function addressStored(page, timeout) {
|
|
1963
|
+
return page.waitForFunction(
|
|
1964
|
+
() => {
|
|
1965
|
+
const fulfilment = window.__zappie.cart().fulfilment;
|
|
1966
|
+
return fulfilment?.type === "delivery" && Boolean(fulfilment.address.placeId);
|
|
1967
|
+
},
|
|
1968
|
+
void 0,
|
|
1969
|
+
timeout === void 0 ? {} : { timeout }
|
|
1970
|
+
).then(
|
|
1971
|
+
() => true,
|
|
1972
|
+
(error) => {
|
|
1973
|
+
if (timeout === void 0) {
|
|
1974
|
+
throw error;
|
|
1975
|
+
}
|
|
1976
|
+
return false;
|
|
1977
|
+
}
|
|
1978
|
+
);
|
|
1979
|
+
}
|
|
1478
1980
|
|
|
1479
1981
|
// src/checks/v13-accessibility.ts
|
|
1480
1982
|
import AxeBuilder from "@axe-core/playwright";
|
|
1481
1983
|
|
|
1482
1984
|
// src/checks/semantics.ts
|
|
1483
|
-
var TOUCH_TARGET_PX =
|
|
1985
|
+
var TOUCH_TARGET_PX = 24;
|
|
1484
1986
|
async function semanticFindings(page, scope) {
|
|
1485
1987
|
return page.evaluate(
|
|
1486
1988
|
({ scope: scope2, minimum }) => {
|
|
@@ -1583,11 +2085,29 @@ var semanticHints = {
|
|
|
1583
2085
|
h1_count: "Give each page exactly one h1 that names it.",
|
|
1584
2086
|
dialog_name_missing: "Name every dialog with aria-labelledby pointing at its title, or with aria-label.",
|
|
1585
2087
|
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
|
|
2088
|
+
touch_target_too_small: "Make every menu control at least 24\xD724 px on a phone, with padding or a larger hit area."
|
|
1587
2089
|
};
|
|
1588
2090
|
|
|
1589
2091
|
// src/checks/v13-accessibility.ts
|
|
1590
2092
|
async function severeViolations(page) {
|
|
2093
|
+
await page.evaluate(
|
|
2094
|
+
() => new Promise((resolve) => {
|
|
2095
|
+
const deadline = performance.now() + 2e3;
|
|
2096
|
+
let quiet = 0;
|
|
2097
|
+
const frame = () => {
|
|
2098
|
+
const running = document.getAnimations().some(
|
|
2099
|
+
(animation) => animation.playState === "running" && animation.effect?.getComputedTiming().iterations !== Infinity
|
|
2100
|
+
);
|
|
2101
|
+
quiet = running ? 0 : quiet + 1;
|
|
2102
|
+
if (quiet >= 3 || performance.now() > deadline) {
|
|
2103
|
+
resolve();
|
|
2104
|
+
return;
|
|
2105
|
+
}
|
|
2106
|
+
requestAnimationFrame(frame);
|
|
2107
|
+
};
|
|
2108
|
+
requestAnimationFrame(frame);
|
|
2109
|
+
})
|
|
2110
|
+
);
|
|
1591
2111
|
const analysis = await new AxeBuilder({ page }).analyze();
|
|
1592
2112
|
return analysis.violations.filter(
|
|
1593
2113
|
(entry) => entry.impact === "critical" || entry.impact === "serious"
|
|
@@ -1621,11 +2141,48 @@ async function holdsFocus(target2) {
|
|
|
1621
2141
|
}
|
|
1622
2142
|
return false;
|
|
1623
2143
|
}
|
|
2144
|
+
var ADD = '[data-zappie-action="add-to-cart"]';
|
|
2145
|
+
async function keyboardReachesAddAndCart(page, itemId, cartInColumn) {
|
|
2146
|
+
const cart = cartInColumn ? [] : [await cartTriggerSelector(page)];
|
|
2147
|
+
if (await page.locator(ADD).count() || !itemId) {
|
|
2148
|
+
return controlsAreReachable(page, [ADD, ...cart]);
|
|
2149
|
+
}
|
|
2150
|
+
const opener = itemOpenerSelector(itemId);
|
|
2151
|
+
if (cart.length && !await controlsAreReachable(page, cart)) {
|
|
2152
|
+
return false;
|
|
2153
|
+
}
|
|
2154
|
+
await open(page);
|
|
2155
|
+
if (!await tabTo(page, opener, 200)) {
|
|
2156
|
+
return false;
|
|
2157
|
+
}
|
|
2158
|
+
await page.keyboard.press("Enter");
|
|
2159
|
+
await itemShown(page);
|
|
2160
|
+
return tabTo(page, ADD);
|
|
2161
|
+
}
|
|
2162
|
+
async function checkoutEnabled(page, timeout) {
|
|
2163
|
+
return page.waitForFunction(
|
|
2164
|
+
() => document.querySelector("[data-zappie-checkout]")?.disabled === false,
|
|
2165
|
+
void 0,
|
|
2166
|
+
{ timeout }
|
|
2167
|
+
).then(
|
|
2168
|
+
() => true,
|
|
2169
|
+
() => false
|
|
2170
|
+
);
|
|
2171
|
+
}
|
|
2172
|
+
async function fillSteadily(input, value) {
|
|
2173
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
2174
|
+
await input.fill(value);
|
|
2175
|
+
await input.page().waitForTimeout(150);
|
|
2176
|
+
if (await input.inputValue() === value) {
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
1624
2181
|
var v13 = {
|
|
1625
2182
|
id: "V13",
|
|
1626
2183
|
title: "Accessibility",
|
|
1627
2184
|
viewports: "both",
|
|
1628
|
-
async run({ page,
|
|
2185
|
+
async run({ page, options, fault }, viewport) {
|
|
1629
2186
|
await page.setViewportSize(viewport);
|
|
1630
2187
|
await open(page);
|
|
1631
2188
|
await applyPageFault(page, fault);
|
|
@@ -1643,21 +2200,23 @@ var v13 = {
|
|
|
1643
2200
|
"No available item can be opened for accessibility checks",
|
|
1644
2201
|
"Keep one available item in the test menu."
|
|
1645
2202
|
);
|
|
1646
|
-
const card = page
|
|
1647
|
-
await card.getByRole("link", { name: "View details" }).click();
|
|
2203
|
+
const { focusTarget: card } = await openItem(page, item.id);
|
|
1648
2204
|
violations.push(...await severeViolations(page));
|
|
1649
2205
|
semantics.push(
|
|
1650
2206
|
...await semanticFindings(page, { page: false, phone: false })
|
|
1651
2207
|
);
|
|
1652
|
-
await page
|
|
2208
|
+
await closeItem(page);
|
|
1653
2209
|
if (!await holdsFocus(card)) {
|
|
1654
2210
|
semantics.push({
|
|
1655
2211
|
code: "focus_not_returned",
|
|
1656
2212
|
message: "Focus did not return to the item card after the item closed"
|
|
1657
2213
|
});
|
|
1658
2214
|
}
|
|
1659
|
-
const
|
|
1660
|
-
await
|
|
2215
|
+
const cart = await cartTrigger(page);
|
|
2216
|
+
const cartInColumn = viewport.width >= 768 && !await cart.isVisible();
|
|
2217
|
+
if (!cartInColumn) {
|
|
2218
|
+
await cart.click();
|
|
2219
|
+
}
|
|
1661
2220
|
violations.push(...await severeViolations(page));
|
|
1662
2221
|
semantics.push(
|
|
1663
2222
|
...await semanticFindings(page, { page: false, phone: false })
|
|
@@ -1671,8 +2230,10 @@ var v13 = {
|
|
|
1671
2230
|
message || "Accessibility violation",
|
|
1672
2231
|
"Fix every serious or critical axe violation in the menu, item dialog, cart and order page."
|
|
1673
2232
|
);
|
|
1674
|
-
|
|
1675
|
-
|
|
2233
|
+
if (!cartInColumn) {
|
|
2234
|
+
await page.keyboard.press("Escape");
|
|
2235
|
+
}
|
|
2236
|
+
if (!cartInColumn && !await holdsFocus(cart)) {
|
|
1676
2237
|
semantics.push({
|
|
1677
2238
|
code: "focus_not_returned",
|
|
1678
2239
|
message: "Focus did not return to the cart control after the cart closed"
|
|
@@ -1691,14 +2252,7 @@ var v13 = {
|
|
|
1691
2252
|
)
|
|
1692
2253
|
].join(" ")
|
|
1693
2254
|
);
|
|
1694
|
-
await
|
|
1695
|
-
{
|
|
1696
|
-
name: "zappie_session",
|
|
1697
|
-
value: "signed",
|
|
1698
|
-
domain: new URL(page.url()).hostname,
|
|
1699
|
-
path: "/"
|
|
1700
|
-
}
|
|
1701
|
-
]);
|
|
2255
|
+
await signIn(page);
|
|
1702
2256
|
await open(page, "/orders/order_fixture");
|
|
1703
2257
|
const order = await severeViolations(page);
|
|
1704
2258
|
assertion(
|
|
@@ -1709,16 +2263,32 @@ var v13 = {
|
|
|
1709
2263
|
);
|
|
1710
2264
|
await open(page);
|
|
1711
2265
|
assertion(
|
|
1712
|
-
await
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
2266
|
+
await keyboardReachesAddAndCart(
|
|
2267
|
+
page,
|
|
2268
|
+
simpleItem(await readMenu(options))?.id,
|
|
2269
|
+
cartInColumn
|
|
2270
|
+
),
|
|
1716
2271
|
"keyboard_marker_unreachable",
|
|
1717
2272
|
"Keyboard navigation did not reach the Add and cart controls",
|
|
1718
2273
|
"Keep the marked Add and cart controls in the tab order."
|
|
1719
2274
|
);
|
|
1720
2275
|
await open(page, "/checkout");
|
|
1721
|
-
|
|
2276
|
+
const tel = page.locator('input[type="tel"]').filter({ visible: true }).first();
|
|
2277
|
+
const phoneRow = page.locator('[data-zappie-field-open="phone"]').filter({ visible: true }).first();
|
|
2278
|
+
await tel.or(phoneRow).first().waitFor();
|
|
2279
|
+
if (!await tel.isVisible() && await checkoutEnabled(page, 3e3)) {
|
|
2280
|
+
await phoneRow.focus();
|
|
2281
|
+
} else {
|
|
2282
|
+
const phone = await revealField(
|
|
2283
|
+
page,
|
|
2284
|
+
"phone",
|
|
2285
|
+
page.locator('input[type="tel"]')
|
|
2286
|
+
);
|
|
2287
|
+
await fillSteadily(phone, "+2348000000001");
|
|
2288
|
+
if (await page.locator('[role="dialog"]').filter({ has: phone }).isVisible()) {
|
|
2289
|
+
await (await dialogConfirm(page, "[data-zappie-field-confirm]"))?.click();
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
1722
2292
|
await page.waitForFunction(
|
|
1723
2293
|
() => !document.querySelector("[data-zappie-checkout]")?.disabled
|
|
1724
2294
|
);
|
|
@@ -1738,7 +2308,7 @@ var v14 = {
|
|
|
1738
2308
|
viewports: "mobile",
|
|
1739
2309
|
async run({ page, metrics, fault, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
|
|
1740
2310
|
await page.setViewportSize(viewport);
|
|
1741
|
-
const cdp = await applyPhoneProfile(page, cpuThrottlingRate2);
|
|
2311
|
+
const { cdp } = await applyPhoneProfile(page, cpuThrottlingRate2);
|
|
1742
2312
|
await page.addInitScript(() => {
|
|
1743
2313
|
const state = { lcp: 0, cls: 0 };
|
|
1744
2314
|
Object.assign(window, { __zappiePerformance: state });
|
|
@@ -1820,7 +2390,7 @@ var v15 = {
|
|
|
1820
2390
|
viewports: "mobile",
|
|
1821
2391
|
async run({ page, metrics, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
|
|
1822
2392
|
await page.setViewportSize(viewport);
|
|
1823
|
-
const cdp = await applyPhoneProfile(page, cpuThrottlingRate2);
|
|
2393
|
+
const { cdp } = await applyPhoneProfile(page, cpuThrottlingRate2);
|
|
1824
2394
|
try {
|
|
1825
2395
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
1826
2396
|
await cdp.send("Network.setCacheDisabled", { cacheDisabled: false });
|
|
@@ -2101,6 +2671,41 @@ async function openSettledMenu(page) {
|
|
|
2101
2671
|
network.stop();
|
|
2102
2672
|
}
|
|
2103
2673
|
}
|
|
2674
|
+
async function revealBagTrigger(page, itemId) {
|
|
2675
|
+
const bag = page.locator(`${triggerSelector("bag")}:visible`).first();
|
|
2676
|
+
if (!itemId || await bag.count()) {
|
|
2677
|
+
return;
|
|
2678
|
+
}
|
|
2679
|
+
const before = await page.evaluate(() => window.__zappie?.cart().count ?? 0).catch(() => 0);
|
|
2680
|
+
const add = '[data-zappie-action="add-to-cart"]';
|
|
2681
|
+
const quick = page.locator(`[data-zappie-item="${itemId}"] ${add}`).filter({ visible: true }).first();
|
|
2682
|
+
if (await quick.count()) {
|
|
2683
|
+
await quick.click();
|
|
2684
|
+
} else {
|
|
2685
|
+
const opener = page.locator(itemOpenerSelector(itemId)).filter({ visible: true }).first();
|
|
2686
|
+
if (!await opener.count()) {
|
|
2687
|
+
return;
|
|
2688
|
+
}
|
|
2689
|
+
await opener.click();
|
|
2690
|
+
const inItem = page.locator(
|
|
2691
|
+
`[role="dialog"] [data-zappie-item] ${add}:visible, main [data-zappie-item] ${add}:visible`
|
|
2692
|
+
).first();
|
|
2693
|
+
if (!await inItem.waitFor({ timeout: 5e3 }).then(
|
|
2694
|
+
() => true,
|
|
2695
|
+
() => false
|
|
2696
|
+
)) {
|
|
2697
|
+
return;
|
|
2698
|
+
}
|
|
2699
|
+
await inItem.click();
|
|
2700
|
+
}
|
|
2701
|
+
await page.waitForFunction(
|
|
2702
|
+
(count) => (window.__zappie?.cart().count ?? 0) > count,
|
|
2703
|
+
before,
|
|
2704
|
+
{ timeout: 5e3 }
|
|
2705
|
+
).catch(() => void 0);
|
|
2706
|
+
await openSettledMenu(page);
|
|
2707
|
+
await bag.waitFor({ timeout: 5e3 }).catch(() => void 0);
|
|
2708
|
+
}
|
|
2104
2709
|
|
|
2105
2710
|
// src/checks/v16-overlays-open-quickly.ts
|
|
2106
2711
|
var OVERLAY_OPEN_BUDGET_MS = 100;
|
|
@@ -2110,64 +2715,89 @@ var median = (values) => {
|
|
|
2110
2715
|
const sorted = [...values].sort((a, b) => a - b);
|
|
2111
2716
|
return sorted[Math.floor(sorted.length / 2)];
|
|
2112
2717
|
};
|
|
2718
|
+
async function firstOpen(fresh, viewport, name, cpuThrottlingRate2, itemId) {
|
|
2719
|
+
const { page } = fresh;
|
|
2720
|
+
try {
|
|
2721
|
+
await page.setViewportSize(viewport);
|
|
2722
|
+
const { cdp, rate } = await applyPhoneProfile(page, cpuThrottlingRate2);
|
|
2723
|
+
try {
|
|
2724
|
+
await openSettledMenu(page);
|
|
2725
|
+
if (name === "bag") {
|
|
2726
|
+
await revealBagTrigger(page, itemId);
|
|
2727
|
+
}
|
|
2728
|
+
const trigger = page.locator(`${triggerSelector(name)}:visible`).first();
|
|
2729
|
+
await trigger.evaluate(
|
|
2730
|
+
(node) => node.scrollIntoView({ block: "center", inline: "nearest" })
|
|
2731
|
+
);
|
|
2732
|
+
await armOverlayTimer(page, name, OPEN_TIMEOUT_MS);
|
|
2733
|
+
await trigger.click({ noWaitAfter: true });
|
|
2734
|
+
const ms = await readOverlayTimer(page);
|
|
2735
|
+
return ms === void 0 ? void 0 : { ms, rate };
|
|
2736
|
+
} finally {
|
|
2737
|
+
await cdp.detach().catch(() => void 0);
|
|
2738
|
+
}
|
|
2739
|
+
} finally {
|
|
2740
|
+
await fresh.close();
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2113
2743
|
var v16 = {
|
|
2114
2744
|
id: "V16",
|
|
2115
2745
|
title: "Overlays open quickly",
|
|
2116
2746
|
viewports: "mobile",
|
|
2117
|
-
async run({ page, metrics, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
|
|
2747
|
+
async run({ page, options, metrics, freshPage, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
|
|
2118
2748
|
await page.setViewportSize(viewport);
|
|
2119
|
-
const
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
await
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
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);
|
|
2749
|
+
const itemId = simpleItem(await readMenu(options))?.id;
|
|
2750
|
+
await openSettledMenu(page);
|
|
2751
|
+
await revealBagTrigger(page, itemId);
|
|
2752
|
+
const names = (await overlayTriggers(page)).map(({ name }) => name);
|
|
2753
|
+
skipWithoutTriggers(names);
|
|
2754
|
+
const timings = {};
|
|
2755
|
+
const samples = {};
|
|
2756
|
+
const rates = {};
|
|
2757
|
+
const unopened = [];
|
|
2758
|
+
for (const name of names) {
|
|
2759
|
+
const opens = [];
|
|
2760
|
+
const opensRates = [];
|
|
2761
|
+
for (let sample = 0; sample < OVERLAY_OPEN_SAMPLES; sample++) {
|
|
2762
|
+
const open2 = await firstOpen(
|
|
2763
|
+
await freshPage(),
|
|
2764
|
+
viewport,
|
|
2765
|
+
name,
|
|
2766
|
+
cpuThrottlingRate2,
|
|
2767
|
+
itemId
|
|
2768
|
+
);
|
|
2769
|
+
if (open2 === void 0) {
|
|
2770
|
+
break;
|
|
2149
2771
|
}
|
|
2772
|
+
opens.push(Math.round(open2.ms));
|
|
2773
|
+
opensRates.push(open2.rate);
|
|
2774
|
+
}
|
|
2775
|
+
if (opens.length < OVERLAY_OPEN_SAMPLES) {
|
|
2776
|
+
unopened.push(name);
|
|
2777
|
+
} else {
|
|
2778
|
+
samples[name] = opens;
|
|
2779
|
+
rates[name] = opensRates;
|
|
2780
|
+
timings[name] = median(opens);
|
|
2150
2781
|
}
|
|
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
2782
|
}
|
|
2783
|
+
metrics.overlayOpenMs = timings;
|
|
2784
|
+
metrics.overlayOpenSamplesMs = samples;
|
|
2785
|
+
metrics.overlayOpenRates = rates;
|
|
2786
|
+
assertion(
|
|
2787
|
+
unopened.length === 0,
|
|
2788
|
+
"overlay_not_opened",
|
|
2789
|
+
`No data-zappie-overlay painted after tapping ${unopened.map((name) => `the ${name} trigger`).join(", ")}`,
|
|
2790
|
+
`Put data-zappie-overlay with the trigger's name on the role="dialog" element that the trigger opens.`
|
|
2791
|
+
);
|
|
2792
|
+
const slow = Object.entries(timings).filter(
|
|
2793
|
+
([, ms]) => ms > OVERLAY_OPEN_BUDGET_MS
|
|
2794
|
+
);
|
|
2795
|
+
assertion(
|
|
2796
|
+
slow.length === 0,
|
|
2797
|
+
"overlay_open_too_slow",
|
|
2798
|
+
`Overlays took longer than ${OVERLAY_OPEN_BUDGET_MS} ms to paint, as the median of ${OVERLAY_OPEN_SAMPLES} first opens: ${slow.map(([name, ms]) => `${name} ${ms} ms (${samples[name].join(", ")} ms)`).join(", ")}`,
|
|
2799
|
+
"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."
|
|
2800
|
+
);
|
|
2171
2801
|
}
|
|
2172
2802
|
};
|
|
2173
2803
|
|
|
@@ -2201,8 +2831,11 @@ var scrollLocked = (page) => page.evaluate(() => {
|
|
|
2201
2831
|
};
|
|
2202
2832
|
return held(document.documentElement) || held(document.body);
|
|
2203
2833
|
});
|
|
2204
|
-
async function checkOverlay(page, name) {
|
|
2834
|
+
async function checkOverlay(page, name, itemId) {
|
|
2205
2835
|
await openSettledMenu(page);
|
|
2836
|
+
if (name === "bag") {
|
|
2837
|
+
await revealBagTrigger(page, itemId);
|
|
2838
|
+
}
|
|
2206
2839
|
const trigger = page.locator(`${triggerSelector(name)}:visible`).first();
|
|
2207
2840
|
await trigger.focus();
|
|
2208
2841
|
await page.keyboard.press("Enter");
|
|
@@ -2280,15 +2913,26 @@ var v17 = {
|
|
|
2280
2913
|
id: "V17",
|
|
2281
2914
|
title: "Overlays manage focus",
|
|
2282
2915
|
viewports: "mobile",
|
|
2283
|
-
async run({ page }, viewport) {
|
|
2916
|
+
async run({ page, options, freshPage }, viewport) {
|
|
2284
2917
|
await page.setViewportSize(viewport);
|
|
2918
|
+
const itemId = simpleItem(await readMenu(options))?.id;
|
|
2285
2919
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
2286
2920
|
await page.locator("[data-zappie-item]").first().waitFor();
|
|
2921
|
+
if (!await page.locator(`${triggerSelector("bag")}:visible`).count()) {
|
|
2922
|
+
await openSettledMenu(page);
|
|
2923
|
+
await revealBagTrigger(page, itemId);
|
|
2924
|
+
}
|
|
2287
2925
|
const names = (await overlayTriggers(page)).map(({ name }) => name);
|
|
2288
2926
|
skipWithoutTriggers(names);
|
|
2289
2927
|
const findings = [];
|
|
2290
2928
|
for (const name of names) {
|
|
2291
|
-
|
|
2929
|
+
const fresh = await freshPage();
|
|
2930
|
+
try {
|
|
2931
|
+
await fresh.page.setViewportSize(viewport);
|
|
2932
|
+
findings.push(...await checkOverlay(fresh.page, name, itemId));
|
|
2933
|
+
} finally {
|
|
2934
|
+
await fresh.close();
|
|
2935
|
+
}
|
|
2292
2936
|
}
|
|
2293
2937
|
const [first] = findings;
|
|
2294
2938
|
if (first) {
|
|
@@ -2362,60 +3006,6 @@ function skippedChecks(suite) {
|
|
|
2362
3006
|
];
|
|
2363
3007
|
}
|
|
2364
3008
|
|
|
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
|
-
|
|
2419
3009
|
// src/report.ts
|
|
2420
3010
|
import { createPrivateKey, randomUUID, sign } from "crypto";
|
|
2421
3011
|
var REPORT_SCHEMA = "https://registry.zappie.ai/v1/schema/verify-report.json";
|
|
@@ -2502,7 +3092,7 @@ async function reset(options) {
|
|
|
2502
3092
|
}
|
|
2503
3093
|
|
|
2504
3094
|
// src/version.ts
|
|
2505
|
-
var PACKAGE_VERSION = true ? "0.2.
|
|
3095
|
+
var PACKAGE_VERSION = true ? "0.2.4" : "0.0.0-development";
|
|
2506
3096
|
|
|
2507
3097
|
// src/runner.ts
|
|
2508
3098
|
var VERSION = PACKAGE_VERSION;
|
|
@@ -2583,18 +3173,7 @@ async function execute(check, run2) {
|
|
|
2583
3173
|
}
|
|
2584
3174
|
}
|
|
2585
3175
|
var label = (viewport) => `${viewport.width}x${viewport.height}`;
|
|
2586
|
-
async function
|
|
2587
|
-
if (!/^pk_test_.+/.test(options.key)) {
|
|
2588
|
-
throw new VerifyError(
|
|
2589
|
-
"live_key_refused",
|
|
2590
|
-
"Verify accepts only pk_test_ keys",
|
|
2591
|
-
"Create a test publishable key and rerun verify."
|
|
2592
|
-
);
|
|
2593
|
-
}
|
|
2594
|
-
const started = Date.now();
|
|
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);
|
|
3176
|
+
async function openContext(browser, options) {
|
|
2598
3177
|
const context = await browser.newContext(browserOptions(options));
|
|
2599
3178
|
if (options.standInForPlatform) {
|
|
2600
3179
|
await context.route(
|
|
@@ -2617,10 +3196,29 @@ async function runVerify(options, fault) {
|
|
|
2617
3196
|
}
|
|
2618
3197
|
);
|
|
2619
3198
|
}
|
|
2620
|
-
|
|
3199
|
+
return context;
|
|
3200
|
+
}
|
|
3201
|
+
async function preparePage(context, options, fault) {
|
|
2621
3202
|
const page = await context.newPage();
|
|
2622
3203
|
page.setDefaultTimeout(Math.min(options.timeoutMs ?? 2e4, 2e4));
|
|
2623
3204
|
await installFault(page, fault);
|
|
3205
|
+
return page;
|
|
3206
|
+
}
|
|
3207
|
+
async function runVerify(options, fault) {
|
|
3208
|
+
if (!/^pk_test_.+/.test(options.key)) {
|
|
3209
|
+
throw new VerifyError(
|
|
3210
|
+
"live_key_refused",
|
|
3211
|
+
"Verify accepts only pk_test_ keys",
|
|
3212
|
+
"Create a test publishable key and rerun verify."
|
|
3213
|
+
);
|
|
3214
|
+
}
|
|
3215
|
+
const started = Date.now();
|
|
3216
|
+
const browser = await chromium.launch();
|
|
3217
|
+
const cpuBenchmark = await benchmarkCpu(browser).catch(() => void 0);
|
|
3218
|
+
const throttlingRate = cpuBenchmark === void 0 ? REFERENCE_CPU_THROTTLING_RATE : cpuThrottlingRate(cpuBenchmark);
|
|
3219
|
+
const context = await openContext(browser, options);
|
|
3220
|
+
await context.tracing.start({ screenshots: true, snapshots: true });
|
|
3221
|
+
const page = await preparePage(context, options, fault);
|
|
2624
3222
|
const metrics = {};
|
|
2625
3223
|
const run2 = {
|
|
2626
3224
|
options,
|
|
@@ -2629,7 +3227,14 @@ async function runVerify(options, fault) {
|
|
|
2629
3227
|
page,
|
|
2630
3228
|
metrics,
|
|
2631
3229
|
cpuThrottlingRate: throttlingRate,
|
|
2632
|
-
...fault ? { fault } : {}
|
|
3230
|
+
...fault ? { fault } : {},
|
|
3231
|
+
async freshPage() {
|
|
3232
|
+
const fresh = await openContext(browser, options);
|
|
3233
|
+
return {
|
|
3234
|
+
page: await preparePage(fresh, options, fault),
|
|
3235
|
+
close: () => fresh.close()
|
|
3236
|
+
};
|
|
3237
|
+
}
|
|
2633
3238
|
};
|
|
2634
3239
|
const results = [await reset(options)];
|
|
2635
3240
|
try {
|