@zappie/verify 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +23 -4
- package/dist/{chunk-CW3HI5TY.js → chunk-5DRY6Y4G.js} +9 -2
- package/dist/chunk-5DRY6Y4G.js.map +1 -0
- package/dist/index.d.ts +35 -3
- package/dist/index.js +557 -145
- package/dist/index.js.map +1 -1
- package/dist/{quote-client-QBLEMQUQ.js → quote-client-FP3RIDBO.js} +2 -2
- package/package.json +2 -2
- package/schema/verify-report.v1.json +25 -0
- package/dist/chunk-CW3HI5TY.js.map +0 -1
- /package/dist/{quote-client-QBLEMQUQ.js.map → quote-client-FP3RIDBO.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
sumMoney,
|
|
13
13
|
validateConfig,
|
|
14
14
|
validateLine
|
|
15
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-5DRY6Y4G.js";
|
|
16
16
|
|
|
17
17
|
// src/browser.ts
|
|
18
18
|
var CHROMIUM_ENV = "ZAPPIE_VERIFY_CHROMIUM";
|
|
@@ -714,6 +714,7 @@ var v02 = {
|
|
|
714
714
|
|
|
715
715
|
// src/fixtures.ts
|
|
716
716
|
var preparedPages = /* @__PURE__ */ new WeakMap();
|
|
717
|
+
var HYDRATION_HOLD_MS = 3200;
|
|
717
718
|
async function installFault(page, fault) {
|
|
718
719
|
if (!fault) {
|
|
719
720
|
return;
|
|
@@ -838,8 +839,17 @@ async function installFault(page, fault) {
|
|
|
838
839
|
});
|
|
839
840
|
}
|
|
840
841
|
if (fault === "hydration-delay") {
|
|
842
|
+
let releaseAt = Date.now() + HYDRATION_HOLD_MS;
|
|
843
|
+
page.on("request", (request) => {
|
|
844
|
+
if (request.isNavigationRequest() && request.frame() === page.mainFrame()) {
|
|
845
|
+
releaseAt = Date.now() + HYDRATION_HOLD_MS;
|
|
846
|
+
}
|
|
847
|
+
});
|
|
841
848
|
await page.route(/\.(?:js|mjs)(?:\?|$)/, async (route) => {
|
|
842
|
-
|
|
849
|
+
const waitMs = releaseAt - Date.now();
|
|
850
|
+
if (waitMs > 0) {
|
|
851
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
852
|
+
}
|
|
843
853
|
await route.continue();
|
|
844
854
|
});
|
|
845
855
|
}
|
|
@@ -1618,6 +1628,12 @@ function catalogues(menu) {
|
|
|
1618
1628
|
};
|
|
1619
1629
|
}).filter((catalogue) => catalogue.items.length > 0);
|
|
1620
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
|
+
}
|
|
1621
1637
|
function fillStartPath(template, menu) {
|
|
1622
1638
|
const first = catalogues(menu)[0]?.id ?? menu.menus?.[0]?.id ?? menu.id;
|
|
1623
1639
|
return { path: cataloguePage(template, first, menu.branchId), menuId: first };
|
|
@@ -1672,6 +1688,42 @@ function itemPath(options, menu, itemId) {
|
|
|
1672
1688
|
return holder && options.cataloguePath ? cataloguePage(options.cataloguePath, holder.id, menu.branchId) : startPath(options);
|
|
1673
1689
|
}
|
|
1674
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
|
+
|
|
1675
1727
|
// ../sdk/src/pricing/index.ts
|
|
1676
1728
|
var cheapest = (prices) => prices.filter((price) => price.amountMinor >= 0).sort((a, b) => a.amountMinor - b.amountMinor)[0];
|
|
1677
1729
|
function startingPrice(item) {
|
|
@@ -1698,15 +1750,6 @@ function showsFromPrice(item) {
|
|
|
1698
1750
|
}
|
|
1699
1751
|
|
|
1700
1752
|
// src/checks/menu-match.ts
|
|
1701
|
-
function menuCoverage(items, shownIds) {
|
|
1702
|
-
const available = items.filter((item) => item.isAvailable);
|
|
1703
|
-
const shown = available.filter((item) => shownIds.has(item.id)).length;
|
|
1704
|
-
return {
|
|
1705
|
-
available: available.length,
|
|
1706
|
-
shown,
|
|
1707
|
-
passes: available.length === 0 || shown / available.length >= 0.95
|
|
1708
|
-
};
|
|
1709
|
-
}
|
|
1710
1753
|
function priceMatches(text, formatted) {
|
|
1711
1754
|
return text?.includes(formatted) ?? false;
|
|
1712
1755
|
}
|
|
@@ -1721,100 +1764,336 @@ function shownPrices(item) {
|
|
|
1721
1764
|
return prices;
|
|
1722
1765
|
}
|
|
1723
1766
|
|
|
1724
|
-
// src/checks/
|
|
1725
|
-
var
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
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
|
+
}
|
|
1743
1823
|
}
|
|
1744
1824
|
}
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
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
|
+
}
|
|
1759
1853
|
}
|
|
1760
1854
|
}
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
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);
|
|
1863
|
+
}
|
|
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;
|
|
1764
1916
|
}
|
|
1765
|
-
|
|
1766
|
-
|
|
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) {
|
|
1767
1927
|
continue;
|
|
1768
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
|
+
);
|
|
1769
2016
|
const prices = shownPrices(item);
|
|
1770
2017
|
assertion(
|
|
1771
|
-
prices.some((price) => priceMatches(text, normalizeText(price))),
|
|
2018
|
+
prices.some((price) => priceMatches(page.text, normalizeText(price))),
|
|
1772
2019
|
"price_mismatch",
|
|
1773
|
-
|
|
2020
|
+
`The page of ${item.name} at ${path4} does not show ${prices.join(" or From ")}`,
|
|
1774
2021
|
"Render prices with format.money from @zappie/sdk, and a From price with startingPrice from @zappie/sdk/pricing."
|
|
1775
2022
|
);
|
|
1776
2023
|
}
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
);
|
|
1790
|
-
assertion(
|
|
1791
|
-
await page.locator("[data-zappie-item]").count(),
|
|
1792
|
-
"item_marker_missing",
|
|
1793
|
-
`The ${menuName} has no item marker`,
|
|
1794
|
-
"Add data-zappie-item to each rendered menu item."
|
|
1795
|
-
);
|
|
1796
|
-
for (const item of items.filter((entry) => !entry.isAvailable)) {
|
|
1797
|
-
const add = page.locator(
|
|
1798
|
-
`[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."
|
|
1799
2036
|
);
|
|
1800
|
-
if (
|
|
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) {
|
|
1801
2042
|
assertion(
|
|
1802
|
-
|
|
2043
|
+
!card.addEnabled,
|
|
1803
2044
|
"unavailable_item_addable",
|
|
1804
2045
|
`Shoppers can add the unavailable item ${item.name}`,
|
|
1805
2046
|
"Disable add-to-cart for unavailable items."
|
|
1806
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;
|
|
2055
|
+
}
|
|
2056
|
+
if (named.length === 0) {
|
|
2057
|
+
continue;
|
|
1807
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);
|
|
1808
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(", ");
|
|
1809
2087
|
}
|
|
1810
2088
|
function nameMatches(text, name) {
|
|
1811
|
-
|
|
2089
|
+
const shown = normalizeText(text);
|
|
2090
|
+
return shown.includes(normalizeText(decodeBasicEntities(name))) || shown.includes(normalizeText(name));
|
|
1812
2091
|
}
|
|
1813
2092
|
function normalizeText(value) {
|
|
1814
|
-
return value.normalize("NFKC").replace(/[
|
|
2093
|
+
return value.normalize("NFKC").replace(/[-]/g, "").replace(/\s+/g, " ").trim().toLocaleLowerCase();
|
|
1815
2094
|
}
|
|
1816
2095
|
function decodeBasicEntities(value) {
|
|
1817
|
-
const
|
|
2096
|
+
const entities2 = {
|
|
1818
2097
|
amp: "&",
|
|
1819
2098
|
apos: "'",
|
|
1820
2099
|
gt: ">",
|
|
@@ -1823,78 +2102,204 @@ function decodeBasicEntities(value) {
|
|
|
1823
2102
|
};
|
|
1824
2103
|
return value.replace(
|
|
1825
2104
|
/&(amp|apos|gt|lt|quot);/gi,
|
|
1826
|
-
(entity) =>
|
|
2105
|
+
(entity) => entities2[entity.slice(1, -1).toLowerCase()] ?? entity
|
|
1827
2106
|
);
|
|
1828
2107
|
}
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
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"
|
|
1843
2155
|
});
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
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)) {
|
|
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) {
|
|
1865
2169
|
return true;
|
|
1866
2170
|
}
|
|
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
2171
|
}
|
|
1883
|
-
|
|
1884
|
-
|
|
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;
|
|
1885
2216
|
}
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
}
|
|
1893
|
-
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;
|
|
1894
2223
|
}
|
|
1895
|
-
}
|
|
2224
|
+
}
|
|
2225
|
+
cards.set(id, card);
|
|
2226
|
+
}
|
|
2227
|
+
const nextLink = document.querySelector(
|
|
2228
|
+
'a[rel~="next"][href], link[rel~="next"][href]'
|
|
1896
2229
|
);
|
|
1897
|
-
|
|
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"
|
|
2235
|
+
);
|
|
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
|
+
}
|
|
1898
2303
|
}
|
|
1899
2304
|
|
|
1900
2305
|
// src/checks/v04-add-simple.ts
|
|
@@ -3539,7 +3944,7 @@ var v16 = {
|
|
|
3539
3944
|
const opens = [];
|
|
3540
3945
|
const opensRates = [];
|
|
3541
3946
|
let opened = true;
|
|
3542
|
-
for (let
|
|
3947
|
+
for (let sample2 = 0; sample2 < OVERLAY_OPEN_SAMPLES && undecided(opens); sample2++) {
|
|
3543
3948
|
const open2 = await firstOpen(
|
|
3544
3949
|
await freshPage(),
|
|
3545
3950
|
viewport,
|
|
@@ -3970,7 +4375,7 @@ async function reset(options) {
|
|
|
3970
4375
|
}
|
|
3971
4376
|
|
|
3972
4377
|
// src/version.ts
|
|
3973
|
-
var PACKAGE_VERSION = true ? "0.3.
|
|
4378
|
+
var PACKAGE_VERSION = true ? "0.3.4" : "0.0.0-development";
|
|
3974
4379
|
|
|
3975
4380
|
// src/runner.ts
|
|
3976
4381
|
var VERSION = PACKAGE_VERSION;
|
|
@@ -3996,10 +4401,14 @@ async function execute(check, run2) {
|
|
|
3996
4401
|
const started = performance.now();
|
|
3997
4402
|
const viewports = selectedViewports(check, run2.options);
|
|
3998
4403
|
const warnings = /* @__PURE__ */ new Map();
|
|
4404
|
+
let catalogue;
|
|
3999
4405
|
const checkRun = {
|
|
4000
4406
|
...run2,
|
|
4001
4407
|
warn(code, message) {
|
|
4002
4408
|
warnings.set(code, message);
|
|
4409
|
+
},
|
|
4410
|
+
recordCatalogue(details) {
|
|
4411
|
+
catalogue = details;
|
|
4003
4412
|
}
|
|
4004
4413
|
};
|
|
4005
4414
|
let retried = false;
|
|
@@ -4024,7 +4433,8 @@ async function execute(check, run2) {
|
|
|
4024
4433
|
code,
|
|
4025
4434
|
message
|
|
4026
4435
|
}))
|
|
4027
|
-
} : {}
|
|
4436
|
+
} : {},
|
|
4437
|
+
...catalogue ? { catalogue } : {}
|
|
4028
4438
|
};
|
|
4029
4439
|
} catch (failure) {
|
|
4030
4440
|
const error = failure instanceof VerifyError ? { code: failure.code, message: failure.message, hint: failure.hint } : {
|
|
@@ -4059,7 +4469,8 @@ async function execute(check, run2) {
|
|
|
4059
4469
|
viewport: viewports.map(label).join(","),
|
|
4060
4470
|
...retried ? { retried } : {},
|
|
4061
4471
|
error,
|
|
4062
|
-
artifacts: { screenshot, trace }
|
|
4472
|
+
artifacts: { screenshot, trace },
|
|
4473
|
+
...catalogue ? { catalogue } : {}
|
|
4063
4474
|
};
|
|
4064
4475
|
}
|
|
4065
4476
|
}
|
|
@@ -4231,6 +4642,7 @@ async function verifyProject(options) {
|
|
|
4231
4642
|
...startPath2 !== void 0 ? { startPath: startPath2 } : {},
|
|
4232
4643
|
...cataloguePath !== void 0 ? { cataloguePath } : {},
|
|
4233
4644
|
...tappableMs !== void 0 ? { tappableMs } : {},
|
|
4645
|
+
...options.catalogueSeed !== void 0 ? { catalogueSeed: options.catalogueSeed } : {},
|
|
4234
4646
|
...warnings.length ? { warnings } : {},
|
|
4235
4647
|
...options.viewports ? { viewports: options.viewports } : {},
|
|
4236
4648
|
...options.artifactsDir ? { artifactsDir: options.artifactsDir } : {},
|