@zappie/verify 0.3.3 → 0.3.5

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
@@ -12,7 +12,7 @@ import {
12
12
  sumMoney,
13
13
  validateConfig,
14
14
  validateLine
15
- } from "./chunk-CW3HI5TY.js";
15
+ } from "./chunk-5DRY6Y4G.js";
16
16
 
17
17
  // src/browser.ts
18
18
  var CHROMIUM_ENV = "ZAPPIE_VERIFY_CHROMIUM";
@@ -1599,13 +1599,13 @@ async function readMenu(options) {
1599
1599
  function sortedItems(menu) {
1600
1600
  return Object.values(menu.items).sort((a, b) => a.id.localeCompare(b.id));
1601
1601
  }
1602
- function simpleItem(menu) {
1603
- return sortedItems(menu).find(
1602
+ function simpleItems(menu) {
1603
+ return sortedItems(menu).filter(
1604
1604
  (item) => item.isAvailable && !item.optionGroups.some((group) => group.required)
1605
1605
  );
1606
1606
  }
1607
- function configurableItem(menu) {
1608
- return sortedItems(menu).find(
1607
+ function configurableItems(menu) {
1608
+ return sortedItems(menu).filter(
1609
1609
  (item) => item.isAvailable && item.optionGroups.some((group) => group.required)
1610
1610
  );
1611
1611
  }
@@ -1628,6 +1628,12 @@ function catalogues(menu) {
1628
1628
  };
1629
1629
  }).filter((catalogue) => catalogue.items.length > 0);
1630
1630
  }
1631
+ function sellableItems(menu) {
1632
+ const held = new Set(menu.categories.flatMap((category) => category.itemIds));
1633
+ return sortedItems(menu).filter(
1634
+ (item) => item.isAvailable && (held.size === 0 || held.has(item.id))
1635
+ );
1636
+ }
1631
1637
  function fillStartPath(template, menu) {
1632
1638
  const first = catalogues(menu)[0]?.id ?? menu.menus?.[0]?.id ?? menu.id;
1633
1639
  return { path: cataloguePage(template, first, menu.branchId), menuId: first };
@@ -1682,6 +1688,42 @@ function itemPath(options, menu, itemId) {
1682
1688
  return holder && options.cataloguePath ? cataloguePage(options.cataloguePath, holder.id, menu.branchId) : startPath(options);
1683
1689
  }
1684
1690
 
1691
+ // src/checks/sample.ts
1692
+ import { randomInt } from "crypto";
1693
+ var SEED_ENV = "ZAPPIE_VERIFY_SEED";
1694
+ var MAX_SEED = 2 ** 32;
1695
+ function sampleSeed(option, env = process.env) {
1696
+ for (const value of [option, env[SEED_ENV]?.trim()]) {
1697
+ if (value === void 0 || value === "") {
1698
+ continue;
1699
+ }
1700
+ const seed = Number(value);
1701
+ if (Number.isInteger(seed) && seed >= 0 && seed < MAX_SEED) {
1702
+ return seed;
1703
+ }
1704
+ }
1705
+ return randomInt(0, MAX_SEED);
1706
+ }
1707
+ function seededRandom(seed) {
1708
+ let state = seed >>> 0;
1709
+ return () => {
1710
+ state = state + 1831565813 >>> 0;
1711
+ let value = state;
1712
+ value = Math.imul(value ^ value >>> 15, value | 1);
1713
+ value ^= value + Math.imul(value ^ value >>> 7, value | 61);
1714
+ return ((value ^ value >>> 14) >>> 0) / MAX_SEED;
1715
+ };
1716
+ }
1717
+ function sample(values, count, random) {
1718
+ const pool = [...values];
1719
+ const size = Math.min(Math.max(0, count), pool.length);
1720
+ for (let index = 0; index < size; index++) {
1721
+ const pick = index + Math.floor(random() * (pool.length - index));
1722
+ [pool[index], pool[pick]] = [pool[pick], pool[index]];
1723
+ }
1724
+ return pool.slice(0, size);
1725
+ }
1726
+
1685
1727
  // ../sdk/src/pricing/index.ts
1686
1728
  var cheapest = (prices) => prices.filter((price) => price.amountMinor >= 0).sort((a, b) => a.amountMinor - b.amountMinor)[0];
1687
1729
  function startingPrice(item) {
@@ -1708,15 +1750,6 @@ function showsFromPrice(item) {
1708
1750
  }
1709
1751
 
1710
1752
  // src/checks/menu-match.ts
1711
- function menuCoverage(items, shownIds) {
1712
- const available = items.filter((item) => item.isAvailable);
1713
- const shown = available.filter((item) => shownIds.has(item.id)).length;
1714
- return {
1715
- available: available.length,
1716
- shown,
1717
- passes: available.length === 0 || shown / available.length >= 0.95
1718
- };
1719
- }
1720
1753
  function priceMatches(text, formatted) {
1721
1754
  return text?.includes(formatted) ?? false;
1722
1755
  }
@@ -1731,100 +1764,336 @@ function shownPrices(item) {
1731
1764
  return prices;
1732
1765
  }
1733
1766
 
1734
- // src/checks/v03-menu.ts
1735
- var v03 = {
1736
- id: "V03",
1737
- title: "Menu matches store",
1738
- viewports: "mobile",
1739
- async run({ page, options, fault }, viewport) {
1740
- const menu = await readMenu(options);
1741
- await page.setViewportSize(viewport);
1742
- const pages = cataloguePages(options, menu);
1743
- const severalMenus = !options.cataloguePath && pages[0]?.name === void 0 && catalogues(menu).length > 1;
1744
- for (const catalogue of pages) {
1745
- await open(page, catalogue.path);
1746
- await applyPageFault(page, fault);
1747
- await judgeCatalogue(
1748
- page,
1749
- catalogue.items,
1750
- catalogue.name === void 0 ? "menu" : `${catalogue.name} menu at ${catalogue.path}`,
1751
- severalMenus
1752
- );
1767
+ // src/checks/sitemap.ts
1768
+ var MAX_SITEMAP_FILES = 10;
1769
+ var entities = {
1770
+ amp: "&",
1771
+ apos: "'",
1772
+ gt: ">",
1773
+ lt: "<",
1774
+ quot: '"'
1775
+ };
1776
+ function locations(xml) {
1777
+ return [...xml.matchAll(/<loc>\s*([^<]*?)\s*<\/loc>/g)].map(
1778
+ (match) => match[1].replace(
1779
+ /&(amp|apos|gt|lt|quot);/g,
1780
+ (entity) => entities[entity.slice(1, -1)] ?? entity
1781
+ )
1782
+ );
1783
+ }
1784
+ function sitemapPath(location2) {
1785
+ try {
1786
+ const url = new URL(location2, "http://site.invalid");
1787
+ return `${url.pathname}${url.search}`;
1788
+ } catch {
1789
+ return void 0;
1790
+ }
1791
+ }
1792
+ async function readSitemap(fetchText) {
1793
+ const queue = ["/sitemap.xml"];
1794
+ const seen = /* @__PURE__ */ new Set();
1795
+ const pages = /* @__PURE__ */ new Set();
1796
+ let found = false;
1797
+ while (queue.length > 0 && seen.size < MAX_SITEMAP_FILES) {
1798
+ const path4 = queue.shift();
1799
+ if (seen.has(path4)) {
1800
+ continue;
1801
+ }
1802
+ seen.add(path4);
1803
+ const xml = await fetchText(path4).catch(() => void 0);
1804
+ if (xml === void 0) {
1805
+ continue;
1806
+ }
1807
+ if (/<sitemapindex[\s>]/.test(xml)) {
1808
+ found = true;
1809
+ for (const location2 of locations(xml)) {
1810
+ const child = sitemapPath(location2);
1811
+ if (child !== void 0) {
1812
+ queue.push(child);
1813
+ }
1814
+ }
1815
+ } else if (/<urlset[\s>]/.test(xml)) {
1816
+ found = true;
1817
+ for (const location2 of locations(xml)) {
1818
+ const page = sitemapPath(location2);
1819
+ if (page !== void 0) {
1820
+ pages.add(page);
1821
+ }
1822
+ }
1753
1823
  }
1754
1824
  }
1755
- };
1756
- async function judgeCatalogue(page, items, menuName, severalMenus) {
1757
- const available = items.filter((item) => item.isAvailable);
1758
- const shownIds = /* @__PURE__ */ new Set();
1759
- const markerText = /* @__PURE__ */ new Map();
1760
- for (const item of available) {
1761
- const marker = page.locator(`[data-zappie-item="${item.id}"]`);
1762
- const text = await visibleMarkerText(marker);
1763
- markerText.set(item.id, text);
1764
- const prices = shownPrices(item);
1765
- if (text.some(
1766
- (value) => nameMatches(value, item.name) && prices.some((price) => priceMatches(value, normalizeText(price)))
1767
- ) || await marker.count() === 0 && await page.getByText(item.name, { exact: true }).filter({ visible: true }).count() > 0) {
1768
- shownIds.add(item.id);
1825
+ return found ? [...pages] : void 0;
1826
+ }
1827
+ function itemIdInPath(path4, itemIds) {
1828
+ let url;
1829
+ try {
1830
+ url = new URL(path4, "http://site.invalid");
1831
+ } catch {
1832
+ return void 0;
1833
+ }
1834
+ const values = [
1835
+ ...url.pathname.split("/").filter(Boolean),
1836
+ ...url.searchParams.values()
1837
+ ];
1838
+ for (const raw of values.reverse()) {
1839
+ let value;
1840
+ try {
1841
+ value = decodeURIComponent(raw);
1842
+ } catch {
1843
+ value = raw;
1844
+ }
1845
+ for (const candidate of [
1846
+ value,
1847
+ `item_${value}`,
1848
+ value.startsWith("item_") ? value.slice("item_".length) : void 0
1849
+ ]) {
1850
+ if (candidate !== void 0 && itemIds.has(candidate)) {
1851
+ return candidate;
1852
+ }
1769
1853
  }
1770
1854
  }
1771
- for (const item of available) {
1772
- if (shownIds.has(item.id)) {
1773
- continue;
1855
+ return void 0;
1856
+ }
1857
+ function itemPages(paths, itemIds) {
1858
+ const pages = /* @__PURE__ */ new Map();
1859
+ for (const path4 of paths) {
1860
+ const id = itemIdInPath(path4, itemIds);
1861
+ if (id !== void 0 && !pages.has(id)) {
1862
+ pages.set(id, path4);
1774
1863
  }
1775
- const text = markerText.get(item.id)?.find((value) => nameMatches(value, item.name));
1776
- if (text === void 0) {
1864
+ }
1865
+ return pages;
1866
+ }
1867
+
1868
+ // src/checks/v03-judge.ts
1869
+ var SAMPLE_SIZE = 20;
1870
+ var MAX_MENU_PAGES = 3;
1871
+ var MIN_PAGE_ITEMS = 8;
1872
+ var NAME_PROBES = 20;
1873
+ function judgedScopes(scopes, seed) {
1874
+ if (scopes.length <= MAX_MENU_PAGES) {
1875
+ return scopes;
1876
+ }
1877
+ const [first, ...rest] = scopes;
1878
+ const drawn = new Set(
1879
+ sample(rest, MAX_MENU_PAGES - 1, seededRandom(seed ^ 2654435769))
1880
+ );
1881
+ return [first, ...rest.filter((scope) => drawn.has(scope))];
1882
+ }
1883
+ function contractItemPath(item) {
1884
+ const id = item.id.startsWith("item_") ? item.id : `item_${item.id}`;
1885
+ return `/item/${encodeURIComponent(id)}`;
1886
+ }
1887
+ async function judgeCatalogue(input) {
1888
+ const { site, catalogue, seed } = input;
1889
+ const random = seededRandom(seed);
1890
+ const details = {
1891
+ seed,
1892
+ pageLoads: 0,
1893
+ availableItems: catalogue.length,
1894
+ firstPageItems: 0,
1895
+ nextPage: "not-needed",
1896
+ sampledItems: []
1897
+ };
1898
+ input.record?.(details);
1899
+ const ids = new Set(Object.keys(input.items));
1900
+ const sitemapPaths = await readSitemap(site.fetchText);
1901
+ const sitemapPages = sitemapPaths && itemPages(sitemapPaths, ids);
1902
+ if (sitemapPages) {
1903
+ details.sitemapItems = sitemapPages.size;
1904
+ }
1905
+ const cardLinks = /* @__PURE__ */ new Map();
1906
+ let paginated = false;
1907
+ for (const [index, scope] of input.scopes.entries()) {
1908
+ const label2 = scope.name === void 0 ? "menu" : `${scope.name} menu at ${scope.path}`;
1909
+ details.pageLoads++;
1910
+ const first = await site.open(scope.path);
1911
+ const shown = await judgePage(first, input, label2, cardLinks);
1912
+ const available = scope.items.filter((item) => item.isAvailable);
1913
+ const count = available.filter((item) => shown.has(item.id)).length;
1914
+ if (index === 0) {
1915
+ details.firstPageItems = count;
1916
+ }
1917
+ if (first.markers === 0 && available.length > 0) {
1918
+ const names = available.slice(0, NAME_PROBES).map((item) => item.name);
1919
+ assertion(
1920
+ !await site.namesShown(names),
1921
+ "item_marker_missing",
1922
+ `The ${label2} has no item marker`,
1923
+ "Add data-zappie-item to each rendered menu item."
1924
+ );
1925
+ }
1926
+ if (count >= available.length) {
1777
1927
  continue;
1778
1928
  }
1929
+ assertion(
1930
+ count > 0 || input.severalMenus,
1931
+ "menu_mismatch",
1932
+ `The ${label2} shows ${count} of ${available.length} available items`,
1933
+ 'Render every available item with a visible data-zappie-item marker that contains its name and price, or link the next page with <a rel="next">. A filter that drops items without a category or a photo hides them from shoppers.'
1934
+ );
1935
+ assertion(
1936
+ count > 0,
1937
+ "menu_mismatch",
1938
+ `The ${label2} shows ${count} of ${available.length} available items`,
1939
+ 'Render every available item with a visible data-zappie-item marker that contains its name and price, or link the next page with <a rel="next">. If each menu has its own page, set verify.cataloguePath, such as /menu?menu={menu}.'
1940
+ );
1941
+ assertion(
1942
+ first.next !== void 0 || first.loadMore,
1943
+ "menu_mismatch",
1944
+ `The ${label2} shows ${count} of ${available.length} available items and links to no next page`,
1945
+ 'Render every available item, or link the next page with <a rel="next">. A filter that drops items without a category or a photo hides them from shoppers.'
1946
+ );
1947
+ const least = Math.min(available.length, MIN_PAGE_ITEMS);
1948
+ assertion(
1949
+ count >= least,
1950
+ "menu_mismatch",
1951
+ `The ${label2} shows ${count} of ${available.length} available items on its first page. A page shows at least ${least}`,
1952
+ `Show at least ${MIN_PAGE_ITEMS} items on each page of the catalogue, or every item when there are fewer.`
1953
+ );
1954
+ paginated = true;
1955
+ let next;
1956
+ if (first.next !== void 0) {
1957
+ details.pageLoads++;
1958
+ next = await site.open(first.next);
1959
+ details.nextPage = "link";
1960
+ } else {
1961
+ next = await site.loadMore();
1962
+ details.nextPage = "load-more";
1963
+ }
1964
+ const nextLabel = first.next === void 0 ? `${label2} after load more` : `next page of the ${label2} at ${first.next}`;
1965
+ const more = await judgePage(next, input, nextLabel, cardLinks);
1966
+ assertion(
1967
+ available.some((item) => more.has(item.id) && !shown.has(item.id)),
1968
+ "next_page_mismatch",
1969
+ `The ${nextLabel} shows no available item the first page didn't`,
1970
+ 'Link the page that holds the next items with <a rel="next">, or make data-zappie-action="load-more" add them.'
1971
+ );
1972
+ }
1973
+ if (sitemapPages && sitemapPages.size > 0) {
1974
+ const missing = catalogue.filter((item) => !sitemapPages.has(item.id));
1975
+ assertion(
1976
+ missing.length === 0,
1977
+ "sitemap_incomplete",
1978
+ `The sitemap lists ${catalogue.length - missing.length} of ${catalogue.length} available items. Missing: ${listed(missing)}`,
1979
+ "List a page for every available item in /sitemap.xml, as the starter's sitemap route does. A filter that drops an item from the sitemap usually hides it from shoppers too."
1980
+ );
1981
+ } else {
1982
+ assertion(
1983
+ !paginated || sitemapPaths !== void 0,
1984
+ "sitemap_missing",
1985
+ "The site serves no /sitemap.xml, so verify can't tell whether the catalogue shows every item beyond its first page",
1986
+ "Serve /sitemap.xml with a page for every available item, as the starter's sitemap route does."
1987
+ );
1988
+ assertion(
1989
+ !paginated,
1990
+ "sitemap_missing",
1991
+ "The sitemap lists no item pages, so verify can't tell whether the catalogue shows every item beyond its first page",
1992
+ "List a page for every available item in /sitemap.xml, as the starter's sitemap route does."
1993
+ );
1994
+ const message = sitemapPaths ? "The sitemap lists no item pages, so verify can't tell whether the catalogue shows every item" : "The site serves no /sitemap.xml, so verify can't tell whether the catalogue shows every item";
1995
+ input.warn?.("sitemap_missing", message);
1996
+ }
1997
+ const drawn = sample(catalogue, SAMPLE_SIZE, random);
1998
+ for (const item of drawn) {
1999
+ details.sampledItems.push(item.id);
2000
+ const path4 = sitemapPages?.get(item.id) ?? cardLinks.get(item.id) ?? contractItemPath(item);
2001
+ details.pageLoads++;
2002
+ const opened = await site.itemPage(path4, item.name);
2003
+ const page = { ...opened, text: normalizeText(opened.text) };
2004
+ assertion(
2005
+ page.status === 200,
2006
+ "item_page_missing",
2007
+ `The page of ${item.name} at ${path4} answered ${page.status}`,
2008
+ "Serve each item's page at the path the sitemap lists, or at /item/{id}, with a 200 response."
2009
+ );
2010
+ assertion(
2011
+ nameMatches(page.text, item.name),
2012
+ "item_page_mismatch",
2013
+ `The page at ${path4} does not show ${item.name}`,
2014
+ "Show the item's name on its own page."
2015
+ );
1779
2016
  const prices = shownPrices(item);
1780
2017
  assertion(
1781
- prices.some((price) => priceMatches(text, normalizeText(price))),
2018
+ prices.some((price) => priceMatches(page.text, normalizeText(price))),
1782
2019
  "price_mismatch",
1783
- `${item.name} does not show ${prices.join(" or From ")}`,
2020
+ `The page of ${item.name} at ${path4} does not show ${prices.join(" or From ")}`,
1784
2021
  "Render prices with format.money from @zappie/sdk, and a From price with startingPrice from @zappie/sdk/pricing."
1785
2022
  );
1786
2023
  }
1787
- const coverage = menuCoverage(items, shownIds);
1788
- assertion(
1789
- coverage.passes || severalMenus,
1790
- "menu_mismatch",
1791
- `The ${menuName} shows ${coverage.shown} of ${coverage.available} available items`,
1792
- "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."
1793
- );
1794
- assertion(
1795
- coverage.passes,
1796
- "menu_mismatch",
1797
- `The ${menuName} shows ${coverage.shown} of ${coverage.available} available items`,
1798
- "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}."
1799
- );
1800
- assertion(
1801
- await page.locator("[data-zappie-item]").count(),
1802
- "item_marker_missing",
1803
- `The ${menuName} has no item marker`,
1804
- "Add data-zappie-item to each rendered menu item."
1805
- );
1806
- for (const item of items.filter((entry) => !entry.isAvailable)) {
1807
- const add = page.locator(
1808
- `[data-zappie-item="${item.id}"] [data-zappie-action="add-to-cart"]`
2024
+ return details;
2025
+ }
2026
+ async function judgePage(page, input, label2, cardLinks) {
2027
+ const shown = /* @__PURE__ */ new Set();
2028
+ for (const view of page.cards) {
2029
+ const card = { ...view, texts: view.texts.map(normalizeText) };
2030
+ const item = input.items[card.id];
2031
+ assertion(
2032
+ item,
2033
+ "unknown_item",
2034
+ `The ${label2} shows data-zappie-item="${card.id}", which is not an item of this store${knownAs(card.id, input.items)}`,
2035
+ "Render items from the menu @zappie/sdk returns, with their own ids in data-zappie-item. Remove demo and stale data."
1809
2036
  );
1810
- if (await add.count()) {
2037
+ if (card.href !== void 0 && !cardLinks.has(item.id)) {
2038
+ cardLinks.set(item.id, card.href);
2039
+ }
2040
+ const named = card.texts.filter((text) => nameMatches(text, item.name));
2041
+ if (!item.isAvailable) {
1811
2042
  assertion(
1812
- await add.isDisabled(),
2043
+ !card.addEnabled,
1813
2044
  "unavailable_item_addable",
1814
2045
  `Shoppers can add the unavailable item ${item.name}`,
1815
2046
  "Disable add-to-cart for unavailable items."
1816
2047
  );
2048
+ assertion(
2049
+ named.length === 0 || card.addDisabled || card.texts.some((text) => showsUnavailable(text, item)),
2050
+ "unavailable_item_shown",
2051
+ `The ${label2} shows ${item.name} for sale, but it is unavailable`,
2052
+ "Hide unavailable items, or mark them sold out: a disabled add-to-cart, the item's stockLabel, or words such as Sold out."
2053
+ );
2054
+ continue;
1817
2055
  }
2056
+ if (named.length === 0) {
2057
+ continue;
2058
+ }
2059
+ const prices = shownPrices(item);
2060
+ assertion(
2061
+ named.some(
2062
+ (text) => prices.some((price) => priceMatches(text, normalizeText(price)))
2063
+ ),
2064
+ "price_mismatch",
2065
+ `${item.name} does not show ${prices.join(" or From ")}`,
2066
+ "Render prices with format.money from @zappie/sdk, and a From price with startingPrice from @zappie/sdk/pricing."
2067
+ );
2068
+ shown.add(item.id);
1818
2069
  }
2070
+ return shown;
2071
+ }
2072
+ function knownAs(id, items) {
2073
+ const known = [`item_${id}`, id.replace(/^item_/, "")].find(
2074
+ (candidate) => candidate !== id && items[candidate] !== void 0
2075
+ );
2076
+ return known ? `. The store's item is ${known}` : "";
2077
+ }
2078
+ function showsUnavailable(text, item) {
2079
+ return item.stockLabel !== void 0 && text.includes(normalizeText(item.stockLabel)) || // Card text runs together, as in "₦7,500.00Sold out", so only letters bound the words.
2080
+ /(?<![a-z])(?:sold[ -]?out|unavailable|out of stock|not available)(?![a-z])/.test(
2081
+ text
2082
+ );
2083
+ }
2084
+ function listed(items) {
2085
+ const names = items.slice(0, 5).map((item) => `${item.name} (${item.id})`);
2086
+ return items.length > 5 ? `${names.join(", ")} and ${items.length - 5} more` : names.join(", ");
1819
2087
  }
1820
2088
  function nameMatches(text, name) {
1821
- return text.includes(normalizeText(decodeBasicEntities(name)));
2089
+ const shown = normalizeText(text);
2090
+ return shown.includes(normalizeText(decodeBasicEntities(name))) || shown.includes(normalizeText(name));
1822
2091
  }
1823
2092
  function normalizeText(value) {
1824
- return value.normalize("NFKC").replace(/[\u00ad\u200b-\u200d\u2060\ufeff]/g, "").replace(/\s+/g, " ").trim().toLocaleLowerCase();
2093
+ return value.normalize("NFKC").replace(/[­​-‍⁠]/g, "").replace(/\s+/g, " ").trim().toLocaleLowerCase();
1825
2094
  }
1826
2095
  function decodeBasicEntities(value) {
1827
- const entities = {
2096
+ const entities2 = {
1828
2097
  amp: "&",
1829
2098
  apos: "'",
1830
2099
  gt: ">",
@@ -1833,78 +2102,234 @@ function decodeBasicEntities(value) {
1833
2102
  };
1834
2103
  return value.replace(
1835
2104
  /&(amp|apos|gt|lt|quot);/gi,
1836
- (entity) => entities[entity.slice(1, -1).toLowerCase()] ?? entity
2105
+ (entity) => entities2[entity.slice(1, -1).toLowerCase()] ?? entity
1837
2106
  );
1838
2107
  }
1839
- async function visibleMarkerText(marker) {
1840
- const text = await marker.evaluateAll(
1841
- (nodes) => nodes.flatMap((node) => {
1842
- for (let current = node; current; current = current.parentElement) {
1843
- const ariaHidden = current.getAttribute("aria-hidden")?.trim().toLowerCase();
1844
- const style = getComputedStyle(current);
1845
- if (current.hasAttribute("hidden") || ariaHidden === "true" || style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || style.contentVisibility === "hidden" || fullyClipped(current, style)) {
1846
- return [];
1847
- }
1848
- }
1849
- const hasBox = [...node.getClientRects()].some((box) => {
1850
- const documentRight = box.right + window.scrollX;
1851
- const documentBottom = box.bottom + window.scrollY;
1852
- return box.width >= 2 && box.height >= 2 && documentRight > 0 && documentBottom > 0;
2108
+
2109
+ // src/checks/v03-menu.ts
2110
+ var RENDER_WAIT_MS = 1e4;
2111
+ var v03 = {
2112
+ id: "V03",
2113
+ title: "Menu matches store",
2114
+ viewports: "mobile",
2115
+ async run({ page, options, fault, warn, recordCatalogue }, viewport) {
2116
+ const menu = await readMenu(options);
2117
+ await page.setViewportSize(viewport);
2118
+ const seed = sampleSeed(options.catalogueSeed);
2119
+ const pages = cataloguePages(options, menu);
2120
+ const severalMenus = !options.cataloguePath && pages[0]?.name === void 0 && catalogues(menu).length > 1;
2121
+ await judgeCatalogue({
2122
+ site: browserSite(page, options, fault),
2123
+ items: menu.items,
2124
+ catalogue: sellableItems(menu),
2125
+ scopes: judgedScopes(pages, seed),
2126
+ seed,
2127
+ severalMenus,
2128
+ warn,
2129
+ ...recordCatalogue ? { record: recordCatalogue } : {}
2130
+ });
2131
+ }
2132
+ };
2133
+ function browserSite(page, options, fault) {
2134
+ const read = () => page.evaluate(readPageView);
2135
+ return {
2136
+ async open(path4) {
2137
+ await open(page, path4);
2138
+ await page.locator("[data-zappie-item]").first().waitFor({ state: "attached", timeout: RENDER_WAIT_MS }).catch(() => void 0);
2139
+ await applyPageFault(page, fault);
2140
+ return read();
2141
+ },
2142
+ async loadMore() {
2143
+ const before = await page.locator("[data-zappie-item]").count();
2144
+ await page.locator('[data-zappie-action="load-more"]').filter({ visible: true }).first().click();
2145
+ await page.waitForFunction(
2146
+ (count) => document.querySelectorAll("[data-zappie-item]").length > count,
2147
+ before,
2148
+ { timeout: RENDER_WAIT_MS }
2149
+ ).catch(() => void 0);
2150
+ return read();
2151
+ },
2152
+ async itemPage(path4, name) {
2153
+ const response = await page.goto(path4, {
2154
+ waitUntil: "domcontentloaded"
1853
2155
  });
1854
- if (!hasBox) {
1855
- return [];
1856
- }
1857
- const text2 = node.textContent;
1858
- return text2 === null ? [] : [text2];
1859
- function fullyClipped(element, style) {
1860
- const box = element.getBoundingClientRect();
1861
- const clip = style.clip.match(
1862
- /^rect\((auto|-?[\d.]+px)[, ]+(auto|-?[\d.]+px)[, ]+(auto|-?[\d.]+px)[, ]+(auto|-?[\d.]+px)\)$/
1863
- );
1864
- if (clip) {
1865
- const top2 = length(clip[1], 0);
1866
- const right2 = length(clip[2], box.width);
1867
- const bottom2 = length(clip[3], box.height);
1868
- const left2 = length(clip[4], 0);
1869
- if (right2 <= left2 || bottom2 <= top2) {
1870
- return true;
1871
- }
1872
- }
1873
- const clipPath = style.clipPath;
1874
- if (/^(circle|ellipse)\(0(?:px|%)?(?:\s|\))/i.test(clipPath)) {
2156
+ await page.waitForFunction(
2157
+ (wanted) => (document.body?.innerText ?? "").normalize("NFKC").replace(/[­​-‍⁠]/g, "").replace(/\s+/g, " ").toLocaleLowerCase().includes(wanted),
2158
+ normalizeText(name),
2159
+ { timeout: RENDER_WAIT_MS }
2160
+ ).catch(() => void 0);
2161
+ return {
2162
+ status: response?.status() ?? 0,
2163
+ text: await page.evaluate(() => document.body?.innerText ?? "")
2164
+ };
2165
+ },
2166
+ async namesShown(names) {
2167
+ for (const name of names) {
2168
+ if (await page.getByText(name, { exact: true }).filter({ visible: true }).count() > 0) {
1875
2169
  return true;
1876
2170
  }
1877
- const inset = clipPath.match(/^inset\(([^)]+)\)/i);
1878
- if (!inset) {
1879
- return false;
1880
- }
1881
- const values = inset[1].split(/\s+/).slice(0, 4).map(
1882
- (value, index) => insetLength(value, index % 2 ? box.width : box.height)
1883
- );
1884
- if (values.some((value) => value === void 0)) {
1885
- return false;
1886
- }
1887
- const top = values[0];
1888
- const right = values.length === 1 ? top : values[1];
1889
- const bottom = values.length < 3 ? top : values[2];
1890
- const left = values.length < 4 ? right : values[3];
1891
- return top + bottom >= box.height || right + left >= box.width;
1892
2171
  }
1893
- function length(value, fallback) {
1894
- return value === "auto" || value === void 0 ? fallback : Number.parseFloat(value);
2172
+ return false;
2173
+ },
2174
+ async fetchText(path4) {
2175
+ const response = await page.request.get(new URL(path4, options.url).href);
2176
+ return response.ok() ? response.text() : void 0;
2177
+ }
2178
+ };
2179
+ }
2180
+ function readPageView() {
2181
+ const add = '[data-zappie-action="add-to-cart"]';
2182
+ const sitePath = (href) => {
2183
+ if (!href) {
2184
+ return void 0;
2185
+ }
2186
+ try {
2187
+ const url = new URL(href, location.href);
2188
+ return url.origin === location.origin ? `${url.pathname}${url.search}` : void 0;
2189
+ } catch {
2190
+ return void 0;
2191
+ }
2192
+ };
2193
+ const markers = [...document.querySelectorAll("[data-zappie-item]")];
2194
+ const cards = /* @__PURE__ */ new Map();
2195
+ for (const node of markers) {
2196
+ if (!visible(node)) {
2197
+ continue;
2198
+ }
2199
+ const id = node.getAttribute("data-zappie-item") ?? "";
2200
+ const card = cards.get(id) ?? {
2201
+ id,
2202
+ texts: [],
2203
+ addEnabled: false,
2204
+ addDisabled: false
2205
+ };
2206
+ card.texts.push(node.textContent ?? "");
2207
+ for (const control of [
2208
+ ...node.matches(add) ? [node] : [],
2209
+ ...node.querySelectorAll(add)
2210
+ ]) {
2211
+ const disabled = control.disabled === true || control.getAttribute("aria-disabled") === "true";
2212
+ if (disabled) {
2213
+ card.addDisabled = true;
2214
+ } else {
2215
+ card.addEnabled = true;
1895
2216
  }
1896
- function insetLength(value, dimension) {
1897
- if (value.endsWith("%")) {
1898
- return Number.parseFloat(value) / 100 * dimension;
1899
- }
1900
- if (value.endsWith("px") || value === "0") {
1901
- return Number.parseFloat(value);
1902
- }
1903
- return void 0;
2217
+ }
2218
+ if (card.href === void 0) {
2219
+ const link = node.closest("a[href]") ?? node.querySelector("a[href]");
2220
+ const href = sitePath(link?.getAttribute("href"));
2221
+ if (href !== void 0) {
2222
+ card.href = href;
1904
2223
  }
1905
- })
2224
+ }
2225
+ cards.set(id, card);
2226
+ }
2227
+ const nextLink = document.querySelector(
2228
+ 'a[rel~="next"][href], link[rel~="next"][href]'
2229
+ );
2230
+ const next = sitePath(nextLink?.getAttribute("href"));
2231
+ const loadMore = [
2232
+ ...document.querySelectorAll('[data-zappie-action="load-more"]')
2233
+ ].some(
2234
+ (control) => visible(control) && control.disabled !== true && control.getAttribute("aria-disabled") !== "true"
1906
2235
  );
1907
- return text.map(normalizeText);
2236
+ return {
2237
+ path: `${location.pathname}${location.search}`,
2238
+ markers: markers.length,
2239
+ cards: [...cards.values()],
2240
+ ...next !== void 0 ? { next } : {},
2241
+ loadMore
2242
+ };
2243
+ function visible(node) {
2244
+ for (let current = node; current; current = current.parentElement) {
2245
+ const ariaHidden = current.getAttribute("aria-hidden")?.trim().toLowerCase();
2246
+ const style = getComputedStyle(current);
2247
+ if (current.hasAttribute("hidden") || ariaHidden === "true" || style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || style.contentVisibility === "hidden" || fullyClipped(current, style)) {
2248
+ return false;
2249
+ }
2250
+ }
2251
+ return [...node.getClientRects()].some((box) => {
2252
+ const documentRight = box.right + window.scrollX;
2253
+ const documentBottom = box.bottom + window.scrollY;
2254
+ return box.width >= 2 && box.height >= 2 && documentRight > 0 && documentBottom > 0;
2255
+ });
2256
+ }
2257
+ function fullyClipped(element, style) {
2258
+ const box = element.getBoundingClientRect();
2259
+ const clip = style.clip.match(
2260
+ /^rect\((auto|-?[\d.]+px)[, ]+(auto|-?[\d.]+px)[, ]+(auto|-?[\d.]+px)[, ]+(auto|-?[\d.]+px)\)$/
2261
+ );
2262
+ if (clip) {
2263
+ const top2 = length(clip[1], 0);
2264
+ const right2 = length(clip[2], box.width);
2265
+ const bottom2 = length(clip[3], box.height);
2266
+ const left2 = length(clip[4], 0);
2267
+ if (right2 <= left2 || bottom2 <= top2) {
2268
+ return true;
2269
+ }
2270
+ }
2271
+ const clipPath = style.clipPath;
2272
+ if (/^(circle|ellipse)\(0(?:px|%)?(?:\s|\))/i.test(clipPath)) {
2273
+ return true;
2274
+ }
2275
+ const inset = clipPath.match(/^inset\(([^)]+)\)/i);
2276
+ if (!inset) {
2277
+ return false;
2278
+ }
2279
+ const values = inset[1].split(/\s+/).slice(0, 4).map(
2280
+ (value, index) => insetLength(value, index % 2 ? box.width : box.height)
2281
+ );
2282
+ if (values.some((value) => value === void 0)) {
2283
+ return false;
2284
+ }
2285
+ const top = values[0];
2286
+ const right = values.length === 1 ? top : values[1];
2287
+ const bottom = values.length < 3 ? top : values[2];
2288
+ const left = values.length < 4 ? right : values[3];
2289
+ return top + bottom >= box.height || right + left >= box.width;
2290
+ }
2291
+ function length(value, fallback) {
2292
+ return value === "auto" || value === void 0 ? fallback : Number.parseFloat(value);
2293
+ }
2294
+ function insetLength(value, dimension) {
2295
+ if (value.endsWith("%")) {
2296
+ return Number.parseFloat(value) / 100 * dimension;
2297
+ }
2298
+ if (value.endsWith("px") || value === "0") {
2299
+ return Number.parseFloat(value);
2300
+ }
2301
+ return void 0;
2302
+ }
2303
+ }
2304
+
2305
+ // src/checks/shown-item.ts
2306
+ async function findShownItem(page, candidates, prepare = async () => {
2307
+ }) {
2308
+ const looked = [];
2309
+ if (candidates.length === 0) {
2310
+ return { looked };
2311
+ }
2312
+ const first = await readShown(page, candidates);
2313
+ looked.push(first.path);
2314
+ if (first.shown || first.next === void 0) {
2315
+ return { ...first.shown ? { shown: first.shown } : {}, looked };
2316
+ }
2317
+ await open(page, first.next);
2318
+ await prepare();
2319
+ const second = await readShown(page, candidates);
2320
+ looked.push(second.path);
2321
+ return { ...second.shown ? { shown: second.shown } : {}, looked };
2322
+ }
2323
+ async function readShown(page, candidates) {
2324
+ await page.locator("[data-zappie-item]").first().waitFor({ state: "attached", timeout: RENDER_WAIT_MS }).catch(() => void 0);
2325
+ const view = await page.evaluate(readPageView);
2326
+ const visible = new Set(view.cards.map((card) => card.id));
2327
+ const item = view.markers === 0 ? candidates[0] : candidates.find((candidate) => visible.has(candidate.id));
2328
+ return {
2329
+ path: view.path,
2330
+ next: view.next,
2331
+ shown: item ? { item, path: view.path } : void 0
2332
+ };
1908
2333
  }
1909
2334
 
1910
2335
  // src/checks/v04-add-simple.ts
@@ -1915,9 +2340,27 @@ var v04 = {
1915
2340
  async run({ page, options, fault }, viewport) {
1916
2341
  await page.setViewportSize(viewport);
1917
2342
  const menu = await readMenu(options);
1918
- const item = simpleItem(menu);
1919
- await open(page, itemPath(options, menu, item?.id));
2343
+ const candidates = simpleItems(menu);
2344
+ await open(page, itemPath(options, menu, candidates[0]?.id));
1920
2345
  await applyPageFault(page, fault);
2346
+ assertion(
2347
+ candidates.length > 0,
2348
+ "simple_item_missing",
2349
+ "The test menu has no available item without required options",
2350
+ "Keep one available item without required options in the test menu."
2351
+ );
2352
+ const { shown, looked } = await findShownItem(
2353
+ page,
2354
+ candidates,
2355
+ () => applyPageFault(page, fault)
2356
+ );
2357
+ assertion(
2358
+ shown,
2359
+ "simple_item_not_shown",
2360
+ `No available item without required options shows on ${looked.join(" or ")}`,
2361
+ 'Show an available item without required options on the first catalogue page, or on the page its rel="next" link opens.'
2362
+ );
2363
+ const item = shown.item;
1921
2364
  const before = await debugValue(page, () => window.__zappie.cart().count);
1922
2365
  await page.evaluate(() => {
1923
2366
  window.__zappieV04Events = [];
@@ -1927,12 +2370,6 @@ var v04 = {
1927
2370
  );
1928
2371
  });
1929
2372
  });
1930
- assertion(
1931
- item,
1932
- "simple_item_missing",
1933
- "The test menu has no available item without required options",
1934
- "Keep one available item without required options in the test menu."
1935
- );
1936
2373
  let openedItem = false;
1937
2374
  let simple = page.locator(`[data-zappie-item="${item.id}"]`).locator('[data-zappie-action="add-to-cart"]');
1938
2375
  if (!await simple.count()) {
@@ -2004,14 +2441,22 @@ var v05 = {
2004
2441
  async run({ page, options }, viewport) {
2005
2442
  await page.setViewportSize(viewport);
2006
2443
  const menu = await readMenu(options);
2007
- const selected = configurableItem(menu);
2008
- await open(page, itemPath(options, menu, selected?.id));
2444
+ const candidates = configurableItems(menu);
2445
+ await open(page, itemPath(options, menu, candidates[0]?.id));
2009
2446
  assertion(
2010
- selected,
2447
+ candidates.length > 0,
2011
2448
  "required_item_missing",
2012
2449
  "The test menu has no available item with required options",
2013
2450
  "Keep one available configurable item in the test menu."
2014
2451
  );
2452
+ const { shown, looked } = await findShownItem(page, candidates);
2453
+ assertion(
2454
+ shown,
2455
+ "required_item_not_shown",
2456
+ `No available item with required options shows on ${looked.join(" or ")}`,
2457
+ 'Show an available item with required options on the first catalogue page, or on the page its rel="next" link opens.'
2458
+ );
2459
+ const selected = shown.item;
2015
2460
  await openItem(page, selected.id);
2016
2461
  const dialog = page.getByRole("dialog");
2017
2462
  await dialog.waitFor();
@@ -2915,8 +3360,8 @@ var v13 = {
2915
3360
  page: true,
2916
3361
  phone: viewport.width < 768
2917
3362
  });
2918
- const item = sortedItems(menu).find((entry) => entry.isAvailable);
2919
- const itemPage = itemPath(options, menu, item?.id);
3363
+ const available = sortedItems(menu).filter((entry) => entry.isAvailable);
3364
+ const itemPage = itemPath(options, menu, available[0]?.id);
2920
3365
  if (itemPage !== start) {
2921
3366
  await open(page, itemPage);
2922
3367
  await applyPageFault(page, fault);
@@ -2929,11 +3374,23 @@ var v13 = {
2929
3374
  );
2930
3375
  }
2931
3376
  assertion(
2932
- item,
3377
+ available.length > 0,
2933
3378
  "available_item_missing",
2934
3379
  "No available item can be opened for accessibility checks",
2935
3380
  "Keep one available item in the test menu."
2936
3381
  );
3382
+ const { shown, looked } = await findShownItem(
3383
+ page,
3384
+ available,
3385
+ () => applyPageFault(page, fault)
3386
+ );
3387
+ assertion(
3388
+ shown,
3389
+ "available_item_not_shown",
3390
+ `No available item shows on ${looked.join(" or ")}`,
3391
+ 'Show an available item on the first catalogue page, or on the page its rel="next" link opens.'
3392
+ );
3393
+ const item = shown.item;
2937
3394
  const { focusTarget: card } = await openItem(page, item.id);
2938
3395
  violations.push(...await severeViolations(page));
2939
3396
  semantics.push(
@@ -2995,9 +3452,11 @@ var v13 = {
2995
3452
  order.map((entry) => entry.id).join(", ") || "Order page has a serious axe violation",
2996
3453
  "Fix serious and critical axe findings on the order page."
2997
3454
  );
2998
- const simple = simpleItem(menu)?.id;
2999
- const simplePage = itemPath(options, menu, simple);
3000
- await open(page, simplePage);
3455
+ const simples = simpleItems(menu);
3456
+ await open(page, itemPath(options, menu, simples[0]?.id));
3457
+ const simpleShown = (await findShownItem(page, simples)).shown;
3458
+ const simple = simpleShown?.item.id ?? simples[0]?.id;
3459
+ const simplePage = simpleShown?.path ?? itemPath(options, menu, simple);
3001
3460
  assertion(
3002
3461
  await keyboardReachesAddAndCart(page, simple, simplePage, cartInColumn),
3003
3462
  "keyboard_marker_unreachable",
@@ -3204,12 +3663,21 @@ async function revealBagTrigger(page, start, item) {
3204
3663
  if (!item || await bag.count()) {
3205
3664
  return;
3206
3665
  }
3207
- const itemId = item.id;
3208
- const marked = page.locator(`[data-zappie-item="${itemId}"]`);
3666
+ const marked = page.locator(
3667
+ item.ids.map((id) => `[data-zappie-item="${id}"]`).join(", ")
3668
+ );
3209
3669
  if (item.path !== start && !await marked.count()) {
3210
3670
  await page.goto(item.path, { waitUntil: "domcontentloaded" });
3211
3671
  await marked.first().waitFor({ timeout: 5e3 }).catch(() => void 0);
3212
3672
  }
3673
+ const found = await findShownItem(
3674
+ page,
3675
+ item.ids.map((id) => ({ id }))
3676
+ ).catch(() => void 0);
3677
+ const itemId = found?.shown?.item.id;
3678
+ if (itemId === void 0) {
3679
+ return;
3680
+ }
3213
3681
  const before = await page.evaluate(() => window.__zappie?.cart().count ?? 0).catch(() => 0);
3214
3682
  const add = '[data-zappie-action="add-to-cart"]';
3215
3683
  const quick = page.locator(`[data-zappie-item="${itemId}"] ${add}`).filter({ visible: true }).first();
@@ -3241,10 +3709,10 @@ async function revealBagTrigger(page, start, item) {
3241
3709
  await bag.waitFor({ timeout: 5e3 }).catch(() => void 0);
3242
3710
  }
3243
3711
  function bagItem(options, menu) {
3244
- const id = simpleItem(menu)?.id;
3712
+ const ids = simpleItems(menu).map((entry) => entry.id);
3245
3713
  return {
3246
3714
  start: startPath(options),
3247
- item: id ? { id, path: itemPath(options, menu, id) } : void 0
3715
+ item: ids.length ? { ids, path: itemPath(options, menu, ids[0]) } : void 0
3248
3716
  };
3249
3717
  }
3250
3718
 
@@ -3549,7 +4017,7 @@ var v16 = {
3549
4017
  const opens = [];
3550
4018
  const opensRates = [];
3551
4019
  let opened = true;
3552
- for (let sample = 0; sample < OVERLAY_OPEN_SAMPLES && undecided(opens); sample++) {
4020
+ for (let sample2 = 0; sample2 < OVERLAY_OPEN_SAMPLES && undecided(opens); sample2++) {
3553
4021
  const open2 = await firstOpen(
3554
4022
  await freshPage(),
3555
4023
  viewport,
@@ -3980,7 +4448,7 @@ async function reset(options) {
3980
4448
  }
3981
4449
 
3982
4450
  // src/version.ts
3983
- var PACKAGE_VERSION = true ? "0.3.3" : "0.0.0-development";
4451
+ var PACKAGE_VERSION = true ? "0.3.5" : "0.0.0-development";
3984
4452
 
3985
4453
  // src/runner.ts
3986
4454
  var VERSION = PACKAGE_VERSION;
@@ -4006,10 +4474,14 @@ async function execute(check, run2) {
4006
4474
  const started = performance.now();
4007
4475
  const viewports = selectedViewports(check, run2.options);
4008
4476
  const warnings = /* @__PURE__ */ new Map();
4477
+ let catalogue;
4009
4478
  const checkRun = {
4010
4479
  ...run2,
4011
4480
  warn(code, message) {
4012
4481
  warnings.set(code, message);
4482
+ },
4483
+ recordCatalogue(details) {
4484
+ catalogue = details;
4013
4485
  }
4014
4486
  };
4015
4487
  let retried = false;
@@ -4034,7 +4506,8 @@ async function execute(check, run2) {
4034
4506
  code,
4035
4507
  message
4036
4508
  }))
4037
- } : {}
4509
+ } : {},
4510
+ ...catalogue ? { catalogue } : {}
4038
4511
  };
4039
4512
  } catch (failure) {
4040
4513
  const error = failure instanceof VerifyError ? { code: failure.code, message: failure.message, hint: failure.hint } : {
@@ -4069,7 +4542,8 @@ async function execute(check, run2) {
4069
4542
  viewport: viewports.map(label).join(","),
4070
4543
  ...retried ? { retried } : {},
4071
4544
  error,
4072
- artifacts: { screenshot, trace }
4545
+ artifacts: { screenshot, trace },
4546
+ ...catalogue ? { catalogue } : {}
4073
4547
  };
4074
4548
  }
4075
4549
  }
@@ -4241,6 +4715,7 @@ async function verifyProject(options) {
4241
4715
  ...startPath2 !== void 0 ? { startPath: startPath2 } : {},
4242
4716
  ...cataloguePath !== void 0 ? { cataloguePath } : {},
4243
4717
  ...tappableMs !== void 0 ? { tappableMs } : {},
4718
+ ...options.catalogueSeed !== void 0 ? { catalogueSeed: options.catalogueSeed } : {},
4244
4719
  ...warnings.length ? { warnings } : {},
4245
4720
  ...options.viewports ? { viewports: options.viewports } : {},
4246
4721
  ...options.artifactsDir ? { artifactsDir: options.artifactsDir } : {},