@zappie/verify 0.2.9 → 0.3.1

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