@zappie/verify 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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";
@@ -279,6 +280,14 @@ function signal(child, value) {
279
280
  }
280
281
  }
281
282
 
283
+ // ../sdk/src/site-config/types.ts
284
+ var DEFAULT_BUDGETS = {
285
+ jsKbGz: 200,
286
+ cssKbGz: 50,
287
+ lcpMs: 2500,
288
+ tappableMs: 3e3
289
+ };
290
+
282
291
  // src/project-config.ts
283
292
  import { readFile as readFile2, stat as stat2 } from "fs/promises";
284
293
  import path2 from "path";
@@ -303,10 +312,15 @@ async function readProjectSettings(cwd, { required }) {
303
312
  }
304
313
  const config = record(value) ? value : {};
305
314
  const verify2 = record(config.verify) ? config.verify : {};
315
+ const budgets = record(config.budgets) ? config.budgets : {};
316
+ const override = record(budgets.override) ? budgets.override : {};
317
+ const tappableMs = budgets.tappableMs;
318
+ const explainedRaisedTappableBudget = typeof override.reason === "string" && override.reason.trim().length > 0;
306
319
  const settings = {
307
320
  ...typeof config.store === "string" ? { store: config.store } : {},
308
321
  ...verify2.startPath !== void 0 ? { startPath: verify2.startPath } : {},
309
- ...verify2.cataloguePath !== void 0 ? { cataloguePath: verify2.cataloguePath } : {}
322
+ ...verify2.cataloguePath !== void 0 ? { cataloguePath: verify2.cataloguePath } : {},
323
+ ...typeof tappableMs === "number" && (tappableMs <= DEFAULT_BUDGETS.tappableMs || explainedRaisedTappableBudget) ? { tappableMs } : {}
310
324
  };
