@zappie/verify 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/README.md +26 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +772 -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,9 @@ 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
|
+
const benchmark = await benchmarkPage(page).catch(() => void 0);
|
|
353
|
+
const rate = benchmark === void 0 ? fallbackRate : cpuThrottlingRate(benchmark);
|
|
293
354
|
const cdp = await page.context().newCDPSession(page);
|
|
294
355
|
await cdp.send("Network.enable");
|
|
295
356
|
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
|
|
@@ -301,7 +362,143 @@ async function applyPhoneProfile(page, rate) {
|
|
|
301
362
|
connectionType: "cellular3g"
|
|
302
363
|
});
|
|
303
364
|
await cdp.send("Emulation.setCPUThrottlingRate", { rate });
|
|
304
|
-
return cdp;
|
|
365
|
+
return { cdp, rate };
|
|
366
|
+
}
|
|
367
|
+
var ITEM_DIALOG = '[data-zappie-overlay="item"], [role="dialog"]';
|
|
368
|
+
async function itemOpener(page, itemId) {
|
|
369
|
+
const marked = `[data-zappie-item="${itemId}"]`;
|
|
370
|
+
await page.locator(marked).first().waitFor();
|
|
371
|
+
const own = page.locator(
|
|
372
|
+
[
|
|
373
|
+
`a[href]${marked}`,
|
|
374
|
+
`button${marked}`,
|
|
375
|
+
`[role="link"]${marked}`,
|
|
376
|
+
`[role="button"]${marked}`
|
|
377
|
+
].join(", ")
|
|
378
|
+
).filter({ visible: true }).first();
|
|
379
|
+
if (await own.count()) {
|
|
380
|
+
return { opener: own, focusTarget: own };
|
|
381
|
+
}
|
|
382
|
+
const card = page.locator(marked).filter({ visible: true }).first();
|
|
383
|
+
const inner = card.locator('a[href], [role="link"]').first();
|
|
384
|
+
if (await inner.count()) {
|
|
385
|
+
return { opener: inner, focusTarget: card };
|
|
386
|
+
}
|
|
387
|
+
const cell = card.locator("xpath=..");
|
|
388
|
+
return {
|
|
389
|
+
opener: cell.locator('a[href], [role="link"]').first(),
|
|
390
|
+
focusTarget: cell
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
async function openItem(page, itemId) {
|
|
394
|
+
const found = await itemOpener(page, itemId);
|
|
395
|
+
await found.opener.click();
|
|
396
|
+
await itemShown(page);
|
|
397
|
+
return found;
|
|
398
|
+
}
|
|
399
|
+
async function itemShown(page) {
|
|
400
|
+
await Promise.race([
|
|
401
|
+
page.waitForURL((url) => /\/item\//.test(url.pathname)),
|
|
402
|
+
page.locator(ITEM_DIALOG).filter({ visible: true }).first().waitFor()
|
|
403
|
+
]);
|
|
404
|
+
}
|
|
405
|
+
async function closeItem(page) {
|
|
406
|
+
const dialog = page.locator(ITEM_DIALOG).filter({ visible: true }).last();
|
|
407
|
+
if (!await dialog.count()) {
|
|
408
|
+
await page.goBack();
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const close = dialog.getByRole("button", { name: /close/i }).filter({ visible: true }).first();
|
|
412
|
+
if (await close.count()) {
|
|
413
|
+
await close.click();
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const outside = page.getByRole("button", { name: /close/i }).filter({ visible: true }).last();
|
|
417
|
+
if (await outside.count()) {
|
|
418
|
+
await outside.click();
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
await page.keyboard.press("Escape");
|
|
422
|
+
}
|
|
423
|
+
function itemOpenerSelector(itemId) {
|
|
424
|
+
const marked = itemId ? `[data-zappie-item="${itemId}"]` : "[data-zappie-item]";
|
|
425
|
+
return [
|
|
426
|
+
`a[href]${marked}`,
|
|
427
|
+
`button${marked}`,
|
|
428
|
+
`[role="link"]${marked}`,
|
|
429
|
+
`[role="button"]${marked}`,
|
|
430
|
+
`${marked} a[href]`
|
|
431
|
+
].join(", ");
|
|
432
|
+
}
|
|
433
|
+
async function cartTriggerSelector(page) {
|
|
434
|
+
return await page.locator("[data-zappie-cart-trigger]").count() ? "[data-zappie-cart-trigger]" : '[data-zappie-overlay-trigger="bag"]';
|
|
435
|
+
}
|
|
436
|
+
async function cartTrigger(page) {
|
|
437
|
+
return page.locator(await cartTriggerSelector(page)).filter({ visible: true }).first();
|
|
438
|
+
}
|
|
439
|
+
async function tabTo(page, selector, presses = 60) {
|
|
440
|
+
for (let index = 0; index < presses; index++) {
|
|
441
|
+
await page.keyboard.press("Tab");
|
|
442
|
+
const inside = await page.evaluate(
|
|
443
|
+
(value) => Boolean(document.activeElement?.closest(value)),
|
|
444
|
+
selector
|
|
445
|
+
);
|
|
446
|
+
if (inside) {
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
async function signIn(page, site = page.url()) {
|
|
453
|
+
await page.context().addCookies([
|
|
454
|
+
{
|
|
455
|
+
name: "zappie_session",
|
|
456
|
+
value: "signed",
|
|
457
|
+
domain: new URL(site).hostname,
|
|
458
|
+
path: "/"
|
|
459
|
+
}
|
|
460
|
+
]);
|
|
461
|
+
}
|
|
462
|
+
async function revealField(page, field, input, reach = async () => {
|
|
463
|
+
}) {
|
|
464
|
+
const site = new URL(page.url());
|
|
465
|
+
const opener = page.locator(`[data-zappie-field-open="${field}"]`).filter({ visible: true }).first();
|
|
466
|
+
const shown = input.filter({ visible: true }).first();
|
|
467
|
+
await shown.or(opener).first().waitFor();
|
|
468
|
+
if (await shown.isVisible()) {
|
|
469
|
+
return shown;
|
|
470
|
+
}
|
|
471
|
+
await opener.click();
|
|
472
|
+
if (await sentToSignIn(page, site, shown)) {
|
|
473
|
+
await signIn(page, site.href);
|
|
474
|
+
await open(page, site.pathname);
|
|
475
|
+
await reach();
|
|
476
|
+
await opener.click();
|
|
477
|
+
}
|
|
478
|
+
await shown.waitFor();
|
|
479
|
+
return shown;
|
|
480
|
+
}
|
|
481
|
+
async function sentToSignIn(page, site, shown) {
|
|
482
|
+
const deadline = Date.now() + 1e4;
|
|
483
|
+
while (Date.now() < deadline) {
|
|
484
|
+
const url = new URL(page.url());
|
|
485
|
+
if (url.origin !== site.origin || url.pathname === "/_zappie/login") {
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
if (await shown.isVisible().catch(() => false)) {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
await page.waitForTimeout(100);
|
|
492
|
+
}
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
async function dialogConfirm(page, confirm) {
|
|
496
|
+
const marked = page.locator(confirm).filter({ visible: true }).first();
|
|
497
|
+
if (await marked.count()) {
|
|
498
|
+
return marked;
|
|
499
|
+
}
|
|
500
|
+
const button = page.locator('[role="dialog"]').filter({ visible: true }).last().locator('footer button:enabled, button[type="submit"]:enabled').filter({ visible: true }).last();
|
|
501
|
+
return await button.count() ? button : void 0;
|
|
305
502
|
}
|
|
306
503
|
|
|
307
504
|
// src/checks/v01-routes.ts
|
|
@@ -373,6 +570,7 @@ var v02 = {
|
|
|
373
570
|
};
|
|
374
571
|
|
|
375
572
|
// src/fixtures.ts
|
|
573
|
+
var preparedPages = /* @__PURE__ */ new WeakMap();
|
|
376
574
|
async function installFault(page, fault) {
|
|
377
575
|
if (!fault) {
|
|
378
576
|
return;
|
|
@@ -408,6 +606,179 @@ async function installFault(page, fault) {
|
|
|
408
606
|
}).observe(document, { childList: true, subtree: true });
|
|
409
607
|
});
|
|
410
608
|
}
|
|
609
|
+
if (fault === "card-link") {
|
|
610
|
+
await page.addInitScript(() => {
|
|
611
|
+
new MutationObserver(() => {
|
|
612
|
+
for (const node of document.querySelectorAll(
|
|
613
|
+
'[role="dialog"] [aria-label="Close item"]'
|
|
614
|
+
)) {
|
|
615
|
+
node.setAttribute("aria-label", "Close");
|
|
616
|
+
}
|
|
617
|
+
}).observe(document, {
|
|
618
|
+
childList: true,
|
|
619
|
+
subtree: true,
|
|
620
|
+
attributes: true,
|
|
621
|
+
attributeFilter: ["aria-label"]
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
if (fault === "no-add-marker") {
|
|
626
|
+
await page.addInitScript(() => {
|
|
627
|
+
new MutationObserver(() => {
|
|
628
|
+
for (const node of document.querySelectorAll(
|
|
629
|
+
'[data-zappie-action="add-to-cart"]'
|
|
630
|
+
)) {
|
|
631
|
+
node.removeAttribute("data-zappie-action");
|
|
632
|
+
}
|
|
633
|
+
}).observe(document, { childList: true, subtree: true });
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
if (fault === "add-enabled-invalid" || fault === "add-enabled-silent") {
|
|
637
|
+
await page.addInitScript((flags) => {
|
|
638
|
+
new MutationObserver(() => {
|
|
639
|
+
for (const original of document.querySelectorAll(
|
|
640
|
+
'[role="dialog"] button[data-zappie-action="add-to-cart"]:not([data-verify-proxied])'
|
|
641
|
+
)) {
|
|
642
|
+
original.setAttribute("data-verify-proxied", "");
|
|
643
|
+
original.removeAttribute("data-zappie-action");
|
|
644
|
+
original.style.display = "none";
|
|
645
|
+
const proxy = document.createElement("button");
|
|
646
|
+
proxy.type = "button";
|
|
647
|
+
proxy.textContent = "Add";
|
|
648
|
+
proxy.setAttribute("data-zappie-action", "add-to-cart");
|
|
649
|
+
proxy.setAttribute("data-verify-proxied", "");
|
|
650
|
+
proxy.addEventListener("click", () => {
|
|
651
|
+
if (!original.disabled) {
|
|
652
|
+
original.click();
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const group = original.closest('[role="dialog"]')?.querySelector(
|
|
656
|
+
'[data-zappie-option-group][aria-required="true"]:not(:has([data-state="checked"]))'
|
|
657
|
+
);
|
|
658
|
+
if (flags && group) {
|
|
659
|
+
group.setAttribute("aria-invalid", "true");
|
|
660
|
+
group.setAttribute("data-invalid", "");
|
|
661
|
+
const message = document.createElement("p");
|
|
662
|
+
message.textContent = "Choose one to continue";
|
|
663
|
+
group.append(message);
|
|
664
|
+
group.focus();
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
original.after(proxy);
|
|
668
|
+
}
|
|
669
|
+
}).observe(document, { childList: true, subtree: true });
|
|
670
|
+
}, fault === "add-enabled-invalid");
|
|
671
|
+
}
|
|
672
|
+
if (fault === "total-on-checkout" || fault === "total-missing") {
|
|
673
|
+
await page.addInitScript((onCheckout) => {
|
|
674
|
+
window.addEventListener(
|
|
675
|
+
"click",
|
|
676
|
+
(event) => {
|
|
677
|
+
const link = event.target?.closest("a[data-zappie-cart-checkout]");
|
|
678
|
+
if (!link) {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
event.preventDefault();
|
|
682
|
+
event.stopImmediatePropagation();
|
|
683
|
+
history.pushState(history.state, "", link.href);
|
|
684
|
+
window.dispatchEvent(
|
|
685
|
+
new PopStateEvent("popstate", { state: history.state })
|
|
686
|
+
);
|
|
687
|
+
},
|
|
688
|
+
{ capture: true }
|
|
689
|
+
);
|
|
690
|
+
new MutationObserver(() => {
|
|
691
|
+
for (const node of document.querySelectorAll(
|
|
692
|
+
'[data-zappie-overlay="bag"] [data-zappie-total]'
|
|
693
|
+
)) {
|
|
694
|
+
sessionStorage.setItem("verify-total", node.textContent ?? "");
|
|
695
|
+
node.removeAttribute("data-zappie-total");
|
|
696
|
+
}
|
|
697
|
+
const text = sessionStorage.getItem("verify-total");
|
|
698
|
+
if (onCheckout && text && location.pathname === "/checkout" && document.querySelector("main") && !document.querySelector("[data-verify-total]")) {
|
|
699
|
+
const total = document.createElement("p");
|
|
700
|
+
total.setAttribute("data-verify-total", "");
|
|
701
|
+
total.setAttribute("data-zappie-total", "");
|
|
702
|
+
total.textContent = text;
|
|
703
|
+
document.querySelector("main").prepend(total);
|
|
704
|
+
}
|
|
705
|
+
}).observe(document, { childList: true, subtree: true });
|
|
706
|
+
}, fault === "total-on-checkout");
|
|
707
|
+
}
|
|
708
|
+
if (fault === "address-confirm" || fault === "address-not-stored") {
|
|
709
|
+
await page.addInitScript((confirms) => {
|
|
710
|
+
let release = false;
|
|
711
|
+
window.addEventListener(
|
|
712
|
+
"click",
|
|
713
|
+
(event) => {
|
|
714
|
+
const option = event.target?.closest(
|
|
715
|
+
'[role="option"]'
|
|
716
|
+
);
|
|
717
|
+
if (!option || release) {
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
event.stopImmediatePropagation();
|
|
721
|
+
event.preventDefault();
|
|
722
|
+
if (!confirms) {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const dialog = document.createElement("div");
|
|
726
|
+
dialog.setAttribute("role", "dialog");
|
|
727
|
+
dialog.setAttribute("aria-label", "Confirm address");
|
|
728
|
+
const footer = document.createElement("footer");
|
|
729
|
+
const button = document.createElement("button");
|
|
730
|
+
button.type = "button";
|
|
731
|
+
button.textContent = "Deliver here";
|
|
732
|
+
button.addEventListener("click", () => {
|
|
733
|
+
dialog.remove();
|
|
734
|
+
release = true;
|
|
735
|
+
option.click();
|
|
736
|
+
release = false;
|
|
737
|
+
});
|
|
738
|
+
footer.append(button);
|
|
739
|
+
dialog.append(footer);
|
|
740
|
+
document.body.append(dialog);
|
|
741
|
+
},
|
|
742
|
+
{ capture: true }
|
|
743
|
+
);
|
|
744
|
+
}, fault === "address-confirm");
|
|
745
|
+
}
|
|
746
|
+
if (fault === "field-rows") {
|
|
747
|
+
await page.addInitScript(() => {
|
|
748
|
+
const fields = [
|
|
749
|
+
["address", "[data-zappie-address-input]"],
|
|
750
|
+
["phone", 'input[type="tel"]']
|
|
751
|
+
];
|
|
752
|
+
new MutationObserver(() => {
|
|
753
|
+
for (const [field, selector] of fields) {
|
|
754
|
+
for (const input of document.querySelectorAll(
|
|
755
|
+
`${selector}:not([data-verify-row])`
|
|
756
|
+
)) {
|
|
757
|
+
input.setAttribute("data-verify-row", "");
|
|
758
|
+
input.hidden = true;
|
|
759
|
+
const row = document.createElement("button");
|
|
760
|
+
row.type = "button";
|
|
761
|
+
row.textContent = `Add your ${field}`;
|
|
762
|
+
row.setAttribute("data-zappie-field-open", field);
|
|
763
|
+
row.addEventListener("click", () => {
|
|
764
|
+
if (!document.cookie.includes("zappie_session=signed")) {
|
|
765
|
+
window.location.assign(
|
|
766
|
+
new URL(
|
|
767
|
+
`/_zappie/login?return=${encodeURIComponent(location.pathname)}`,
|
|
768
|
+
location.href
|
|
769
|
+
).href
|
|
770
|
+
);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
row.remove();
|
|
774
|
+
input.hidden = false;
|
|
775
|
+
});
|
|
776
|
+
input.before(row);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}).observe(document, { childList: true, subtree: true });
|
|
780
|
+
});
|
|
781
|
+
}
|
|
411
782
|
if (fault === "overlay-markers") {
|
|
412
783
|
await page.addInitScript(() => {
|
|
413
784
|
new MutationObserver(() => {
|
|
@@ -426,7 +797,32 @@ async function installFault(page, fault) {
|
|
|
426
797
|
}).observe(document, { childList: true, subtree: true });
|
|
427
798
|
});
|
|
428
799
|
}
|
|
429
|
-
if (fault === "bag-on-tap") {
|
|
800
|
+
if (fault === "bag-after-add" || fault === "bag-after-add-on-tap") {
|
|
801
|
+
await page.addInitScript(() => {
|
|
802
|
+
const sync = () => {
|
|
803
|
+
let count = 0;
|
|
804
|
+
try {
|
|
805
|
+
count = window.__zappie?.cart().count ?? 0;
|
|
806
|
+
} catch {
|
|
807
|
+
}
|
|
808
|
+
for (const node of document.querySelectorAll(
|
|
809
|
+
'[data-zappie-overlay-trigger="bag"]'
|
|
810
|
+
)) {
|
|
811
|
+
if (count === 0) {
|
|
812
|
+
node.style.setProperty("display", "none", "important");
|
|
813
|
+
} else {
|
|
814
|
+
node.style.removeProperty("display");
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
new MutationObserver(sync).observe(document, {
|
|
819
|
+
childList: true,
|
|
820
|
+
subtree: true
|
|
821
|
+
});
|
|
822
|
+
window.setInterval(sync, 50);
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
if (fault === "bag-on-tap" || fault === "bag-after-add-on-tap") {
|
|
430
826
|
let release = () => {
|
|
431
827
|
};
|
|
432
828
|
let tapped = Promise.resolve();
|
|
@@ -462,24 +858,26 @@ async function installFault(page, fault) {
|
|
|
462
858
|
);
|
|
463
859
|
}
|
|
464
860
|
if (fault === "one-slow-open") {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
861
|
+
const browser = page.context().browser();
|
|
862
|
+
const index = browser ? preparedPages.get(browser) ?? 0 : 0;
|
|
863
|
+
if (browser) {
|
|
864
|
+
preparedPages.set(browser, index + 1);
|
|
865
|
+
}
|
|
866
|
+
if (index > 0 && (index - 1) % 3 === 0) {
|
|
867
|
+
await page.addInitScript(() => {
|
|
868
|
+
document.addEventListener(
|
|
869
|
+
"click",
|
|
870
|
+
(event) => {
|
|
871
|
+
if (event.target instanceof Element && event.target.closest("[data-zappie-overlay-trigger]")) {
|
|
872
|
+
const until = performance.now() + 250;
|
|
873
|
+
while (performance.now() < until) {
|
|
874
|
+
}
|
|
477
875
|
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
);
|
|
482
|
-
}
|
|
876
|
+
},
|
|
877
|
+
{ capture: true }
|
|
878
|
+
);
|
|
879
|
+
});
|
|
880
|
+
}
|
|
483
881
|
}
|
|
484
882
|
if (fault === "late-hydration") {
|
|
485
883
|
await page.route(
|
|
@@ -643,6 +1041,22 @@ async function applyPageFault(page, fault) {
|
|
|
643
1041
|
}
|
|
644
1042
|
});
|
|
645
1043
|
}
|
|
1044
|
+
if (fault === "card-link") {
|
|
1045
|
+
await page.waitForFunction(() => {
|
|
1046
|
+
const link = document.querySelector("a[data-zappie-item]");
|
|
1047
|
+
return link !== null && Object.keys(link).some((key) => key.startsWith("__reactProps$"));
|
|
1048
|
+
});
|
|
1049
|
+
await page.locator("a[data-zappie-item]").evaluateAll((links) => {
|
|
1050
|
+
for (const link of links) {
|
|
1051
|
+
const id = link.getAttribute("data-zappie-item");
|
|
1052
|
+
const card = link.parentElement?.querySelector(
|
|
1053
|
+
`article[data-zappie-item="${id}"]`
|
|
1054
|
+
);
|
|
1055
|
+
link.textContent = card?.querySelector("h3")?.textContent ?? "Item";
|
|
1056
|
+
card?.removeAttribute("data-zappie-item");
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
646
1060
|
if (fault === "missing-marker") {
|
|
647
1061
|
await page.locator("[data-zappie-item]").evaluateAll((nodes) => {
|
|
648
1062
|
for (const node of nodes) {
|
|
@@ -878,11 +1292,11 @@ var v05 = {
|
|
|
878
1292
|
"The test menu has no available item with required options",
|
|
879
1293
|
"Keep one available configurable item in the test menu."
|
|
880
1294
|
);
|
|
881
|
-
|
|
882
|
-
await item.locator("xpath=..").getByRole("link", { name: "View details" }).click();
|
|
1295
|
+
await openItem(page, selected.id);
|
|
883
1296
|
const dialog = page.getByRole("dialog");
|
|
884
1297
|
await dialog.waitFor();
|
|
885
1298
|
const required = dialog.locator('[data-zappie-option-group][aria-required="true"]').first();
|
|
1299
|
+
await required.waitFor({ timeout: 5e3 }).catch(() => void 0);
|
|
886
1300
|
assertion(
|
|
887
1301
|
await required.count(),
|
|
888
1302
|
"required_group_missing",
|
|
@@ -891,23 +1305,41 @@ var v05 = {
|
|
|
891
1305
|
);
|
|
892
1306
|
const before = await debugValue(page, () => window.__zappie.cart().count);
|
|
893
1307
|
const add = dialog.locator('[data-zappie-action="add-to-cart"]');
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
1308
|
+
const unsatisfied = dialog.locator(
|
|
1309
|
+
'[data-zappie-option-group][aria-required="true"]:not(:has([data-state="checked"]))'
|
|
1310
|
+
);
|
|
1311
|
+
const blocked2 = await add.isDisabled();
|
|
1312
|
+
if (!blocked2) {
|
|
1313
|
+
const group = unsatisfied.first();
|
|
1314
|
+
const textBefore = (await group.innerText().catch(() => "")).trim();
|
|
1315
|
+
await add.click();
|
|
1316
|
+
await page.waitForTimeout(500);
|
|
1317
|
+
const added = await page.evaluate(
|
|
1318
|
+
(count) => window.__zappie.cart().count > count,
|
|
1319
|
+
before
|
|
1320
|
+
);
|
|
1321
|
+
assertion(
|
|
1322
|
+
!added && await dialog.isVisible(),
|
|
1323
|
+
"required_option_bypassed",
|
|
1324
|
+
"Add put the item in the cart before the shopper chose a required option",
|
|
1325
|
+
"Disable Add until the shopper completes every required option group, or keep the item open and flag the group when Add is tapped."
|
|
1326
|
+
);
|
|
1327
|
+
assertion(
|
|
1328
|
+
await group.evaluate(flagged, textBefore),
|
|
1329
|
+
"required_option_unflagged",
|
|
1330
|
+
"Tapping Add before a required choice did not flag the group",
|
|
1331
|
+
"Mark the group that needs a choice with aria-invalid or data-invalid and show a message saying why."
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
1334
|
+
for (let index = 0; index < 5 && (blocked2 ? await add.isDisabled() : await unsatisfied.count() > 0); index++) {
|
|
1335
|
+
const next = unsatisfied.first();
|
|
904
1336
|
assertion(
|
|
905
|
-
await
|
|
1337
|
+
await next.count(),
|
|
906
1338
|
"required_option_unselectable",
|
|
907
1339
|
"A required option group has no selectable choice",
|
|
908
1340
|
"Keep an available choice in every required option group."
|
|
909
1341
|
);
|
|
910
|
-
await
|
|
1342
|
+
await next.locator('[data-zappie-option]:not([aria-disabled="true"])').first().click();
|
|
911
1343
|
}
|
|
912
1344
|
assertion(
|
|
913
1345
|
await add.isEnabled(),
|
|
@@ -935,6 +1367,22 @@ var v05 = {
|
|
|
935
1367
|
);
|
|
936
1368
|
}
|
|
937
1369
|
};
|
|
1370
|
+
function flagged(group, textBefore) {
|
|
1371
|
+
const invalid = group.getAttribute("aria-invalid") === "true" || group.hasAttribute("data-invalid") || group.querySelector('[aria-invalid="true"], [data-invalid]') !== null;
|
|
1372
|
+
if (!invalid) {
|
|
1373
|
+
return false;
|
|
1374
|
+
}
|
|
1375
|
+
const shown = (element) => element instanceof HTMLElement && element.getClientRects().length > 0 && Boolean(element.innerText.trim());
|
|
1376
|
+
if (group instanceof HTMLElement && group.innerText.trim().length > textBefore.length) {
|
|
1377
|
+
return true;
|
|
1378
|
+
}
|
|
1379
|
+
const referenced = ["aria-describedby", "aria-errormessage"].flatMap((name) => group.getAttribute(name)?.split(/\s+/) ?? []).some((id) => shown(document.getElementById(id)));
|
|
1380
|
+
if (referenced) {
|
|
1381
|
+
return true;
|
|
1382
|
+
}
|
|
1383
|
+
const dialog = group.closest('[role="dialog"]') ?? document;
|
|
1384
|
+
return [...dialog.querySelectorAll('[role="alert"]')].some(shown);
|
|
1385
|
+
}
|
|
938
1386
|
|
|
939
1387
|
// src/checks/v06-quote.ts
|
|
940
1388
|
var v06 = {
|
|
@@ -946,9 +1394,20 @@ var v06 = {
|
|
|
946
1394
|
if (new URL(page.url()).pathname !== "/") {
|
|
947
1395
|
await open(page);
|
|
948
1396
|
}
|
|
949
|
-
const total = page.locator("[data-zappie-total]");
|
|
950
|
-
|
|
951
|
-
|
|
1397
|
+
const total = page.locator("[data-zappie-total]").filter({ visible: true });
|
|
1398
|
+
const bagOpen = page.locator('[data-zappie-overlay="bag"]').filter({ visible: true });
|
|
1399
|
+
if (!await total.count()) {
|
|
1400
|
+
if (!await bagOpen.count()) {
|
|
1401
|
+
await (await cartTrigger(page)).click();
|
|
1402
|
+
}
|
|
1403
|
+
await total.first().waitFor({ timeout: 2e3 }).catch(() => void 0);
|
|
1404
|
+
}
|
|
1405
|
+
if (!await total.count()) {
|
|
1406
|
+
const handover = page.locator('[data-zappie-overlay="bag"], [role="dialog"]').filter({ visible: true }).locator('[data-zappie-cart-checkout], a[href*="/checkout"]').filter({ visible: true }).first();
|
|
1407
|
+
if (await handover.count()) {
|
|
1408
|
+
await handover.click();
|
|
1409
|
+
await page.waitForURL((url) => url.pathname.endsWith("/checkout"));
|
|
1410
|
+
}
|
|
952
1411
|
}
|
|
953
1412
|
await page.waitForFunction(
|
|
954
1413
|
() => window.__zappie.events.some((entry) => entry.name === "quote"),
|
|
@@ -962,11 +1421,21 @@ var v06 = {
|
|
|
962
1421
|
"The cart did not produce a quote within 2 seconds",
|
|
963
1422
|
"Open the cart through the SDK and render Totals so it requests a quote."
|
|
964
1423
|
);
|
|
965
|
-
|
|
1424
|
+
let expected = format.money(quote.total);
|
|
1425
|
+
let rendered = null;
|
|
1426
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
1427
|
+
const latest = await page.evaluate(() => window.__zappie.quote());
|
|
1428
|
+
expected = format.money((latest ?? quote).total);
|
|
1429
|
+
rendered = await total.first().textContent({ timeout: 5e3 }).catch(() => null);
|
|
1430
|
+
if (rendered === null || rendered.includes(expected)) {
|
|
1431
|
+
break;
|
|
1432
|
+
}
|
|
1433
|
+
await page.waitForTimeout(250);
|
|
1434
|
+
}
|
|
966
1435
|
assertion(
|
|
967
|
-
rendered?.includes(
|
|
1436
|
+
rendered?.includes(expected),
|
|
968
1437
|
"total_mismatch",
|
|
969
|
-
`Rendered total does not match ${
|
|
1438
|
+
`Rendered total does not match ${expected}`,
|
|
970
1439
|
"Render quote.total with the SDK Money formatter."
|
|
971
1440
|
);
|
|
972
1441
|
}
|
|
@@ -1101,7 +1570,7 @@ var v07 = {
|
|
|
1101
1570
|
await window.__zappie.cart.inject({ lines: lines2 });
|
|
1102
1571
|
}, lines);
|
|
1103
1572
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
1104
|
-
await
|
|
1573
|
+
await (await cartTrigger(page)).click();
|
|
1105
1574
|
const codes = unavailable ? ["price_changed", "item_unavailable"] : ["price_changed"];
|
|
1106
1575
|
for (const code of codes) {
|
|
1107
1576
|
const issue = page.locator(`[data-zappie-issue="${code}"]`).first();
|
|
@@ -1424,14 +1893,28 @@ var v10 = {
|
|
|
1424
1893
|
"Render a delivery control with data-zappie-fulfilment=delivery."
|
|
1425
1894
|
);
|
|
1426
1895
|
await delivery.click();
|
|
1427
|
-
const input =
|
|
1428
|
-
|
|
1896
|
+
const input = await revealField(
|
|
1897
|
+
page,
|
|
1898
|
+
"address",
|
|
1899
|
+
page.locator("[data-zappie-address-input]"),
|
|
1900
|
+
() => delivery.click()
|
|
1901
|
+
);
|
|
1429
1902
|
await input.fill(ADDRESS);
|
|
1430
1903
|
await page.getByText(ADDRESS, { exact: true }).last().click();
|
|
1431
|
-
await page
|
|
1432
|
-
const
|
|
1433
|
-
|
|
1434
|
-
|
|
1904
|
+
if (!await addressStored(page, 3e3)) {
|
|
1905
|
+
const confirm = await dialogConfirm(
|
|
1906
|
+
page,
|
|
1907
|
+
"[data-zappie-address-confirm]"
|
|
1908
|
+
);
|
|
1909
|
+
assertion(
|
|
1910
|
+
confirm,
|
|
1911
|
+
"delivery_address_missing",
|
|
1912
|
+
"The selected address was not stored",
|
|
1913
|
+
"Store the resolved address on delivery fulfilment when the shopper picks it, or mark the control that confirms it with data-zappie-address-confirm."
|
|
1914
|
+
);
|
|
1915
|
+
await confirm.click();
|
|
1916
|
+
}
|
|
1917
|
+
await addressStored(page);
|
|
1435
1918
|
const fulfilment = await debugValue(
|
|
1436
1919
|
page,
|
|
1437
1920
|
() => window.__zappie.cart().fulfilment
|
|
@@ -1475,12 +1958,30 @@ var v10 = {
|
|
|
1475
1958
|
);
|
|
1476
1959
|
}
|
|
1477
1960
|
};
|
|
1961
|
+
async function addressStored(page, timeout) {
|
|
1962
|
+
return page.waitForFunction(
|
|
1963
|
+
() => {
|
|
1964
|
+
const fulfilment = window.__zappie.cart().fulfilment;
|
|
1965
|
+
return fulfilment?.type === "delivery" && Boolean(fulfilment.address.placeId);
|
|
1966
|
+
},
|
|
1967
|
+
void 0,
|
|
1968
|
+
timeout === void 0 ? {} : { timeout }
|
|
1969
|
+
).then(
|
|
1970
|
+
() => true,
|
|
1971
|
+
(error) => {
|
|
1972
|
+
if (timeout === void 0) {
|
|
1973
|
+
throw error;
|
|
1974
|
+
}
|
|
1975
|
+
return false;
|
|
1976
|
+
}
|
|
1977
|
+
);
|
|
1978
|
+
}
|
|
1478
1979
|
|
|
1479
1980
|
// src/checks/v13-accessibility.ts
|
|
1480
1981
|
import AxeBuilder from "@axe-core/playwright";
|
|
1481
1982
|
|
|
1482
1983
|
// src/checks/semantics.ts
|
|
1483
|
-
var TOUCH_TARGET_PX =
|
|
1984
|
+
var TOUCH_TARGET_PX = 24;
|
|
1484
1985
|
async function semanticFindings(page, scope) {
|
|
1485
1986
|
return page.evaluate(
|
|
1486
1987
|
({ scope: scope2, minimum }) => {
|
|
@@ -1583,11 +2084,28 @@ var semanticHints = {
|
|
|
1583
2084
|
h1_count: "Give each page exactly one h1 that names it.",
|
|
1584
2085
|
dialog_name_missing: "Name every dialog with aria-labelledby pointing at its title, or with aria-label.",
|
|
1585
2086
|
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
|
|
2087
|
+
touch_target_too_small: "Make every menu control at least 24\xD724 px on a phone, with padding or a larger hit area."
|
|
1587
2088
|
};
|
|
1588
2089
|
|
|
1589
2090
|
// src/checks/v13-accessibility.ts
|
|
1590
2091
|
async function severeViolations(page) {
|
|
2092
|
+
await page.evaluate(
|
|
2093
|
+
() => new Promise(
|
|
2094
|
+
(resolve) => requestAnimationFrame(
|
|
2095
|
+
() => requestAnimationFrame(() => requestAnimationFrame(resolve))
|
|
2096
|
+
)
|
|
2097
|
+
)
|
|
2098
|
+
);
|
|
2099
|
+
await page.evaluate(
|
|
2100
|
+
() => Promise.race([
|
|
2101
|
+
Promise.all(
|
|
2102
|
+
document.getAnimations().filter(
|
|
2103
|
+
(animation) => animation.effect?.getComputedTiming().iterations !== Infinity
|
|
2104
|
+
).map((animation) => animation.finished.catch(() => void 0))
|
|
2105
|
+
),
|
|
2106
|
+
new Promise((resolve) => setTimeout(resolve, 2e3))
|
|
2107
|
+
])
|
|
2108
|
+
);
|
|
1591
2109
|
const analysis = await new AxeBuilder({ page }).analyze();
|
|
1592
2110
|
return analysis.violations.filter(
|
|
1593
2111
|
(entry) => entry.impact === "critical" || entry.impact === "serious"
|
|
@@ -1621,11 +2139,29 @@ async function holdsFocus(target2) {
|
|
|
1621
2139
|
}
|
|
1622
2140
|
return false;
|
|
1623
2141
|
}
|
|
2142
|
+
var ADD = '[data-zappie-action="add-to-cart"]';
|
|
2143
|
+
async function keyboardReachesAddAndCart(page, itemId, cartInColumn) {
|
|
2144
|
+
const cart = cartInColumn ? [] : [await cartTriggerSelector(page)];
|
|
2145
|
+
if (await page.locator(ADD).count() || !itemId) {
|
|
2146
|
+
return controlsAreReachable(page, [ADD, ...cart]);
|
|
2147
|
+
}
|
|
2148
|
+
const opener = itemOpenerSelector(itemId);
|
|
2149
|
+
if (cart.length && !await controlsAreReachable(page, cart)) {
|
|
2150
|
+
return false;
|
|
2151
|
+
}
|
|
2152
|
+
await open(page);
|
|
2153
|
+
if (!await tabTo(page, opener, 200)) {
|
|
2154
|
+
return false;
|
|
2155
|
+
}
|
|
2156
|
+
await page.keyboard.press("Enter");
|
|
2157
|
+
await itemShown(page);
|
|
2158
|
+
return tabTo(page, ADD);
|
|
2159
|
+
}
|
|
1624
2160
|
var v13 = {
|
|
1625
2161
|
id: "V13",
|
|
1626
2162
|
title: "Accessibility",
|
|
1627
2163
|
viewports: "both",
|
|
1628
|
-
async run({ page,
|
|
2164
|
+
async run({ page, options, fault }, viewport) {
|
|
1629
2165
|
await page.setViewportSize(viewport);
|
|
1630
2166
|
await open(page);
|
|
1631
2167
|
await applyPageFault(page, fault);
|
|
@@ -1643,21 +2179,23 @@ var v13 = {
|
|
|
1643
2179
|
"No available item can be opened for accessibility checks",
|
|
1644
2180
|
"Keep one available item in the test menu."
|
|
1645
2181
|
);
|
|
1646
|
-
const card = page
|
|
1647
|
-
await card.getByRole("link", { name: "View details" }).click();
|
|
2182
|
+
const { focusTarget: card } = await openItem(page, item.id);
|
|
1648
2183
|
violations.push(...await severeViolations(page));
|
|
1649
2184
|
semantics.push(
|
|
1650
2185
|
...await semanticFindings(page, { page: false, phone: false })
|
|
1651
2186
|
);
|
|
1652
|
-
await page
|
|
2187
|
+
await closeItem(page);
|
|
1653
2188
|
if (!await holdsFocus(card)) {
|
|
1654
2189
|
semantics.push({
|
|
1655
2190
|
code: "focus_not_returned",
|
|
1656
2191
|
message: "Focus did not return to the item card after the item closed"
|
|
1657
2192
|
});
|
|
1658
2193
|
}
|
|
1659
|
-
const
|
|
1660
|
-
await
|
|
2194
|
+
const cart = await cartTrigger(page);
|
|
2195
|
+
const cartInColumn = viewport.width >= 768 && !await cart.isVisible();
|
|
2196
|
+
if (!cartInColumn) {
|
|
2197
|
+
await cart.click();
|
|
2198
|
+
}
|
|
1661
2199
|
violations.push(...await severeViolations(page));
|
|
1662
2200
|
semantics.push(
|
|
1663
2201
|
...await semanticFindings(page, { page: false, phone: false })
|
|
@@ -1671,8 +2209,10 @@ var v13 = {
|
|
|
1671
2209
|
message || "Accessibility violation",
|
|
1672
2210
|
"Fix every serious or critical axe violation in the menu, item dialog, cart and order page."
|
|
1673
2211
|
);
|
|
1674
|
-
|
|
1675
|
-
|
|
2212
|
+
if (!cartInColumn) {
|
|
2213
|
+
await page.keyboard.press("Escape");
|
|
2214
|
+
}
|
|
2215
|
+
if (!cartInColumn && !await holdsFocus(cart)) {
|
|
1676
2216
|
semantics.push({
|
|
1677
2217
|
code: "focus_not_returned",
|
|
1678
2218
|
message: "Focus did not return to the cart control after the cart closed"
|
|
@@ -1691,14 +2231,7 @@ var v13 = {
|
|
|
1691
2231
|
)
|
|
1692
2232
|
].join(" ")
|
|
1693
2233
|
);
|
|
1694
|
-
await
|
|
1695
|
-
{
|
|
1696
|
-
name: "zappie_session",
|
|
1697
|
-
value: "signed",
|
|
1698
|
-
domain: new URL(page.url()).hostname,
|
|
1699
|
-
path: "/"
|
|
1700
|
-
}
|
|
1701
|
-
]);
|
|
2234
|
+
await signIn(page);
|
|
1702
2235
|
await open(page, "/orders/order_fixture");
|
|
1703
2236
|
const order = await severeViolations(page);
|
|
1704
2237
|
assertion(
|
|
@@ -1709,16 +2242,25 @@ var v13 = {
|
|
|
1709
2242
|
);
|
|
1710
2243
|
await open(page);
|
|
1711
2244
|
assertion(
|
|
1712
|
-
await
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
2245
|
+
await keyboardReachesAddAndCart(
|
|
2246
|
+
page,
|
|
2247
|
+
simpleItem(await readMenu(options))?.id,
|
|
2248
|
+
cartInColumn
|
|
2249
|
+
),
|
|
1716
2250
|
"keyboard_marker_unreachable",
|
|
1717
2251
|
"Keyboard navigation did not reach the Add and cart controls",
|
|
1718
2252
|
"Keep the marked Add and cart controls in the tab order."
|
|
1719
2253
|
);
|
|
1720
2254
|
await open(page, "/checkout");
|
|
1721
|
-
await
|
|
2255
|
+
const phone = await revealField(
|
|
2256
|
+
page,
|
|
2257
|
+
"phone",
|
|
2258
|
+
page.locator('input[type="tel"]')
|
|
2259
|
+
);
|
|
2260
|
+
await phone.fill("+2348000000001");
|
|
2261
|
+
if (await page.locator('[role="dialog"]').filter({ has: phone }).isVisible()) {
|
|
2262
|
+
await (await dialogConfirm(page, "[data-zappie-field-confirm]"))?.click();
|
|
2263
|
+
}
|
|
1722
2264
|
await page.waitForFunction(
|
|
1723
2265
|
() => !document.querySelector("[data-zappie-checkout]")?.disabled
|
|
1724
2266
|
);
|
|
@@ -1738,7 +2280,7 @@ var v14 = {
|
|
|
1738
2280
|
viewports: "mobile",
|
|
1739
2281
|
async run({ page, metrics, fault, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
|
|
1740
2282
|
await page.setViewportSize(viewport);
|
|
1741
|
-
const cdp = await applyPhoneProfile(page, cpuThrottlingRate2);
|
|
2283
|
+
const { cdp } = await applyPhoneProfile(page, cpuThrottlingRate2);
|
|
1742
2284
|
await page.addInitScript(() => {
|
|
1743
2285
|
const state = { lcp: 0, cls: 0 };
|
|
1744
2286
|
Object.assign(window, { __zappiePerformance: state });
|
|
@@ -1820,7 +2362,7 @@ var v15 = {
|
|
|
1820
2362
|
viewports: "mobile",
|
|
1821
2363
|
async run({ page, metrics, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
|
|
1822
2364
|
await page.setViewportSize(viewport);
|
|
1823
|
-
const cdp = await applyPhoneProfile(page, cpuThrottlingRate2);
|
|
2365
|
+
const { cdp } = await applyPhoneProfile(page, cpuThrottlingRate2);
|
|
1824
2366
|
try {
|
|
1825
2367
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
1826
2368
|
await cdp.send("Network.setCacheDisabled", { cacheDisabled: false });
|
|
@@ -2101,6 +2643,41 @@ async function openSettledMenu(page) {
|
|
|
2101
2643
|
network.stop();
|
|
2102
2644
|
}
|
|
2103
2645
|
}
|
|
2646
|
+
async function revealBagTrigger(page, itemId) {
|
|
2647
|
+
const bag = page.locator(`${triggerSelector("bag")}:visible`).first();
|
|
2648
|
+
if (!itemId || await bag.count()) {
|
|
2649
|
+
return;
|
|
2650
|
+
}
|
|
2651
|
+
const before = await page.evaluate(() => window.__zappie?.cart().count ?? 0).catch(() => 0);
|
|
2652
|
+
const add = '[data-zappie-action="add-to-cart"]';
|
|
2653
|
+
const quick = page.locator(`[data-zappie-item="${itemId}"] ${add}`).filter({ visible: true }).first();
|
|
2654
|
+
if (await quick.count()) {
|
|
2655
|
+
await quick.click();
|
|
2656
|
+
} else {
|
|
2657
|
+
const opener = page.locator(itemOpenerSelector(itemId)).filter({ visible: true }).first();
|
|
2658
|
+
if (!await opener.count()) {
|
|
2659
|
+
return;
|
|
2660
|
+
}
|
|
2661
|
+
await opener.click();
|
|
2662
|
+
const inItem = page.locator(
|
|
2663
|
+
`[role="dialog"] [data-zappie-item] ${add}:visible, main [data-zappie-item] ${add}:visible`
|
|
2664
|
+
).first();
|
|
2665
|
+
if (!await inItem.waitFor({ timeout: 5e3 }).then(
|
|
2666
|
+
() => true,
|
|
2667
|
+
() => false
|
|
2668
|
+
)) {
|
|
2669
|
+
return;
|
|
2670
|
+
}
|
|
2671
|
+
await inItem.click();
|
|
2672
|
+
}
|
|
2673
|
+
await page.waitForFunction(
|
|
2674
|
+
(count) => (window.__zappie?.cart().count ?? 0) > count,
|
|
2675
|
+
before,
|
|
2676
|
+
{ timeout: 5e3 }
|
|
2677
|
+
).catch(() => void 0);
|
|
2678
|
+
await openSettledMenu(page);
|
|
2679
|
+
await bag.waitFor({ timeout: 5e3 }).catch(() => void 0);
|
|
2680
|
+
}
|
|
2104
2681
|
|
|
2105
2682
|
// src/checks/v16-overlays-open-quickly.ts
|
|
2106
2683
|
var OVERLAY_OPEN_BUDGET_MS = 100;
|
|
@@ -2110,64 +2687,89 @@ var median = (values) => {
|
|
|
2110
2687
|
const sorted = [...values].sort((a, b) => a - b);
|
|
2111
2688
|
return sorted[Math.floor(sorted.length / 2)];
|
|
2112
2689
|
};
|
|
2690
|
+
async function firstOpen(fresh, viewport, name, cpuThrottlingRate2, itemId) {
|
|
2691
|
+
const { page } = fresh;
|
|
2692
|
+
try {
|
|
2693
|
+
await page.setViewportSize(viewport);
|
|
2694
|
+
const { cdp, rate } = await applyPhoneProfile(page, cpuThrottlingRate2);
|
|
2695
|
+
try {
|
|
2696
|
+
await openSettledMenu(page);
|
|
2697
|
+
if (name === "bag") {
|
|
2698
|
+
await revealBagTrigger(page, itemId);
|
|
2699
|
+
}
|
|
2700
|
+
const trigger = page.locator(`${triggerSelector(name)}:visible`).first();
|
|
2701
|
+
await trigger.evaluate(
|
|
2702
|
+
(node) => node.scrollIntoView({ block: "center", inline: "nearest" })
|
|
2703
|
+
);
|
|
2704
|
+
await armOverlayTimer(page, name, OPEN_TIMEOUT_MS);
|
|
2705
|
+
await trigger.click({ noWaitAfter: true });
|
|
2706
|
+
const ms = await readOverlayTimer(page);
|
|
2707
|
+
return ms === void 0 ? void 0 : { ms, rate };
|
|
2708
|
+
} finally {
|
|
2709
|
+
await cdp.detach().catch(() => void 0);
|
|
2710
|
+
}
|
|
2711
|
+
} finally {
|
|
2712
|
+
await fresh.close();
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2113
2715
|
var v16 = {
|
|
2114
2716
|
id: "V16",
|
|
2115
2717
|
title: "Overlays open quickly",
|
|
2116
2718
|
viewports: "mobile",
|
|
2117
|
-
async run({ page, metrics, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
|
|
2719
|
+
async run({ page, options, metrics, freshPage, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
|
|
2118
2720
|
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);
|
|
2721
|
+
const itemId = simpleItem(await readMenu(options))?.id;
|
|
2722
|
+
await openSettledMenu(page);
|
|
2723
|
+
await revealBagTrigger(page, itemId);
|
|
2724
|
+
const names = (await overlayTriggers(page)).map(({ name }) => name);
|
|
2725
|
+
skipWithoutTriggers(names);
|
|
2726
|
+
const timings = {};
|
|
2727
|
+
const samples = {};
|
|
2728
|
+
const rates = {};
|
|
2729
|
+
const unopened = [];
|
|
2730
|
+
for (const name of names) {
|
|
2731
|
+
const opens = [];
|
|
2732
|
+
const opensRates = [];
|
|
2733
|
+
for (let sample = 0; sample < OVERLAY_OPEN_SAMPLES; sample++) {
|
|
2734
|
+
const open2 = await firstOpen(
|
|
2735
|
+
await freshPage(),
|
|
2736
|
+
viewport,
|
|
2737
|
+
name,
|
|
2738
|
+
cpuThrottlingRate2,
|
|
2739
|
+
itemId
|
|
2740
|
+
);
|
|
2741
|
+
if (open2 === void 0) {
|
|
2742
|
+
break;
|
|
2149
2743
|
}
|
|
2744
|
+
opens.push(Math.round(open2.ms));
|
|
2745
|
+
opensRates.push(open2.rate);
|
|
2746
|
+
}
|
|
2747
|
+
if (opens.length < OVERLAY_OPEN_SAMPLES) {
|
|
2748
|
+
unopened.push(name);
|
|
2749
|
+
} else {
|
|
2750
|
+
samples[name] = opens;
|
|
2751
|
+
rates[name] = opensRates;
|
|
2752
|
+
timings[name] = median(opens);
|
|
2150
2753
|
}
|
|
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
2754
|
}
|
|
2755
|
+
metrics.overlayOpenMs = timings;
|
|
2756
|
+
metrics.overlayOpenSamplesMs = samples;
|
|
2757
|
+
metrics.overlayOpenRates = rates;
|
|
2758
|
+
assertion(
|
|
2759
|
+
unopened.length === 0,
|
|
2760
|
+
"overlay_not_opened",
|
|
2761
|
+
`No data-zappie-overlay painted after tapping ${unopened.map((name) => `the ${name} trigger`).join(", ")}`,
|
|
2762
|
+
`Put data-zappie-overlay with the trigger's name on the role="dialog" element that the trigger opens.`
|
|
2763
|
+
);
|
|
2764
|
+
const slow = Object.entries(timings).filter(
|
|
2765
|
+
([, ms]) => ms > OVERLAY_OPEN_BUDGET_MS
|
|
2766
|
+
);
|
|
2767
|
+
assertion(
|
|
2768
|
+
slow.length === 0,
|
|
2769
|
+
"overlay_open_too_slow",
|
|
2770
|
+
`Overlays took longer than ${OVERLAY_OPEN_BUDGET_MS} ms to paint, as the median of ${OVERLAY_OPEN_SAMPLES} first opens: ${slow.map(([name, ms]) => `${name} ${ms} ms (${samples[name].join(", ")} ms)`).join(", ")}`,
|
|
2771
|
+
"Load overlay code with deferred() from @zappie/foundations, spread useIntentPreload on the trigger, and keep work out of the first render so the overlay paints within 100 ms of the tap."
|
|
2772
|
+
);
|
|
2171
2773
|
}
|
|
2172
2774
|
};
|
|
2173
2775
|
|
|
@@ -2201,8 +2803,11 @@ var scrollLocked = (page) => page.evaluate(() => {
|
|
|
2201
2803
|
};
|
|
2202
2804
|
return held(document.documentElement) || held(document.body);
|
|
2203
2805
|
});
|
|
2204
|
-
async function checkOverlay(page, name) {
|
|
2806
|
+
async function checkOverlay(page, name, itemId) {
|
|
2205
2807
|
await openSettledMenu(page);
|
|
2808
|
+
if (name === "bag") {
|
|
2809
|
+
await revealBagTrigger(page, itemId);
|
|
2810
|
+
}
|
|
2206
2811
|
const trigger = page.locator(`${triggerSelector(name)}:visible`).first();
|
|
2207
2812
|
await trigger.focus();
|
|
2208
2813
|
await page.keyboard.press("Enter");
|
|
@@ -2280,15 +2885,26 @@ var v17 = {
|
|
|
2280
2885
|
id: "V17",
|
|
2281
2886
|
title: "Overlays manage focus",
|
|
2282
2887
|
viewports: "mobile",
|
|
2283
|
-
async run({ page }, viewport) {
|
|
2888
|
+
async run({ page, options, freshPage }, viewport) {
|
|
2284
2889
|
await page.setViewportSize(viewport);
|
|
2890
|
+
const itemId = simpleItem(await readMenu(options))?.id;
|
|
2285
2891
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
2286
2892
|
await page.locator("[data-zappie-item]").first().waitFor();
|
|
2893
|
+
if (!await page.locator(`${triggerSelector("bag")}:visible`).count()) {
|
|
2894
|
+
await openSettledMenu(page);
|
|
2895
|
+
await revealBagTrigger(page, itemId);
|
|
2896
|
+
}
|
|
2287
2897
|
const names = (await overlayTriggers(page)).map(({ name }) => name);
|
|
2288
2898
|
skipWithoutTriggers(names);
|
|
2289
2899
|
const findings = [];
|
|
2290
2900
|
for (const name of names) {
|
|
2291
|
-
|
|
2901
|
+
const fresh = await freshPage();
|
|
2902
|
+
try {
|
|
2903
|
+
await fresh.page.setViewportSize(viewport);
|
|
2904
|
+
findings.push(...await checkOverlay(fresh.page, name, itemId));
|
|
2905
|
+
} finally {
|
|
2906
|
+
await fresh.close();
|
|
2907
|
+
}
|
|
2292
2908
|
}
|
|
2293
2909
|
const [first] = findings;
|
|
2294
2910
|
if (first) {
|
|
@@ -2362,60 +2978,6 @@ function skippedChecks(suite) {
|
|
|
2362
2978
|
];
|
|
2363
2979
|
}
|
|
2364
2980
|
|
|
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
2981
|
// src/report.ts
|
|
2420
2982
|
import { createPrivateKey, randomUUID, sign } from "crypto";
|
|
2421
2983
|
var REPORT_SCHEMA = "https://registry.zappie.ai/v1/schema/verify-report.json";
|
|
@@ -2502,7 +3064,7 @@ async function reset(options) {
|
|
|
2502
3064
|
}
|
|
2503
3065
|
|
|
2504
3066
|
// src/version.ts
|
|
2505
|
-
var PACKAGE_VERSION = true ? "0.2.
|
|
3067
|
+
var PACKAGE_VERSION = true ? "0.2.3" : "0.0.0-development";
|
|
2506
3068
|
|
|
2507
3069
|
// src/runner.ts
|
|
2508
3070
|
var VERSION = PACKAGE_VERSION;
|
|
@@ -2583,18 +3145,7 @@ async function execute(check, run2) {
|
|
|
2583
3145
|
}
|
|
2584
3146
|
}
|
|
2585
3147
|
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);
|
|
3148
|
+
async function openContext(browser, options) {
|
|
2598
3149
|
const context = await browser.newContext(browserOptions(options));
|
|
2599
3150
|
if (options.standInForPlatform) {
|
|
2600
3151
|
await context.route(
|
|
@@ -2617,10 +3168,29 @@ async function runVerify(options, fault) {
|
|
|
2617
3168
|
}
|
|
2618
3169
|
);
|
|
2619
3170
|
}
|
|
2620
|
-
|
|
3171
|
+
return context;
|
|
3172
|
+
}
|
|
3173
|
+
async function preparePage(context, options, fault) {
|
|
2621
3174
|
const page = await context.newPage();
|
|
2622
3175
|
page.setDefaultTimeout(Math.min(options.timeoutMs ?? 2e4, 2e4));
|
|
2623
3176
|
await installFault(page, fault);
|
|
3177
|
+
return page;
|
|
3178
|
+
}
|
|
3179
|
+
async function runVerify(options, fault) {
|
|
3180
|
+
if (!/^pk_test_.+/.test(options.key)) {
|
|
3181
|
+
throw new VerifyError(
|
|
3182
|
+
"live_key_refused",
|
|
3183
|
+
"Verify accepts only pk_test_ keys",
|
|
3184
|
+
"Create a test publishable key and rerun verify."
|
|
3185
|
+
);
|
|
3186
|
+
}
|
|
3187
|
+
const started = Date.now();
|
|
3188
|
+
const browser = await chromium.launch();
|
|
3189
|
+
const cpuBenchmark = await benchmarkCpu(browser).catch(() => void 0);
|
|
3190
|
+
const throttlingRate = cpuBenchmark === void 0 ? REFERENCE_CPU_THROTTLING_RATE : cpuThrottlingRate(cpuBenchmark);
|
|
3191
|
+
const context = await openContext(browser, options);
|
|
3192
|
+
await context.tracing.start({ screenshots: true, snapshots: true });
|
|
3193
|
+
const page = await preparePage(context, options, fault);
|
|
2624
3194
|
const metrics = {};
|
|
2625
3195
|
const run2 = {
|
|
2626
3196
|
options,
|
|
@@ -2629,7 +3199,14 @@ async function runVerify(options, fault) {
|
|
|
2629
3199
|
page,
|
|
2630
3200
|
metrics,
|
|
2631
3201
|
cpuThrottlingRate: throttlingRate,
|
|
2632
|
-
...fault ? { fault } : {}
|
|
3202
|
+
...fault ? { fault } : {},
|
|
3203
|
+
async freshPage() {
|
|
3204
|
+
const fresh = await openContext(browser, options);
|
|
3205
|
+
return {
|
|
3206
|
+
page: await preparePage(fresh, options, fault),
|
|
3207
|
+
close: () => fresh.close()
|
|
3208
|
+
};
|
|
3209
|
+
}
|
|
2633
3210
|
};
|
|
2634
3211
|
const results = [await reset(options)];
|
|
2635
3212
|
try {
|