@zappie/verify 0.2.9 → 0.3.1
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 +20 -0
- package/README.md +27 -1
- package/dist/index.d.ts +48 -1
- package/dist/index.js +754 -366
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/schema/verify-report.v1.json +20 -1
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
requestWithRetry,
|
|
10
10
|
resolveSourceMenu,
|
|
11
11
|
stableErrorCode,
|
|
12
|
+
sumMoney,
|
|
12
13
|
validateConfig,
|
|
13
14
|
validateLine
|
|
14
15
|
} from "./chunk-CW3HI5TY.js";
|
|
@@ -287,7 +288,37 @@ function supportsTypeScriptImport(version = process.versions.node) {
|
|
|
287
288
|
const [major = 0, minor = 0] = version.split(".").map(Number);
|
|
288
289
|
return major > 22 || major === 22 && minor >= 18;
|
|
289
290
|
}
|
|
290
|
-
async function
|
|
291
|
+
async function readProjectSettings(cwd, { required }) {
|
|
292
|
+
let value;
|
|
293
|
+
try {
|
|
294
|
+
value = await loadConfig(cwd);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (required) {
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
const exists = await stat2(path2.join(cwd, "zappie.config.ts")).then(
|
|
300
|
+
() => true,
|
|
301
|
+
() => false
|
|
302
|
+
);
|
|
303
|
+
return exists && error instanceof VerifyError ? { ignored: error.message } : {};
|
|
304
|
+
}
|
|
305
|
+
const config = record(value) ? value : {};
|
|
306
|
+
const verify2 = record(config.verify) ? config.verify : {};
|
|
307
|
+
const settings = {
|
|
308
|
+
...typeof config.store === "string" ? { store: config.store } : {},
|
|
309
|
+
...verify2.startPath !== void 0 ? { startPath: verify2.startPath } : {},
|
|
310
|
+
...verify2.cataloguePath !== void 0 ? { cataloguePath: verify2.cataloguePath } : {}
|
|
311
|
+
};
|
|
312
|
+
if (required && settings.store === void 0) {
|
|
313
|
+
throw new VerifyError(
|
|
314
|
+
"store_missing",
|
|
315
|
+
"zappie.config.ts must define a store",
|
|
316
|
+
"Define store in zappie.config.ts or pass --store."
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
return settings;
|
|
320
|
+
}
|
|
321
|
+
async function loadConfig(cwd) {
|
|
291
322
|
if (!supportsTypeScriptImport()) {
|
|
292
323
|
throw new VerifyError(
|
|
293
324
|
"unsupported_node",
|
|
@@ -300,10 +331,7 @@ async function readStore(cwd) {
|
|
|
300
331
|
const version = (await stat2(file)).mtimeMs;
|
|
301
332
|
await readFile2(file, "utf8");
|
|
302
333
|
const loaded = await import(`${pathToFileURL(file).href}?verify=${version}`);
|
|
303
|
-
|
|
304
|
-
if (value && typeof value === "object" && "store" in value && typeof value.store === "string") {
|
|
305
|
-
return value.store;
|
|
306
|
-
}
|
|
334
|
+
return loaded.default;
|
|
307
335
|
} catch (error) {
|
|
308
336
|
const message = error instanceof Error ? error.message : String(error);
|
|
309
337
|
throw new VerifyError(
|
|
@@ -312,12 +340,8 @@ async function readStore(cwd) {
|
|
|
312
340
|
"Create a readable zappie.config.ts or pass --store."
|
|
313
341
|
);
|
|
314
342
|
}
|
|
315
|
-
throw new VerifyError(
|
|
316
|
-
"store_missing",
|
|
317
|
-
"zappie.config.ts must define a store",
|
|
318
|
-
"Define store in zappie.config.ts or pass --store."
|
|
319
|
-
);
|
|
320
343
|
}
|
|
344
|
+
var record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
321
345
|
|
|
322
346
|
// src/runner.ts
|
|
323
347
|
import {
|
|
@@ -327,6 +351,53 @@ import {
|
|
|
327
351
|
import { mkdir } from "fs/promises";
|
|
328
352
|
import path3 from "path";
|
|
329
353
|
|
|
354
|
+
// src/paths.ts
|
|
355
|
+
var DEFAULT_START_PATH = "/";
|
|
356
|
+
var ROUTE = /^\/(?!\/)[^\\]*$/;
|
|
357
|
+
var SITE = "http://site.invalid";
|
|
358
|
+
function isRoute(path4) {
|
|
359
|
+
return typeof path4 === "string" && ROUTE.test(path4) && new URL(path4, SITE).origin === SITE;
|
|
360
|
+
}
|
|
361
|
+
function startPath(options) {
|
|
362
|
+
return options.startPath ?? DEFAULT_START_PATH;
|
|
363
|
+
}
|
|
364
|
+
function hasPlaceholders(path4) {
|
|
365
|
+
return path4.includes("{menu}") || path4.includes("{branch}");
|
|
366
|
+
}
|
|
367
|
+
function checkPaths(options) {
|
|
368
|
+
const { startPath: start, cataloguePath } = options;
|
|
369
|
+
if (start !== void 0 && !isRoute(start)) {
|
|
370
|
+
throw new VerifyError(
|
|
371
|
+
"invalid_start_path",
|
|
372
|
+
`The start path ${JSON.stringify(start)} is not a route on the site`,
|
|
373
|
+
"Set verify.startPath or --start to a route that starts with one /, such as /menu or /menu?menu={menu}."
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
if (cataloguePath !== void 0 && (!isRoute(cataloguePath) || !cataloguePath.includes("{menu}"))) {
|
|
377
|
+
throw new VerifyError(
|
|
378
|
+
"invalid_catalogue_path",
|
|
379
|
+
`The catalogue path ${JSON.stringify(cataloguePath)} is not a route with {menu}`,
|
|
380
|
+
"Set verify.cataloguePath to a route that starts with one / and puts {menu} where the menu id goes, such as /menu?menu={menu}."
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function cataloguePage(cataloguePath, menuId, branchId) {
|
|
385
|
+
return cataloguePath.replaceAll("{menu}", encodeURIComponent(menuId)).replaceAll("{branch}", encodeURIComponent(branchId));
|
|
386
|
+
}
|
|
387
|
+
function isAtPath(url, path4) {
|
|
388
|
+
let current;
|
|
389
|
+
try {
|
|
390
|
+
current = new URL(url);
|
|
391
|
+
} catch {
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
if (current.protocol !== "http:" && current.protocol !== "https:") {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
const wanted = new URL(path4, current);
|
|
398
|
+
return current.pathname === wanted.pathname && (wanted.search === "" || current.search === wanted.search);
|
|
399
|
+
}
|
|
400
|
+
|
|
330
401
|
// src/cpu.ts
|
|
331
402
|
var REFERENCE_CPU_BENCHMARK = 26e3;
|
|
332
403
|
var REFERENCE_CPU_THROTTLING_RATE = 4;
|
|
@@ -384,7 +455,7 @@ async function benchmarkCpu(browser) {
|
|
|
384
455
|
}
|
|
385
456
|
|
|
386
457
|
// src/checks/common.ts
|
|
387
|
-
async function open(page, path4
|
|
458
|
+
async function open(page, path4) {
|
|
388
459
|
const response = await page.goto(path4, { waitUntil: "domcontentloaded" });
|
|
389
460
|
assertion(
|
|
390
461
|
response?.status() === 200,
|
|
@@ -554,8 +625,8 @@ async function dialogConfirm(page, confirm) {
|
|
|
554
625
|
}
|
|
555
626
|
|
|
556
627
|
// src/checks/routes.ts
|
|
557
|
-
async function publicRoutePaths(request, baseUrl) {
|
|
558
|
-
const paths = /* @__PURE__ */ new Set(["/"]);
|
|
628
|
+
async function publicRoutePaths(request, baseUrl, start = "/") {
|
|
629
|
+
const paths = /* @__PURE__ */ new Set(["/", start]);
|
|
559
630
|
const sitemap = await request.get(new URL("/sitemap.xml", baseUrl).href);
|
|
560
631
|
if (sitemap.ok()) {
|
|
561
632
|
for (const match of (await sitemap.text()).matchAll(
|
|
@@ -583,7 +654,11 @@ var v01 = {
|
|
|
583
654
|
};
|
|
584
655
|
page.on("pageerror", onPageError);
|
|
585
656
|
page.on("console", onConsole);
|
|
586
|
-
for (const path4 of await publicRoutePaths(
|
|
657
|
+
for (const path4 of await publicRoutePaths(
|
|
658
|
+
page.request,
|
|
659
|
+
options.url,
|
|
660
|
+
startPath(options)
|
|
661
|
+
)) {
|
|
587
662
|
await open(page, path4);
|
|
588
663
|
}
|
|
589
664
|
page.off("pageerror", onPageError);
|
|
@@ -604,7 +679,7 @@ var v02 = {
|
|
|
604
679
|
viewports: "mobile",
|
|
605
680
|
async run({ page, options }, viewport) {
|
|
606
681
|
await page.setViewportSize(viewport);
|
|
607
|
-
await open(page);
|
|
682
|
+
await open(page, startPath(options));
|
|
608
683
|
const debug = await debugValue(page, () => ({
|
|
609
684
|
store: window.__zappie?.store,
|
|
610
685
|
version: window.__zappie?.version
|
|
@@ -660,6 +735,95 @@ async function installFault(page, fault) {
|
|
|
660
735
|
await route.fulfill({ response, headers });
|
|
661
736
|
});
|
|
662
737
|
}
|
|
738
|
+
if (fault === "catalogue-directory") {
|
|
739
|
+
await page.route(
|
|
740
|
+
(url) => url.pathname === "/" && !url.searchParams.has("menu"),
|
|
741
|
+
async (route) => {
|
|
742
|
+
if (route.request().resourceType() !== "document") {
|
|
743
|
+
await route.continue();
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
const response = await route.fetch();
|
|
747
|
+
const headers = { ...response.headers() };
|
|
748
|
+
delete headers["content-length"];
|
|
749
|
+
delete headers["content-encoding"];
|
|
750
|
+
await route.fulfill({
|
|
751
|
+
response,
|
|
752
|
+
headers: { ...headers, "content-type": "text/html; charset=utf-8" },
|
|
753
|
+
body: [
|
|
754
|
+
'<!doctype html><html lang="en"><head><meta charset="utf-8">',
|
|
755
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
756
|
+
"<title>Choose a menu</title></head><body><main>",
|
|
757
|
+
"<h1>Choose a menu</h1><ul>",
|
|
758
|
+
'<li><a href="/?menu=menu_all_day">All day</a></li>',
|
|
759
|
+
'<li><a href="/?menu=menu_treats">Treats</a></li>',
|
|
760
|
+
"</ul></main></body></html>"
|
|
761
|
+
].join("")
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
if (fault === "catalogue-gap") {
|
|
767
|
+
await page.addInitScript(() => {
|
|
768
|
+
new MutationObserver(() => {
|
|
769
|
+
if (new URLSearchParams(location.search).get("menu") !== "menu_treats") {
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
for (const node of document.querySelectorAll(
|
|
773
|
+
'[data-zappie-item="item_zobo"]'
|
|
774
|
+
)) {
|
|
775
|
+
node.remove();
|
|
776
|
+
}
|
|
777
|
+
}).observe(document, { childList: true, subtree: true });
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
if (fault === "oversized" || fault === "late-chunk") {
|
|
781
|
+
const large = randomScript("__verifyLarge", 26e4);
|
|
782
|
+
await page.route(
|
|
783
|
+
"**/__verify-large.js",
|
|
784
|
+
(route) => route.fulfill({ contentType: "text/javascript", body: large })
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
if (fault === "oversized") {
|
|
788
|
+
await page.route(
|
|
789
|
+
(url) => url.pathname === "/",
|
|
790
|
+
async (route) => {
|
|
791
|
+
if (route.request().resourceType() !== "document") {
|
|
792
|
+
await route.continue();
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const response = await route.fetch();
|
|
796
|
+
const headers = { ...response.headers() };
|
|
797
|
+
delete headers["content-length"];
|
|
798
|
+
delete headers["content-encoding"];
|
|
799
|
+
await route.fulfill({
|
|
800
|
+
response,
|
|
801
|
+
headers,
|
|
802
|
+
body: (await response.text()).replace(
|
|
803
|
+
"</head>",
|
|
804
|
+
'<script defer src="/__verify-large.js"></script></head>'
|
|
805
|
+
)
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
if (fault === "late-chunk") {
|
|
811
|
+
await page.route(
|
|
812
|
+
"**/__verify-late.js",
|
|
813
|
+
(route) => route.fulfill({
|
|
814
|
+
contentType: "text/javascript",
|
|
815
|
+
body: randomScript("__verifyLate", 46e4)
|
|
816
|
+
})
|
|
817
|
+
);
|
|
818
|
+
await page.addInitScript(() => {
|
|
819
|
+
window.addEventListener("load", () => {
|
|
820
|
+
setTimeout(() => {
|
|
821
|
+
const late = "/__verify-late.js";
|
|
822
|
+
void import(late);
|
|
823
|
+
}, 0);
|
|
824
|
+
});
|
|
825
|
+
});
|
|
826
|
+
}
|
|
663
827
|
if (fault === "hydration-delay") {
|
|
664
828
|
await page.route(/\.(?:js|mjs)(?:\?|$)/, async (route) => {
|
|
665
829
|
await new Promise((resolve) => setTimeout(resolve, 3500));
|
|
@@ -738,7 +902,7 @@ async function installFault(page, fault) {
|
|
|
738
902
|
return;
|
|
739
903
|
}
|
|
740
904
|
const group = original.closest('[role="dialog"]')?.querySelector(
|
|
741
|
-
'[data-zappie-option-group][aria-required="true"]:not(:has([data-state="checked"]))'
|
|
905
|
+
'[data-zappie-option-group]:is([data-zappie-required="true"], [aria-required="true"]):has([data-zappie-option]):not(:has([data-state="checked"]))'
|
|
742
906
|
);
|
|
743
907
|
if (flags && group) {
|
|
744
908
|
group.setAttribute("aria-invalid", "true");
|
|
@@ -1080,6 +1244,23 @@ ${await response.text()}`
|
|
|
1080
1244
|
});
|
|
1081
1245
|
});
|
|
1082
1246
|
}
|
|
1247
|
+
if (fault === "from-price") {
|
|
1248
|
+
await page.addInitScript(() => {
|
|
1249
|
+
new MutationObserver(() => {
|
|
1250
|
+
for (const node of document.querySelectorAll(
|
|
1251
|
+
'[data-zappie-item="item_jollof"] .z-kit-money'
|
|
1252
|
+
)) {
|
|
1253
|
+
if (node.textContent !== "From \u20A65,000") {
|
|
1254
|
+
node.textContent = "From \u20A65,000";
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
}).observe(document, {
|
|
1258
|
+
childList: true,
|
|
1259
|
+
subtree: true,
|
|
1260
|
+
characterData: true
|
|
1261
|
+
});
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1083
1264
|
if (fault === "add-stuck") {
|
|
1084
1265
|
await page.addInitScript(() => {
|
|
1085
1266
|
document.addEventListener(
|
|
@@ -1157,20 +1338,14 @@ async function applyPageFault(page, fault) {
|
|
|
1157
1338
|
document.body.append(button);
|
|
1158
1339
|
});
|
|
1159
1340
|
}
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
(route) => route.fulfill({
|
|
1169
|
-
contentType: "text/javascript",
|
|
1170
|
-
body: `window.__verifyLarge=${JSON.stringify(payload)}`
|
|
1171
|
-
})
|
|
1172
|
-
);
|
|
1173
|
-
}
|
|
1341
|
+
}
|
|
1342
|
+
function randomScript(name, length) {
|
|
1343
|
+
let state = 1;
|
|
1344
|
+
const payload = Array.from({ length }, () => {
|
|
1345
|
+
state = state * 48271 % 2147483647;
|
|
1346
|
+
return String.fromCharCode(33 + state % 90);
|
|
1347
|
+
}).join("");
|
|
1348
|
+
return `window.${name}=${JSON.stringify(payload)}`;
|
|
1174
1349
|
}
|
|
1175
1350
|
|
|
1176
1351
|
// src/checks/catalogue.ts
|
|
@@ -1203,6 +1378,103 @@ function configurableItem(menu) {
|
|
|
1203
1378
|
(item) => item.isAvailable && item.optionGroups.some((group) => group.required)
|
|
1204
1379
|
);
|
|
1205
1380
|
}
|
|
1381
|
+
function catalogues(menu) {
|
|
1382
|
+
const sources = menu.menus?.length ? menu.menus : [
|
|
1383
|
+
{
|
|
1384
|
+
id: menu.id,
|
|
1385
|
+
name: menu.name,
|
|
1386
|
+
categoryIds: menu.categories.map((category) => category.id)
|
|
1387
|
+
}
|
|
1388
|
+
];
|
|
1389
|
+
return sources.map((source) => {
|
|
1390
|
+
const ids = new Set(
|
|
1391
|
+
menu.categories.filter((category) => source.categoryIds.includes(category.id)).flatMap((category) => category.itemIds)
|
|
1392
|
+
);
|
|
1393
|
+
return {
|
|
1394
|
+
id: source.id,
|
|
1395
|
+
name: source.name,
|
|
1396
|
+
items: sortedItems(menu).filter((item) => ids.has(item.id))
|
|
1397
|
+
};
|
|
1398
|
+
}).filter((catalogue) => catalogue.items.length > 0);
|
|
1399
|
+
}
|
|
1400
|
+
function fillStartPath(template, menu) {
|
|
1401
|
+
const first = catalogues(menu)[0]?.id ?? menu.menus?.[0]?.id ?? menu.id;
|
|
1402
|
+
return { path: cataloguePage(template, first, menu.branchId), menuId: first };
|
|
1403
|
+
}
|
|
1404
|
+
var startMenus = /* @__PURE__ */ new WeakMap();
|
|
1405
|
+
async function fillStartOptions(options, read = readMenu) {
|
|
1406
|
+
const template = options.startPath;
|
|
1407
|
+
if (template === void 0 || !hasPlaceholders(template)) {
|
|
1408
|
+
return options;
|
|
1409
|
+
}
|
|
1410
|
+
let filled;
|
|
1411
|
+
try {
|
|
1412
|
+
filled = fillStartPath(template, await read(options));
|
|
1413
|
+
} catch (failure) {
|
|
1414
|
+
if (failure instanceof VerifyError) {
|
|
1415
|
+
throw failure;
|
|
1416
|
+
}
|
|
1417
|
+
throw new VerifyError(
|
|
1418
|
+
"start_path_unfilled",
|
|
1419
|
+
`Verify could not read the menu to fill the start path ${template}: ${failure instanceof Error ? failure.message : String(failure)}`,
|
|
1420
|
+
"Check the store and key, or set a start path without {menu} or {branch}."
|
|
1421
|
+
);
|
|
1422
|
+
}
|
|
1423
|
+
checkPaths({ startPath: filled.path });
|
|
1424
|
+
const result = { ...options, startPath: filled.path };
|
|
1425
|
+
if (template.includes("{menu}")) {
|
|
1426
|
+
startMenus.set(result, filled.menuId);
|
|
1427
|
+
}
|
|
1428
|
+
return result;
|
|
1429
|
+
}
|
|
1430
|
+
function cataloguePages(options, menu) {
|
|
1431
|
+
const { cataloguePath } = options;
|
|
1432
|
+
const startMenu = startMenus.get(options);
|
|
1433
|
+
const shown = !cataloguePath && startMenu !== void 0 ? catalogues(menu).find((catalogue) => catalogue.id === startMenu) : void 0;
|
|
1434
|
+
if (shown) {
|
|
1435
|
+
return [{ path: startPath(options), items: shown.items, name: shown.name }];
|
|
1436
|
+
}
|
|
1437
|
+
const pages = cataloguePath ? catalogues(menu) : [];
|
|
1438
|
+
if (!cataloguePath || pages.length === 0) {
|
|
1439
|
+
return [{ path: startPath(options), items: sortedItems(menu) }];
|
|
1440
|
+
}
|
|
1441
|
+
return pages.map((catalogue) => ({
|
|
1442
|
+
path: cataloguePage(cataloguePath, catalogue.id, menu.branchId),
|
|
1443
|
+
items: catalogue.items,
|
|
1444
|
+
name: catalogue.name
|
|
1445
|
+
}));
|
|
1446
|
+
}
|
|
1447
|
+
function itemPath(options, menu, itemId) {
|
|
1448
|
+
const holder = options.cataloguePath && itemId ? catalogues(menu).find(
|
|
1449
|
+
(catalogue) => catalogue.items.some((item) => item.id === itemId)
|
|
1450
|
+
) : void 0;
|
|
1451
|
+
return holder && options.cataloguePath ? cataloguePage(options.cataloguePath, holder.id, menu.branchId) : startPath(options);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// ../sdk/src/pricing/index.ts
|
|
1455
|
+
var cheapest = (prices) => prices.filter((price) => price.amountMinor >= 0).sort((a, b) => a.amountMinor - b.amountMinor)[0];
|
|
1456
|
+
function startingPrice(item) {
|
|
1457
|
+
const available = item.variants.filter((variant) => variant.isAvailable);
|
|
1458
|
+
const base = cheapest(
|
|
1459
|
+
(available.length ? available : item.variants).map(
|
|
1460
|
+
(variant) => variant.price
|
|
1461
|
+
)
|
|
1462
|
+
) ?? item.price;
|
|
1463
|
+
return sumMoney(
|
|
1464
|
+
item.optionGroups.flatMap(
|
|
1465
|
+
(group) => !group.isAttribute && group.required && group.kind === "select" ? cheapest(group.choices.map((choice) => choice.price)) ?? [] : []
|
|
1466
|
+
),
|
|
1467
|
+
base
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
function showsFromPrice(item) {
|
|
1471
|
+
const prices = [
|
|
1472
|
+
...new Set(
|
|
1473
|
+
item.variants.filter((variant) => variant.isAvailable).map((variant) => variant.price.amountMinor)
|
|
1474
|
+
)
|
|
1475
|
+
];
|
|
1476
|
+
return prices.length > 1 || prices.length === 1 && startingPrice(item).amountMinor > prices[0];
|
|
1477
|
+
}
|
|
1206
1478
|
|
|
1207
1479
|
// src/checks/menu-match.ts
|
|
1208
1480
|
function menuCoverage(items, shownIds) {
|
|
@@ -1217,6 +1489,16 @@ function menuCoverage(items, shownIds) {
|
|
|
1217
1489
|
function priceMatches(text, formatted) {
|
|
1218
1490
|
return text?.includes(formatted) ?? false;
|
|
1219
1491
|
}
|
|
1492
|
+
function shownPrices(item) {
|
|
1493
|
+
const prices = [format.money(item.price)];
|
|
1494
|
+
if (showsFromPrice(item)) {
|
|
1495
|
+
const from = format.money(startingPrice(item));
|
|
1496
|
+
if (from !== prices[0]) {
|
|
1497
|
+
prices.push(from);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
return prices;
|
|
1501
|
+
}
|
|
1220
1502
|
|
|
1221
1503
|
// src/checks/v03-menu.ts
|
|
1222
1504
|
var v03 = {
|
|
@@ -1226,54 +1508,72 @@ var v03 = {
|
|
|
1226
1508
|
async run({ page, options, fault }, viewport) {
|
|
1227
1509
|
const menu = await readMenu(options);
|
|
1228
1510
|
await page.setViewportSize(viewport);
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
const
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
}
|
|
1240
|
-
const coverage = menuCoverage(items, shownIds);
|
|
1241
|
-
assertion(
|
|
1242
|
-
coverage.passes,
|
|
1243
|
-
"menu_mismatch",
|
|
1244
|
-
`The menu shows ${coverage.shown} of ${coverage.available} available items`,
|
|
1245
|
-
"Render at least 95% of available menu items with data-zappie-item markers or visible names."
|
|
1246
|
-
);
|
|
1247
|
-
assertion(
|
|
1248
|
-
await page.locator("[data-zappie-item]").count(),
|
|
1249
|
-
"item_marker_missing",
|
|
1250
|
-
"The menu has no item marker",
|
|
1251
|
-
"Add data-zappie-item to each rendered menu item."
|
|
1252
|
-
);
|
|
1253
|
-
for (const item of items.filter((entry) => !entry.isAvailable)) {
|
|
1254
|
-
const add = page.locator(
|
|
1255
|
-
`[data-zappie-item="${item.id}"] [data-zappie-action="add-to-cart"]`
|
|
1511
|
+
const pages = cataloguePages(options, menu);
|
|
1512
|
+
const severalMenus = !options.cataloguePath && pages[0]?.name === void 0 && catalogues(menu).length > 1;
|
|
1513
|
+
for (const catalogue of pages) {
|
|
1514
|
+
await open(page, catalogue.path);
|
|
1515
|
+
await applyPageFault(page, fault);
|
|
1516
|
+
await judgeCatalogue(
|
|
1517
|
+
page,
|
|
1518
|
+
catalogue.items,
|
|
1519
|
+
catalogue.name === void 0 ? "menu" : `${catalogue.name} menu at ${catalogue.path}`,
|
|
1520
|
+
severalMenus
|
|
1256
1521
|
);
|
|
1257
|
-
if (await add.count()) {
|
|
1258
|
-
assertion(
|
|
1259
|
-
await add.isDisabled(),
|
|
1260
|
-
"unavailable_item_addable",
|
|
1261
|
-
`Shoppers can add the unavailable item ${item.name}`,
|
|
1262
|
-
"Disable add-to-cart for unavailable items."
|
|
1263
|
-
);
|
|
1264
|
-
}
|
|
1265
1522
|
}
|
|
1266
|
-
|
|
1267
|
-
|
|
1523
|
+
}
|
|
1524
|
+
};
|
|
1525
|
+
async function judgeCatalogue(page, items, menuName, severalMenus) {
|
|
1526
|
+
const available = items.filter((item) => item.isAvailable);
|
|
1527
|
+
const shownIds = /* @__PURE__ */ new Set();
|
|
1528
|
+
for (const item of available) {
|
|
1529
|
+
const marker = page.locator(`[data-zappie-item="${item.id}"]`);
|
|
1530
|
+
if (await marker.count() > 0 || await page.getByText(item.name, { exact: true }).count() > 0) {
|
|
1531
|
+
shownIds.add(item.id);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
const coverage = menuCoverage(items, shownIds);
|
|
1535
|
+
assertion(
|
|
1536
|
+
coverage.passes || severalMenus,
|
|
1537
|
+
"menu_mismatch",
|
|
1538
|
+
`The ${menuName} shows ${coverage.shown} of ${coverage.available} available items`,
|
|
1539
|
+
"Render at least 95% of available menu items with data-zappie-item markers or visible names."
|
|
1540
|
+
);
|
|
1541
|
+
assertion(
|
|
1542
|
+
coverage.passes,
|
|
1543
|
+
"menu_mismatch",
|
|
1544
|
+
`The ${menuName} shows ${coverage.shown} of ${coverage.available} available items`,
|
|
1545
|
+
"Render at least 95% of available menu items with data-zappie-item markers or visible names. If each menu has its own page, set verify.cataloguePath, such as /menu?menu={menu}."
|
|
1546
|
+
);
|
|
1547
|
+
assertion(
|
|
1548
|
+
await page.locator("[data-zappie-item]").count(),
|
|
1549
|
+
"item_marker_missing",
|
|
1550
|
+
`The ${menuName} has no item marker`,
|
|
1551
|
+
"Add data-zappie-item to each rendered menu item."
|
|
1552
|
+
);
|
|
1553
|
+
for (const item of items.filter((entry) => !entry.isAvailable)) {
|
|
1554
|
+
const add = page.locator(
|
|
1555
|
+
`[data-zappie-item="${item.id}"] [data-zappie-action="add-to-cart"]`
|
|
1556
|
+
);
|
|
1557
|
+
if (await add.count()) {
|
|
1268
1558
|
assertion(
|
|
1269
|
-
|
|
1270
|
-
"
|
|
1271
|
-
|
|
1272
|
-
"
|
|
1559
|
+
await add.isDisabled(),
|
|
1560
|
+
"unavailable_item_addable",
|
|
1561
|
+
`Shoppers can add the unavailable item ${item.name}`,
|
|
1562
|
+
"Disable add-to-cart for unavailable items."
|
|
1273
1563
|
);
|
|
1274
1564
|
}
|
|
1275
1565
|
}
|
|
1276
|
-
|
|
1566
|
+
for (const item of available.slice(0, 2)) {
|
|
1567
|
+
const text = await page.locator(`[data-zappie-item="${item.id}"]`).first().textContent();
|
|
1568
|
+
const prices = shownPrices(item);
|
|
1569
|
+
assertion(
|
|
1570
|
+
prices.some((price) => priceMatches(text, price)),
|
|
1571
|
+
"price_mismatch",
|
|
1572
|
+
`${item.name} does not show ${prices.join(" or From ")}`,
|
|
1573
|
+
"Render prices with format.money from @zappie/sdk, and a From price with startingPrice from @zappie/sdk/pricing."
|
|
1574
|
+
);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1277
1577
|
|
|
1278
1578
|
// src/checks/v04-add-simple.ts
|
|
1279
1579
|
var v04 = {
|
|
@@ -1282,7 +1582,9 @@ var v04 = {
|
|
|
1282
1582
|
viewports: "both",
|
|
1283
1583
|
async run({ page, options, fault }, viewport) {
|
|
1284
1584
|
await page.setViewportSize(viewport);
|
|
1285
|
-
await
|
|
1585
|
+
const menu = await readMenu(options);
|
|
1586
|
+
const item = simpleItem(menu);
|
|
1587
|
+
await open(page, itemPath(options, menu, item?.id));
|
|
1286
1588
|
await applyPageFault(page, fault);
|
|
1287
1589
|
const before = await debugValue(page, () => window.__zappie.cart().count);
|
|
1288
1590
|
await page.evaluate(() => {
|
|
@@ -1293,7 +1595,6 @@ var v04 = {
|
|
|
1293
1595
|
);
|
|
1294
1596
|
});
|
|
1295
1597
|
});
|
|
1296
|
-
const item = simpleItem(await readMenu(options));
|
|
1297
1598
|
assertion(
|
|
1298
1599
|
item,
|
|
1299
1600
|
"simple_item_missing",
|
|
@@ -1363,14 +1664,16 @@ var v04 = {
|
|
|
1363
1664
|
};
|
|
1364
1665
|
|
|
1365
1666
|
// src/checks/v05-add-options.ts
|
|
1667
|
+
var REQUIRED_GROUP = '[data-zappie-option-group]:is([data-zappie-required="true"], [aria-required="true"])';
|
|
1366
1668
|
var v05 = {
|
|
1367
1669
|
id: "V05",
|
|
1368
1670
|
title: "Add an item with required options",
|
|
1369
1671
|
viewports: "both",
|
|
1370
1672
|
async run({ page, options }, viewport) {
|
|
1371
1673
|
await page.setViewportSize(viewport);
|
|
1372
|
-
await
|
|
1373
|
-
const selected = configurableItem(
|
|
1674
|
+
const menu = await readMenu(options);
|
|
1675
|
+
const selected = configurableItem(menu);
|
|
1676
|
+
await open(page, itemPath(options, menu, selected?.id));
|
|
1374
1677
|
assertion(
|
|
1375
1678
|
selected,
|
|
1376
1679
|
"required_item_missing",
|
|
@@ -1380,22 +1683,22 @@ var v05 = {
|
|
|
1380
1683
|
await openItem(page, selected.id);
|
|
1381
1684
|
const dialog = page.getByRole("dialog");
|
|
1382
1685
|
await dialog.waitFor();
|
|
1383
|
-
const required = dialog.locator(
|
|
1686
|
+
const required = dialog.locator(REQUIRED_GROUP).first();
|
|
1384
1687
|
await required.waitFor({ timeout: 5e3 }).catch(() => void 0);
|
|
1385
1688
|
assertion(
|
|
1386
1689
|
await required.count(),
|
|
1387
1690
|
"required_group_missing",
|
|
1388
1691
|
"The item dialog has no marked required option group",
|
|
1389
|
-
"
|
|
1692
|
+
"Mark each option group with data-zappie-option-group and each required one with data-zappie-required, as OptionGroup from @zappie/foundations does."
|
|
1390
1693
|
);
|
|
1391
1694
|
const before = await debugValue(page, () => window.__zappie.cart().count);
|
|
1392
1695
|
const add = dialog.locator('[data-zappie-action="add-to-cart"]');
|
|
1393
1696
|
const unsatisfied = dialog.locator(
|
|
1394
|
-
|
|
1697
|
+
`${REQUIRED_GROUP}:has([data-zappie-option]):not(:has([data-state="checked"]))`
|
|
1395
1698
|
);
|
|
1396
1699
|
const blocked2 = await add.isDisabled();
|
|
1397
|
-
|
|
1398
|
-
|
|
1700
|
+
const group = unsatisfied.first();
|
|
1701
|
+
if (!blocked2 && await group.count() > 0) {
|
|
1399
1702
|
const textBefore = (await group.innerText().catch(() => "")).trim();
|
|
1400
1703
|
await add.click();
|
|
1401
1704
|
await page.waitForTimeout(500);
|
|
@@ -1474,10 +1777,13 @@ var v06 = {
|
|
|
1474
1777
|
id: "V06",
|
|
1475
1778
|
title: "Cart quote and total",
|
|
1476
1779
|
viewports: "both",
|
|
1477
|
-
async run({ page }, viewport) {
|
|
1780
|
+
async run({ page, options }, viewport) {
|
|
1478
1781
|
await page.setViewportSize(viewport);
|
|
1479
|
-
|
|
1480
|
-
|
|
1782
|
+
const start = startPath(options);
|
|
1783
|
+
const stay = isAtPath(page.url(), start) || start !== DEFAULT_START_PATH && await (await cartTrigger(page)).isVisible().catch(() => false);
|
|
1784
|
+
if (!stay) {
|
|
1785
|
+
await open(page, start);
|
|
1786
|
+
await page.waitForFunction(() => Boolean(window.__zappie));
|
|
1481
1787
|
}
|
|
1482
1788
|
const total = page.locator("[data-zappie-total]").filter({ visible: true });
|
|
1483
1789
|
const bagOpen = page.locator('[data-zappie-overlay="bag"]').filter({ visible: true });
|
|
@@ -1602,9 +1908,9 @@ var v07 = {
|
|
|
1602
1908
|
id: "V07",
|
|
1603
1909
|
title: "Handle quote issues",
|
|
1604
1910
|
viewports: "mobile",
|
|
1605
|
-
async run({ page }, viewport) {
|
|
1911
|
+
async run({ page, options }, viewport) {
|
|
1606
1912
|
await page.setViewportSize(viewport);
|
|
1607
|
-
await open(page);
|
|
1913
|
+
await open(page, startPath(options));
|
|
1608
1914
|
await page.waitForFunction(() => window.__zappie?.cart().isHydrated);
|
|
1609
1915
|
const debug = await page.evaluate(() => ({
|
|
1610
1916
|
inject: typeof window.__zappie?.cart.inject === "function",
|
|
@@ -2226,7 +2532,7 @@ async function holdsFocus(target2) {
|
|
|
2226
2532
|
return false;
|
|
2227
2533
|
}
|
|
2228
2534
|
var ADD = '[data-zappie-action="add-to-cart"]';
|
|
2229
|
-
async function keyboardReachesAddAndCart(page, itemId, cartInColumn) {
|
|
2535
|
+
async function keyboardReachesAddAndCart(page, itemId, itemPage, cartInColumn) {
|
|
2230
2536
|
const cart = cartInColumn ? [] : [await cartTriggerSelector(page)];
|
|
2231
2537
|
if (await page.locator(ADD).count() || !itemId) {
|
|
2232
2538
|
return controlsAreReachable(page, [ADD, ...cart]);
|
|
@@ -2235,7 +2541,7 @@ async function keyboardReachesAddAndCart(page, itemId, cartInColumn) {
|
|
|
2235
2541
|
if (cart.length && !await controlsAreReachable(page, cart)) {
|
|
2236
2542
|
return false;
|
|
2237
2543
|
}
|
|
2238
|
-
await open(page);
|
|
2544
|
+
await open(page, itemPage);
|
|
2239
2545
|
if (!await tabTo(page, opener, 200)) {
|
|
2240
2546
|
return false;
|
|
2241
2547
|
}
|
|
@@ -2268,16 +2574,28 @@ var v13 = {
|
|
|
2268
2574
|
viewports: "both",
|
|
2269
2575
|
async run({ page, options, fault }, viewport) {
|
|
2270
2576
|
await page.setViewportSize(viewport);
|
|
2271
|
-
await
|
|
2577
|
+
const menu = await readMenu(options);
|
|
2578
|
+
const start = startPath(options);
|
|
2579
|
+
await open(page, start);
|
|
2272
2580
|
await applyPageFault(page, fault);
|
|
2273
2581
|
const violations = await severeViolations(page);
|
|
2274
2582
|
const semantics = await semanticFindings(page, {
|
|
2275
2583
|
page: true,
|
|
2276
2584
|
phone: viewport.width < 768
|
|
2277
2585
|
});
|
|
2278
|
-
const item = sortedItems(
|
|
2279
|
-
|
|
2280
|
-
)
|
|
2586
|
+
const item = sortedItems(menu).find((entry) => entry.isAvailable);
|
|
2587
|
+
const itemPage = itemPath(options, menu, item?.id);
|
|
2588
|
+
if (itemPage !== start) {
|
|
2589
|
+
await open(page, itemPage);
|
|
2590
|
+
await applyPageFault(page, fault);
|
|
2591
|
+
violations.push(...await severeViolations(page));
|
|
2592
|
+
semantics.push(
|
|
2593
|
+
...await semanticFindings(page, {
|
|
2594
|
+
page: true,
|
|
2595
|
+
phone: viewport.width < 768
|
|
2596
|
+
})
|
|
2597
|
+
);
|
|
2598
|
+
}
|
|
2281
2599
|
assertion(
|
|
2282
2600
|
item,
|
|
2283
2601
|
"available_item_missing",
|
|
@@ -2345,13 +2663,11 @@ var v13 = {
|
|
|
2345
2663
|
order.map((entry) => entry.id).join(", ") || "Order page has a serious axe violation",
|
|
2346
2664
|
"Fix serious and critical axe findings on the order page."
|
|
2347
2665
|
);
|
|
2348
|
-
|
|
2666
|
+
const simple = simpleItem(menu)?.id;
|
|
2667
|
+
const simplePage = itemPath(options, menu, simple);
|
|
2668
|
+
await open(page, simplePage);
|
|
2349
2669
|
assertion(
|
|
2350
|
-
await keyboardReachesAddAndCart(
|
|
2351
|
-
page,
|
|
2352
|
-
simpleItem(await readMenu(options))?.id,
|
|
2353
|
-
cartInColumn
|
|
2354
|
-
),
|
|
2670
|
+
await keyboardReachesAddAndCart(page, simple, simplePage, cartInColumn),
|
|
2355
2671
|
"keyboard_marker_unreachable",
|
|
2356
2672
|
"Keyboard navigation did not reach the Add and cart controls",
|
|
2357
2673
|
"Keep the marked Add and cart controls in the tab order."
|
|
@@ -2385,250 +2701,46 @@ var v13 = {
|
|
|
2385
2701
|
}
|
|
2386
2702
|
};
|
|
2387
2703
|
|
|
2388
|
-
// src/checks/
|
|
2389
|
-
var
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
);
|
|
2404
|
-
}).observe({ type: "largest-contentful-paint", buffered: true });
|
|
2405
|
-
new PerformanceObserver((list) => {
|
|
2406
|
-
state.cls += list.getEntries().filter(
|
|
2407
|
-
(entry) => !entry.hadRecentInput
|
|
2408
|
-
).reduce(
|
|
2409
|
-
(sum, entry) => sum + (entry.value ?? 0),
|
|
2410
|
-
0
|
|
2411
|
-
);
|
|
2412
|
-
}).observe({ type: "layout-shift", buffered: true });
|
|
2413
|
-
});
|
|
2414
|
-
await page.goto("/", { waitUntil: "load" });
|
|
2415
|
-
await applyPageFault(page, fault);
|
|
2416
|
-
if (fault === "oversized") {
|
|
2417
|
-
await page.addScriptTag({ url: "/__verify-large.js" });
|
|
2704
|
+
// src/checks/overlays.ts
|
|
2705
|
+
var OVERLAY_NAMES = ["bag", "branch", "search", "item"];
|
|
2706
|
+
var IDLE_FALLBACK_MS = 2e3;
|
|
2707
|
+
var NETWORK_QUIET_MS = 500;
|
|
2708
|
+
var SETTLE_ROUNDS = 3;
|
|
2709
|
+
var SETTLE_TIMEOUT_MS = 15e3;
|
|
2710
|
+
var triggerSelector = (name) => `[data-zappie-overlay-trigger="${name}"]`;
|
|
2711
|
+
var overlaySelector = (name) => `[data-zappie-overlay="${name}"]`;
|
|
2712
|
+
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.';
|
|
2713
|
+
async function overlayTriggers(page) {
|
|
2714
|
+
const found = [];
|
|
2715
|
+
for (const name of OVERLAY_NAMES) {
|
|
2716
|
+
const trigger = page.locator(`${triggerSelector(name)}:visible`).first();
|
|
2717
|
+
if (await trigger.count() > 0) {
|
|
2718
|
+
found.push({ name, trigger });
|
|
2418
2719
|
}
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2720
|
+
}
|
|
2721
|
+
return found;
|
|
2722
|
+
}
|
|
2723
|
+
function skipWithoutTriggers(found) {
|
|
2724
|
+
if (found.length === 0) {
|
|
2725
|
+
skipCheck(
|
|
2726
|
+
"overlay_markers_missing",
|
|
2727
|
+
"The menu has no data-zappie-overlay-trigger controls",
|
|
2728
|
+
markersHint
|
|
2729
|
+
);
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
function armOverlayTimer(page, name, timeoutMs) {
|
|
2733
|
+
return page.evaluate(
|
|
2734
|
+
({ selector, timeout }) => {
|
|
2735
|
+
const state = {};
|
|
2736
|
+
Object.assign(window, { __zappieOverlayTimer: state });
|
|
2737
|
+
const visible = () => {
|
|
2738
|
+
const node = document.querySelector(selector);
|
|
2739
|
+
if (!(node instanceof HTMLElement)) {
|
|
2740
|
+
return false;
|
|
2428
2741
|
}
|
|
2429
|
-
const
|
|
2430
|
-
|
|
2431
|
-
new CompressionStream("gzip")
|
|
2432
|
-
);
|
|
2433
|
-
return compressed ? (await new Response(compressed).arrayBuffer()).byteLength : 0;
|
|
2434
|
-
};
|
|
2435
|
-
const total = async (extension) => (await Promise.all(
|
|
2436
|
-
resources.filter(
|
|
2437
|
-
(entry) => new URL(entry.name).pathname.endsWith(extension)
|
|
2438
|
-
).map(gzipped)
|
|
2439
|
-
)).reduce((sum, size) => sum + size, 0);
|
|
2440
|
-
return { ...observed, js: await total(".js"), css: await total(".css") };
|
|
2441
|
-
});
|
|
2442
|
-
metrics.menuJsKbGz = Math.round(values.js / 1024 * 10) / 10;
|
|
2443
|
-
metrics.menuCssKbGz = Math.round(values.css / 1024 * 10) / 10;
|
|
2444
|
-
metrics.lcpMs = Math.round(values.lcp);
|
|
2445
|
-
metrics.cls = Math.round(values.cls * 1e3) / 1e3;
|
|
2446
|
-
assertion(
|
|
2447
|
-
values.js <= 200 * 1024,
|
|
2448
|
-
"bundle_too_large",
|
|
2449
|
-
`Menu uses ${values.js} B of gzipped JavaScript`,
|
|
2450
|
-
"Split route code until the menu transfers at most 200 KiB of JavaScript."
|
|
2451
|
-
);
|
|
2452
|
-
assertion(
|
|
2453
|
-
values.lcp <= 2500,
|
|
2454
|
-
"lcp_too_slow",
|
|
2455
|
-
`Menu LCP was ${Math.round(values.lcp)} ms`,
|
|
2456
|
-
"Reduce critical rendering work until mobile LCP is at most 2.5 seconds."
|
|
2457
|
-
);
|
|
2458
|
-
assertion(
|
|
2459
|
-
values.cls <= 0.1,
|
|
2460
|
-
"cls_too_high",
|
|
2461
|
-
`Menu CLS was ${values.cls.toFixed(3)}`,
|
|
2462
|
-
"Reserve layout space so CLS stays at or below 0.1."
|
|
2463
|
-
);
|
|
2464
|
-
await cdp.detach();
|
|
2465
|
-
}
|
|
2466
|
-
};
|
|
2467
|
-
|
|
2468
|
-
// src/checks/v15-tappable-product-cards.ts
|
|
2469
|
-
var TAPPABLE_BUDGET_MS = 3e3;
|
|
2470
|
-
var OPEN_BUDGET_MS = 300;
|
|
2471
|
-
var v15 = {
|
|
2472
|
-
id: "V15",
|
|
2473
|
-
title: "Tappable product cards",
|
|
2474
|
-
viewports: "mobile",
|
|
2475
|
-
async run({ page, metrics, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
|
|
2476
|
-
await page.setViewportSize(viewport);
|
|
2477
|
-
const { cdp } = await applyPhoneProfile(page, cpuThrottlingRate2);
|
|
2478
|
-
try {
|
|
2479
|
-
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
2480
|
-
await cdp.send("Network.setCacheDisabled", { cacheDisabled: false });
|
|
2481
|
-
const link = page.locator('a[href*="/item/"]').first();
|
|
2482
|
-
await link.waitFor();
|
|
2483
|
-
const href = await link.getAttribute("href");
|
|
2484
|
-
assertion(
|
|
2485
|
-
href !== null,
|
|
2486
|
-
"product_card_link_missing",
|
|
2487
|
-
"The first product card has no destination",
|
|
2488
|
-
"Link the product card to its item page."
|
|
2489
|
-
);
|
|
2490
|
-
const itemPath = new URL(href, page.url()).pathname;
|
|
2491
|
-
await page.waitForFunction(() => "__reactRouterManifest" in window, null, {
|
|
2492
|
-
timeout: 8e3
|
|
2493
|
-
}).catch(() => void 0);
|
|
2494
|
-
const itemModule = await page.evaluate(itemRouteModule, itemPath);
|
|
2495
|
-
const prefetched = page.waitForResponse(
|
|
2496
|
-
(response) => {
|
|
2497
|
-
const path4 = new URL(response.url()).pathname;
|
|
2498
|
-
if (path4 === itemModule) {
|
|
2499
|
-
return true;
|
|
2500
|
-
}
|
|
2501
|
-
return path4 === `${itemPath}.data` && response.request().headers()["sec-purpose"] === "prefetch";
|
|
2502
|
-
},
|
|
2503
|
-
{ timeout: 8e3 }
|
|
2504
|
-
).then(async (response) => {
|
|
2505
|
-
await response.finished();
|
|
2506
|
-
return true;
|
|
2507
|
-
}).catch(() => false);
|
|
2508
|
-
await link.evaluate(
|
|
2509
|
-
(node) => node.scrollIntoView({ block: "center", inline: "nearest" })
|
|
2510
|
-
);
|
|
2511
|
-
await page.waitForFunction(() => {
|
|
2512
|
-
const candidate = document.querySelector('a[href*="/item/"]');
|
|
2513
|
-
return candidate !== null && Object.keys(candidate).some(
|
|
2514
|
-
(key) => key.startsWith("__reactProps$") || key.startsWith("__reactFiber$")
|
|
2515
|
-
);
|
|
2516
|
-
});
|
|
2517
|
-
const tappableMs = await page.evaluate(() => performance.now());
|
|
2518
|
-
metrics.productCardTappableMs = Math.round(tappableMs);
|
|
2519
|
-
assertion(
|
|
2520
|
-
tappableMs <= TAPPABLE_BUDGET_MS,
|
|
2521
|
-
"product_card_tappable_too_late",
|
|
2522
|
-
`The first product card became tappable in ${Math.round(tappableMs)} ms`,
|
|
2523
|
-
"Reduce critical scripts, fonts and high-priority images until product cards become tappable within 3 seconds."
|
|
2524
|
-
);
|
|
2525
|
-
assertion(
|
|
2526
|
-
await prefetched,
|
|
2527
|
-
"product_card_prefetch_missing",
|
|
2528
|
-
"The first product card did not prefetch its item page",
|
|
2529
|
-
'Prefetch item links with prefetch="viewport".'
|
|
2530
|
-
);
|
|
2531
|
-
await page.waitForLoadState("networkidle");
|
|
2532
|
-
await page.evaluate(() => {
|
|
2533
|
-
document.addEventListener(
|
|
2534
|
-
"click",
|
|
2535
|
-
(event) => {
|
|
2536
|
-
const target2 = event.target;
|
|
2537
|
-
if (target2?.closest('a[href*="/item/"]')) {
|
|
2538
|
-
window.__zappieTapAt = performance.now();
|
|
2539
|
-
}
|
|
2540
|
-
},
|
|
2541
|
-
{ capture: true, once: true }
|
|
2542
|
-
);
|
|
2543
|
-
});
|
|
2544
|
-
await link.click({ noWaitAfter: true });
|
|
2545
|
-
await Promise.race([
|
|
2546
|
-
page.waitForURL((url) => /\/item\//.test(url.pathname)),
|
|
2547
|
-
page.locator('[role="dialog"] [data-zappie-item]').waitFor({ state: "visible" })
|
|
2548
|
-
]);
|
|
2549
|
-
const openMs = await page.evaluate(
|
|
2550
|
-
() => performance.now() - (window.__zappieTapAt ?? 0)
|
|
2551
|
-
);
|
|
2552
|
-
metrics.productCardOpenMs = Math.round(openMs);
|
|
2553
|
-
assertion(
|
|
2554
|
-
openMs <= OPEN_BUDGET_MS,
|
|
2555
|
-
"product_card_open_too_slow",
|
|
2556
|
-
`The first product card opened in ${Math.round(openMs)} ms`,
|
|
2557
|
-
'Set routeDiscovery to initial and prefetch item links with prefetch="viewport" so the item surface opens within 300 ms.'
|
|
2558
|
-
);
|
|
2559
|
-
} finally {
|
|
2560
|
-
await cdp.detach();
|
|
2561
|
-
}
|
|
2562
|
-
}
|
|
2563
|
-
};
|
|
2564
|
-
function itemRouteModule(itemPath) {
|
|
2565
|
-
const manifest = window.__reactRouterManifest;
|
|
2566
|
-
if (!manifest) {
|
|
2567
|
-
return void 0;
|
|
2568
|
-
}
|
|
2569
|
-
const segments = (path4) => path4.split("/").filter(Boolean);
|
|
2570
|
-
const fullPath = (route2) => {
|
|
2571
|
-
const parts = [];
|
|
2572
|
-
for (let current = route2; current; current = current.parentId ? manifest.routes[current.parentId] : void 0) {
|
|
2573
|
-
if (current.path) {
|
|
2574
|
-
parts.unshift(current.path);
|
|
2575
|
-
}
|
|
2576
|
-
}
|
|
2577
|
-
return segments(parts.join("/"));
|
|
2578
|
-
};
|
|
2579
|
-
const wanted = segments(itemPath);
|
|
2580
|
-
const route = Object.values(manifest.routes).find((entry) => {
|
|
2581
|
-
if (!entry.path) {
|
|
2582
|
-
return false;
|
|
2583
|
-
}
|
|
2584
|
-
const pattern = fullPath(entry);
|
|
2585
|
-
return pattern.length === wanted.length && pattern.every(
|
|
2586
|
-
(part, index) => part.startsWith(":") || part === wanted[index]
|
|
2587
|
-
);
|
|
2588
|
-
});
|
|
2589
|
-
return route ? new URL(route.module, location.href).pathname : void 0;
|
|
2590
|
-
}
|
|
2591
|
-
|
|
2592
|
-
// src/checks/overlays.ts
|
|
2593
|
-
var OVERLAY_NAMES = ["bag", "branch", "search", "item"];
|
|
2594
|
-
var IDLE_FALLBACK_MS = 2e3;
|
|
2595
|
-
var NETWORK_QUIET_MS = 500;
|
|
2596
|
-
var SETTLE_ROUNDS = 3;
|
|
2597
|
-
var SETTLE_TIMEOUT_MS = 15e3;
|
|
2598
|
-
var triggerSelector = (name) => `[data-zappie-overlay-trigger="${name}"]`;
|
|
2599
|
-
var overlaySelector = (name) => `[data-zappie-overlay="${name}"]`;
|
|
2600
|
-
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.';
|
|
2601
|
-
async function overlayTriggers(page) {
|
|
2602
|
-
const found = [];
|
|
2603
|
-
for (const name of OVERLAY_NAMES) {
|
|
2604
|
-
const trigger = page.locator(`${triggerSelector(name)}:visible`).first();
|
|
2605
|
-
if (await trigger.count() > 0) {
|
|
2606
|
-
found.push({ name, trigger });
|
|
2607
|
-
}
|
|
2608
|
-
}
|
|
2609
|
-
return found;
|
|
2610
|
-
}
|
|
2611
|
-
function skipWithoutTriggers(found) {
|
|
2612
|
-
if (found.length === 0) {
|
|
2613
|
-
skipCheck(
|
|
2614
|
-
"overlay_markers_missing",
|
|
2615
|
-
"The menu has no data-zappie-overlay-trigger controls",
|
|
2616
|
-
markersHint
|
|
2617
|
-
);
|
|
2618
|
-
}
|
|
2619
|
-
}
|
|
2620
|
-
function armOverlayTimer(page, name, timeoutMs) {
|
|
2621
|
-
return page.evaluate(
|
|
2622
|
-
({ selector, timeout }) => {
|
|
2623
|
-
const state = {};
|
|
2624
|
-
Object.assign(window, { __zappieOverlayTimer: state });
|
|
2625
|
-
const visible = () => {
|
|
2626
|
-
const node = document.querySelector(selector);
|
|
2627
|
-
if (!(node instanceof HTMLElement)) {
|
|
2628
|
-
return false;
|
|
2629
|
-
}
|
|
2630
|
-
const box = node.getBoundingClientRect();
|
|
2631
|
-
return box.width > 0 && box.height > 0 && node.checkVisibility();
|
|
2742
|
+
const box = node.getBoundingClientRect();
|
|
2743
|
+
return box.width > 0 && box.height > 0 && node.checkVisibility();
|
|
2632
2744
|
};
|
|
2633
2745
|
document.addEventListener(
|
|
2634
2746
|
"click",
|
|
@@ -2719,10 +2831,10 @@ function hydrated() {
|
|
|
2719
2831
|
)
|
|
2720
2832
|
);
|
|
2721
2833
|
}
|
|
2722
|
-
async function openSettledMenu(page) {
|
|
2834
|
+
async function openSettledMenu(page, start) {
|
|
2723
2835
|
const network = watchNetwork(page);
|
|
2724
2836
|
try {
|
|
2725
|
-
await page.goto(
|
|
2837
|
+
await page.goto(start, { waitUntil: "domcontentloaded" });
|
|
2726
2838
|
await page.locator("[data-zappie-item]").first().waitFor();
|
|
2727
2839
|
const deadline = Date.now() + SETTLE_TIMEOUT_MS;
|
|
2728
2840
|
const remaining = () => Math.max(deadline - Date.now(), 1);
|
|
@@ -2755,11 +2867,17 @@ async function openSettledMenu(page) {
|
|
|
2755
2867
|
network.stop();
|
|
2756
2868
|
}
|
|
2757
2869
|
}
|
|
2758
|
-
async function revealBagTrigger(page,
|
|
2870
|
+
async function revealBagTrigger(page, start, item) {
|
|
2759
2871
|
const bag = page.locator(`${triggerSelector("bag")}:visible`).first();
|
|
2760
|
-
if (!
|
|
2872
|
+
if (!item || await bag.count()) {
|
|
2761
2873
|
return;
|
|
2762
2874
|
}
|
|
2875
|
+
const itemId = item.id;
|
|
2876
|
+
const marked = page.locator(`[data-zappie-item="${itemId}"]`);
|
|
2877
|
+
if (item.path !== start && !await marked.count()) {
|
|
2878
|
+
await page.goto(item.path, { waitUntil: "domcontentloaded" });
|
|
2879
|
+
await marked.first().waitFor({ timeout: 5e3 }).catch(() => void 0);
|
|
2880
|
+
}
|
|
2763
2881
|
const before = await page.evaluate(() => window.__zappie?.cart().count ?? 0).catch(() => 0);
|
|
2764
2882
|
const add = '[data-zappie-action="add-to-cart"]';
|
|
2765
2883
|
const quick = page.locator(`[data-zappie-item="${itemId}"] ${add}`).filter({ visible: true }).first();
|
|
@@ -2787,9 +2905,253 @@ async function revealBagTrigger(page, itemId) {
|
|
|
2787
2905
|
before,
|
|
2788
2906
|
{ timeout: 5e3 }
|
|
2789
2907
|
).catch(() => void 0);
|
|
2790
|
-
await openSettledMenu(page);
|
|
2908
|
+
await openSettledMenu(page, start);
|
|
2791
2909
|
await bag.waitFor({ timeout: 5e3 }).catch(() => void 0);
|
|
2792
2910
|
}
|
|
2911
|
+
function bagItem(options, menu) {
|
|
2912
|
+
const id = simpleItem(menu)?.id;
|
|
2913
|
+
return {
|
|
2914
|
+
start: startPath(options),
|
|
2915
|
+
item: id ? { id, path: itemPath(options, menu, id) } : void 0
|
|
2916
|
+
};
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
// src/checks/v14-performance.ts
|
|
2920
|
+
var LOADED_JS_BUDGET_KB = 350;
|
|
2921
|
+
var LOADED_WAIT_MS = 1e4;
|
|
2922
|
+
var v14 = {
|
|
2923
|
+
id: "V14",
|
|
2924
|
+
title: "Performance budget",
|
|
2925
|
+
viewports: "mobile",
|
|
2926
|
+
async run({ page, options, metrics, fault, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
|
|
2927
|
+
await page.setViewportSize(viewport);
|
|
2928
|
+
const { cdp } = await applyPhoneProfile(page, cpuThrottlingRate2);
|
|
2929
|
+
await page.addInitScript(() => {
|
|
2930
|
+
const state = { lcp: 0, cls: 0, documentScripts: [] };
|
|
2931
|
+
Object.assign(window, { __zappiePerformance: state });
|
|
2932
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
2933
|
+
state.documentScripts = [
|
|
2934
|
+
...[
|
|
2935
|
+
...document.querySelectorAll("script[src]")
|
|
2936
|
+
].map((node) => node.src),
|
|
2937
|
+
...[
|
|
2938
|
+
...document.querySelectorAll(
|
|
2939
|
+
'link[rel="modulepreload"][href], link[rel="preload"][as="script"][href]'
|
|
2940
|
+
)
|
|
2941
|
+
].map((node) => node.href)
|
|
2942
|
+
];
|
|
2943
|
+
});
|
|
2944
|
+
new PerformanceObserver((list) => {
|
|
2945
|
+
state.lcp = Math.max(
|
|
2946
|
+
state.lcp,
|
|
2947
|
+
...list.getEntries().map((entry) => entry.startTime)
|
|
2948
|
+
);
|
|
2949
|
+
}).observe({ type: "largest-contentful-paint", buffered: true });
|
|
2950
|
+
new PerformanceObserver((list) => {
|
|
2951
|
+
state.cls += list.getEntries().filter(
|
|
2952
|
+
(entry) => !entry.hadRecentInput
|
|
2953
|
+
).reduce(
|
|
2954
|
+
(sum, entry) => sum + (entry.value ?? 0),
|
|
2955
|
+
0
|
|
2956
|
+
);
|
|
2957
|
+
}).observe({ type: "layout-shift", buffered: true });
|
|
2958
|
+
});
|
|
2959
|
+
const network = watchNetwork(page);
|
|
2960
|
+
await page.goto(startPath(options), { waitUntil: "load" });
|
|
2961
|
+
await applyPageFault(page, fault);
|
|
2962
|
+
await page.locator("[data-zappie-item]").first().waitFor();
|
|
2963
|
+
await page.waitForTimeout(500);
|
|
2964
|
+
await network.quiet(Date.now() + LOADED_WAIT_MS);
|
|
2965
|
+
network.stop();
|
|
2966
|
+
const values = await page.evaluate(async () => {
|
|
2967
|
+
const observed = window.__zappiePerformance ?? {
|
|
2968
|
+
lcp: 0,
|
|
2969
|
+
cls: 0,
|
|
2970
|
+
documentScripts: []
|
|
2971
|
+
};
|
|
2972
|
+
const resources = performance.getEntriesByType("resource");
|
|
2973
|
+
const named = new Set(observed.documentScripts);
|
|
2974
|
+
const needed = (entry) => named.has(entry.name);
|
|
2975
|
+
const gzipped = async (entry) => {
|
|
2976
|
+
if (typeof CompressionStream === "undefined") {
|
|
2977
|
+
const timing = entry;
|
|
2978
|
+
return timing.encodedBodySize || timing.transferSize;
|
|
2979
|
+
}
|
|
2980
|
+
const response = await fetch(entry.name);
|
|
2981
|
+
const compressed = response.body?.pipeThrough(
|
|
2982
|
+
new CompressionStream("gzip")
|
|
2983
|
+
);
|
|
2984
|
+
return compressed ? (await new Response(compressed).arrayBuffer()).byteLength : 0;
|
|
2985
|
+
};
|
|
2986
|
+
const total = async (extension, counted = () => true) => (await Promise.all(
|
|
2987
|
+
resources.filter(
|
|
2988
|
+
(entry) => new URL(entry.name).pathname.endsWith(extension) && counted(entry)
|
|
2989
|
+
).map(gzipped)
|
|
2990
|
+
)).reduce((sum, size) => sum + size, 0);
|
|
2991
|
+
return {
|
|
2992
|
+
lcp: observed.lcp,
|
|
2993
|
+
cls: observed.cls,
|
|
2994
|
+
js: await total(".js", needed),
|
|
2995
|
+
loadedJs: await total(".js"),
|
|
2996
|
+
css: await total(".css")
|
|
2997
|
+
};
|
|
2998
|
+
});
|
|
2999
|
+
metrics.menuJsKbGz = Math.round(values.js / 1024 * 10) / 10;
|
|
3000
|
+
metrics.menuJsLoadedKbGz = Math.round(values.loadedJs / 1024 * 10) / 10;
|
|
3001
|
+
metrics.menuCssKbGz = Math.round(values.css / 1024 * 10) / 10;
|
|
3002
|
+
metrics.lcpMs = Math.round(values.lcp);
|
|
3003
|
+
metrics.cls = Math.round(values.cls * 1e3) / 1e3;
|
|
3004
|
+
assertion(
|
|
3005
|
+
values.js <= 200 * 1024,
|
|
3006
|
+
"bundle_too_large",
|
|
3007
|
+
`Menu uses ${values.js} B of gzipped JavaScript`,
|
|
3008
|
+
"Split route code until the menu transfers at most 200 KiB of JavaScript."
|
|
3009
|
+
);
|
|
3010
|
+
assertion(
|
|
3011
|
+
values.loadedJs <= LOADED_JS_BUDGET_KB * 1024,
|
|
3012
|
+
"loaded_js_too_large",
|
|
3013
|
+
`The menu loads ${values.loadedJs} B of gzipped JavaScript in all, counting what it imports after it starts`,
|
|
3014
|
+
`Load what the menu imports after it starts only when a shopper needs it, until the menu loads at most ${LOADED_JS_BUDGET_KB} KiB of JavaScript in all.`
|
|
3015
|
+
);
|
|
3016
|
+
assertion(
|
|
3017
|
+
values.lcp <= 2500,
|
|
3018
|
+
"lcp_too_slow",
|
|
3019
|
+
`Menu LCP was ${Math.round(values.lcp)} ms`,
|
|
3020
|
+
"Reduce critical rendering work until mobile LCP is at most 2.5 seconds."
|
|
3021
|
+
);
|
|
3022
|
+
assertion(
|
|
3023
|
+
values.cls <= 0.1,
|
|
3024
|
+
"cls_too_high",
|
|
3025
|
+
`Menu CLS was ${values.cls.toFixed(3)}`,
|
|
3026
|
+
"Reserve layout space so CLS stays at or below 0.1."
|
|
3027
|
+
);
|
|
3028
|
+
await cdp.detach();
|
|
3029
|
+
}
|
|
3030
|
+
};
|
|
3031
|
+
|
|
3032
|
+
// src/checks/v15-tappable-product-cards.ts
|
|
3033
|
+
var TAPPABLE_BUDGET_MS = 3e3;
|
|
3034
|
+
var OPEN_BUDGET_MS = 300;
|
|
3035
|
+
var v15 = {
|
|
3036
|
+
id: "V15",
|
|
3037
|
+
title: "Tappable product cards",
|
|
3038
|
+
viewports: "mobile",
|
|
3039
|
+
async run({ page, options, metrics, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
|
|
3040
|
+
await page.setViewportSize(viewport);
|
|
3041
|
+
const { cdp } = await applyPhoneProfile(page, cpuThrottlingRate2);
|
|
3042
|
+
try {
|
|
3043
|
+
await page.goto(startPath(options), { waitUntil: "domcontentloaded" });
|
|
3044
|
+
await cdp.send("Network.setCacheDisabled", { cacheDisabled: false });
|
|
3045
|
+
const link = page.locator('a[href*="/item/"]').first();
|
|
3046
|
+
await link.waitFor();
|
|
3047
|
+
const href = await link.getAttribute("href");
|
|
3048
|
+
assertion(
|
|
3049
|
+
href !== null,
|
|
3050
|
+
"product_card_link_missing",
|
|
3051
|
+
"The first product card has no destination",
|
|
3052
|
+
"Link the product card to its item page."
|
|
3053
|
+
);
|
|
3054
|
+
const itemPath2 = new URL(href, page.url()).pathname;
|
|
3055
|
+
await page.waitForFunction(() => "__reactRouterManifest" in window, null, {
|
|
3056
|
+
timeout: 8e3
|
|
3057
|
+
}).catch(() => void 0);
|
|
3058
|
+
const itemModule = await page.evaluate(itemRouteModule, itemPath2);
|
|
3059
|
+
const prefetched = page.waitForResponse(
|
|
3060
|
+
(response) => {
|
|
3061
|
+
const path4 = new URL(response.url()).pathname;
|
|
3062
|
+
if (path4 === itemModule) {
|
|
3063
|
+
return true;
|
|
3064
|
+
}
|
|
3065
|
+
return path4 === `${itemPath2}.data` && response.request().headers()["sec-purpose"] === "prefetch";
|
|
3066
|
+
},
|
|
3067
|
+
{ timeout: 8e3 }
|
|
3068
|
+
).then(async (response) => {
|
|
3069
|
+
await response.finished();
|
|
3070
|
+
return true;
|
|
3071
|
+
}).catch(() => false);
|
|
3072
|
+
await link.evaluate(
|
|
3073
|
+
(node) => node.scrollIntoView({ block: "center", inline: "nearest" })
|
|
3074
|
+
);
|
|
3075
|
+
await page.waitForFunction(() => {
|
|
3076
|
+
const candidate = document.querySelector('a[href*="/item/"]');
|
|
3077
|
+
return candidate !== null && Object.keys(candidate).some(
|
|
3078
|
+
(key) => key.startsWith("__reactProps$") || key.startsWith("__reactFiber$")
|
|
3079
|
+
);
|
|
3080
|
+
});
|
|
3081
|
+
const tappableMs = await page.evaluate(() => performance.now());
|
|
3082
|
+
metrics.productCardTappableMs = Math.round(tappableMs);
|
|
3083
|
+
assertion(
|
|
3084
|
+
tappableMs <= TAPPABLE_BUDGET_MS,
|
|
3085
|
+
"product_card_tappable_too_late",
|
|
3086
|
+
`The first product card became tappable in ${Math.round(tappableMs)} ms`,
|
|
3087
|
+
"Reduce critical scripts, fonts and high-priority images until product cards become tappable within 3 seconds."
|
|
3088
|
+
);
|
|
3089
|
+
assertion(
|
|
3090
|
+
await prefetched,
|
|
3091
|
+
"product_card_prefetch_missing",
|
|
3092
|
+
"The first product card did not prefetch its item page",
|
|
3093
|
+
'Prefetch item links with prefetch="viewport".'
|
|
3094
|
+
);
|
|
3095
|
+
await page.waitForLoadState("networkidle");
|
|
3096
|
+
await page.evaluate(() => {
|
|
3097
|
+
document.addEventListener(
|
|
3098
|
+
"click",
|
|
3099
|
+
(event) => {
|
|
3100
|
+
const target2 = event.target;
|
|
3101
|
+
if (target2?.closest('a[href*="/item/"]')) {
|
|
3102
|
+
window.__zappieTapAt = performance.now();
|
|
3103
|
+
}
|
|
3104
|
+
},
|
|
3105
|
+
{ capture: true, once: true }
|
|
3106
|
+
);
|
|
3107
|
+
});
|
|
3108
|
+
await link.click({ noWaitAfter: true });
|
|
3109
|
+
await Promise.race([
|
|
3110
|
+
page.waitForURL((url) => /\/item\//.test(url.pathname)),
|
|
3111
|
+
page.locator('[role="dialog"] [data-zappie-item]').waitFor({ state: "visible" })
|
|
3112
|
+
]);
|
|
3113
|
+
const openMs = await page.evaluate(
|
|
3114
|
+
() => performance.now() - (window.__zappieTapAt ?? 0)
|
|
3115
|
+
);
|
|
3116
|
+
metrics.productCardOpenMs = Math.round(openMs);
|
|
3117
|
+
assertion(
|
|
3118
|
+
openMs <= OPEN_BUDGET_MS,
|
|
3119
|
+
"product_card_open_too_slow",
|
|
3120
|
+
`The first product card opened in ${Math.round(openMs)} ms`,
|
|
3121
|
+
'Set routeDiscovery to initial and prefetch item links with prefetch="viewport" so the item surface opens within 300 ms.'
|
|
3122
|
+
);
|
|
3123
|
+
} finally {
|
|
3124
|
+
await cdp.detach();
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
};
|
|
3128
|
+
function itemRouteModule(itemPath2) {
|
|
3129
|
+
const manifest = window.__reactRouterManifest;
|
|
3130
|
+
if (!manifest) {
|
|
3131
|
+
return void 0;
|
|
3132
|
+
}
|
|
3133
|
+
const segments = (path4) => path4.split("/").filter(Boolean);
|
|
3134
|
+
const fullPath = (route2) => {
|
|
3135
|
+
const parts = [];
|
|
3136
|
+
for (let current = route2; current; current = current.parentId ? manifest.routes[current.parentId] : void 0) {
|
|
3137
|
+
if (current.path) {
|
|
3138
|
+
parts.unshift(current.path);
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
return segments(parts.join("/"));
|
|
3142
|
+
};
|
|
3143
|
+
const wanted = segments(itemPath2);
|
|
3144
|
+
const route = Object.values(manifest.routes).find((entry) => {
|
|
3145
|
+
if (!entry.path) {
|
|
3146
|
+
return false;
|
|
3147
|
+
}
|
|
3148
|
+
const pattern = fullPath(entry);
|
|
3149
|
+
return pattern.length === wanted.length && pattern.every(
|
|
3150
|
+
(part, index) => part.startsWith(":") || part === wanted[index]
|
|
3151
|
+
);
|
|
3152
|
+
});
|
|
3153
|
+
return route ? new URL(route.module, location.href).pathname : void 0;
|
|
3154
|
+
}
|
|
2793
3155
|
|
|
2794
3156
|
// src/checks/v16-overlays-open-quickly.ts
|
|
2795
3157
|
var OVERLAY_OPEN_BUDGET_MS = 100;
|
|
@@ -2804,15 +3166,15 @@ var median = (values) => {
|
|
|
2804
3166
|
const sorted = [...values].sort((a, b) => a - b);
|
|
2805
3167
|
return sorted[Math.floor(sorted.length / 2)];
|
|
2806
3168
|
};
|
|
2807
|
-
async function firstOpen(fresh, viewport, name, cpuThrottlingRate2,
|
|
3169
|
+
async function firstOpen(fresh, viewport, name, cpuThrottlingRate2, start, item) {
|
|
2808
3170
|
const { page } = fresh;
|
|
2809
3171
|
try {
|
|
2810
3172
|
await page.setViewportSize(viewport);
|
|
2811
3173
|
const { cdp, rate } = await applyPhoneProfile(page, cpuThrottlingRate2);
|
|
2812
3174
|
try {
|
|
2813
|
-
await openSettledMenu(page);
|
|
3175
|
+
await openSettledMenu(page, start);
|
|
2814
3176
|
if (name === "bag") {
|
|
2815
|
-
await revealBagTrigger(page,
|
|
3177
|
+
await revealBagTrigger(page, start, item);
|
|
2816
3178
|
}
|
|
2817
3179
|
const trigger = page.locator(`${triggerSelector(name)}:visible`).first();
|
|
2818
3180
|
await trigger.evaluate(
|
|
@@ -2835,9 +3197,9 @@ var v16 = {
|
|
|
2835
3197
|
viewports: "mobile",
|
|
2836
3198
|
async run({ page, options, metrics, freshPage, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
|
|
2837
3199
|
await page.setViewportSize(viewport);
|
|
2838
|
-
const
|
|
2839
|
-
await openSettledMenu(page);
|
|
2840
|
-
await revealBagTrigger(page,
|
|
3200
|
+
const { start, item } = bagItem(options, await readMenu(options));
|
|
3201
|
+
await openSettledMenu(page, start);
|
|
3202
|
+
await revealBagTrigger(page, start, item);
|
|
2841
3203
|
const names = (await overlayTriggers(page)).map(({ name }) => name);
|
|
2842
3204
|
skipWithoutTriggers(names);
|
|
2843
3205
|
const timings = {};
|
|
@@ -2854,7 +3216,8 @@ var v16 = {
|
|
|
2854
3216
|
viewport,
|
|
2855
3217
|
name,
|
|
2856
3218
|
cpuThrottlingRate2,
|
|
2857
|
-
|
|
3219
|
+
start,
|
|
3220
|
+
item
|
|
2858
3221
|
);
|
|
2859
3222
|
if (open2 === void 0) {
|
|
2860
3223
|
opened = false;
|
|
@@ -2922,10 +3285,10 @@ var scrollLocked = (page) => page.evaluate(() => {
|
|
|
2922
3285
|
};
|
|
2923
3286
|
return held(document.documentElement) || held(document.body);
|
|
2924
3287
|
});
|
|
2925
|
-
async function checkOverlay(page, name,
|
|
2926
|
-
await openSettledMenu(page);
|
|
3288
|
+
async function checkOverlay(page, name, start, item) {
|
|
3289
|
+
await openSettledMenu(page, start);
|
|
2927
3290
|
if (name === "bag") {
|
|
2928
|
-
await revealBagTrigger(page,
|
|
3291
|
+
await revealBagTrigger(page, start, item);
|
|
2929
3292
|
}
|
|
2930
3293
|
const trigger = page.locator(`${triggerSelector(name)}:visible`).first();
|
|
2931
3294
|
await trigger.focus();
|
|
@@ -3006,12 +3369,12 @@ var v17 = {
|
|
|
3006
3369
|
viewports: "mobile",
|
|
3007
3370
|
async run({ page, options, freshPage }, viewport) {
|
|
3008
3371
|
await page.setViewportSize(viewport);
|
|
3009
|
-
const
|
|
3010
|
-
await page.goto(
|
|
3372
|
+
const { start, item } = bagItem(options, await readMenu(options));
|
|
3373
|
+
await page.goto(start, { waitUntil: "domcontentloaded" });
|
|
3011
3374
|
await page.locator("[data-zappie-item]").first().waitFor();
|
|
3012
3375
|
if (!await page.locator(`${triggerSelector("bag")}:visible`).count()) {
|
|
3013
|
-
await openSettledMenu(page);
|
|
3014
|
-
await revealBagTrigger(page,
|
|
3376
|
+
await openSettledMenu(page, start);
|
|
3377
|
+
await revealBagTrigger(page, start, item);
|
|
3015
3378
|
}
|
|
3016
3379
|
const names = (await overlayTriggers(page)).map(({ name }) => name);
|
|
3017
3380
|
skipWithoutTriggers(names);
|
|
@@ -3020,7 +3383,7 @@ var v17 = {
|
|
|
3020
3383
|
const fresh = await freshPage();
|
|
3021
3384
|
try {
|
|
3022
3385
|
await fresh.page.setViewportSize(viewport);
|
|
3023
|
-
findings.push(...await checkOverlay(fresh.page, name,
|
|
3386
|
+
findings.push(...await checkOverlay(fresh.page, name, start, item));
|
|
3024
3387
|
} finally {
|
|
3025
3388
|
await fresh.close();
|
|
3026
3389
|
}
|
|
@@ -3056,7 +3419,11 @@ var v18 = {
|
|
|
3056
3419
|
async run({ page, options, warn }, viewport) {
|
|
3057
3420
|
await page.setViewportSize(viewport);
|
|
3058
3421
|
let reportOnlyFound = false;
|
|
3059
|
-
for (const path4 of await publicRoutePaths(
|
|
3422
|
+
for (const path4 of await publicRoutePaths(
|
|
3423
|
+
page.request,
|
|
3424
|
+
options.url,
|
|
3425
|
+
startPath(options)
|
|
3426
|
+
)) {
|
|
3060
3427
|
const response = await page.goto(new URL(path4, options.url).href, {
|
|
3061
3428
|
waitUntil: "domcontentloaded"
|
|
3062
3429
|
});
|
|
@@ -3220,6 +3587,7 @@ function printSummary(report) {
|
|
|
3220
3587
|
return `${check.result.toUpperCase().padEnd(7)} ${check.id} ${check.title}${suffix}${warnings}`;
|
|
3221
3588
|
});
|
|
3222
3589
|
lines.push(
|
|
3590
|
+
...(report.warnings ?? []).map((warning) => `WARNING: ${warning.message}`),
|
|
3223
3591
|
`${report.result.toUpperCase()}: ${report.summary.passed} passed, ${report.summary.failed} failed, ${report.summary.skipped} skipped`,
|
|
3224
3592
|
report.signature ? "Report signed (ed25519)." : "Report unsigned."
|
|
3225
3593
|
);
|
|
@@ -3273,7 +3641,7 @@ async function reset(options) {
|
|
|
3273
3641
|
}
|
|
3274
3642
|
|
|
3275
3643
|
// src/version.ts
|
|
3276
|
-
var PACKAGE_VERSION = true ? "0.
|
|
3644
|
+
var PACKAGE_VERSION = true ? "0.3.1" : "0.0.0-development";
|
|
3277
3645
|
|
|
3278
3646
|
// src/runner.ts
|
|
3279
3647
|
var VERSION = PACKAGE_VERSION;
|
|
@@ -3398,14 +3766,16 @@ async function preparePage(context, options, fault) {
|
|
|
3398
3766
|
await installFault(page, fault);
|
|
3399
3767
|
return page;
|
|
3400
3768
|
}
|
|
3401
|
-
async function runVerify(
|
|
3402
|
-
if (!/^pk_test_.+/.test(
|
|
3769
|
+
async function runVerify(input, fault) {
|
|
3770
|
+
if (!/^pk_test_.+/.test(input.key)) {
|
|
3403
3771
|
throw new VerifyError(
|
|
3404
3772
|
"live_key_refused",
|
|
3405
3773
|
"Verify accepts only pk_test_ keys",
|
|
3406
3774
|
"Create a test publishable key and rerun verify."
|
|
3407
3775
|
);
|
|
3408
3776
|
}
|
|
3777
|
+
checkPaths(input);
|
|
3778
|
+
const options = await fillStartOptions(input);
|
|
3409
3779
|
const wanted = options.only ? expandOnly(options.only, knownCheckIds()) : void 0;
|
|
3410
3780
|
const started = Date.now();
|
|
3411
3781
|
const executablePath = browserExecutable(options);
|
|
@@ -3470,13 +3840,17 @@ async function runVerify(options, fault) {
|
|
|
3470
3840
|
sdkVersion,
|
|
3471
3841
|
browser: `chromium ${browser.version()}${executablePath ? ` (${executablePath})` : ""}`,
|
|
3472
3842
|
...cpuBenchmark === void 0 ? {} : { cpuBenchmark },
|
|
3473
|
-
cpuThrottlingRate: throttlingRate
|
|
3843
|
+
cpuThrottlingRate: throttlingRate,
|
|
3844
|
+
startPath: startPath(options),
|
|
3845
|
+
...options.startPath !== input.startPath ? { startPathTemplate: input.startPath } : {},
|
|
3846
|
+
...options.cataloguePath ? { cataloguePath: options.cataloguePath } : {}
|
|
3474
3847
|
},
|
|
3475
3848
|
startedAt: new Date(started).toISOString(),
|
|
3476
3849
|
durationMs: Date.now() - started,
|
|
3477
3850
|
result: summary.failed ? "failed" : "passed",
|
|
3478
3851
|
summary,
|
|
3479
3852
|
checks: results,
|
|
3853
|
+
...options.warnings?.length ? { warnings: options.warnings } : {},
|
|
3480
3854
|
metrics
|
|
3481
3855
|
};
|
|
3482
3856
|
return signReport(
|
|
@@ -3496,7 +3870,16 @@ async function runVerify(options, fault) {
|
|
|
3496
3870
|
|
|
3497
3871
|
// src/project.ts
|
|
3498
3872
|
async function verifyProject(options) {
|
|
3499
|
-
const
|
|
3873
|
+
const settings = options.store && options.startPath && options.cataloguePath ? {} : await readProjectSettings(options.cwd, { required: !options.store });
|
|
3874
|
+
const store = options.store ?? settings.store;
|
|
3875
|
+
const startPath2 = options.startPath ?? settings.startPath;
|
|
3876
|
+
const warnings = "ignored" in settings && settings.ignored ? [
|
|
3877
|
+
{
|
|
3878
|
+
code: "config_settings_ignored",
|
|
3879
|
+
message: `${settings.ignored}, so verify ran without its verify.startPath and verify.cataloguePath. Fix zappie.config.ts, or pass the start path with --start.`
|
|
3880
|
+
}
|
|
3881
|
+
] : [];
|
|
3882
|
+
const cataloguePath = options.cataloguePath ?? settings.cataloguePath;
|
|
3500
3883
|
let preview;
|
|
3501
3884
|
try {
|
|
3502
3885
|
preview = options.url ? void 0 : await startLocalPreview({
|
|
@@ -3515,6 +3898,9 @@ async function verifyProject(options) {
|
|
|
3515
3898
|
suite: options.suite,
|
|
3516
3899
|
runner: options.runner ?? "cli",
|
|
3517
3900
|
...options.only ? { only: options.only } : {},
|
|
3901
|
+
...startPath2 !== void 0 ? { startPath: startPath2 } : {},
|
|
3902
|
+
...cataloguePath !== void 0 ? { cataloguePath } : {},
|
|
3903
|
+
...warnings.length ? { warnings } : {},
|
|
3518
3904
|
...options.viewports ? { viewports: options.viewports } : {},
|
|
3519
3905
|
...options.artifactsDir ? { artifactsDir: options.artifactsDir } : {},
|
|
3520
3906
|
...options.timeoutMs ? { timeoutMs: options.timeoutMs } : {},
|
|
@@ -3550,6 +3936,8 @@ function normalizeUrl(value) {
|
|
|
3550
3936
|
// src/regression.ts
|
|
3551
3937
|
var DEFAULT_REGRESSION_THRESHOLDS = {
|
|
3552
3938
|
menuJsKbGz: { relative: 0.05, absolute: 2 },
|
|
3939
|
+
// What idle and intent preloads fetch varies a little between runs, so its allowance is wider.
|
|
3940
|
+
menuJsLoadedKbGz: { relative: 0.1, absolute: 10 },
|
|
3553
3941
|
menuCssKbGz: { relative: 0.05, absolute: 1 },
|
|
3554
3942
|
lcpMs: { relative: 0.1, absolute: 150 },
|
|
3555
3943
|
cls: { relative: 0, absolute: 0.02 },
|