311
325
  if (required && settings.store === void 0) {
312
326
  throw new VerifyError(
@@ -360,13 +374,16 @@ function isRoute(path4) {
360
374
  function startPath(options) {
361
375
  return options.startPath ?? DEFAULT_START_PATH;
362
376
  }
377
+ function hasPlaceholders(path4) {
378
+ return path4.includes("{menu}") || path4.includes("{branch}");
379
+ }
363
380
  function checkPaths(options) {
364
381
  const { startPath: start, cataloguePath } = options;
365
382
  if (start !== void 0 && !isRoute(start)) {
366
383
  throw new VerifyError(
367
384
  "invalid_start_path",
368
385
  `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."
386
+ "Set verify.startPath or --start to a route that starts with one /, such as /menu or /menu?menu={menu}."
370
387
  );
371
388
  }
372
389
  if (cataloguePath !== void 0 && (!isRoute(cataloguePath) || !cataloguePath.includes("{menu}"))) {
@@ -898,7 +915,7 @@ async function installFault(page, fault) {
898
915
  return;
899
916
  }
900
917
  const group = original.closest('[role="dialog"]')?.querySelector(
901
- '[data-zappie-option-group][aria-required="true"]:not(:has([data-state="checked"]))'
918
+ '[data-zappie-option-group]:is([data-zappie-required="true"], [aria-required="true"]):has([data-zappie-option]):not(:has([data-state="checked"]))'
902
919
  );
903
920
  if (flags && group) {
904
921
  group.setAttribute("aria-invalid", "true");
@@ -1240,6 +1257,23 @@ ${await response.text()}`
1240
1257
  });
1241
1258
  });
1242
1259
  }
1260
+ if (fault === "from-price") {
1261
+ await page.addInitScript(() => {
1262
+ new MutationObserver(() => {
1263
+ for (const node of document.querySelectorAll(
1264
+ '[data-zappie-item="item_jollof"] .z-kit-money'
1265
+ )) {
1266
+ if (node.textContent !== "From \u20A65,000") {
1267
+ node.textContent = "From \u20A65,000";
1268
+ }
1269
+ }
1270
+ }).observe(document, {
1271
+ childList: true,
1272
+ subtree: true,
1273
+ characterData: true
1274
+ });
1275
+ });
1276
+ }
1243
1277
  if (fault === "add-stuck") {
1244
1278
  await page.addInitScript(() => {
1245
1279
  document.addEventListener(
@@ -1277,14 +1311,237 @@ ${await response.text()}`
1277
1311
  }).observe(document, { childList: true, subtree: true });
1278
1312
  });
1279
1313
  }
1314
+ if (isMarkupFault(fault)) {
1315
+ await page.addInitScript(keepMarkupFault, fault);
1316
+ }
1280
1317
  }
1281
- async function applyPageFault(page, fault) {
1282
- if (fault === "quick-add-via-item") {
1283
- await page.locator('[data-zappie-item] [data-zappie-action="add-to-cart"]').evaluateAll((nodes) => {
1284
- for (const node of nodes) {
1285
- node.remove();
1318
+ var markupFaults = [
1319
+ "from-price",
1320
+ "quick-add-via-item",
1321
+ "missing-marker",
1322
+ "hidden-marker-attribute",
1323
+ "hidden-marker-display",
1324
+ "hidden-marker-visibility",
1325
+ "hidden-marker-aria",
1326
+ "zero-size-marker",
1327
+ "tiny-marker",
1328
+ "clipped-marker",
1329
+ "clip-path-marker",
1330
+ "negative-coordinate-marker",
1331
+ "popular-name-only-markers",
1332
+ "marker-without-name",
1333
+ "uppercase-marker-name",
1334
+ "scrollable-markers",
1335
+ "content-visibility-auto-markers",
1336
+ "nfkc-marker-name",
1337
+ "entity-marker-name",
1338
+ "entity-apos-marker-name",
1339
+ "entity-gt-marker-name",
1340
+ "entity-lt-marker-name",
1341
+ "entity-quot-marker-name",
1342
+ "soft-hyphen-marker-name",
1343
+ "zero-width-marker-name",
1344
+ "axe"
1345
+ ];
1346
+ function isMarkupFault(fault) {
1347
+ return markupFaults.includes(fault);
1348
+ }
1349
+ function keepMarkupFault(fault) {
1350
+ const outermost = "[data-zappie-item]:not([data-zappie-item] [data-zappie-item])";
1351
+ const markers = () => document.querySelectorAll("[data-zappie-item]");
1352
+ const setText = (node, text) => {
1353
+ if (node && node.textContent !== text) {
1354
+ node.textContent = text;
1355
+ }
1356
+ };
1357
+ const styles = {
1358
+ "hidden-marker-display": [["display", "none"]],
1359
+ "hidden-marker-visibility": [["visibility", "hidden"]],
1360
+ "zero-size-marker": [
1361
+ ["border", "0"],
1362
+ ["height", "0"],
1363
+ ["margin", "0"],
1364
+ ["min-height", "0"],
1365
+ ["min-width", "0"],
1366
+ ["overflow", "hidden"],
1367
+ ["padding", "0"],
1368
+ ["width", "0"]
1369
+ ],
1370
+ "tiny-marker": [
1371
+ ["border", "0"],
1372
+ ["height", "1px"],
1373
+ ["min-height", "0"],
1374
+ ["min-width", "0"],
1375
+ ["overflow", "hidden"],
1376
+ ["padding", "0"],
1377
+ ["width", "1px"]
1378
+ ],
1379
+ "clipped-marker": [
1380
+ ["clip", "rect(0 0 0 0)"],
1381
+ ["position", "absolute"]
1382
+ ],
1383
+ "clip-path-marker": [["clip-path", "inset(50%)"]],
1384
+ "negative-coordinate-marker": [
1385
+ ["left", "-10000px"],
1386
+ ["position", "absolute"],
1387
+ ["top", "-10000px"]
1388
+ ],
1389
+ "uppercase-marker-name": [["text-transform", "uppercase"]]
1390
+ };
1391
+ const names = {
1392
+ "nfkc-marker-name": "Jollof rice",
1393
+ "entity-marker-name": "Jollof & rice",
1394
+ "entity-apos-marker-name": "Jollof ' rice",
1395
+ "entity-gt-marker-name": "Jollof > rice",
1396
+ "entity-lt-marker-name": "Jollof < rice",
1397
+ "entity-quot-marker-name": 'Jollof " rice',
1398
+ "soft-hyphen-marker-name": "Jollof rice",
1399
+ "zero-width-marker-name": "Jollof rice"
1400
+ };
1401
+ const apply = () => {
1402
+ const style = styles[fault];
1403
+ if (style) {
1404
+ for (const node of markers()) {
1405
+ for (const [property, value] of style) {
1406
+ node.style.setProperty(property, value, "important");
1407
+ }
1286
1408
  }
1287
- });
1409
+ }
1410
+ const name = names[fault];
1411
+ if (name !== void 0) {
1412
+ for (const node of document.querySelectorAll(
1413
+ '[data-zappie-item="item_jollof"]'
1414
+ )) {
1415
+ setText(node.querySelector("h1, h2, h3"), name);
1416
+ }
1417
+ }
1418
+ if (fault === "from-price") {
1419
+ for (const node of document.querySelectorAll(
1420
+ '[data-zappie-item="item_jollof"] .z-kit-money'
1421
+ )) {
1422
+ setText(node, "From \u20A65,000");
1423
+ }
1424
+ }
1425
+ if (fault === "quick-add-via-item") {
1426
+ for (const node of document.querySelectorAll(
1427
+ '[data-zappie-item] [data-zappie-action="add-to-cart"]'
1428
+ )) {
1429
+ if (!node.closest('[data-zappie-overlay="item"], [role="dialog"]')) {
1430
+ node.remove();
1431
+ }
1432
+ }
1433
+ }
1434
+ if (fault === "missing-marker") {
1435
+ for (const node of markers()) {
1436
+ node.removeAttribute("data-zappie-item");
1437
+ }
1438
+ }
1439
+ if (fault === "hidden-marker-attribute") {
1440
+ for (const node of markers()) {
1441
+ if (!node.hasAttribute("hidden")) {
1442
+ node.setAttribute("hidden", "");
1443
+ }
1444
+ }
1445
+ }
1446
+ if (fault === "hidden-marker-aria") {
1447
+ for (const node of markers()) {
1448
+ if (node.getAttribute("aria-hidden") !== "true") {
1449
+ node.setAttribute("aria-hidden", "true");
1450
+ }
1451
+ }
1452
+ }
1453
+ if (fault === "marker-without-name") {
1454
+ for (const heading of document.querySelectorAll(
1455
+ "[data-zappie-item] h1, [data-zappie-item] h2, [data-zappie-item] h3"
1456
+ )) {
1457
+ heading.closest("[data-zappie-item]")?.before(heading);
1458
+ }
1459
+ }
1460
+ if (fault === "popular-name-only-markers") {
1461
+ const main = document.querySelector("main");
1462
+ const cards = [
1463
+ ...document.querySelectorAll("article[data-zappie-item]")
1464
+ ].map((card) => ({
1465
+ id: card.getAttribute("data-zappie-item") ?? "",
1466
+ href: card.querySelector('a[href*="/item/"]')?.href ?? "#",
1467
+ name: card.querySelector("h1, h2, h3")?.textContent ?? "Item"
1468
+ }));
1469
+ const key = JSON.stringify(cards);
1470
+ const current = document.querySelector("[data-verify-popular]");
1471
+ if (main && cards.length > 0 && (current?.getAttribute("data-verify-popular") !== key || main.firstElementChild !== current)) {
1472
+ current?.remove();
1473
+ const popular = document.createElement("section");
1474
+ popular.setAttribute("data-verify-popular", key);
1475
+ const heading = document.createElement("h2");
1476
+ heading.textContent = "Popular";
1477
+ popular.append(heading);
1478
+ for (const card of cards) {
1479
+ const link = document.createElement("a");
1480
+ link.href = card.href;
1481
+ link.setAttribute("data-zappie-item", card.id);
1482
+ link.textContent = card.name;
1483
+ popular.append(link);
1484
+ }
1485
+ main.prepend(popular);
1486
+ }
1487
+ }
1488
+ if (fault === "scrollable-markers") {
1489
+ for (const node of document.querySelectorAll(outermost)) {
1490
+ if (node.parentElement?.hasAttribute("data-verify-scroll-track")) {
1491
+ continue;
1492
+ }
1493
+ const scroller = document.createElement("div");
1494
+ scroller.style.cssText = "height: 1px; overflow: auto; width: 1px";
1495
+ const track = document.createElement("div");
1496
+ track.setAttribute("data-verify-scroll-track", "");
1497
+ track.style.cssText = "padding-left: 1000px; width: 2000px";
1498
+ node.before(scroller);
1499
+ scroller.append(track);
1500
+ track.append(node);
1501
+ }
1502
+ }
1503
+ if (fault === "content-visibility-auto-markers") {
1504
+ const nodes = [...document.querySelectorAll(outermost)].filter(
1505
+ (node) => !node.parentElement?.hasAttribute("data-verify-deferred")
1506
+ );
1507
+ if (nodes.length > 0) {
1508
+ let section = document.querySelector("[data-verify-deferred]");
1509
+ if (!section) {
1510
+ section = document.createElement("section");
1511
+ section.setAttribute("data-verify-deferred", "");
1512
+ section.style.cssText = "content-visibility:auto;contain-intrinsic-size:auto 1000px;margin-top:10000px";
1513
+ nodes[0]?.before(section);
1514
+ }
1515
+ section.append(...nodes);
1516
+ }
1517
+ }
1518
+ if (fault === "axe" && document.body && !document.querySelector("[data-verify-axe]")) {
1519
+ const button = document.createElement("button");
1520
+ button.setAttribute("data-verify-axe", "");
1521
+ button.style.color = "#fff";
1522
+ button.style.background = "#fff";
1523
+ document.body.append(button);
1524
+ }
1525
+ };
1526
+ let started = false;
1527
+ window.__verifyMarkupFault = () => {
1528
+ started = true;
1529
+ apply();
1530
+ };
1531
+ new MutationObserver(() => {
1532
+ if (started) {
1533
+ apply();
1534
+ }
1535
+ }).observe(document, { childList: true, subtree: true });
1536
+ }
1537
+ function pageHydrated() {
1538
+ const hydrated2 = (node) => node !== null && Object.keys(node).some((key) => key.startsWith("__reactProps$"));
1539
+ return hydrated2(document.body) && [...document.querySelectorAll("[data-zappie-item]")].every(hydrated2);
1540
+ }
1541
+ async function applyPageFault(page, fault) {
1542
+ if (fault && isMarkupFault(fault)) {
1543
+ await page.waitForFunction(pageHydrated);
1544
+ await page.evaluate(() => window.__verifyMarkupFault?.());
1288
1545
  }
1289
1546
  if (fault === "card-link") {
1290
1547
  await page.waitForFunction(() => {
@@ -1302,21 +1559,6 @@ async function applyPageFault(page, fault) {
1302
1559
  }
1303
1560
  });
1304
1561
  }
1305
- if (fault === "missing-marker") {
1306
- await page.locator("[data-zappie-item]").evaluateAll((nodes) => {
1307
- for (const node of nodes) {
1308
- node.removeAttribute("data-zappie-item");
1309
- }
1310
- });
1311
- }
1312
- if (fault === "axe") {
1313
- await page.evaluate(() => {
1314
- const button = document.createElement("button");
1315
- button.style.color = "#fff";
1316
- button.style.background = "#fff";
1317
- document.body.append(button);
1318
- });
1319
- }
1320
1562
  }
1321
1563
  function randomScript(name, length) {
1322
1564
  let state = 1;
@@ -1376,8 +1618,43 @@ function catalogues(menu) {
1376
1618
  };
1377
1619
  }).filter((catalogue) => catalogue.items.length > 0);
1378
1620
  }
1621
+ function fillStartPath(template, menu) {
1622
+ const first = catalogues(menu)[0]?.id ?? menu.menus?.[0]?.id ?? menu.id;
1623
+ return { path: cataloguePage(template, first, menu.branchId), menuId: first };
1624
+ }
1625
+ var startMenus = /* @__PURE__ */ new WeakMap();
1626
+ async function fillStartOptions(options, read = readMenu) {
1627
+ const template = options.startPath;
1628
+ if (template === void 0 || !hasPlaceholders(template)) {
1629
+ return options;
1630
+ }
1631
+ let filled;
1632
+ try {
1633
+ filled = fillStartPath(template, await read(options));
1634
+ } catch (failure) {
1635
+ if (failure instanceof VerifyError) {
1636
+ throw failure;
1637
+ }
1638
+ throw new VerifyError(
1639
+ "start_path_unfilled",
1640
+ `Verify could not read the menu to fill the start path ${template}: ${failure instanceof Error ? failure.message : String(failure)}`,
1641
+ "Check the store and key, or set a start path without {menu} or {branch}."
1642
+ );
1643
+ }
1644
+ checkPaths({ startPath: filled.path });
1645
+ const result = { ...options, startPath: filled.path };
1646
+ if (template.includes("{menu}")) {
1647
+ startMenus.set(result, filled.menuId);
1648
+ }
1649
+ return result;
1650
+ }
1379
1651
  function cataloguePages(options, menu) {
1380
1652
  const { cataloguePath } = options;
1653
+ const startMenu = startMenus.get(options);
1654
+ const shown = !cataloguePath && startMenu !== void 0 ? catalogues(menu).find((catalogue) => catalogue.id === startMenu) : void 0;
1655
+ if (shown) {
1656
+ return [{ path: startPath(options), items: shown.items, name: shown.name }];
1657
+ }
1381
1658
  const pages = cataloguePath ? catalogues(menu) : [];
1382
1659
  if (!cataloguePath || pages.length === 0) {
1383
1660
  return [{ path: startPath(options), items: sortedItems(menu) }];
@@ -1395,6 +1672,31 @@ function itemPath(options, menu, itemId) {
1395
1672
  return holder && options.cataloguePath ? cataloguePage(options.cataloguePath, holder.id, menu.branchId) : startPath(options);
1396
1673
  }
1397
1674
 
1675
+ // ../sdk/src/pricing/index.ts
1676
+ var cheapest = (prices) => prices.filter((price) => price.amountMinor >= 0).sort((a, b) => a.amountMinor - b.amountMinor)[0];
1677
+ function startingPrice(item) {
1678
+ const available = item.variants.filter((variant) => variant.isAvailable);
1679
+ const base = cheapest(
1680
+ (available.length ? available : item.variants).map(
1681
+ (variant) => variant.price
1682
+ )
1683
+ ) ?? item.price;
1684
+ return sumMoney(
1685
+ item.optionGroups.flatMap(
1686
+ (group) => !group.isAttribute && group.required && group.kind === "select" ? cheapest(group.choices.map((choice) => choice.price)) ?? [] : []
1687
+ ),
1688
+ base
1689
+ );
1690
+ }
1691
+ function showsFromPrice(item) {
1692
+ const prices = [
1693
+ ...new Set(
1694
+ item.variants.filter((variant) => variant.isAvailable).map((variant) => variant.price.amountMinor)
1695
+ )
1696
+ ];
1697
+ return prices.length > 1 || prices.length === 1 && startingPrice(item).amountMinor > prices[0];
1698
+ }
1699
+
1398
1700
  // src/checks/menu-match.ts
1399
1701
  function menuCoverage(items, shownIds) {
1400
1702
  const available = items.filter((item) => item.isAvailable);
@@ -1408,6 +1710,16 @@ function menuCoverage(items, shownIds) {
1408
1710
  function priceMatches(text, formatted) {
1409
1711
  return text?.includes(formatted) ?? false;
1410
1712
  }
1713
+ function shownPrices(item) {
1714
+ const prices = [format.money(item.price)];
1715
+ if (showsFromPrice(item)) {
1716
+ const from = format.money(startingPrice(item));
1717
+ if (from !== prices[0]) {
1718
+ prices.push(from);
1719
+ }
1720
+ }
1721
+ return prices;
1722
+ }
1411
1723
 
1412
1724
  // src/checks/v03-menu.ts
1413
1725
  var v03 = {
@@ -1417,8 +1729,9 @@ var v03 = {
1417
1729
  async run({ page, options, fault }, viewport) {
1418
1730
  const menu = await readMenu(options);
1419
1731
  await page.setViewportSize(viewport);
1420
- const severalMenus = !options.cataloguePath && catalogues(menu).length > 1;
1421
- for (const catalogue of cataloguePages(options, menu)) {
1732
+ const pages = cataloguePages(options, menu);
1733
+ const severalMenus = !options.cataloguePath && pages[0]?.name === void 0 && catalogues(menu).length > 1;
1734
+ for (const catalogue of pages) {
1422
1735
  await open(page, catalogue.path);
1423
1736
  await applyPageFault(page, fault);
1424
1737
  await judgeCatalogue(
@@ -1433,24 +1746,46 @@ var v03 = {
1433
1746
  async function judgeCatalogue(page, items, menuName, severalMenus) {
1434
1747
  const available = items.filter((item) => item.isAvailable);
1435
1748
  const shownIds = /* @__PURE__ */ new Set();
1749
+ const markerText = /* @__PURE__ */ new Map();
1436
1750
  for (const item of available) {
1437
1751
  const marker = page.locator(`[data-zappie-item="${item.id}"]`);
1438
- if (await marker.count() > 0 || await page.getByText(item.name, { exact: true }).count() > 0) {
1752
+ const text = await visibleMarkerText(marker);
1753
+ markerText.set(item.id, text);
1754
+ const prices = shownPrices(item);
1755
+ if (text.some(
1756
+ (value) => nameMatches(value, item.name) && prices.some((price) => priceMatches(value, normalizeText(price)))
1757
+ ) || await marker.count() === 0 && await page.getByText(item.name, { exact: true }).filter({ visible: true }).count() > 0) {
1439
1758
  shownIds.add(item.id);
1440
1759
  }
1441
1760
  }
1761
+ for (const item of available) {
1762
+ if (shownIds.has(item.id)) {
1763
+ continue;
1764
+ }
1765
+ const text = markerText.get(item.id)?.find((value) => nameMatches(value, item.name));
1766
+ if (text === void 0) {
1767
+ continue;
1768
+ }
1769
+ const prices = shownPrices(item);
1770
+ assertion(
1771
+ prices.some((price) => priceMatches(text, normalizeText(price))),
1772
+ "price_mismatch",
1773
+ `${item.name} does not show ${prices.join(" or From ")}`,
1774
+ "Render prices with format.money from @zappie/sdk, and a From price with startingPrice from @zappie/sdk/pricing."
1775
+ );
1776
+ }
1442
1777
  const coverage = menuCoverage(items, shownIds);
1443
1778
  assertion(
1444
1779
  coverage.passes || severalMenus,
1445
1780
  "menu_mismatch",
1446
1781
  `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."
1782
+ "Render at least 95% of available menu items with visible data-zappie-item markers that contain their name and price, or visible names when no marker exists."
1448
1783
  );
1449
1784
  assertion(
1450
1785
  coverage.passes,
1451
1786
  "menu_mismatch",
1452
1787
  `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}."
1788
+ "Render at least 95% of available menu items with visible data-zappie-item markers that contain their name and price, or visible names when no marker exists. If each menu has its own page, set verify.cataloguePath, such as /menu?menu={menu}."
1454
1789
  );
1455
1790
  assertion(
1456
1791
  await page.locator("[data-zappie-item]").count(),
@@ -1471,15 +1806,95 @@ async function judgeCatalogue(page, items, menuName, severalMenus) {
1471
1806
  );
1472
1807
  }
1473
1808
  }
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
- }
1809
+ }
1810
+ function nameMatches(text, name) {
1811
+ return text.includes(normalizeText(decodeBasicEntities(name)));
1812
+ }
1813
+ function normalizeText(value) {
1814
+ return value.normalize("NFKC").replace(/[\u00ad\u200b-\u200d\u2060\ufeff]/g, "").replace(/\s+/g, " ").trim().toLocaleLowerCase();
1815
+ }
1816
+ function decodeBasicEntities(value) {
1817
+ const entities = {
1818
+ amp: "&",
1819
+ apos: "'",
1820
+ gt: ">",
1821
+ lt: "<",
1822
+ quot: '"'
1823
+ };
1824
+ return value.replace(
1825
+ /&(amp|apos|gt|lt|quot);/gi,
1826
+ (entity) => entities[entity.slice(1, -1).toLowerCase()] ?? entity
1827
+ );
1828
+ }
1829
+ async function visibleMarkerText(marker) {
1830
+ const text = await marker.evaluateAll(
1831
+ (nodes) => nodes.flatMap((node) => {
1832
+ for (let current = node; current; current = current.parentElement) {
1833
+ const ariaHidden = current.getAttribute("aria-hidden")?.trim().toLowerCase();
1834
+ const style = getComputedStyle(current);
1835
+ if (current.hasAttribute("hidden") || ariaHidden === "true" || style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || style.contentVisibility === "hidden" || fullyClipped(current, style)) {
1836
+ return [];
1837
+ }
1838
+ }
1839
+ const hasBox = [...node.getClientRects()].some((box) => {
1840
+ const documentRight = box.right + window.scrollX;
1841
+ const documentBottom = box.bottom + window.scrollY;
1842
+ return box.width >= 2 && box.height >= 2 && documentRight > 0 && documentBottom > 0;
1843
+ });
1844
+ if (!hasBox) {
1845
+ return [];
1846
+ }
1847
+ const text2 = node.textContent;
1848
+ return text2 === null ? [] : [text2];
1849
+ function fullyClipped(element, style) {
1850
+ const box = element.getBoundingClientRect();
1851
+ const clip = style.clip.match(
1852
+ /^rect\((auto|-?[\d.]+px)[, ]+(auto|-?[\d.]+px)[, ]+(auto|-?[\d.]+px)[, ]+(auto|-?[\d.]+px)\)$/
1853
+ );
1854
+ if (clip) {
1855
+ const top2 = length(clip[1], 0);
1856
+ const right2 = length(clip[2], box.width);
1857
+ const bottom2 = length(clip[3], box.height);
1858
+ const left2 = length(clip[4], 0);
1859
+ if (right2 <= left2 || bottom2 <= top2) {
1860
+ return true;
1861
+ }
1862
+ }
1863
+ const clipPath = style.clipPath;
1864
+ if (/^(circle|ellipse)\(0(?:px|%)?(?:\s|\))/i.test(clipPath)) {
1865
+ return true;
1866
+ }
1867
+ const inset = clipPath.match(/^inset\(([^)]+)\)/i);
1868
+ if (!inset) {
1869
+ return false;
1870
+ }
1871
+ const values = inset[1].split(/\s+/).slice(0, 4).map(
1872
+ (value, index) => insetLength(value, index % 2 ? box.width : box.height)
1873
+ );
1874
+ if (values.some((value) => value === void 0)) {
1875
+ return false;
1876
+ }
1877
+ const top = values[0];
1878
+ const right = values.length === 1 ? top : values[1];
1879
+ const bottom = values.length < 3 ? top : values[2];
1880
+ const left = values.length < 4 ? right : values[3];
1881
+ return top + bottom >= box.height || right + left >= box.width;
1882
+ }
1883
+ function length(value, fallback) {
1884
+ return value === "auto" || value === void 0 ? fallback : Number.parseFloat(value);
1885
+ }
1886
+ function insetLength(value, dimension) {
1887
+ if (value.endsWith("%")) {
1888
+ return Number.parseFloat(value) / 100 * dimension;
1889
+ }
1890
+ if (value.endsWith("px") || value === "0") {
1891
+ return Number.parseFloat(value);
1892
+ }
1893
+ return void 0;
1894
+ }
1895
+ })
1896
+ );
1897
+ return text.map(normalizeText);
1483
1898
  }
1484
1899
 
1485
1900
  // src/checks/v04-add-simple.ts
@@ -1571,6 +1986,7 @@ var v04 = {
1571
1986
  };
1572
1987
 
1573
1988
  // src/checks/v05-add-options.ts
1989
+ var REQUIRED_GROUP = '[data-zappie-option-group]:is([data-zappie-required="true"], [aria-required="true"])';
1574
1990
  var v05 = {
1575
1991
  id: "V05",
1576
1992
  title: "Add an item with required options",
@@ -1589,22 +2005,22 @@ var v05 = {
1589
2005
  await openItem(page, selected.id);
1590
2006
  const dialog = page.getByRole("dialog");
1591
2007
  await dialog.waitFor();
1592
- const required = dialog.locator('[data-zappie-option-group][aria-required="true"]').first();
2008
+ const required = dialog.locator(REQUIRED_GROUP).first();
1593
2009
  await required.waitFor({ timeout: 5e3 }).catch(() => void 0);
1594
2010
  assertion(
1595
2011
  await required.count(),
1596
2012
  "required_group_missing",
1597
2013
  "The item dialog has no marked required option group",
1598
- "Expose required groups through data-zappie-option-group and aria-required."
2014
+ "Mark each option group with data-zappie-option-group and each required one with data-zappie-required, as OptionGroup from @zappie/foundations does."
1599
2015
  );
1600
2016
  const before = await debugValue(page, () => window.__zappie.cart().count);
1601
2017
  const add = dialog.locator('[data-zappie-action="add-to-cart"]');
1602
2018
  const unsatisfied = dialog.locator(
1603
- '[data-zappie-option-group][aria-required="true"]:not(:has([data-state="checked"]))'
2019
+ `${REQUIRED_GROUP}:has([data-zappie-option]):not(:has([data-state="checked"]))`
1604
2020
  );
1605
2021
  const blocked2 = await add.isDisabled();
1606
- if (!blocked2) {
1607
- const group = unsatisfied.first();
2022
+ const group = unsatisfied.first();
2023
+ if (!blocked2 && await group.count() > 0) {
1608
2024
  const textBefore = (await group.innerText().catch(() => "")).trim();
1609
2025
  await add.click();
1610
2026
  await page.waitForTimeout(500);
@@ -2936,13 +3352,20 @@ var v14 = {
2936
3352
  };
2937
3353
 
2938
3354
  // src/checks/v15-tappable-product-cards.ts
2939
- var TAPPABLE_BUDGET_MS = 3e3;
2940
3355
  var OPEN_BUDGET_MS = 300;
3356
+ function tappableBudgetMs(configured) {
3357
+ if (configured === void 0 || !Number.isFinite(configured)) {
3358
+ return DEFAULT_BUDGETS.tappableMs;
3359
+ }
3360
+ return Math.min(5e3, Math.max(DEFAULT_BUDGETS.tappableMs, configured));
3361
+ }
2941
3362
  var v15 = {
2942
3363
  id: "V15",
2943
3364
  title: "Tappable product cards",
2944
3365
  viewports: "mobile",
2945
3366
  async run({ page, options, metrics, cpuThrottlingRate: cpuThrottlingRate2 }, viewport) {
3367
+ const limitMs = tappableBudgetMs(options.tappableMs);
3368
+ metrics.productCardTappableLimitMs = limitMs;
2946
3369
  await page.setViewportSize(viewport);
2947
3370
  const { cdp } = await applyPhoneProfile(page, cpuThrottlingRate2);
2948
3371
  try {
@@ -2987,10 +3410,10 @@ var v15 = {
2987
3410
  const tappableMs = await page.evaluate(() => performance.now());
2988
3411
  metrics.productCardTappableMs = Math.round(tappableMs);
2989
3412
  assertion(
2990
- tappableMs <= TAPPABLE_BUDGET_MS,
3413
+ tappableMs <= limitMs,
2991
3414
  "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."
3415
+ `The first product card became tappable in ${Math.round(tappableMs)} ms; the site limit is ${limitMs} ms`,
3416
+ "Reduce critical scripts, fonts and high-priority images until product cards meet the site's tappable limit."
2994
3417
  );
2995
3418
  assertion(
2996
3419
  await prefetched,
@@ -3547,7 +3970,7 @@ async function reset(options) {
3547
3970
  }
3548
3971
 
3549
3972
  // src/version.ts
3550
- var PACKAGE_VERSION = true ? "0.3.0" : "0.0.0-development";
3973
+ var PACKAGE_VERSION = true ? "0.3.2" : "0.0.0-development";
3551
3974
 
3552
3975
  // src/runner.ts
3553
3976
  var VERSION = PACKAGE_VERSION;
@@ -3672,15 +4095,16 @@ async function preparePage(context, options, fault) {
3672
4095
  await installFault(page, fault);
3673
4096
  return page;
3674
4097
  }
3675
- async function runVerify(options, fault) {
3676
- if (!/^pk_test_.+/.test(options.key)) {
4098
+ async function runVerify(input, fault) {
4099
+ if (!/^pk_test_.+/.test(input.key)) {
3677
4100
  throw new VerifyError(
3678
4101
  "live_key_refused",
3679
4102
  "Verify accepts only pk_test_ keys",
3680
4103
  "Create a test publishable key and rerun verify."
3681
4104
  );
3682
4105
  }
3683
- checkPaths(options);
4106
+ checkPaths(input);
4107
+ const options = await fillStartOptions(input);
3684
4108
  const wanted = options.only ? expandOnly(options.only, knownCheckIds()) : void 0;
3685
4109
  const started = Date.now();
3686
4110
  const executablePath = browserExecutable(options);
@@ -3747,6 +4171,7 @@ async function runVerify(options, fault) {
3747
4171
  ...cpuBenchmark === void 0 ? {} : { cpuBenchmark },
3748
4172
  cpuThrottlingRate: throttlingRate,
3749
4173
  startPath: startPath(options),
4174
+ ...options.startPath !== input.startPath ? { startPathTemplate: input.startPath } : {},
3750
4175
  ...options.cataloguePath ? { cataloguePath: options.cataloguePath } : {}
3751
4176
  },
3752
4177
  startedAt: new Date(started).toISOString(),
@@ -3774,16 +4199,17 @@ async function runVerify(options, fault) {
3774
4199
 
3775
4200
  // src/project.ts
3776
4201
  async function verifyProject(options) {
3777
- const settings = options.store && options.startPath && options.cataloguePath ? {} : await readProjectSettings(options.cwd, { required: !options.store });
4202
+ const settings = options.store && options.startPath && options.cataloguePath && options.tappableMs !== void 0 ? {} : await readProjectSettings(options.cwd, { required: !options.store });
3778
4203
  const store = options.store ?? settings.store;
3779
4204
  const startPath2 = options.startPath ?? settings.startPath;
3780
4205
  const warnings = "ignored" in settings && settings.ignored ? [
3781
4206
  {
3782
4207
  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.`
4208
+ message: `${settings.ignored}, so verify ran without its route settings or tappable budget. Fix zappie.config.ts before rerunning verify.`
3784
4209
  }
3785
4210
  ] : [];
3786
4211
  const cataloguePath = options.cataloguePath ?? settings.cataloguePath;
4212
+ const tappableMs = options.tappableMs ?? settings.tappableMs;
3787
4213
  let preview;
3788
4214
  try {
3789
4215
  preview = options.url ? void 0 : await startLocalPreview({
@@ -3804,6 +4230,7 @@ async function verifyProject(options) {
3804
4230
  ...options.only ? { only: options.only } : {},
3805
4231
  ...startPath2 !== void 0 ? { startPath: startPath2 } : {},
3806
4232
  ...cataloguePath !== void 0 ? { cataloguePath } : {},
4233
+ ...tappableMs !== void 0 ? { tappableMs } : {},
3807
4234
  ...warnings.length ? { warnings } : {},
3808
4235
  ...options.viewports ? { viewports: options.viewports } : {},
3809
4236
  ...options.artifactsDir ? { artifactsDir: options.artifactsDir } : {},