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