@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/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 readStore(cwd) {
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
- const value = loaded.default;
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(page.request, options.url)) {
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
- if (fault === "oversized") {
1161
- let state = 1;
1162
- const payload = Array.from({ length: 26e4 }, () => {
1163
- state = state * 48271 % 2147483647;
1164
- return String.fromCharCode(33 + state % 90);
1165
- }).join("");
1166
- await page.route(
1167
- "**/__verify-large.js",
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
- await open(page);
1230
- await applyPageFault(page, fault);
1231
- const items = sortedItems(menu);
1232
- const available = items.filter((item) => item.isAvailable);
1233
- const shownIds = /* @__PURE__ */ new Set();
1234
- for (const item of available) {
1235
- const marker = page.locator(`[data-zappie-item="${item.id}"]`);
1236
- if (await marker.count() > 0 || await page.getByText(item.name, { exact: true }).count() > 0) {
1237
- shownIds.add(item.id);
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
- for (const item of available.slice(0, 2)) {
1267
- const text = await page.locator(`[data-zappie-item="${item.id}"]`).first().textContent();
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
- priceMatches(text, format.money(item.price)),
1270
- "price_mismatch",
1271
- `${item.name} does not show ${format.money(item.price)}`,
1272
- "Render prices with format.money from @zappie/sdk."
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 open(page);
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 open(page);
1373
- const selected = configurableItem(await readMenu(options));
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
- if (new URL(page.url()).pathname !== "/") {
1480
- await open(page);
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 open(page);
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(await readMenu(options)).find(
2279
- (entry) => entry.isAvailable
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
- await open(page);
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/v14-performance.ts
2389
- var v14 = {
2390
- id: "V14",
2391
- title: "Performance budget",
2392
- viewports: "mobile",
2393
- async run({ page, metrics, fault, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
2394
- await page.setViewportSize(viewport);
2395
- const { cdp } = await applyPhoneProfile(page, cpuThrottlingRate2);
2396
- await page.addInitScript(() => {
2397
- const state = { lcp: 0, cls: 0 };
2398
- Object.assign(window, { __zappiePerformance: state });
2399
- new PerformanceObserver((list) => {
2400
- state.lcp = Math.max(
2401
- state.lcp,
2402
- ...list.getEntries().map((entry) => entry.startTime)
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
- await page.locator("[data-zappie-item]").first().waitFor();
2420
- await page.waitForTimeout(500);
2421
- const values = await page.evaluate(async () => {
2422
- const observed = window.__zappiePerformance ?? { lcp: 0, cls: 0 };
2423
- const resources = performance.getEntriesByType("resource");
2424
- const gzipped = async (entry) => {
2425
- if (typeof CompressionStream === "undefined") {
2426
- const timing = entry;
2427
- return timing.encodedBodySize || timing.transferSize;
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("/", { waitUntil: "domcontentloaded" });
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, itemId) {
2776
+ async function revealBagTrigger(page, start, item) {
2759
2777
  const bag = page.locator(`${triggerSelector("bag")}:visible`).first();
2760
- if (!itemId || await bag.count()) {
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, itemId) {
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, itemId);
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 itemId = simpleItem(await readMenu(options))?.id;
2839
- await openSettledMenu(page);
2840
- await revealBagTrigger(page, itemId);
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
- itemId
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, itemId) {
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, itemId);
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 itemId = simpleItem(await readMenu(options))?.id;
3010
- await page.goto("/", { waitUntil: "domcontentloaded" });
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, itemId);
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, itemId));
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(page.request, options.url)) {
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.2.8" : "0.0.0-development";
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 store = options.store ?? await readStore(options.cwd);
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 },