@zappie/verify 0.2.8 → 0.3.0
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 +25 -1
- package/dist/index.d.ts +39 -1
- package/dist/index.js +636 -344
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
- package/schema/verify-report.v1.json +16 -1
package/dist/index.js
CHANGED
|
@@ -287,7 +287,37 @@ function supportsTypeScriptImport(version = process.versions.node) {
|
|
|
287
287
|
const [major = 0, minor = 0] = version.split(".").map(Number);
|
|
288
288
|
return major > 22 || major === 22 && minor >= 18;
|
|
289
289
|
}
|
|
290
|
-
async function
|
|
290
|
+
async function readProjectSettings(cwd, { required }) {
|
|
291
|
+
let value;
|
|
292
|
+
try {
|
|
293
|
+
value = await loadConfig(cwd);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (required) {
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
const exists = await stat2(path2.join(cwd, "zappie.config.ts")).then(
|
|
299
|
+
() => true,
|
|
300
|
+
() => false
|
|
301
|
+
);
|
|
302
|
+
return exists && error instanceof VerifyError ? { ignored: error.message } : {};
|
|
303
|
+
}
|
|
304
|
+
const config = record(value) ? value : {};
|
|
305
|
+
const verify2 = record(config.verify) ? config.verify : {};
|
|
306
|
+
const settings = {
|
|
307
|
+
...typeof config.store === "string" ? { store: config.store } : {},
|
|
308
|
+
...verify2.startPath !== void 0 ? { startPath: verify2.startPath } : {},
|
|
309
|
+
...verify2.cataloguePath !== void 0 ? { cataloguePath: verify2.cataloguePath } : {}
|
|
310
|
+
};
|
|
311
|
+
if (required && settings.store === void 0) {
|
|
312
|
+
throw new VerifyError(
|
|
313
|
+
"store_missing",
|
|
314
|
+
"zappie.config.ts must define a store",
|
|
315
|
+
"Define store in zappie.config.ts or pass --store."
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return settings;
|
|
319
|
+
}
|
|
320
|
+
async function loadConfig(cwd) {
|
|
291
321
|
if (!supportsTypeScriptImport()) {
|
|
292
322
|
throw new VerifyError(
|
|
293
323
|
"unsupported_node",
|
|
@@ -300,10 +330,7 @@ async function readStore(cwd) {
|
|
|
300
330
|
const version = (await stat2(file)).mtimeMs;
|
|
301
331
|
await readFile2(file, "utf8");
|
|
302
332
|
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
|
-
}
|
|
333
|
+
return loaded.default;
|
|
307
334
|
} catch (error) {
|
|
308
335
|
const message = error instanceof Error ? error.message : String(error);
|
|
309
336
|
throw new VerifyError(
|
|
@@ -312,12 +339,8 @@ async function readStore(cwd) {
|
|
|
312
339
|
"Create a readable zappie.config.ts or pass --store."
|
|
313
340
|
);
|
|
314
341
|
}
|
|
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
342
|
}
|
|
343
|
+
var record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
321
344
|
|
|
322
345
|
// src/runner.ts
|
|
323
346
|
import {
|
|
@@ -327,6 +350,50 @@ import {
|
|
|
327
350
|
import { mkdir } from "fs/promises";
|
|
328
351
|
import path3 from "path";
|
|
329
352
|
|
|
353
|
+
// src/paths.ts
|
|
354
|
+
var DEFAULT_START_PATH = "/";
|
|
355
|
+
var ROUTE = /^\/(?!\/)[^\\]*$/;
|
|
356
|
+
var SITE = "http://site.invalid";
|
|
357
|
+
function isRoute(path4) {
|
|
358
|
+
return typeof path4 === "string" && ROUTE.test(path4) && new URL(path4, SITE).origin === SITE;
|
|
359
|
+
}
|
|
360
|
+
function startPath(options) {
|
|
361
|
+
return options.startPath ?? DEFAULT_START_PATH;
|
|
362
|
+
}
|
|
363
|
+
function checkPaths(options) {
|
|
364
|
+
const { startPath: start, cataloguePath } = options;
|
|
365
|
+
if (start !== void 0 && !isRoute(start)) {
|
|
366
|
+
throw new VerifyError(
|
|
367
|
+
"invalid_start_path",
|
|
368
|
+
`The start path ${JSON.stringify(start)} is not a route on the site`,
|
|
369
|
+
"Set verify.startPath or --start to a route that starts with one /, such as /menu."
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
if (cataloguePath !== void 0 && (!isRoute(cataloguePath) || !cataloguePath.includes("{menu}"))) {
|
|
373
|
+
throw new VerifyError(
|
|
374
|
+
"invalid_catalogue_path",
|
|
375
|
+
`The catalogue path ${JSON.stringify(cataloguePath)} is not a route with {menu}`,
|
|
376
|
+
"Set verify.cataloguePath to a route that starts with one / and puts {menu} where the menu id goes, such as /menu?menu={menu}."
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function cataloguePage(cataloguePath, menuId, branchId) {
|
|
381
|
+
return cataloguePath.replaceAll("{menu}", encodeURIComponent(menuId)).replaceAll("{branch}", encodeURIComponent(branchId));
|
|
382
|
+
}
|
|
383
|
+
function isAtPath(url, path4) {
|
|
384
|
+
let current;
|
|
385
|
+
try {
|
|
386
|
+
current = new URL(url);
|
|
387
|
+
} catch {
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
if (current.protocol !== "http:" && current.protocol !== "https:") {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
const wanted = new URL(path4, current);
|
|
394
|
+
return current.pathname === wanted.pathname && (wanted.search === "" || current.search === wanted.search);
|
|
395
|
+
}
|
|
396
|
+
|
|
330
397
|
// src/cpu.ts
|
|
331
398
|
var REFERENCE_CPU_BENCHMARK = 26e3;
|
|
332
399
|
var REFERENCE_CPU_THROTTLING_RATE = 4;
|
|
@@ -384,7 +451,7 @@ async function benchmarkCpu(browser) {
|
|
|
384
451
|
}
|
|
385
452
|
|
|
386
453
|
// src/checks/common.ts
|
|
387
|
-
async function open(page, path4
|
|
454
|
+
async function open(page, path4) {
|
|
388
455
|
const response = await page.goto(path4, { waitUntil: "domcontentloaded" });
|
|
389
456
|
assertion(
|
|
390
457
|
response?.status() === 200,
|
|
@@ -554,8 +621,8 @@ async function dialogConfirm(page, confirm) {
|
|
|
554
621
|
}
|
|
555
622
|
|
|
556
623
|
// src/checks/routes.ts
|
|
557
|
-
async function publicRoutePaths(request, baseUrl) {
|
|
558
|
-
const paths = /* @__PURE__ */ new Set(["/"]);
|
|
624
|
+
async function publicRoutePaths(request, baseUrl, start = "/") {
|
|
625
|
+
const paths = /* @__PURE__ */ new Set(["/", start]);
|
|
559
626
|
const sitemap = await request.get(new URL("/sitemap.xml", baseUrl).href);
|
|
560
627
|
if (sitemap.ok()) {
|
|
561
628
|
for (const match of (await sitemap.text()).matchAll(
|
|
@@ -583,7 +650,11 @@ var v01 = {
|
|
|
583
650
|
};
|
|
584
651
|
page.on("pageerror", onPageError);
|
|
585
652
|
page.on("console", onConsole);
|
|
586
|
-
for (const path4 of await publicRoutePaths(
|
|
653
|
+
for (const path4 of await publicRoutePaths(
|
|
654
|
+
page.request,
|
|
655
|
+
options.url,
|
|
656
|
+
startPath(options)
|
|
657
|
+
)) {
|
|
587
658
|
await open(page, path4);
|
|
588
659
|
}
|
|
589
660
|
page.off("pageerror", onPageError);
|
|
@@ -604,7 +675,7 @@ var v02 = {
|
|
|
604
675
|
viewports: "mobile",
|
|
605
676
|
async run({ page, options }, viewport) {
|
|
606
677
|
await page.setViewportSize(viewport);
|
|
607
|
-
await open(page);
|
|
678
|
+
await open(page, startPath(options));
|
|
608
679
|
const debug = await debugValue(page, () => ({
|
|
609
680
|
store: window.__zappie?.store,
|
|
610
681
|
version: window.__zappie?.version
|
|
@@ -660,6 +731,95 @@ async function installFault(page, fault) {
|
|
|
660
731
|
await route.fulfill({ response, headers });
|
|
661
732
|
});
|
|
662
733
|
}
|
|
734
|
+
if (fault === "catalogue-directory") {
|
|
735
|
+
await page.route(
|
|
736
|
+
(url) => url.pathname === "/" && !url.searchParams.has("menu"),
|
|
737
|
+
async (route) => {
|
|
738
|
+
if (route.request().resourceType() !== "document") {
|
|
739
|
+
await route.continue();
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
const response = await route.fetch();
|
|
743
|
+
const headers = { ...response.headers() };
|
|
744
|
+
delete headers["content-length"];
|
|
745
|
+
delete headers["content-encoding"];
|
|
746
|
+
await route.fulfill({
|
|
747
|
+
response,
|
|
748
|
+
headers: { ...headers, "content-type": "text/html; charset=utf-8" },
|
|
749
|
+
body: [
|
|
750
|
+
'<!doctype html><html lang="en"><head><meta charset="utf-8">',
|
|
751
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
752
|
+
"<title>Choose a menu</title></head><body><main>",
|
|
753
|
+
"<h1>Choose a menu</h1><ul>",
|
|
754
|
+
'<li><a href="/?menu=menu_all_day">All day</a></li>',
|
|
755
|
+
'<li><a href="/?menu=menu_treats">Treats</a></li>',
|
|
756
|
+
"</ul></main></body></html>"
|
|
757
|
+
].join("")
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
if (fault === "catalogue-gap") {
|
|
763
|
+
await page.addInitScript(() => {
|
|
764
|
+
new MutationObserver(() => {
|
|
765
|
+
if (new URLSearchParams(location.search).get("menu") !== "menu_treats") {
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
for (const node of document.querySelectorAll(
|
|
769
|
+
'[data-zappie-item="item_zobo"]'
|
|
770
|
+
)) {
|
|
771
|
+
node.remove();
|
|
772
|
+
}
|
|
773
|
+
}).observe(document, { childList: true, subtree: true });
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
if (fault === "oversized" || fault === "late-chunk") {
|
|
777
|
+
const large = randomScript("__verifyLarge", 26e4);
|
|
778
|
+
await page.route(
|
|
779
|
+
"**/__verify-large.js",
|
|
780
|
+
(route) => route.fulfill({ contentType: "text/javascript", body: large })
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
if (fault === "oversized") {
|
|
784
|
+
await page.route(
|
|
785
|
+
(url) => url.pathname === "/",
|
|
786
|
+
async (route) => {
|
|
787
|
+
if (route.request().resourceType() !== "document") {
|
|
788
|
+
await route.continue();
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
const response = await route.fetch();
|
|
792
|
+
const headers = { ...response.headers() };
|
|
793
|
+
delete headers["content-length"];
|
|
794
|
+
delete headers["content-encoding"];
|
|
795
|
+
await route.fulfill({
|
|
796
|
+
response,
|
|
797
|
+
headers,
|
|
798
|
+
body: (await response.text()).replace(
|
|
799
|
+
"</head>",
|
|
800
|
+
'<script defer src="/__verify-large.js"></script></head>'
|
|
801
|
+
)
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
if (fault === "late-chunk") {
|
|
807
|
+
await page.route(
|
|
808
|
+
"**/__verify-late.js",
|
|
809
|
+
(route) => route.fulfill({
|
|
810
|
+
contentType: "text/javascript",
|
|
811
|
+
body: randomScript("__verifyLate", 46e4)
|
|
812
|
+
})
|
|
813
|
+
);
|
|
814
|
+
await page.addInitScript(() => {
|
|
815
|
+
window.addEventListener("load", () => {
|
|
816
|
+
setTimeout(() => {
|
|
817
|
+
const late = "/__verify-late.js";
|
|
818
|
+
void import(late);
|
|
819
|
+
}, 0);
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
}
|
|
663
823
|
if (fault === "hydration-delay") {
|
|
664
824
|
await page.route(/\.(?:js|mjs)(?:\?|$)/, async (route) => {
|
|
665
825
|
await new Promise((resolve) => setTimeout(resolve, 3500));
|
|
@@ -1157,20 +1317,14 @@ async function applyPageFault(page, fault) {
|
|
|
1157
1317
|
document.body.append(button);
|
|
1158
1318
|
});
|
|
1159
1319
|
}
|
|
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
|
-
}
|
|
1320
|
+
}
|
|
1321
|
+
function randomScript(name, length) {
|
|
1322
|
+
let state = 1;
|
|
1323
|
+
const payload = Array.from({ length }, () => {
|
|
1324
|
+
state = state * 48271 % 2147483647;
|
|
1325
|
+
return String.fromCharCode(33 + state % 90);
|
|
1326
|
+
}).join("");
|
|
1327
|
+
return `window.${name}=${JSON.stringify(payload)}`;
|
|
1174
1328
|
}
|
|
1175
1329
|
|
|
1176
1330
|
// src/checks/catalogue.ts
|
|
@@ -1203,6 +1357,43 @@ function configurableItem(menu) {
|
|
|
1203
1357
|
(item) => item.isAvailable && item.optionGroups.some((group) => group.required)
|
|
1204
1358
|
);
|
|
1205
1359
|
}
|
|
1360
|
+
function catalogues(menu) {
|
|
1361
|
+
const sources = menu.menus?.length ? menu.menus : [
|
|
1362
|
+
{
|
|
1363
|
+
id: menu.id,
|
|
1364
|
+
name: menu.name,
|
|
1365
|
+
categoryIds: menu.categories.map((category) => category.id)
|
|
1366
|
+
}
|
|
1367
|
+
];
|
|
1368
|
+
return sources.map((source) => {
|
|
1369
|
+
const ids = new Set(
|
|
1370
|
+
menu.categories.filter((category) => source.categoryIds.includes(category.id)).flatMap((category) => category.itemIds)
|
|
1371
|
+
);
|
|
1372
|
+
return {
|
|
1373
|
+
id: source.id,
|
|
1374
|
+
name: source.name,
|
|
1375
|
+
items: sortedItems(menu).filter((item) => ids.has(item.id))
|
|
1376
|
+
};
|
|
1377
|
+
}).filter((catalogue) => catalogue.items.length > 0);
|
|
1378
|
+
}
|
|
1379
|
+
function cataloguePages(options, menu) {
|
|
1380
|
+
const { cataloguePath } = options;
|
|
1381
|
+
const pages = cataloguePath ? catalogues(menu) : [];
|
|
1382
|
+
if (!cataloguePath || pages.length === 0) {
|
|
1383
|
+
return [{ path: startPath(options), items: sortedItems(menu) }];
|
|
1384
|
+
}
|
|
1385
|
+
return pages.map((catalogue) => ({
|
|
1386
|
+
path: cataloguePage(cataloguePath, catalogue.id, menu.branchId),
|
|
1387
|
+
items: catalogue.items,
|
|
1388
|
+
name: catalogue.name
|
|
1389
|
+
}));
|
|
1390
|
+
}
|
|
1391
|
+
function itemPath(options, menu, itemId) {
|
|
1392
|
+
const holder = options.cataloguePath && itemId ? catalogues(menu).find(
|
|
1393
|
+
(catalogue) => catalogue.items.some((item) => item.id === itemId)
|
|
1394
|
+
) : void 0;
|
|
1395
|
+
return holder && options.cataloguePath ? cataloguePage(options.cataloguePath, holder.id, menu.branchId) : startPath(options);
|
|
1396
|
+
}
|
|
1206
1397
|
|
|
1207
1398
|
// src/checks/menu-match.ts
|
|
1208
1399
|
function menuCoverage(items, shownIds) {
|
|
@@ -1226,54 +1417,70 @@ var v03 = {
|
|
|
1226
1417
|
async run({ page, options, fault }, viewport) {
|
|
1227
1418
|
const menu = await readMenu(options);
|
|
1228
1419
|
await page.setViewportSize(viewport);
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
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"]`
|
|
1420
|
+
const severalMenus = !options.cataloguePath && catalogues(menu).length > 1;
|
|
1421
|
+
for (const catalogue of cataloguePages(options, menu)) {
|
|
1422
|
+
await open(page, catalogue.path);
|
|
1423
|
+
await applyPageFault(page, fault);
|
|
1424
|
+
await judgeCatalogue(
|
|
1425
|
+
page,
|
|
1426
|
+
catalogue.items,
|
|
1427
|
+
catalogue.name === void 0 ? "menu" : `${catalogue.name} menu at ${catalogue.path}`,
|
|
1428
|
+
severalMenus
|
|
1256
1429
|
);
|
|
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
1430
|
}
|
|
1266
|
-
|
|
1267
|
-
|
|
1431
|
+
}
|
|
1432
|
+
};
|
|
1433
|
+
async function judgeCatalogue(page, items, menuName, severalMenus) {
|
|
1434
|
+
const available = items.filter((item) => item.isAvailable);
|
|
1435
|
+
const shownIds = /* @__PURE__ */ new Set();
|
|
1436
|
+
for (const item of available) {
|
|
1437
|
+
const marker = page.locator(`[data-zappie-item="${item.id}"]`);
|
|
1438
|
+
if (await marker.count() > 0 || await page.getByText(item.name, { exact: true }).count() > 0) {
|
|
1439
|
+
shownIds.add(item.id);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
const coverage = menuCoverage(items, shownIds);
|
|
1443
|
+
assertion(
|
|
1444
|
+
coverage.passes || severalMenus,
|
|
1445
|
+
"menu_mismatch",
|
|
1446
|
+
`The ${menuName} shows ${coverage.shown} of ${coverage.available} available items`,
|
|
1447
|
+
"Render at least 95% of available menu items with data-zappie-item markers or visible names."
|
|
1448
|
+
);
|
|
1449
|
+
assertion(
|
|
1450
|
+
coverage.passes,
|
|
1451
|
+
"menu_mismatch",
|
|
1452
|
+
`The ${menuName} shows ${coverage.shown} of ${coverage.available} available items`,
|
|
1453
|
+
"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}."
|
|
1454
|
+
);
|
|
1455
|
+
assertion(
|
|
1456
|
+
await page.locator("[data-zappie-item]").count(),
|
|
1457
|
+
"item_marker_missing",
|
|
1458
|
+
`The ${menuName} has no item marker`,
|
|
1459
|
+
"Add data-zappie-item to each rendered menu item."
|
|
1460
|
+
);
|
|
1461
|
+
for (const item of items.filter((entry) => !entry.isAvailable)) {
|
|
1462
|
+
const add = page.locator(
|
|
1463
|
+
`[data-zappie-item="${item.id}"] [data-zappie-action="add-to-cart"]`
|
|
1464
|
+
);
|
|
1465
|
+
if (await add.count()) {
|
|
1268
1466
|
assertion(
|
|
1269
|
-
|
|
1270
|
-
"
|
|
1271
|
-
|
|
1272
|
-
"
|
|
1467
|
+
await add.isDisabled(),
|
|
1468
|
+
"unavailable_item_addable",
|
|
1469
|
+
`Shoppers can add the unavailable item ${item.name}`,
|
|
1470
|
+
"Disable add-to-cart for unavailable items."
|
|
1273
1471
|
);
|
|
1274
1472
|
}
|
|
1275
1473
|
}
|
|
1276
|
-
|
|
1474
|
+
for (const item of available.slice(0, 2)) {
|
|
1475
|
+
const text = await page.locator(`[data-zappie-item="${item.id}"]`).first().textContent();
|
|
1476
|
+
assertion(
|
|
1477
|
+
priceMatches(text, format.money(item.price)),
|
|
1478
|
+
"price_mismatch",
|
|
1479
|
+
`${item.name} does not show ${format.money(item.price)}`,
|
|
1480
|
+
"Render prices with format.money from @zappie/sdk."
|
|
1481
|
+
);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1277
1484
|
|
|
1278
1485
|
// src/checks/v04-add-simple.ts
|
|
1279
1486
|
var v04 = {
|
|
@@ -1282,7 +1489,9 @@ var v04 = {
|
|
|
1282
1489
|
viewports: "both",
|
|
1283
1490
|
async run({ page, options, fault }, viewport) {
|
|
1284
1491
|
await page.setViewportSize(viewport);
|
|
1285
|
-
await
|
|
1492
|
+
const menu = await readMenu(options);
|
|
1493
|
+
const item = simpleItem(menu);
|
|
1494
|
+
await open(page, itemPath(options, menu, item?.id));
|
|
1286
1495
|
await applyPageFault(page, fault);
|
|
1287
1496
|
const before = await debugValue(page, () => window.__zappie.cart().count);
|
|
1288
1497
|
await page.evaluate(() => {
|
|
@@ -1293,7 +1502,6 @@ var v04 = {
|
|
|
1293
1502
|
);
|
|
1294
1503
|
});
|
|
1295
1504
|
});
|
|
1296
|
-
const item = simpleItem(await readMenu(options));
|
|
1297
1505
|
assertion(
|
|
1298
1506
|
item,
|
|
1299
1507
|
"simple_item_missing",
|
|
@@ -1369,8 +1577,9 @@ var v05 = {
|
|
|
1369
1577
|
viewports: "both",
|
|
1370
1578
|
async run({ page, options }, viewport) {
|
|
1371
1579
|
await page.setViewportSize(viewport);
|
|
1372
|
-
await
|
|
1373
|
-
const selected = configurableItem(
|
|
1580
|
+
const menu = await readMenu(options);
|
|
1581
|
+
const selected = configurableItem(menu);
|
|
1582
|
+
await open(page, itemPath(options, menu, selected?.id));
|
|
1374
1583
|
assertion(
|
|
1375
1584
|
selected,
|
|
1376
1585
|
"required_item_missing",
|
|
@@ -1474,10 +1683,13 @@ var v06 = {
|
|
|
1474
1683
|
id: "V06",
|
|
1475
1684
|
title: "Cart quote and total",
|
|
1476
1685
|
viewports: "both",
|
|
1477
|
-
async run({ page }, viewport) {
|
|
1686
|
+
async run({ page, options }, viewport) {
|
|
1478
1687
|
await page.setViewportSize(viewport);
|
|
1479
|
-
|
|
1480
|
-
|
|
1688
|
+
const start = startPath(options);
|
|
1689
|
+
const stay = isAtPath(page.url(), start) || start !== DEFAULT_START_PATH && await (await cartTrigger(page)).isVisible().catch(() => false);
|
|
1690
|
+
if (!stay) {
|
|
1691
|
+
await open(page, start);
|
|
1692
|
+
await page.waitForFunction(() => Boolean(window.__zappie));
|
|
1481
1693
|
}
|
|
1482
1694
|
const total = page.locator("[data-zappie-total]").filter({ visible: true });
|
|
1483
1695
|
const bagOpen = page.locator('[data-zappie-overlay="bag"]').filter({ visible: true });
|
|
@@ -1602,9 +1814,9 @@ var v07 = {
|
|
|
1602
1814
|
id: "V07",
|
|
1603
1815
|
title: "Handle quote issues",
|
|
1604
1816
|
viewports: "mobile",
|
|
1605
|
-
async run({ page }, viewport) {
|
|
1817
|
+
async run({ page, options }, viewport) {
|
|
1606
1818
|
await page.setViewportSize(viewport);
|
|
1607
|
-
await open(page);
|
|
1819
|
+
await open(page, startPath(options));
|
|
1608
1820
|
await page.waitForFunction(() => window.__zappie?.cart().isHydrated);
|
|
1609
1821
|
const debug = await page.evaluate(() => ({
|
|
1610
1822
|
inject: typeof window.__zappie?.cart.inject === "function",
|
|
@@ -2226,7 +2438,7 @@ async function holdsFocus(target2) {
|
|
|
2226
2438
|
return false;
|
|
2227
2439
|
}
|
|
2228
2440
|
var ADD = '[data-zappie-action="add-to-cart"]';
|
|
2229
|
-
async function keyboardReachesAddAndCart(page, itemId, cartInColumn) {
|
|
2441
|
+
async function keyboardReachesAddAndCart(page, itemId, itemPage, cartInColumn) {
|
|
2230
2442
|
const cart = cartInColumn ? [] : [await cartTriggerSelector(page)];
|
|
2231
2443
|
if (await page.locator(ADD).count() || !itemId) {
|
|
2232
2444
|
return controlsAreReachable(page, [ADD, ...cart]);
|
|
@@ -2235,7 +2447,7 @@ async function keyboardReachesAddAndCart(page, itemId, cartInColumn) {
|
|
|
2235
2447
|
if (cart.length && !await controlsAreReachable(page, cart)) {
|
|
2236
2448
|
return false;
|
|
2237
2449
|
}
|
|
2238
|
-
await open(page);
|
|
2450
|
+
await open(page, itemPage);
|
|
2239
2451
|
if (!await tabTo(page, opener, 200)) {
|
|
2240
2452
|
return false;
|
|
2241
2453
|
}
|
|
@@ -2268,16 +2480,28 @@ var v13 = {
|
|
|
2268
2480
|
viewports: "both",
|
|
2269
2481
|
async run({ page, options, fault }, viewport) {
|
|
2270
2482
|
await page.setViewportSize(viewport);
|
|
2271
|
-
await
|
|
2483
|
+
const menu = await readMenu(options);
|
|
2484
|
+
const start = startPath(options);
|
|
2485
|
+
await open(page, start);
|
|
2272
2486
|
await applyPageFault(page, fault);
|
|
2273
2487
|
const violations = await severeViolations(page);
|
|
2274
2488
|
const semantics = await semanticFindings(page, {
|
|
2275
2489
|
page: true,
|
|
2276
2490
|
phone: viewport.width < 768
|
|
2277
2491
|
});
|
|
2278
|
-
const item = sortedItems(
|
|
2279
|
-
|
|
2280
|
-
)
|
|
2492
|
+
const item = sortedItems(menu).find((entry) => entry.isAvailable);
|
|
2493
|
+
const itemPage = itemPath(options, menu, item?.id);
|
|
2494
|
+
if (itemPage !== start) {
|
|
2495
|
+
await open(page, itemPage);
|
|
2496
|
+
await applyPageFault(page, fault);
|
|
2497
|
+
violations.push(...await severeViolations(page));
|
|
2498
|
+
semantics.push(
|
|
2499
|
+
...await semanticFindings(page, {
|
|
2500
|
+
page: true,
|
|
2501
|
+
phone: viewport.width < 768
|
|
2502
|
+
})
|
|
2503
|
+
);
|
|
2504
|
+
}
|
|
2281
2505
|
assertion(
|
|
2282
2506
|
item,
|
|
2283
2507
|
"available_item_missing",
|
|
@@ -2345,13 +2569,11 @@ var v13 = {
|
|
|
2345
2569
|
order.map((entry) => entry.id).join(", ") || "Order page has a serious axe violation",
|
|
2346
2570
|
"Fix serious and critical axe findings on the order page."
|
|
2347
2571
|
);
|
|
2348
|
-
|
|
2572
|
+
const simple = simpleItem(menu)?.id;
|
|
2573
|
+
const simplePage = itemPath(options, menu, simple);
|
|
2574
|
+
await open(page, simplePage);
|
|
2349
2575
|
assertion(
|
|
2350
|
-
await keyboardReachesAddAndCart(
|
|
2351
|
-
page,
|
|
2352
|
-
simpleItem(await readMenu(options))?.id,
|
|
2353
|
-
cartInColumn
|
|
2354
|
-
),
|
|
2576
|
+
await keyboardReachesAddAndCart(page, simple, simplePage, cartInColumn),
|
|
2355
2577
|
"keyboard_marker_unreachable",
|
|
2356
2578
|
"Keyboard navigation did not reach the Add and cart controls",
|
|
2357
2579
|
"Keep the marked Add and cart controls in the tab order."
|
|
@@ -2385,235 +2607,31 @@ var v13 = {
|
|
|
2385
2607
|
}
|
|
2386
2608
|
};
|
|
2387
2609
|
|
|
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" });
|
|
2610
|
+
// src/checks/overlays.ts
|
|
2611
|
+
var OVERLAY_NAMES = ["bag", "branch", "search", "item"];
|
|
2612
|
+
var IDLE_FALLBACK_MS = 2e3;
|
|
2613
|
+
var NETWORK_QUIET_MS = 500;
|
|
2614
|
+
var SETTLE_ROUNDS = 3;
|
|
2615
|
+
var SETTLE_TIMEOUT_MS = 15e3;
|
|
2616
|
+
var triggerSelector = (name) => `[data-zappie-overlay-trigger="${name}"]`;
|
|
2617
|
+
var overlaySelector = (name) => `[data-zappie-overlay="${name}"]`;
|
|
2618
|
+
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.';
|
|
2619
|
+
async function overlayTriggers(page) {
|
|
2620
|
+
const found = [];
|
|
2621
|
+
for (const name of OVERLAY_NAMES) {
|
|
2622
|
+
const trigger = page.locator(`${triggerSelector(name)}:visible`).first();
|
|
2623
|
+
if (await trigger.count() > 0) {
|
|
2624
|
+
found.push({ name, trigger });
|
|
2418
2625
|
}
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
}
|
|
2429
|
-
const response = await fetch(entry.name);
|
|
2430
|
-
const compressed = response.body?.pipeThrough(
|
|
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
|
|
2626
|
+
}
|
|
2627
|
+
return found;
|
|
2628
|
+
}
|
|
2629
|
+
function skipWithoutTriggers(found) {
|
|
2630
|
+
if (found.length === 0) {
|
|
2631
|
+
skipCheck(
|
|
2632
|
+
"overlay_markers_missing",
|
|
2633
|
+
"The menu has no data-zappie-overlay-trigger controls",
|
|
2634
|
+
markersHint
|
|
2617
2635
|
);
|
|
2618
2636
|
}
|
|
2619
2637
|
}
|
|
@@ -2719,10 +2737,10 @@ function hydrated() {
|
|
|
2719
2737
|
)
|
|
2720
2738
|
);
|
|
2721
2739
|
}
|
|
2722
|
-
async function openSettledMenu(page) {
|
|
2740
|
+
async function openSettledMenu(page, start) {
|
|
2723
2741
|
const network = watchNetwork(page);
|
|
2724
2742
|
try {
|
|
2725
|
-
await page.goto(
|
|
2743
|
+
await page.goto(start, { waitUntil: "domcontentloaded" });
|
|
2726
2744
|
await page.locator("[data-zappie-item]").first().waitFor();
|
|
2727
2745
|
const deadline = Date.now() + SETTLE_TIMEOUT_MS;
|
|
2728
2746
|
const remaining = () => Math.max(deadline - Date.now(), 1);
|
|
@@ -2755,11 +2773,17 @@ async function openSettledMenu(page) {
|
|
|
2755
2773
|
network.stop();
|
|
2756
2774
|
}
|
|
2757
2775
|
}
|
|
2758
|
-
async function revealBagTrigger(page,
|
|
2776
|
+
async function revealBagTrigger(page, start, item) {
|
|
2759
2777
|
const bag = page.locator(`${triggerSelector("bag")}:visible`).first();
|
|
2760
|
-
if (!
|
|
2778
|
+
if (!item || await bag.count()) {
|
|
2761
2779
|
return;
|
|
2762
2780
|
}
|
|
2781
|
+
const itemId = item.id;
|
|
2782
|
+
const marked = page.locator(`[data-zappie-item="${itemId}"]`);
|
|
2783
|
+
if (item.path !== start && !await marked.count()) {
|
|
2784
|
+
await page.goto(item.path, { waitUntil: "domcontentloaded" });
|
|
2785
|
+
await marked.first().waitFor({ timeout: 5e3 }).catch(() => void 0);
|
|
2786
|
+
}
|
|
2763
2787
|
const before = await page.evaluate(() => window.__zappie?.cart().count ?? 0).catch(() => 0);
|
|
2764
2788
|
const add = '[data-zappie-action="add-to-cart"]';
|
|
2765
2789
|
const quick = page.locator(`[data-zappie-item="${itemId}"] ${add}`).filter({ visible: true }).first();
|
|
@@ -2787,9 +2811,253 @@ async function revealBagTrigger(page, itemId) {
|
|
|
2787
2811
|
before,
|
|
2788
2812
|
{ timeout: 5e3 }
|
|
2789
2813
|
).catch(() => void 0);
|
|
2790
|
-
await openSettledMenu(page);
|
|
2814
|
+
await openSettledMenu(page, start);
|
|
2791
2815
|
await bag.waitFor({ timeout: 5e3 }).catch(() => void 0);
|
|
2792
2816
|
}
|
|
2817
|
+
function bagItem(options, menu) {
|
|
2818
|
+
const id = simpleItem(menu)?.id;
|
|
2819
|
+
return {
|
|
2820
|
+
start: startPath(options),
|
|
2821
|
+
item: id ? { id, path: itemPath(options, menu, id) } : void 0
|
|
2822
|
+
};
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
// src/checks/v14-performance.ts
|
|
2826
|
+
var LOADED_JS_BUDGET_KB = 350;
|
|
2827
|
+
var LOADED_WAIT_MS = 1e4;
|
|
2828
|
+
var v14 = {
|
|
2829
|
+
id: "V14",
|
|
2830
|
+
title: "Performance budget",
|
|
2831
|
+
viewports: "mobile",
|
|
2832
|
+
async run({ page, options, metrics, fault, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
|
|
2833
|
+
await page.setViewportSize(viewport);
|
|
2834
|
+
const { cdp } = await applyPhoneProfile(page, cpuThrottlingRate2);
|
|
2835
|
+
await page.addInitScript(() => {
|
|
2836
|
+
const state = { lcp: 0, cls: 0, documentScripts: [] };
|
|
2837
|
+
Object.assign(window, { __zappiePerformance: state });
|
|
2838
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
2839
|
+
state.documentScripts = [
|
|
2840
|
+
...[
|
|
2841
|
+
...document.querySelectorAll("script[src]")
|
|
2842
|
+
].map((node) => node.src),
|
|
2843
|
+
...[
|
|
2844
|
+
...document.querySelectorAll(
|
|
2845
|
+
'link[rel="modulepreload"][href], link[rel="preload"][as="script"][href]'
|
|
2846
|
+
)
|
|
2847
|
+
].map((node) => node.href)
|
|
2848
|
+
];
|
|
2849
|
+
});
|
|
2850
|
+
new PerformanceObserver((list) => {
|
|
2851
|
+
state.lcp = Math.max(
|
|
2852
|
+
state.lcp,
|
|
2853
|
+
...list.getEntries().map((entry) => entry.startTime)
|
|
2854
|
+
);
|
|
2855
|
+
}).observe({ type: "largest-contentful-paint", buffered: true });
|
|
2856
|
+
new PerformanceObserver((list) => {
|
|
2857
|
+
state.cls += list.getEntries().filter(
|
|
2858
|
+
(entry) => !entry.hadRecentInput
|
|
2859
|
+
).reduce(
|
|
2860
|
+
(sum, entry) => sum + (entry.value ?? 0),
|
|
2861
|
+
0
|
|
2862
|
+
);
|
|
2863
|
+
}).observe({ type: "layout-shift", buffered: true });
|
|
2864
|
+
});
|
|
2865
|
+
const network = watchNetwork(page);
|
|
2866
|
+
await page.goto(startPath(options), { waitUntil: "load" });
|
|
2867
|
+
await applyPageFault(page, fault);
|
|
2868
|
+
await page.locator("[data-zappie-item]").first().waitFor();
|
|
2869
|
+
await page.waitForTimeout(500);
|
|
2870
|
+
await network.quiet(Date.now() + LOADED_WAIT_MS);
|
|
2871
|
+
network.stop();
|
|
2872
|
+
const values = await page.evaluate(async () => {
|
|
2873
|
+
const observed = window.__zappiePerformance ?? {
|
|
2874
|
+
lcp: 0,
|
|
2875
|
+
cls: 0,
|
|
2876
|
+
documentScripts: []
|
|
2877
|
+
};
|
|
2878
|
+
const resources = performance.getEntriesByType("resource");
|
|
2879
|
+
const named = new Set(observed.documentScripts);
|
|
2880
|
+
const needed = (entry) => named.has(entry.name);
|
|
2881
|
+
const gzipped = async (entry) => {
|
|
2882
|
+
if (typeof CompressionStream === "undefined") {
|
|
2883
|
+
const timing = entry;
|
|
2884
|
+
return timing.encodedBodySize || timing.transferSize;
|
|
2885
|
+
}
|
|
2886
|
+
const response = await fetch(entry.name);
|
|
2887
|
+
const compressed = response.body?.pipeThrough(
|
|
2888
|
+
new CompressionStream("gzip")
|
|
2889
|
+
);
|
|
2890
|
+
return compressed ? (await new Response(compressed).arrayBuffer()).byteLength : 0;
|
|
2891
|
+
};
|
|
2892
|
+
const total = async (extension, counted = () => true) => (await Promise.all(
|
|
2893
|
+
resources.filter(
|
|
2894
|
+
(entry) => new URL(entry.name).pathname.endsWith(extension) && counted(entry)
|
|
2895
|
+
).map(gzipped)
|
|
2896
|
+
)).reduce((sum, size) => sum + size, 0);
|
|
2897
|
+
return {
|
|
2898
|
+
lcp: observed.lcp,
|
|
2899
|
+
cls: observed.cls,
|
|
2900
|
+
js: await total(".js", needed),
|
|
2901
|
+
loadedJs: await total(".js"),
|
|
2902
|
+
css: await total(".css")
|
|
2903
|
+
};
|
|
2904
|
+
});
|
|
2905
|
+
metrics.menuJsKbGz = Math.round(values.js / 1024 * 10) / 10;
|
|
2906
|
+
metrics.menuJsLoadedKbGz = Math.round(values.loadedJs / 1024 * 10) / 10;
|
|
2907
|
+
metrics.menuCssKbGz = Math.round(values.css / 1024 * 10) / 10;
|
|
2908
|
+
metrics.lcpMs = Math.round(values.lcp);
|
|
2909
|
+
metrics.cls = Math.round(values.cls * 1e3) / 1e3;
|
|
2910
|
+
assertion(
|
|
2911
|
+
values.js <= 200 * 1024,
|
|
2912
|
+
"bundle_too_large",
|
|
2913
|
+
`Menu uses ${values.js} B of gzipped JavaScript`,
|
|
2914
|
+
"Split route code until the menu transfers at most 200 KiB of JavaScript."
|
|
2915
|
+
);
|
|
2916
|
+
assertion(
|
|
2917
|
+
values.loadedJs <= LOADED_JS_BUDGET_KB * 1024,
|
|
2918
|
+
"loaded_js_too_large",
|
|
2919
|
+
`The menu loads ${values.loadedJs} B of gzipped JavaScript in all, counting what it imports after it starts`,
|
|
2920
|
+
`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.`
|
|
2921
|
+
);
|
|
2922
|
+
assertion(
|
|
2923
|
+
values.lcp <= 2500,
|
|
2924
|
+
"lcp_too_slow",
|
|
2925
|
+
`Menu LCP was ${Math.round(values.lcp)} ms`,
|
|
2926
|
+
"Reduce critical rendering work until mobile LCP is at most 2.5 seconds."
|
|
2927
|
+
);
|
|
2928
|
+
assertion(
|
|
2929
|
+
values.cls <= 0.1,
|
|
2930
|
+
"cls_too_high",
|
|
2931
|
+
`Menu CLS was ${values.cls.toFixed(3)}`,
|
|
2932
|
+
"Reserve layout space so CLS stays at or below 0.1."
|
|
2933
|
+
);
|
|
2934
|
+
await cdp.detach();
|
|
2935
|
+
}
|
|
2936
|
+
};
|
|
2937
|
+
|
|
2938
|
+
// src/checks/v15-tappable-product-cards.ts
|
|
2939
|
+
var TAPPABLE_BUDGET_MS = 3e3;
|
|
2940
|
+
var OPEN_BUDGET_MS = 300;
|
|
2941
|
+
var v15 = {
|
|
2942
|
+
id: "V15",
|
|
2943
|
+
title: "Tappable product cards",
|
|
2944
|
+
viewports: "mobile",
|
|
2945
|
+
async run({ page, options, metrics, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
|
|
2946
|
+
await page.setViewportSize(viewport);
|
|
2947
|
+
const { cdp } = await applyPhoneProfile(page, cpuThrottlingRate2);
|
|
2948
|
+
try {
|
|
2949
|
+
await page.goto(startPath(options), { waitUntil: "domcontentloaded" });
|
|
2950
|
+
await cdp.send("Network.setCacheDisabled", { cacheDisabled: false });
|
|
2951
|
+
const link = page.locator('a[href*="/item/"]').first();
|
|
2952
|
+
await link.waitFor();
|
|
2953
|
+
const href = await link.getAttribute("href");
|
|
2954
|
+
assertion(
|
|
2955
|
+
href !== null,
|
|
2956
|
+
"product_card_link_missing",
|
|
2957
|
+
"The first product card has no destination",
|
|
2958
|
+
"Link the product card to its item page."
|
|
2959
|
+
);
|
|
2960
|
+
const itemPath2 = new URL(href, page.url()).pathname;
|
|
2961
|
+
await page.waitForFunction(() => "__reactRouterManifest" in window, null, {
|
|
2962
|
+
timeout: 8e3
|
|
2963
|
+
}).catch(() => void 0);
|
|
2964
|
+
const itemModule = await page.evaluate(itemRouteModule, itemPath2);
|
|
2965
|
+
const prefetched = page.waitForResponse(
|
|
2966
|
+
(response) => {
|
|
2967
|
+
const path4 = new URL(response.url()).pathname;
|
|
2968
|
+
if (path4 === itemModule) {
|
|
2969
|
+
return true;
|
|
2970
|
+
}
|
|
2971
|
+
return path4 === `${itemPath2}.data` && response.request().headers()["sec-purpose"] === "prefetch";
|
|
2972
|
+
},
|
|
2973
|
+
{ timeout: 8e3 }
|
|
2974
|
+
).then(async (response) => {
|
|
2975
|
+
await response.finished();
|
|
2976
|
+
return true;
|
|
2977
|
+
}).catch(() => false);
|
|
2978
|
+
await link.evaluate(
|
|
2979
|
+
(node) => node.scrollIntoView({ block: "center", inline: "nearest" })
|
|
2980
|
+
);
|
|
2981
|
+
await page.waitForFunction(() => {
|
|
2982
|
+
const candidate = document.querySelector('a[href*="/item/"]');
|
|
2983
|
+
return candidate !== null && Object.keys(candidate).some(
|
|
2984
|
+
(key) => key.startsWith("__reactProps$") || key.startsWith("__reactFiber$")
|
|
2985
|
+
);
|
|
2986
|
+
});
|
|
2987
|
+
const tappableMs = await page.evaluate(() => performance.now());
|
|
2988
|
+
metrics.productCardTappableMs = Math.round(tappableMs);
|
|
2989
|
+
assertion(
|
|
2990
|
+
tappableMs <= TAPPABLE_BUDGET_MS,
|
|
2991
|
+
"product_card_tappable_too_late",
|
|
2992
|
+
`The first product card became tappable in ${Math.round(tappableMs)} ms`,
|
|
2993
|
+
"Reduce critical scripts, fonts and high-priority images until product cards become tappable within 3 seconds."
|
|
2994
|
+
);
|
|
2995
|
+
assertion(
|
|
2996
|
+
await prefetched,
|
|
2997
|
+
"product_card_prefetch_missing",
|
|
2998
|
+
"The first product card did not prefetch its item page",
|
|
2999
|
+
'Prefetch item links with prefetch="viewport".'
|
|
3000
|
+
);
|
|
3001
|
+
await page.waitForLoadState("networkidle");
|
|
3002
|
+
await page.evaluate(() => {
|
|
3003
|
+
document.addEventListener(
|
|
3004
|
+
"click",
|
|
3005
|
+
(event) => {
|
|
3006
|
+
const target2 = event.target;
|
|
3007
|
+
if (target2?.closest('a[href*="/item/"]')) {
|
|
3008
|
+
window.__zappieTapAt = performance.now();
|
|
3009
|
+
}
|
|
3010
|
+
},
|
|
3011
|
+
{ capture: true, once: true }
|
|
3012
|
+
);
|
|
3013
|
+
});
|
|
3014
|
+
await link.click({ noWaitAfter: true });
|
|
3015
|
+
await Promise.race([
|
|
3016
|
+
page.waitForURL((url) => /\/item\//.test(url.pathname)),
|
|
3017
|
+
page.locator('[role="dialog"] [data-zappie-item]').waitFor({ state: "visible" })
|
|
3018
|
+
]);
|
|
3019
|
+
const openMs = await page.evaluate(
|
|
3020
|
+
() => performance.now() - (window.__zappieTapAt ?? 0)
|
|
3021
|
+
);
|
|
3022
|
+
metrics.productCardOpenMs = Math.round(openMs);
|
|
3023
|
+
assertion(
|
|
3024
|
+
openMs <= OPEN_BUDGET_MS,
|
|
3025
|
+
"product_card_open_too_slow",
|
|
3026
|
+
`The first product card opened in ${Math.round(openMs)} ms`,
|
|
3027
|
+
'Set routeDiscovery to initial and prefetch item links with prefetch="viewport" so the item surface opens within 300 ms.'
|
|
3028
|
+
);
|
|
3029
|
+
} finally {
|
|
3030
|
+
await cdp.detach();
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
};
|
|
3034
|
+
function itemRouteModule(itemPath2) {
|
|
3035
|
+
const manifest = window.__reactRouterManifest;
|
|
3036
|
+
if (!manifest) {
|
|
3037
|
+
return void 0;
|
|
3038
|
+
}
|
|
3039
|
+
const segments = (path4) => path4.split("/").filter(Boolean);
|
|
3040
|
+
const fullPath = (route2) => {
|
|
3041
|
+
const parts = [];
|
|
3042
|
+
for (let current = route2; current; current = current.parentId ? manifest.routes[current.parentId] : void 0) {
|
|
3043
|
+
if (current.path) {
|
|
3044
|
+
parts.unshift(current.path);
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
return segments(parts.join("/"));
|
|
3048
|
+
};
|
|
3049
|
+
const wanted = segments(itemPath2);
|
|
3050
|
+
const route = Object.values(manifest.routes).find((entry) => {
|
|
3051
|
+
if (!entry.path) {
|
|
3052
|
+
return false;
|
|
3053
|
+
}
|
|
3054
|
+
const pattern = fullPath(entry);
|
|
3055
|
+
return pattern.length === wanted.length && pattern.every(
|
|
3056
|
+
(part, index) => part.startsWith(":") || part === wanted[index]
|
|
3057
|
+
);
|
|
3058
|
+
});
|
|
3059
|
+
return route ? new URL(route.module, location.href).pathname : void 0;
|
|
3060
|
+
}
|
|
2793
3061
|
|
|
2794
3062
|
// src/checks/v16-overlays-open-quickly.ts
|
|
2795
3063
|
var OVERLAY_OPEN_BUDGET_MS = 100;
|
|
@@ -2804,15 +3072,15 @@ var median = (values) => {
|
|
|
2804
3072
|
const sorted = [...values].sort((a, b) => a - b);
|
|
2805
3073
|
return sorted[Math.floor(sorted.length / 2)];
|
|
2806
3074
|
};
|
|
2807
|
-
async function firstOpen(fresh, viewport, name, cpuThrottlingRate2,
|
|
3075
|
+
async function firstOpen(fresh, viewport, name, cpuThrottlingRate2, start, item) {
|
|
2808
3076
|
const { page } = fresh;
|
|
2809
3077
|
try {
|
|
2810
3078
|
await page.setViewportSize(viewport);
|
|
2811
3079
|
const { cdp, rate } = await applyPhoneProfile(page, cpuThrottlingRate2);
|
|
2812
3080
|
try {
|
|
2813
|
-
await openSettledMenu(page);
|
|
3081
|
+
await openSettledMenu(page, start);
|
|
2814
3082
|
if (name === "bag") {
|
|
2815
|
-
await revealBagTrigger(page,
|
|
3083
|
+
await revealBagTrigger(page, start, item);
|
|
2816
3084
|
}
|
|
2817
3085
|
const trigger = page.locator(`${triggerSelector(name)}:visible`).first();
|
|
2818
3086
|
await trigger.evaluate(
|
|
@@ -2835,9 +3103,9 @@ var v16 = {
|
|
|
2835
3103
|
viewports: "mobile",
|
|
2836
3104
|
async run({ page, options, metrics, freshPage, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
|
|
2837
3105
|
await page.setViewportSize(viewport);
|
|
2838
|
-
const
|
|
2839
|
-
await openSettledMenu(page);
|
|
2840
|
-
await revealBagTrigger(page,
|
|
3106
|
+
const { start, item } = bagItem(options, await readMenu(options));
|
|
3107
|
+
await openSettledMenu(page, start);
|
|
3108
|
+
await revealBagTrigger(page, start, item);
|
|
2841
3109
|
const names = (await overlayTriggers(page)).map(({ name }) => name);
|
|
2842
3110
|
skipWithoutTriggers(names);
|
|
2843
3111
|
const timings = {};
|
|
@@ -2854,7 +3122,8 @@ var v16 = {
|
|
|
2854
3122
|
viewport,
|
|
2855
3123
|
name,
|
|
2856
3124
|
cpuThrottlingRate2,
|
|
2857
|
-
|
|
3125
|
+
start,
|
|
3126
|
+
item
|
|
2858
3127
|
);
|
|
2859
3128
|
if (open2 === void 0) {
|
|
2860
3129
|
opened = false;
|
|
@@ -2922,10 +3191,10 @@ var scrollLocked = (page) => page.evaluate(() => {
|
|
|
2922
3191
|
};
|
|
2923
3192
|
return held(document.documentElement) || held(document.body);
|
|
2924
3193
|
});
|
|
2925
|
-
async function checkOverlay(page, name,
|
|
2926
|
-
await openSettledMenu(page);
|
|
3194
|
+
async function checkOverlay(page, name, start, item) {
|
|
3195
|
+
await openSettledMenu(page, start);
|
|
2927
3196
|
if (name === "bag") {
|
|
2928
|
-
await revealBagTrigger(page,
|
|
3197
|
+
await revealBagTrigger(page, start, item);
|
|
2929
3198
|
}
|
|
2930
3199
|
const trigger = page.locator(`${triggerSelector(name)}:visible`).first();
|
|
2931
3200
|
await trigger.focus();
|
|
@@ -3006,12 +3275,12 @@ var v17 = {
|
|
|
3006
3275
|
viewports: "mobile",
|
|
3007
3276
|
async run({ page, options, freshPage }, viewport) {
|
|
3008
3277
|
await page.setViewportSize(viewport);
|
|
3009
|
-
const
|
|
3010
|
-
await page.goto(
|
|
3278
|
+
const { start, item } = bagItem(options, await readMenu(options));
|
|
3279
|
+
await page.goto(start, { waitUntil: "domcontentloaded" });
|
|
3011
3280
|
await page.locator("[data-zappie-item]").first().waitFor();
|
|
3012
3281
|
if (!await page.locator(`${triggerSelector("bag")}:visible`).count()) {
|
|
3013
|
-
await openSettledMenu(page);
|
|
3014
|
-
await revealBagTrigger(page,
|
|
3282
|
+
await openSettledMenu(page, start);
|
|
3283
|
+
await revealBagTrigger(page, start, item);
|
|
3015
3284
|
}
|
|
3016
3285
|
const names = (await overlayTriggers(page)).map(({ name }) => name);
|
|
3017
3286
|
skipWithoutTriggers(names);
|
|
@@ -3020,7 +3289,7 @@ var v17 = {
|
|
|
3020
3289
|
const fresh = await freshPage();
|
|
3021
3290
|
try {
|
|
3022
3291
|
await fresh.page.setViewportSize(viewport);
|
|
3023
|
-
findings.push(...await checkOverlay(fresh.page, name,
|
|
3292
|
+
findings.push(...await checkOverlay(fresh.page, name, start, item));
|
|
3024
3293
|
} finally {
|
|
3025
3294
|
await fresh.close();
|
|
3026
3295
|
}
|
|
@@ -3056,7 +3325,11 @@ var v18 = {
|
|
|
3056
3325
|
async run({ page, options, warn }, viewport) {
|
|
3057
3326
|
await page.setViewportSize(viewport);
|
|
3058
3327
|
let reportOnlyFound = false;
|
|
3059
|
-
for (const path4 of await publicRoutePaths(
|
|
3328
|
+
for (const path4 of await publicRoutePaths(
|
|
3329
|
+
page.request,
|
|
3330
|
+
options.url,
|
|
3331
|
+
startPath(options)
|
|
3332
|
+
)) {
|
|
3060
3333
|
const response = await page.goto(new URL(path4, options.url).href, {
|
|
3061
3334
|
waitUntil: "domcontentloaded"
|
|
3062
3335
|
});
|
|
@@ -3220,6 +3493,7 @@ function printSummary(report) {
|
|
|
3220
3493
|
return `${check.result.toUpperCase().padEnd(7)} ${check.id} ${check.title}${suffix}${warnings}`;
|
|
3221
3494
|
});
|
|
3222
3495
|
lines.push(
|
|
3496
|
+
...(report.warnings ?? []).map((warning) => `WARNING: ${warning.message}`),
|
|
3223
3497
|
`${report.result.toUpperCase()}: ${report.summary.passed} passed, ${report.summary.failed} failed, ${report.summary.skipped} skipped`,
|
|
3224
3498
|
report.signature ? "Report signed (ed25519)." : "Report unsigned."
|
|
3225
3499
|
);
|
|
@@ -3273,7 +3547,7 @@ async function reset(options) {
|
|
|
3273
3547
|
}
|
|
3274
3548
|
|
|
3275
3549
|
// src/version.ts
|
|
3276
|
-
var PACKAGE_VERSION = true ? "0.
|
|
3550
|
+
var PACKAGE_VERSION = true ? "0.3.0" : "0.0.0-development";
|
|
3277
3551
|
|
|
3278
3552
|
// src/runner.ts
|
|
3279
3553
|
var VERSION = PACKAGE_VERSION;
|
|
@@ -3406,6 +3680,7 @@ async function runVerify(options, fault) {
|
|
|
3406
3680
|
"Create a test publishable key and rerun verify."
|
|
3407
3681
|
);
|
|
3408
3682
|
}
|
|
3683
|
+
checkPaths(options);
|
|
3409
3684
|
const wanted = options.only ? expandOnly(options.only, knownCheckIds()) : void 0;
|
|
3410
3685
|
const started = Date.now();
|
|
3411
3686
|
const executablePath = browserExecutable(options);
|
|
@@ -3470,13 +3745,16 @@ async function runVerify(options, fault) {
|
|
|
3470
3745
|
sdkVersion,
|
|
3471
3746
|
browser: `chromium ${browser.version()}${executablePath ? ` (${executablePath})` : ""}`,
|
|
3472
3747
|
...cpuBenchmark === void 0 ? {} : { cpuBenchmark },
|
|
3473
|
-
cpuThrottlingRate: throttlingRate
|
|
3748
|
+
cpuThrottlingRate: throttlingRate,
|
|
3749
|
+
startPath: startPath(options),
|
|
3750
|
+
...options.cataloguePath ? { cataloguePath: options.cataloguePath } : {}
|
|
3474
3751
|
},
|
|
3475
3752
|
startedAt: new Date(started).toISOString(),
|
|
3476
3753
|
durationMs: Date.now() - started,
|
|
3477
3754
|
result: summary.failed ? "failed" : "passed",
|
|
3478
3755
|
summary,
|
|
3479
3756
|
checks: results,
|
|
3757
|
+
...options.warnings?.length ? { warnings: options.warnings } : {},
|
|
3480
3758
|
metrics
|
|
3481
3759
|
};
|
|
3482
3760
|
return signReport(
|
|
@@ -3496,7 +3774,16 @@ async function runVerify(options, fault) {
|
|
|
3496
3774
|
|
|
3497
3775
|
// src/project.ts
|
|
3498
3776
|
async function verifyProject(options) {
|
|
3499
|
-
const
|
|
3777
|
+
const settings = options.store && options.startPath && options.cataloguePath ? {} : await readProjectSettings(options.cwd, { required: !options.store });
|
|
3778
|
+
const store = options.store ?? settings.store;
|
|
3779
|
+
const startPath2 = options.startPath ?? settings.startPath;
|
|
3780
|
+
const warnings = "ignored" in settings && settings.ignored ? [
|
|
3781
|
+
{
|
|
3782
|
+
code: "config_settings_ignored",
|
|
3783
|
+
message: `${settings.ignored}, so verify ran without its verify.startPath and verify.cataloguePath. Fix zappie.config.ts, or pass the start path with --start.`
|
|
3784
|
+
}
|
|
3785
|
+
] : [];
|
|
3786
|
+
const cataloguePath = options.cataloguePath ?? settings.cataloguePath;
|
|
3500
3787
|
let preview;
|
|
3501
3788
|
try {
|
|
3502
3789
|
preview = options.url ? void 0 : await startLocalPreview({
|
|
@@ -3515,6 +3802,9 @@ async function verifyProject(options) {
|
|
|
3515
3802
|
suite: options.suite,
|
|
3516
3803
|
runner: options.runner ?? "cli",
|
|
3517
3804
|
...options.only ? { only: options.only } : {},
|
|
3805
|
+
...startPath2 !== void 0 ? { startPath: startPath2 } : {},
|
|
3806
|
+
...cataloguePath !== void 0 ? { cataloguePath } : {},
|
|
3807
|
+
...warnings.length ? { warnings } : {},
|
|
3518
3808
|
...options.viewports ? { viewports: options.viewports } : {},
|
|
3519
3809
|
...options.artifactsDir ? { artifactsDir: options.artifactsDir } : {},
|
|
3520
3810
|
...options.timeoutMs ? { timeoutMs: options.timeoutMs } : {},
|
|
@@ -3550,6 +3840,8 @@ function normalizeUrl(value) {
|
|
|
3550
3840
|
// src/regression.ts
|
|
3551
3841
|
var DEFAULT_REGRESSION_THRESHOLDS = {
|
|
3552
3842
|
menuJsKbGz: { relative: 0.05, absolute: 2 },
|
|
3843
|
+
// What idle and intent preloads fetch varies a little between runs, so its allowance is wider.
|
|
3844
|
+
menuJsLoadedKbGz: { relative: 0.1, absolute: 10 },
|
|
3553
3845
|
menuCssKbGz: { relative: 0.05, absolute: 1 },
|
|
3554
3846
|
lcpMs: { relative: 0.1, absolute: 150 },
|
|
3555
3847
|
cls: { relative: 0, absolute: 0.02 },
|