@zappie/verify 0.1.5 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +18 -1
- package/dist/{category-page-UBHOB5IN.js → category-page-I3HDIAJA.js} +7 -3
- package/dist/category-page-I3HDIAJA.js.map +1 -0
- package/dist/{chunk-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 +53 -5
- package/dist/index.js +842 -30
- 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 +17 -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";
|
|
@@ -285,7 +289,7 @@ async function debugValue(page, expression) {
|
|
|
285
289
|
}
|
|
286
290
|
var mobile = { width: 390, height: 844 };
|
|
287
291
|
var desktop = { width: 1440, height: 900 };
|
|
288
|
-
async function applyPhoneProfile(page) {
|
|
292
|
+
async function applyPhoneProfile(page, rate) {
|
|
289
293
|
const cdp = await page.context().newCDPSession(page);
|
|
290
294
|
await cdp.send("Network.enable");
|
|
291
295
|
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
|
|
@@ -296,7 +300,7 @@ async function applyPhoneProfile(page) {
|
|
|
296
300
|
uploadThroughput: 1e5,
|
|
297
301
|
connectionType: "cellular3g"
|
|
298
302
|
});
|
|
299
|
-
await cdp.send("Emulation.setCPUThrottlingRate", { rate
|
|
303
|
+
await cdp.send("Emulation.setCPUThrottlingRate", { rate });
|
|
300
304
|
return cdp;
|
|
301
305
|
}
|
|
302
306
|
|
|
@@ -391,6 +395,161 @@ async function installFault(page, fault) {
|
|
|
391
395
|
await route.continue();
|
|
392
396
|
});
|
|
393
397
|
}
|
|
398
|
+
if (fault === "extra-h1") {
|
|
399
|
+
await page.addInitScript(() => {
|
|
400
|
+
new MutationObserver(() => {
|
|
401
|
+
const main = document.querySelector("main");
|
|
402
|
+
if (main && !document.querySelector("[data-verify-extra-h1]")) {
|
|
403
|
+
const heading = document.createElement("h1");
|
|
404
|
+
heading.setAttribute("data-verify-extra-h1", "");
|
|
405
|
+
heading.textContent = "Second heading";
|
|
406
|
+
main.append(heading);
|
|
407
|
+
}
|
|
408
|
+
}).observe(document, { childList: true, subtree: true });
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
if (fault === "overlay-markers") {
|
|
412
|
+
await page.addInitScript(() => {
|
|
413
|
+
new MutationObserver(() => {
|
|
414
|
+
for (const node of document.querySelectorAll(
|
|
415
|
+
"[data-zappie-cart-trigger]:not([data-zappie-overlay-trigger])"
|
|
416
|
+
)) {
|
|
417
|
+
node.setAttribute("data-zappie-overlay-trigger", "bag");
|
|
418
|
+
}
|
|
419
|
+
for (const node of document.querySelectorAll(
|
|
420
|
+
'[role="dialog"]:not([data-zappie-overlay])'
|
|
421
|
+
)) {
|
|
422
|
+
if (node.textContent?.includes("Your cart")) {
|
|
423
|
+
node.setAttribute("data-zappie-overlay", "bag");
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}).observe(document, { childList: true, subtree: true });
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
if (fault === "bag-on-tap") {
|
|
430
|
+
let release = () => {
|
|
431
|
+
};
|
|
432
|
+
let tapped = Promise.resolve();
|
|
433
|
+
const reset2 = () => {
|
|
434
|
+
release();
|
|
435
|
+
tapped = new Promise((resolve) => {
|
|
436
|
+
release = resolve;
|
|
437
|
+
});
|
|
438
|
+
};
|
|
439
|
+
reset2();
|
|
440
|
+
page.on("framenavigated", (frame) => {
|
|
441
|
+
if (frame === page.mainFrame()) {
|
|
442
|
+
reset2();
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
await page.exposeFunction("__verifyTapped", () => release());
|
|
446
|
+
await page.addInitScript(() => {
|
|
447
|
+
document.addEventListener(
|
|
448
|
+
"click",
|
|
449
|
+
() => {
|
|
450
|
+
void window.__verifyTapped?.();
|
|
451
|
+
},
|
|
452
|
+
{ capture: true }
|
|
453
|
+
);
|
|
454
|
+
});
|
|
455
|
+
await page.route(
|
|
456
|
+
/\/assets\/cart-drawer-[^/]*\.js(?:\?|$)/,
|
|
457
|
+
async (route) => {
|
|
458
|
+
await tapped;
|
|
459
|
+
await route.continue().catch(() => {
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
if (fault === "one-slow-open") {
|
|
465
|
+
await page.addInitScript(() => {
|
|
466
|
+
document.addEventListener(
|
|
467
|
+
"click",
|
|
468
|
+
(event) => {
|
|
469
|
+
if (!(event.target instanceof Element) || !event.target.closest("[data-zappie-overlay-trigger]")) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const taps = Number(sessionStorage.getItem("__verifyTaps") ?? 0);
|
|
473
|
+
sessionStorage.setItem("__verifyTaps", String(taps + 1));
|
|
474
|
+
if (taps % 3 === 0) {
|
|
475
|
+
const until = performance.now() + 250;
|
|
476
|
+
while (performance.now() < until) {
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
{ capture: true }
|
|
481
|
+
);
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
if (fault === "late-hydration") {
|
|
485
|
+
await page.route(
|
|
486
|
+
/\/assets\/entry\.client-[^/]*\.js(?:\?|$)/,
|
|
487
|
+
async (route) => {
|
|
488
|
+
const response = await route.fetch();
|
|
489
|
+
const headers = Object.fromEntries(
|
|
490
|
+
Object.entries(response.headers()).filter(
|
|
491
|
+
([name]) => !["content-encoding", "content-length"].includes(name)
|
|
492
|
+
)
|
|
493
|
+
);
|
|
494
|
+
await route.fulfill({
|
|
495
|
+
response,
|
|
496
|
+
headers,
|
|
497
|
+
body: `await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
498
|
+
${await response.text()}`
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
if (fault === "late-idle") {
|
|
504
|
+
await page.addInitScript(() => {
|
|
505
|
+
const request = window.requestIdleCallback?.bind(window);
|
|
506
|
+
const cancel = window.cancelIdleCallback?.bind(window);
|
|
507
|
+
if (!request || !cancel) {
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const handles = /* @__PURE__ */ new Map();
|
|
511
|
+
let next = 1;
|
|
512
|
+
window.requestIdleCallback = (callback, options) => {
|
|
513
|
+
const handle = next++;
|
|
514
|
+
handles.set(
|
|
515
|
+
handle,
|
|
516
|
+
window.setTimeout(() => {
|
|
517
|
+
handles.set(handle, -request(callback, options));
|
|
518
|
+
}, 1500)
|
|
519
|
+
);
|
|
520
|
+
return handle;
|
|
521
|
+
};
|
|
522
|
+
window.cancelIdleCallback = (handle) => {
|
|
523
|
+
const scheduled = handles.get(handle);
|
|
524
|
+
if (scheduled === void 0) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (scheduled < 0) {
|
|
528
|
+
cancel(-scheduled);
|
|
529
|
+
} else {
|
|
530
|
+
window.clearTimeout(scheduled);
|
|
531
|
+
}
|
|
532
|
+
handles.delete(handle);
|
|
533
|
+
};
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
if (fault === "no-overlay-markers") {
|
|
537
|
+
await page.addInitScript(() => {
|
|
538
|
+
new MutationObserver(() => {
|
|
539
|
+
for (const node of document.querySelectorAll(
|
|
540
|
+
"[data-zappie-overlay-trigger], [data-zappie-overlay]"
|
|
541
|
+
)) {
|
|
542
|
+
node.removeAttribute("data-zappie-overlay-trigger");
|
|
543
|
+
node.removeAttribute("data-zappie-overlay");
|
|
544
|
+
}
|
|
545
|
+
}).observe(document, {
|
|
546
|
+
childList: true,
|
|
547
|
+
subtree: true,
|
|
548
|
+
attributes: true,
|
|
549
|
+
attributeFilter: ["data-zappie-overlay-trigger", "data-zappie-overlay"]
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
}
|
|
394
553
|
if (fault === "quick-add-via-item") {
|
|
395
554
|
await page.addInitScript(() => {
|
|
396
555
|
const removeQuickAdds = () => {
|
|
@@ -1319,6 +1478,115 @@ var v10 = {
|
|
|
1319
1478
|
|
|
1320
1479
|
// src/checks/v13-accessibility.ts
|
|
1321
1480
|
import AxeBuilder from "@axe-core/playwright";
|
|
1481
|
+
|
|
1482
|
+
// src/checks/semantics.ts
|
|
1483
|
+
var TOUCH_TARGET_PX = 44;
|
|
1484
|
+
async function semanticFindings(page, scope) {
|
|
1485
|
+
return page.evaluate(
|
|
1486
|
+
({ scope: scope2, minimum }) => {
|
|
1487
|
+
const findings = [];
|
|
1488
|
+
const rendered = (element) => element.checkVisibility() && !element.closest('[aria-hidden="true"]');
|
|
1489
|
+
const describe = (element) => {
|
|
1490
|
+
const id = element.id ? `#${element.id}` : "";
|
|
1491
|
+
const text = (element.textContent ?? "").trim().replace(/\s+/g, " ");
|
|
1492
|
+
return `<${element.tagName.toLowerCase()}${id}>${text ? ` "${text.slice(0, 40)}"` : ""}`;
|
|
1493
|
+
};
|
|
1494
|
+
if (scope2.page) {
|
|
1495
|
+
const main = [
|
|
1496
|
+
...document.querySelectorAll('main, [role="main"]')
|
|
1497
|
+
].filter(rendered);
|
|
1498
|
+
if (main.length === 0) {
|
|
1499
|
+
findings.push({
|
|
1500
|
+
code: "main_landmark_missing",
|
|
1501
|
+
message: "The page has no main landmark"
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
const headings = [
|
|
1505
|
+
...document.querySelectorAll('h1, [role="heading"][aria-level="1"]')
|
|
1506
|
+
].filter(
|
|
1507
|
+
(element) => rendered(element) && !element.closest('[role="dialog"], dialog')
|
|
1508
|
+
);
|
|
1509
|
+
if (headings.length !== 1) {
|
|
1510
|
+
findings.push({
|
|
1511
|
+
code: "h1_count",
|
|
1512
|
+
message: `The page has ${headings.length} h1 headings${headings.length > 1 ? `: ${headings.map(describe).join(", ")}` : ""}`
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
const named = (element) => {
|
|
1517
|
+
const label2 = element.getAttribute("aria-label")?.trim();
|
|
1518
|
+
if (label2) {
|
|
1519
|
+
return true;
|
|
1520
|
+
}
|
|
1521
|
+
const ids = element.getAttribute("aria-labelledby")?.split(/\s+/) ?? [];
|
|
1522
|
+
return ids.some(
|
|
1523
|
+
(id) => Boolean(document.getElementById(id)?.textContent?.trim())
|
|
1524
|
+
);
|
|
1525
|
+
};
|
|
1526
|
+
for (const dialog of document.querySelectorAll(
|
|
1527
|
+
'[role="dialog"], [role="alertdialog"], dialog[open]'
|
|
1528
|
+
)) {
|
|
1529
|
+
if (rendered(dialog) && !named(dialog)) {
|
|
1530
|
+
findings.push({
|
|
1531
|
+
code: "dialog_name_missing",
|
|
1532
|
+
message: `${describe(dialog)} has no accessible name`
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
for (const element of document.querySelectorAll("[tabindex]")) {
|
|
1537
|
+
if (Number(element.getAttribute("tabindex")) > 0) {
|
|
1538
|
+
findings.push({
|
|
1539
|
+
code: "positive_tabindex",
|
|
1540
|
+
message: `${describe(element)} has tabindex="${element.getAttribute("tabindex")}"`
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
if (scope2.phone) {
|
|
1545
|
+
const controls = document.querySelectorAll(
|
|
1546
|
+
'button, a[href], input:not([type="hidden"]), select, textarea, summary, [role="button"], [role="link"], [role="tab"], [role="checkbox"], [role="radio"], [role="switch"], [role="menuitem"], [role="option"]'
|
|
1547
|
+
);
|
|
1548
|
+
const small = [];
|
|
1549
|
+
for (const element of controls) {
|
|
1550
|
+
if (!rendered(element) || element.matches(":disabled") || // Inline text links sit in a line of text; WCAG exempts them.
|
|
1551
|
+
element.matches("a") && getComputedStyle(element).display === "inline") {
|
|
1552
|
+
continue;
|
|
1553
|
+
}
|
|
1554
|
+
const box = element.getBoundingClientRect();
|
|
1555
|
+
const label2 = element.closest("label")?.getBoundingClientRect();
|
|
1556
|
+
if (box.width <= 2 || box.height <= 2) {
|
|
1557
|
+
if (!label2) {
|
|
1558
|
+
continue;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
const width = Math.max(box.width, label2?.width ?? 0);
|
|
1562
|
+
const height = Math.max(box.height, label2?.height ?? 0);
|
|
1563
|
+
if (width + 0.5 < minimum || height + 0.5 < minimum) {
|
|
1564
|
+
small.push(
|
|
1565
|
+
`${describe(element)} ${Math.round(width)}\xD7${Math.round(height)} px`
|
|
1566
|
+
);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
if (small.length > 0) {
|
|
1570
|
+
findings.push({
|
|
1571
|
+
code: "touch_target_too_small",
|
|
1572
|
+
message: `Controls under ${minimum}\xD7${minimum} px on a phone: ${small.slice(0, 8).join(", ")}${small.length > 8 ? ` and ${small.length - 8} more` : ""}`
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
return findings;
|
|
1577
|
+
},
|
|
1578
|
+
{ scope, minimum: TOUCH_TARGET_PX }
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
var semanticHints = {
|
|
1582
|
+
main_landmark_missing: "Wrap the page's main content in a <main> element.",
|
|
1583
|
+
h1_count: "Give each page exactly one h1 that names it.",
|
|
1584
|
+
dialog_name_missing: "Name every dialog with aria-labelledby pointing at its title, or with aria-label.",
|
|
1585
|
+
positive_tabindex: "Remove positive tabindex values. Use the DOM order, or tabindex 0 and -1.",
|
|
1586
|
+
touch_target_too_small: "Make every menu control at least 44\xD744 px on a phone, with padding or a larger hit area."
|
|
1587
|
+
};
|
|
1588
|
+
|
|
1589
|
+
// src/checks/v13-accessibility.ts
|
|
1322
1590
|
async function severeViolations(page) {
|
|
1323
1591
|
const analysis = await new AxeBuilder({ page }).analyze();
|
|
1324
1592
|
return analysis.violations.filter(
|
|
@@ -1343,6 +1611,16 @@ async function controlsAreReachable(page, selectors) {
|
|
|
1343
1611
|
}
|
|
1344
1612
|
return false;
|
|
1345
1613
|
}
|
|
1614
|
+
async function holdsFocus(target2) {
|
|
1615
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
1616
|
+
const inside = await target2.evaluate((node) => node.contains(document.activeElement)).catch(() => false);
|
|
1617
|
+
if (inside) {
|
|
1618
|
+
return true;
|
|
1619
|
+
}
|
|
1620
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1621
|
+
}
|
|
1622
|
+
return false;
|
|
1623
|
+
}
|
|
1346
1624
|
var v13 = {
|
|
1347
1625
|
id: "V13",
|
|
1348
1626
|
title: "Accessibility",
|
|
@@ -1352,6 +1630,10 @@ var v13 = {
|
|
|
1352
1630
|
await open(page);
|
|
1353
1631
|
await applyPageFault(page, fault);
|
|
1354
1632
|
const violations = await severeViolations(page);
|
|
1633
|
+
const semantics = await semanticFindings(page, {
|
|
1634
|
+
page: true,
|
|
1635
|
+
phone: viewport.width < 768
|
|
1636
|
+
});
|
|
1355
1637
|
const item = sortedItems(await readMenu(options)).find(
|
|
1356
1638
|
(entry) => entry.isAvailable
|
|
1357
1639
|
);
|
|
@@ -1361,11 +1643,25 @@ var v13 = {
|
|
|
1361
1643
|
"No available item can be opened for accessibility checks",
|
|
1362
1644
|
"Keep one available item in the test menu."
|
|
1363
1645
|
);
|
|
1364
|
-
|
|
1646
|
+
const card = page.locator(`[data-zappie-item="${item.id}"]`).first().locator("xpath=..");
|
|
1647
|
+
await card.getByRole("link", { name: "View details" }).click();
|
|
1365
1648
|
violations.push(...await severeViolations(page));
|
|
1649
|
+
semantics.push(
|
|
1650
|
+
...await semanticFindings(page, { page: false, phone: false })
|
|
1651
|
+
);
|
|
1366
1652
|
await page.getByRole("button", { name: "Close item" }).click();
|
|
1367
|
-
await
|
|
1653
|
+
if (!await holdsFocus(card)) {
|
|
1654
|
+
semantics.push({
|
|
1655
|
+
code: "focus_not_returned",
|
|
1656
|
+
message: "Focus did not return to the item card after the item closed"
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
const cartTrigger = page.locator("[data-zappie-cart-trigger]").first();
|
|
1660
|
+
await cartTrigger.click();
|
|
1368
1661
|
violations.push(...await severeViolations(page));
|
|
1662
|
+
semantics.push(
|
|
1663
|
+
...await semanticFindings(page, { page: false, phone: false })
|
|
1664
|
+
);
|
|
1369
1665
|
const message = violations.flatMap(
|
|
1370
1666
|
(entry) => entry.nodes.map((node) => `${entry.id}: ${node.target.join(" ")}`)
|
|
1371
1667
|
).join(", ");
|
|
@@ -1376,6 +1672,25 @@ var v13 = {
|
|
|
1376
1672
|
"Fix every serious or critical axe violation in the menu, item dialog, cart and order page."
|
|
1377
1673
|
);
|
|
1378
1674
|
await page.keyboard.press("Escape");
|
|
1675
|
+
if (!await holdsFocus(cartTrigger)) {
|
|
1676
|
+
semantics.push({
|
|
1677
|
+
code: "focus_not_returned",
|
|
1678
|
+
message: "Focus did not return to the cart control after the cart closed"
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
const [semantic] = semantics;
|
|
1682
|
+
assertion(
|
|
1683
|
+
!semantic,
|
|
1684
|
+
semantic?.code ?? "semantics",
|
|
1685
|
+
semantics.map(({ message: message2 }) => message2).join(". "),
|
|
1686
|
+
[
|
|
1687
|
+
...new Set(
|
|
1688
|
+
semantics.map(
|
|
1689
|
+
({ code }) => semanticHints[code] ?? "Return focus to the control that opened an overlay when it closes."
|
|
1690
|
+
)
|
|
1691
|
+
)
|
|
1692
|
+
].join(" ")
|
|
1693
|
+
);
|
|
1379
1694
|
await context.addCookies([
|
|
1380
1695
|
{
|
|
1381
1696
|
name: "zappie_session",
|
|
@@ -1421,9 +1736,9 @@ var v14 = {
|
|
|
1421
1736
|
id: "V14",
|
|
1422
1737
|
title: "Performance budget",
|
|
1423
1738
|
viewports: "mobile",
|
|
1424
|
-
async run({ page, metrics, fault }, viewport) {
|
|
1739
|
+
async run({ page, metrics, fault, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
|
|
1425
1740
|
await page.setViewportSize(viewport);
|
|
1426
|
-
const cdp = await applyPhoneProfile(page);
|
|
1741
|
+
const cdp = await applyPhoneProfile(page, cpuThrottlingRate2);
|
|
1427
1742
|
await page.addInitScript(() => {
|
|
1428
1743
|
const state = { lcp: 0, cls: 0 };
|
|
1429
1744
|
Object.assign(window, { __zappiePerformance: state });
|
|
@@ -1451,24 +1766,27 @@ var v14 = {
|
|
|
1451
1766
|
await page.waitForTimeout(500);
|
|
1452
1767
|
const values = await page.evaluate(async () => {
|
|
1453
1768
|
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
|
-
|
|
1769
|
+
const resources = performance.getEntriesByType("resource");
|
|
1770
|
+
const gzipped = async (entry) => {
|
|
1771
|
+
if (typeof CompressionStream === "undefined") {
|
|
1772
|
+
const timing = entry;
|
|
1773
|
+
return timing.encodedBodySize || timing.transferSize;
|
|
1774
|
+
}
|
|
1775
|
+
const response = await fetch(entry.name);
|
|
1776
|
+
const compressed = response.body?.pipeThrough(
|
|
1777
|
+
new CompressionStream("gzip")
|
|
1778
|
+
);
|
|
1779
|
+
return compressed ? (await new Response(compressed).arrayBuffer()).byteLength : 0;
|
|
1780
|
+
};
|
|
1781
|
+
const total = async (extension) => (await Promise.all(
|
|
1782
|
+
resources.filter(
|
|
1783
|
+
(entry) => new URL(entry.name).pathname.endsWith(extension)
|
|
1784
|
+
).map(gzipped)
|
|
1785
|
+
)).reduce((sum, size) => sum + size, 0);
|
|
1786
|
+
return { ...observed, js: await total(".js"), css: await total(".css") };
|
|
1470
1787
|
});
|
|
1471
1788
|
metrics.menuJsKbGz = Math.round(values.js / 1024 * 10) / 10;
|
|
1789
|
+
metrics.menuCssKbGz = Math.round(values.css / 1024 * 10) / 10;
|
|
1472
1790
|
metrics.lcpMs = Math.round(values.lcp);
|
|
1473
1791
|
metrics.cls = Math.round(values.cls * 1e3) / 1e3;
|
|
1474
1792
|
assertion(
|
|
@@ -1500,9 +1818,9 @@ var v15 = {
|
|
|
1500
1818
|
id: "V15",
|
|
1501
1819
|
title: "Tappable product cards",
|
|
1502
1820
|
viewports: "mobile",
|
|
1503
|
-
async run({ page, metrics }, viewport) {
|
|
1821
|
+
async run({ page, metrics, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
|
|
1504
1822
|
await page.setViewportSize(viewport);
|
|
1505
|
-
const cdp = await applyPhoneProfile(page);
|
|
1823
|
+
const cdp = await applyPhoneProfile(page, cpuThrottlingRate2);
|
|
1506
1824
|
try {
|
|
1507
1825
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
1508
1826
|
await cdp.send("Network.setCacheDisabled", { cacheDisabled: false });
|
|
@@ -1617,6 +1935,372 @@ function itemRouteModule(itemPath) {
|
|
|
1617
1935
|
return route ? new URL(route.module, location.href).pathname : void 0;
|
|
1618
1936
|
}
|
|
1619
1937
|
|
|
1938
|
+
// src/checks/overlays.ts
|
|
1939
|
+
var OVERLAY_NAMES = ["bag", "branch", "search", "item"];
|
|
1940
|
+
var IDLE_FALLBACK_MS = 2e3;
|
|
1941
|
+
var NETWORK_QUIET_MS = 500;
|
|
1942
|
+
var SETTLE_ROUNDS = 3;
|
|
1943
|
+
var SETTLE_TIMEOUT_MS = 15e3;
|
|
1944
|
+
var triggerSelector = (name) => `[data-zappie-overlay-trigger="${name}"]`;
|
|
1945
|
+
var overlaySelector = (name) => `[data-zappie-overlay="${name}"]`;
|
|
1946
|
+
var markersHint = 'Add data-zappie-overlay-trigger="bag", "branch", "search" or "item" to each control that opens that overlay, and data-zappie-overlay with the same name on its role="dialog" element. Until the menu has them, this check is skipped.';
|
|
1947
|
+
async function overlayTriggers(page) {
|
|
1948
|
+
const found = [];
|
|
1949
|
+
for (const name of OVERLAY_NAMES) {
|
|
1950
|
+
const trigger = page.locator(`${triggerSelector(name)}:visible`).first();
|
|
1951
|
+
if (await trigger.count() > 0) {
|
|
1952
|
+
found.push({ name, trigger });
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
return found;
|
|
1956
|
+
}
|
|
1957
|
+
function skipWithoutTriggers(found) {
|
|
1958
|
+
if (found.length === 0) {
|
|
1959
|
+
skipCheck(
|
|
1960
|
+
"overlay_markers_missing",
|
|
1961
|
+
"The menu has no data-zappie-overlay-trigger controls",
|
|
1962
|
+
markersHint
|
|
1963
|
+
);
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
function armOverlayTimer(page, name, timeoutMs) {
|
|
1967
|
+
return page.evaluate(
|
|
1968
|
+
({ selector, timeout }) => {
|
|
1969
|
+
const state = {};
|
|
1970
|
+
Object.assign(window, { __zappieOverlayTimer: state });
|
|
1971
|
+
const visible = () => {
|
|
1972
|
+
const node = document.querySelector(selector);
|
|
1973
|
+
if (!(node instanceof HTMLElement)) {
|
|
1974
|
+
return false;
|
|
1975
|
+
}
|
|
1976
|
+
const box = node.getBoundingClientRect();
|
|
1977
|
+
return box.width > 0 && box.height > 0 && node.checkVisibility();
|
|
1978
|
+
};
|
|
1979
|
+
document.addEventListener(
|
|
1980
|
+
"click",
|
|
1981
|
+
(event) => {
|
|
1982
|
+
const started = event.timeStamp;
|
|
1983
|
+
const frame = () => {
|
|
1984
|
+
if (visible()) {
|
|
1985
|
+
requestAnimationFrame(() => {
|
|
1986
|
+
state.ms = performance.now() - started;
|
|
1987
|
+
});
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
if (performance.now() - started > timeout) {
|
|
1991
|
+
state.ms = -1;
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
requestAnimationFrame(frame);
|
|
1995
|
+
};
|
|
1996
|
+
requestAnimationFrame(frame);
|
|
1997
|
+
},
|
|
1998
|
+
{ capture: true, once: true }
|
|
1999
|
+
);
|
|
2000
|
+
},
|
|
2001
|
+
{ selector: overlaySelector(name), timeout: timeoutMs }
|
|
2002
|
+
);
|
|
2003
|
+
}
|
|
2004
|
+
async function readOverlayTimer(page) {
|
|
2005
|
+
const handle = await page.waitForFunction(
|
|
2006
|
+
() => window.__zappieOverlayTimer?.ms
|
|
2007
|
+
);
|
|
2008
|
+
const ms = await handle.jsonValue();
|
|
2009
|
+
return ms < 0 ? void 0 : ms;
|
|
2010
|
+
}
|
|
2011
|
+
async function overlayIsOpen(page, name) {
|
|
2012
|
+
return page.locator(`${overlaySelector(name)}:visible`).first().isVisible().catch(() => false);
|
|
2013
|
+
}
|
|
2014
|
+
function watchNetwork(page) {
|
|
2015
|
+
const inflight = /* @__PURE__ */ new Set();
|
|
2016
|
+
const activity = { at: Date.now(), requests: 0 };
|
|
2017
|
+
const started = (request) => {
|
|
2018
|
+
inflight.add(request);
|
|
2019
|
+
activity.at = Date.now();
|
|
2020
|
+
activity.requests++;
|
|
2021
|
+
};
|
|
2022
|
+
const ended = (request) => {
|
|
2023
|
+
inflight.delete(request);
|
|
2024
|
+
activity.at = Date.now();
|
|
2025
|
+
};
|
|
2026
|
+
page.on("request", started);
|
|
2027
|
+
page.on("requestfinished", ended);
|
|
2028
|
+
page.on("requestfailed", ended);
|
|
2029
|
+
return {
|
|
2030
|
+
get requests() {
|
|
2031
|
+
return activity.requests;
|
|
2032
|
+
},
|
|
2033
|
+
/** Resolves once no request has been in flight for 500 ms, or at the deadline. */
|
|
2034
|
+
async quiet(deadline) {
|
|
2035
|
+
for (; ; ) {
|
|
2036
|
+
const now = Date.now();
|
|
2037
|
+
if (now >= deadline) {
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
if (inflight.size === 0 && now - activity.at >= NETWORK_QUIET_MS) {
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2044
|
+
}
|
|
2045
|
+
},
|
|
2046
|
+
/** Starts the quiet window again from now. */
|
|
2047
|
+
restart() {
|
|
2048
|
+
activity.at = Date.now();
|
|
2049
|
+
},
|
|
2050
|
+
stop() {
|
|
2051
|
+
page.off("request", started);
|
|
2052
|
+
page.off("requestfinished", ended);
|
|
2053
|
+
page.off("requestfailed", ended);
|
|
2054
|
+
}
|
|
2055
|
+
};
|
|
2056
|
+
}
|
|
2057
|
+
function hydrated() {
|
|
2058
|
+
const nodes = [
|
|
2059
|
+
document.querySelector("[data-zappie-item]"),
|
|
2060
|
+
...document.querySelectorAll("[data-zappie-overlay-trigger]")
|
|
2061
|
+
];
|
|
2062
|
+
return nodes.every(
|
|
2063
|
+
(node) => node !== null && Object.keys(node).some(
|
|
2064
|
+
(key) => key.startsWith("__reactProps$") || key.startsWith("__reactFiber$")
|
|
2065
|
+
)
|
|
2066
|
+
);
|
|
2067
|
+
}
|
|
2068
|
+
async function openSettledMenu(page) {
|
|
2069
|
+
const network = watchNetwork(page);
|
|
2070
|
+
try {
|
|
2071
|
+
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
2072
|
+
await page.locator("[data-zappie-item]").first().waitFor();
|
|
2073
|
+
const deadline = Date.now() + SETTLE_TIMEOUT_MS;
|
|
2074
|
+
const remaining = () => Math.max(deadline - Date.now(), 1);
|
|
2075
|
+
await page.waitForFunction(hydrated, null, { timeout: remaining() }).catch(() => void 0);
|
|
2076
|
+
await page.waitForLoadState("load", { timeout: remaining() }).catch(() => void 0);
|
|
2077
|
+
network.restart();
|
|
2078
|
+
await network.quiet(deadline);
|
|
2079
|
+
for (let round = 0; round < SETTLE_ROUNDS; round++) {
|
|
2080
|
+
const before = network.requests;
|
|
2081
|
+
await page.evaluate(
|
|
2082
|
+
({ fallback, remaining: remaining2 }) => new Promise((resolve) => {
|
|
2083
|
+
window.setTimeout(resolve, remaining2);
|
|
2084
|
+
const idle = (then) => {
|
|
2085
|
+
if (typeof window.requestIdleCallback === "function") {
|
|
2086
|
+
window.requestIdleCallback(() => then());
|
|
2087
|
+
} else {
|
|
2088
|
+
window.setTimeout(then, fallback);
|
|
2089
|
+
}
|
|
2090
|
+
};
|
|
2091
|
+
idle(() => idle(resolve));
|
|
2092
|
+
}),
|
|
2093
|
+
{ fallback: IDLE_FALLBACK_MS, remaining: remaining() }
|
|
2094
|
+
);
|
|
2095
|
+
await network.quiet(deadline);
|
|
2096
|
+
if (network.requests === before || Date.now() >= deadline) {
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
} finally {
|
|
2101
|
+
network.stop();
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
// src/checks/v16-overlays-open-quickly.ts
|
|
2106
|
+
var OVERLAY_OPEN_BUDGET_MS = 100;
|
|
2107
|
+
var OVERLAY_OPEN_SAMPLES = 3;
|
|
2108
|
+
var OPEN_TIMEOUT_MS = 5e3;
|
|
2109
|
+
var median = (values) => {
|
|
2110
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
2111
|
+
return sorted[Math.floor(sorted.length / 2)];
|
|
2112
|
+
};
|
|
2113
|
+
var v16 = {
|
|
2114
|
+
id: "V16",
|
|
2115
|
+
title: "Overlays open quickly",
|
|
2116
|
+
viewports: "mobile",
|
|
2117
|
+
async run({ page, metrics, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
|
|
2118
|
+
await page.setViewportSize(viewport);
|
|
2119
|
+
const cdp = await applyPhoneProfile(page, cpuThrottlingRate2);
|
|
2120
|
+
try {
|
|
2121
|
+
await openSettledMenu(page);
|
|
2122
|
+
const names = (await overlayTriggers(page)).map(({ name }) => name);
|
|
2123
|
+
skipWithoutTriggers(names);
|
|
2124
|
+
await cdp.send("Network.setCacheDisabled", { cacheDisabled: false });
|
|
2125
|
+
const timings = {};
|
|
2126
|
+
const samples = {};
|
|
2127
|
+
const unopened = [];
|
|
2128
|
+
for (const name of names) {
|
|
2129
|
+
const opens = [];
|
|
2130
|
+
for (let sample = 0; sample < OVERLAY_OPEN_SAMPLES; sample++) {
|
|
2131
|
+
await openSettledMenu(page);
|
|
2132
|
+
const trigger = page.locator(`${triggerSelector(name)}:visible`).first();
|
|
2133
|
+
await trigger.evaluate(
|
|
2134
|
+
(node) => node.scrollIntoView({ block: "center", inline: "nearest" })
|
|
2135
|
+
);
|
|
2136
|
+
await armOverlayTimer(page, name, OPEN_TIMEOUT_MS);
|
|
2137
|
+
await trigger.click({ noWaitAfter: true });
|
|
2138
|
+
const ms = await readOverlayTimer(page);
|
|
2139
|
+
if (ms === void 0) {
|
|
2140
|
+
break;
|
|
2141
|
+
}
|
|
2142
|
+
opens.push(Math.round(ms));
|
|
2143
|
+
}
|
|
2144
|
+
if (opens.length < OVERLAY_OPEN_SAMPLES) {
|
|
2145
|
+
unopened.push(name);
|
|
2146
|
+
} else {
|
|
2147
|
+
samples[name] = opens;
|
|
2148
|
+
timings[name] = median(opens);
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
metrics.overlayOpenMs = timings;
|
|
2152
|
+
metrics.overlayOpenSamplesMs = samples;
|
|
2153
|
+
assertion(
|
|
2154
|
+
unopened.length === 0,
|
|
2155
|
+
"overlay_not_opened",
|
|
2156
|
+
`No data-zappie-overlay painted after tapping ${unopened.map((name) => `the ${name} trigger`).join(", ")}`,
|
|
2157
|
+
`Put data-zappie-overlay with the trigger's name on the role="dialog" element that the trigger opens.`
|
|
2158
|
+
);
|
|
2159
|
+
const slow = Object.entries(timings).filter(
|
|
2160
|
+
([, ms]) => ms > OVERLAY_OPEN_BUDGET_MS
|
|
2161
|
+
);
|
|
2162
|
+
assertion(
|
|
2163
|
+
slow.length === 0,
|
|
2164
|
+
"overlay_open_too_slow",
|
|
2165
|
+
`Overlays took longer than ${OVERLAY_OPEN_BUDGET_MS} ms to paint, as the median of ${OVERLAY_OPEN_SAMPLES} opens: ${slow.map(([name, ms]) => `${name} ${ms} ms (${samples[name].join(", ")} ms)`).join(", ")}`,
|
|
2166
|
+
"Load overlay code with deferred() from @zappie/foundations, spread useIntentPreload on the trigger, and keep work out of the first render so the overlay paints within 100 ms of the tap."
|
|
2167
|
+
);
|
|
2168
|
+
} finally {
|
|
2169
|
+
await cdp.detach();
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
};
|
|
2173
|
+
|
|
2174
|
+
// src/checks/v17-overlay-focus.ts
|
|
2175
|
+
var focusInside = (page, name) => page.evaluate(
|
|
2176
|
+
(selector) => Boolean(document.activeElement?.closest(selector)) || // A native modal dialog keeps focus in its top layer even when the element itself is the dialog.
|
|
2177
|
+
document.activeElement === document.querySelector(selector),
|
|
2178
|
+
overlaySelector(name)
|
|
2179
|
+
);
|
|
2180
|
+
async function eventually(check, timeoutMs = 1e3) {
|
|
2181
|
+
const deadline = Date.now() + timeoutMs;
|
|
2182
|
+
for (; ; ) {
|
|
2183
|
+
if (await check()) {
|
|
2184
|
+
return true;
|
|
2185
|
+
}
|
|
2186
|
+
if (Date.now() > deadline) {
|
|
2187
|
+
return false;
|
|
2188
|
+
}
|
|
2189
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
var settlesInside = (page, name) => eventually(() => focusInside(page, name), 300);
|
|
2193
|
+
var scrollLocked = (page) => page.evaluate(() => {
|
|
2194
|
+
const root = document.scrollingElement ?? document.documentElement;
|
|
2195
|
+
if (root.scrollHeight <= window.innerHeight) {
|
|
2196
|
+
return true;
|
|
2197
|
+
}
|
|
2198
|
+
const held = (element) => {
|
|
2199
|
+
const style = getComputedStyle(element);
|
|
2200
|
+
return ["hidden", "clip"].includes(style.overflowY) || ["hidden", "clip"].includes(style.overflow) || style.position === "fixed";
|
|
2201
|
+
};
|
|
2202
|
+
return held(document.documentElement) || held(document.body);
|
|
2203
|
+
});
|
|
2204
|
+
async function checkOverlay(page, name) {
|
|
2205
|
+
await openSettledMenu(page);
|
|
2206
|
+
const trigger = page.locator(`${triggerSelector(name)}:visible`).first();
|
|
2207
|
+
await trigger.focus();
|
|
2208
|
+
await page.keyboard.press("Enter");
|
|
2209
|
+
if (!await eventually(() => overlayIsOpen(page, name), 5e3)) {
|
|
2210
|
+
return [
|
|
2211
|
+
{
|
|
2212
|
+
code: "overlay_not_opened",
|
|
2213
|
+
message: `Enter on the ${name} trigger did not open its data-zappie-overlay`,
|
|
2214
|
+
hint: "Open the overlay from the keyboard as well as by tap, and mark its dialog with data-zappie-overlay."
|
|
2215
|
+
}
|
|
2216
|
+
];
|
|
2217
|
+
}
|
|
2218
|
+
const findings = [];
|
|
2219
|
+
if (!await eventually(() => focusInside(page, name))) {
|
|
2220
|
+
findings.push({
|
|
2221
|
+
code: "overlay_focus_not_moved",
|
|
2222
|
+
message: `Focus stayed outside the ${name} overlay after it opened`,
|
|
2223
|
+
hint: "Move focus into the overlay when it opens. Overlay from @zappie/foundations does this."
|
|
2224
|
+
});
|
|
2225
|
+
} else {
|
|
2226
|
+
const stops = await page.locator(overlaySelector(name)).first().evaluate(
|
|
2227
|
+
(node) => node.querySelectorAll(
|
|
2228
|
+
'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
2229
|
+
).length
|
|
2230
|
+
);
|
|
2231
|
+
let escaped = false;
|
|
2232
|
+
for (let index = 0; index < Math.min(stops + 2, 30) && !escaped; index++) {
|
|
2233
|
+
await page.keyboard.press("Tab");
|
|
2234
|
+
escaped = !await settlesInside(page, name);
|
|
2235
|
+
}
|
|
2236
|
+
if (!escaped) {
|
|
2237
|
+
await page.keyboard.press("Shift+Tab");
|
|
2238
|
+
escaped = !await settlesInside(page, name);
|
|
2239
|
+
}
|
|
2240
|
+
if (escaped) {
|
|
2241
|
+
findings.push({
|
|
2242
|
+
code: "overlay_focus_escaped",
|
|
2243
|
+
message: `Tab moved focus out of the open ${name} overlay`,
|
|
2244
|
+
hint: "Keep Tab and Shift+Tab inside the overlay while it is open. Overlay from @zappie/foundations traps focus."
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
if (!await scrollLocked(page)) {
|
|
2249
|
+
findings.push({
|
|
2250
|
+
code: "overlay_scroll_unlocked",
|
|
2251
|
+
message: `The page behind the open ${name} overlay can still scroll`,
|
|
2252
|
+
hint: "Lock page scroll while the overlay is open. Overlay from @zappie/foundations does this."
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
2255
|
+
await page.keyboard.press("Escape");
|
|
2256
|
+
if (!await eventually(async () => !await overlayIsOpen(page, name))) {
|
|
2257
|
+
findings.push({
|
|
2258
|
+
code: "overlay_escape_ignored",
|
|
2259
|
+
message: `Escape did not close the ${name} overlay`,
|
|
2260
|
+
hint: "Close the overlay on Escape."
|
|
2261
|
+
});
|
|
2262
|
+
return findings;
|
|
2263
|
+
}
|
|
2264
|
+
const returned = await eventually(
|
|
2265
|
+
() => page.evaluate(
|
|
2266
|
+
(selector) => Boolean(document.activeElement?.closest(selector)),
|
|
2267
|
+
triggerSelector(name)
|
|
2268
|
+
)
|
|
2269
|
+
);
|
|
2270
|
+
if (!returned) {
|
|
2271
|
+
findings.push({
|
|
2272
|
+
code: "overlay_focus_not_returned",
|
|
2273
|
+
message: `Focus did not return to the ${name} trigger after the overlay closed`,
|
|
2274
|
+
hint: "Return focus to the control that opened the overlay when it closes. Overlay from @zappie/foundations does this."
|
|
2275
|
+
});
|
|
2276
|
+
}
|
|
2277
|
+
return findings;
|
|
2278
|
+
}
|
|
2279
|
+
var v17 = {
|
|
2280
|
+
id: "V17",
|
|
2281
|
+
title: "Overlays manage focus",
|
|
2282
|
+
viewports: "mobile",
|
|
2283
|
+
async run({ page }, viewport) {
|
|
2284
|
+
await page.setViewportSize(viewport);
|
|
2285
|
+
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
2286
|
+
await page.locator("[data-zappie-item]").first().waitFor();
|
|
2287
|
+
const names = (await overlayTriggers(page)).map(({ name }) => name);
|
|
2288
|
+
skipWithoutTriggers(names);
|
|
2289
|
+
const findings = [];
|
|
2290
|
+
for (const name of names) {
|
|
2291
|
+
findings.push(...await checkOverlay(page, name));
|
|
2292
|
+
}
|
|
2293
|
+
const [first] = findings;
|
|
2294
|
+
if (first) {
|
|
2295
|
+
throw new VerifyError(
|
|
2296
|
+
first.code,
|
|
2297
|
+
findings.map(({ message }) => message).join(". "),
|
|
2298
|
+
[...new Set(findings.map(({ hint }) => hint))].join(" ")
|
|
2299
|
+
);
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
};
|
|
2303
|
+
|
|
1620
2304
|
// src/checks/index.ts
|
|
1621
2305
|
var checks = [
|
|
1622
2306
|
v01,
|
|
@@ -1630,7 +2314,9 @@ var checks = [
|
|
|
1630
2314
|
v10,
|
|
1631
2315
|
v13,
|
|
1632
2316
|
v14,
|
|
1633
|
-
v15
|
|
2317
|
+
v15,
|
|
2318
|
+
v16,
|
|
2319
|
+
v17
|
|
1634
2320
|
];
|
|
1635
2321
|
var blocked = [
|
|
1636
2322
|
[
|
|
@@ -1676,6 +2362,60 @@ function skippedChecks(suite) {
|
|
|
1676
2362
|
];
|
|
1677
2363
|
}
|
|
1678
2364
|
|
|
2365
|
+
// src/cpu.ts
|
|
2366
|
+
var REFERENCE_CPU_BENCHMARK = 26e3;
|
|
2367
|
+
var REFERENCE_CPU_THROTTLING_RATE = 4;
|
|
2368
|
+
var MIN_RATE = 1;
|
|
2369
|
+
var MAX_RATE = 8;
|
|
2370
|
+
var ROUNDS = 5;
|
|
2371
|
+
var ROUND_MS = 100;
|
|
2372
|
+
function cpuThrottlingRate(benchmark) {
|
|
2373
|
+
if (!Number.isFinite(benchmark) || benchmark <= 0) {
|
|
2374
|
+
return REFERENCE_CPU_THROTTLING_RATE;
|
|
2375
|
+
}
|
|
2376
|
+
const rate = REFERENCE_CPU_THROTTLING_RATE * benchmark / REFERENCE_CPU_BENCHMARK;
|
|
2377
|
+
return Math.round(Math.min(Math.max(rate, MIN_RATE), MAX_RATE) * 10) / 10;
|
|
2378
|
+
}
|
|
2379
|
+
async function benchmarkCpu(browser) {
|
|
2380
|
+
const context = await browser.newContext();
|
|
2381
|
+
try {
|
|
2382
|
+
const page = await context.newPage();
|
|
2383
|
+
return await page.evaluate(
|
|
2384
|
+
({ rounds, roundMs }) => {
|
|
2385
|
+
const work = () => {
|
|
2386
|
+
let text = "";
|
|
2387
|
+
for (let index = 0; index < 500; index++) {
|
|
2388
|
+
text += String.fromCharCode(97 + index % 26);
|
|
2389
|
+
}
|
|
2390
|
+
const values = Array.from(
|
|
2391
|
+
{ length: 500 },
|
|
2392
|
+
(_, index) => index * 7919 % 500
|
|
2393
|
+
);
|
|
2394
|
+
values.sort((a, b) => a - b);
|
|
2395
|
+
return text.length + values[0];
|
|
2396
|
+
};
|
|
2397
|
+
const scores = [];
|
|
2398
|
+
let sink = 0;
|
|
2399
|
+
for (let round = 0; round < rounds; round++) {
|
|
2400
|
+
const started = performance.now();
|
|
2401
|
+
let iterations = 0;
|
|
2402
|
+
while (performance.now() - started < roundMs) {
|
|
2403
|
+
sink += work();
|
|
2404
|
+
iterations++;
|
|
2405
|
+
}
|
|
2406
|
+
scores.push(iterations * 1e3 / (performance.now() - started));
|
|
2407
|
+
}
|
|
2408
|
+
scores.sort((a, b) => a - b);
|
|
2409
|
+
window.__verifySink = sink;
|
|
2410
|
+
return Math.round(scores[Math.floor(scores.length / 2)]);
|
|
2411
|
+
},
|
|
2412
|
+
{ rounds: ROUNDS, roundMs: ROUND_MS }
|
|
2413
|
+
);
|
|
2414
|
+
} finally {
|
|
2415
|
+
await context.close();
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
|
|
1679
2419
|
// src/report.ts
|
|
1680
2420
|
import { createPrivateKey, randomUUID, sign } from "crypto";
|
|
1681
2421
|
var REPORT_SCHEMA = "https://registry.zappie.ai/v1/schema/verify-report.json";
|
|
@@ -1762,7 +2502,7 @@ async function reset(options) {
|
|
|
1762
2502
|
}
|
|
1763
2503
|
|
|
1764
2504
|
// src/version.ts
|
|
1765
|
-
var PACKAGE_VERSION = true ? "0.
|
|
2505
|
+
var PACKAGE_VERSION = true ? "0.2.2" : "0.0.0-development";
|
|
1766
2506
|
|
|
1767
2507
|
// src/runner.ts
|
|
1768
2508
|
var VERSION = PACKAGE_VERSION;
|
|
@@ -1811,7 +2551,7 @@ async function execute(check, run2) {
|
|
|
1811
2551
|
message: failure instanceof Error ? failure.message : String(failure),
|
|
1812
2552
|
hint: `Fix the failure in ${check.id} (${check.title.toLowerCase()}) and rerun verify.`
|
|
1813
2553
|
};
|
|
1814
|
-
if (error.code === "dependency_blocked") {
|
|
2554
|
+
if (error.code === "dependency_blocked" || failure instanceof VerifyError && failure.skip) {
|
|
1815
2555
|
return {
|
|
1816
2556
|
id: check.id,
|
|
1817
2557
|
title: check.title,
|
|
@@ -1853,12 +2593,29 @@ async function runVerify(options, fault) {
|
|
|
1853
2593
|
}
|
|
1854
2594
|
const started = Date.now();
|
|
1855
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);
|
|
1856
2598
|
const context = await browser.newContext(browserOptions(options));
|
|
1857
2599
|
if (options.standInForPlatform) {
|
|
1858
2600
|
await context.route(
|
|
1859
2601
|
"**/_zappie/view",
|
|
1860
2602
|
(route) => route.fulfill({ status: 204 })
|
|
1861
2603
|
);
|
|
2604
|
+
await context.route(
|
|
2605
|
+
(url) => url.pathname === "/_zappie/session",
|
|
2606
|
+
async (route) => {
|
|
2607
|
+
const cookie = await route.request().headerValue("cookie") ?? "";
|
|
2608
|
+
const signedIn = cookie.split(";").some((part) => part.trim() === "zappie_session=signed");
|
|
2609
|
+
await route.fulfill({
|
|
2610
|
+
status: 200,
|
|
2611
|
+
contentType: "application/json",
|
|
2612
|
+
headers: { "cache-control": "no-store" },
|
|
2613
|
+
body: JSON.stringify({
|
|
2614
|
+
customer: signedIn ? { id: "customer_verify", given_name: "Test" } : null
|
|
2615
|
+
})
|
|
2616
|
+
});
|
|
2617
|
+
}
|
|
2618
|
+
);
|
|
1862
2619
|
}
|
|
1863
2620
|
await context.tracing.start({ screenshots: true, snapshots: true });
|
|
1864
2621
|
const page = await context.newPage();
|
|
@@ -1871,6 +2628,7 @@ async function runVerify(options, fault) {
|
|
|
1871
2628
|
context,
|
|
1872
2629
|
page,
|
|
1873
2630
|
metrics,
|
|
2631
|
+
cpuThrottlingRate: throttlingRate,
|
|
1874
2632
|
...fault ? { fault } : {}
|
|
1875
2633
|
};
|
|
1876
2634
|
const results = [await reset(options)];
|
|
@@ -1899,7 +2657,9 @@ async function runVerify(options, fault) {
|
|
|
1899
2657
|
runner: options.runner ?? "cli",
|
|
1900
2658
|
verifyVersion: VERSION,
|
|
1901
2659
|
sdkVersion,
|
|
1902
|
-
browser: `chromium ${browser.version()}
|
|
2660
|
+
browser: `chromium ${browser.version()}`,
|
|
2661
|
+
...cpuBenchmark === void 0 ? {} : { cpuBenchmark },
|
|
2662
|
+
cpuThrottlingRate: throttlingRate
|
|
1903
2663
|
},
|
|
1904
2664
|
startedAt: new Date(started).toISOString(),
|
|
1905
2665
|
durationMs: Date.now() - started,
|
|
@@ -1971,12 +2731,64 @@ function normalizeUrl(value) {
|
|
|
1971
2731
|
}
|
|
1972
2732
|
}
|
|
1973
2733
|
|
|
2734
|
+
// src/regression.ts
|
|
2735
|
+
var DEFAULT_REGRESSION_THRESHOLDS = {
|
|
2736
|
+
menuJsKbGz: { relative: 0.05, absolute: 2 },
|
|
2737
|
+
menuCssKbGz: { relative: 0.05, absolute: 1 },
|
|
2738
|
+
lcpMs: { relative: 0.1, absolute: 150 },
|
|
2739
|
+
cls: { relative: 0, absolute: 0.02 },
|
|
2740
|
+
productCardTappableMs: { relative: 0.1, absolute: 150 },
|
|
2741
|
+
productCardOpenMs: { relative: 0.15, absolute: 30 },
|
|
2742
|
+
overlayOpenMs: { relative: 0.15, absolute: 20 }
|
|
2743
|
+
};
|
|
2744
|
+
function flatten(metrics) {
|
|
2745
|
+
const values = /* @__PURE__ */ new Map();
|
|
2746
|
+
for (const [key, value] of Object.entries(metrics)) {
|
|
2747
|
+
if (key === "overlayOpenMs" && value && typeof value === "object") {
|
|
2748
|
+
for (const [name, ms] of Object.entries(value)) {
|
|
2749
|
+
if (Number.isFinite(ms)) {
|
|
2750
|
+
values.set(`overlayOpenMs.${name}`, {
|
|
2751
|
+
kind: "overlayOpenMs",
|
|
2752
|
+
value: ms
|
|
2753
|
+
});
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
} else if (key in DEFAULT_REGRESSION_THRESHOLDS && typeof value === "number" && Number.isFinite(value)) {
|
|
2757
|
+
values.set(key, { kind: key, value });
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
return values;
|
|
2761
|
+
}
|
|
2762
|
+
function findRegressions(baseline, head, thresholds = {}) {
|
|
2763
|
+
const limits = { ...DEFAULT_REGRESSION_THRESHOLDS, ...thresholds };
|
|
2764
|
+
const current = flatten(head.metrics);
|
|
2765
|
+
const regressions = [];
|
|
2766
|
+
for (const [metric, { kind, value: base }] of flatten(baseline.metrics)) {
|
|
2767
|
+
const { relative, absolute } = limits[kind];
|
|
2768
|
+
const allowed = base + Math.max(absolute, relative * base);
|
|
2769
|
+
const value = current.get(metric)?.value;
|
|
2770
|
+
if (value === void 0 || value > allowed) {
|
|
2771
|
+
regressions.push({
|
|
2772
|
+
metric,
|
|
2773
|
+
base,
|
|
2774
|
+
head: value,
|
|
2775
|
+
allowed: Math.round(allowed * 1e3) / 1e3
|
|
2776
|
+
});
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
return regressions.sort(
|
|
2780
|
+
(left, right) => left.metric.localeCompare(right.metric)
|
|
2781
|
+
);
|
|
2782
|
+
}
|
|
2783
|
+
|
|
1974
2784
|
// src/index.ts
|
|
1975
2785
|
function verify(options) {
|
|
1976
2786
|
return runVerify(options);
|
|
1977
2787
|
}
|
|
1978
2788
|
export {
|
|
2789
|
+
DEFAULT_REGRESSION_THRESHOLDS,
|
|
1979
2790
|
VerifyError,
|
|
2791
|
+
findRegressions,
|
|
1980
2792
|
printSummary,
|
|
1981
2793
|
verify,
|
|
1982
2794
|
verifyProject
|