@luigisbox/luma 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +90 -0
- package/README.md +615 -2
- package/a11y/audit.mjs +735 -0
- package/a11y/axe.mjs +73 -0
- package/a11y/report.mjs +347 -0
- package/a11y/run.mjs +256 -0
- package/a11y/wcag.mjs +233 -0
- package/bin/config.mjs +60 -0
- package/bin/luma.mjs +99 -0
- package/bin/paths.mjs +11 -0
- package/bin/tools.mjs +43 -0
- package/build/baseline.d.mts +8 -0
- package/build/baseline.mjs +167 -0
- package/build/tooling.mjs +102 -0
- package/build/vite.mjs +507 -0
- package/chrome/block.mjs +8 -0
- package/chrome/run.mjs +150 -0
- package/docs/ACCESSIBILITY.md +501 -0
- package/docs/INTEGRATION-PERFORMANCE.md +213 -0
- package/docs/PARITY.md +946 -0
- package/footprint/model.mjs +184 -0
- package/footprint/run.mjs +331 -0
- package/package.json +58 -4
- package/parity/alignment.mjs +109 -0
- package/parity/archive.mjs +223 -0
- package/parity/args.mjs +137 -0
- package/parity/behaviour.mjs +379 -0
- package/parity/compare.mjs +985 -0
- package/parity/engine.mjs +848 -0
- package/parity/hints.mjs +132 -0
- package/parity/hold.mjs +54 -0
- package/parity/init.mjs +703 -0
- package/parity/map.mjs +640 -0
- package/parity/owner.mjs +170 -0
- package/parity/pairing.mjs +110 -0
- package/parity/pixels.mjs +33 -0
- package/parity/probe.mjs +1239 -0
- package/parity/report.mjs +236 -0
- package/parity/run.mjs +267 -0
- package/snippet/early-hold.js +64 -0
- package/snippet/early-hold.min.js +1 -0
- package/src/analytics/chain.ts +137 -0
- package/src/analytics/collect.ts +64 -0
- package/src/analytics/datalayer.ts +67 -0
- package/src/analytics/from-ga4.ts +68 -0
- package/src/analytics/from-luma.ts +21 -0
- package/src/analytics/index.ts +24 -0
- package/src/analytics/outbox.ts +118 -0
- package/src/analytics/page.ts +46 -0
- package/src/analytics/pageview.ts +82 -0
- package/src/analytics/ref.ts +19 -0
- package/src/analytics/setup.ts +41 -0
- package/src/analytics/start.ts +53 -0
- package/src/analytics/triggers.ts +239 -0
- package/src/analytics/user.ts +12 -0
- package/src/analytics/wire.ts +67 -0
- package/src/api/ai.ts +98 -0
- package/src/api/index.ts +87 -0
- package/src/api/token-key.ts +42 -0
- package/src/api/token.ts +312 -0
- package/src/core/a11y-keys.ts +60 -0
- package/src/core/a11y.ts +160 -0
- package/src/core/boot-state.ts +6 -0
- package/src/core/color.ts +42 -0
- package/src/core/context.ts +108 -0
- package/src/core/early-rules.ts +96 -0
- package/src/core/entries.ts +25 -0
- package/src/core/fold.ts +7 -0
- package/src/core/health.ts +26 -0
- package/src/core/hold.ts +74 -0
- package/src/core/i18n.ts +66 -0
- package/src/core/image-load.ts +102 -0
- package/src/core/image-tags.ts +26 -0
- package/src/core/images.ts +170 -0
- package/src/core/integration.ts +126 -0
- package/src/core/kept.ts +25 -0
- package/src/core/listings.ts +100 -0
- package/src/core/locales.ts +14 -0
- package/src/core/own.ts +49 -0
- package/src/core/page.ts +14 -0
- package/src/core/prefetch.ts +88 -0
- package/src/core/recommenders.ts +166 -0
- package/src/core/reconciler.ts +397 -0
- package/src/core/result.ts +73 -0
- package/src/core/routes.ts +115 -0
- package/src/core/scheduler.ts +83 -0
- package/src/core/sense.ts +107 -0
- package/src/core/speculation.ts +59 -0
- package/src/core/stash.ts +38 -0
- package/src/core/storage.ts +45 -0
- package/src/core/styles.ts +106 -0
- package/src/core/support.ts +12 -0
- package/src/core/takeover.ts +220 -0
- package/src/core/templates.ts +74 -0
- package/src/core/tiles.ts +137 -0
- package/src/core/turbo.ts +64 -0
- package/src/core/url.ts +164 -0
- package/src/core/widget-inits.ts +40 -0
- package/src/env.d.ts +3 -0
- package/src/lit.ts +11 -0
- package/src/locales/cs.json +94 -0
- package/src/locales/de.json +88 -0
- package/src/locales/en.json +88 -0
- package/src/main.ts +493 -0
- package/src/types.ts +778 -0
- package/src/widgets/autocomplete/index.ts +1387 -0
- package/src/widgets/autocomplete/style-autocomplete.css +266 -0
- package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
- package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
- package/src/widgets/listing/element.ts +1772 -0
- package/src/widgets/listing/flow.ts +30 -0
- package/src/widgets/listing/instant.ts +133 -0
- package/src/widgets/listing/intent.ts +193 -0
- package/src/widgets/listing/query.ts +298 -0
- package/src/widgets/listing/stash.ts +97 -0
- package/src/widgets/listing/state.ts +131 -0
- package/src/widgets/listing/view.ts +257 -0
- package/src/widgets/plp/index.ts +11 -0
- package/src/widgets/plp/route.ts +263 -0
- package/src/widgets/recommender/index.ts +261 -0
- package/src/widgets/recommender/style-recommender.css +26 -0
- package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
- package/src/widgets/recommender/template-recommender-popup.ts +12 -0
- package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
- package/src/widgets/search/assets.ts +7 -0
- package/src/widgets/search/first.ts +15 -0
- package/src/widgets/search/form-url.ts +59 -0
- package/src/widgets/search/in-place.ts +226 -0
- package/src/widgets/search/index.ts +16 -0
- package/src/widgets/search/range.ts +260 -0
- package/src/widgets/search/route.ts +32 -0
- package/src/widgets/search/speculate.ts +202 -0
- package/src/widgets/search/style-search.css +65 -0
- package/src/widgets/search/template-search-box.ts +17 -0
- package/src/widgets/search/template-search-facets-default.ts +73 -0
- package/src/widgets/search/template-search-header.ts +14 -0
- package/src/widgets/search/template-search-pagination.ts +12 -0
- package/src/widgets/search/template-search-range-default.ts +48 -0
- package/src/widgets/search/template-search-result-default.ts +13 -0
- package/src/widgets/search/template-search-results-default.ts +8 -0
- package/tasks/model.mjs +116 -0
- package/tasks/run.mjs +305 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// Behaviour parity (docs/PARITY.md, "Behaviour"): an interactive element can
|
|
2
|
+
// look identical to theirs and do nothing (e.g. a link that sends another
|
|
3
|
+
// product's id, such as `/wishlist/add?id=<id>`), yet a visual comparison
|
|
4
|
+
// alone scores it as matching. So the map declares interactions per part (a
|
|
5
|
+
// click, a tap, a check, a select), the engine takes each on both sides for
|
|
6
|
+
// the same paired product and records its outcome (the navigation, the
|
|
7
|
+
// requests, the watched state), and this module compares the outcomes:
|
|
8
|
+
// - an outcome that differs is MAJOR (another URL, another request, another
|
|
9
|
+
// product's id in it, a counter that moves otherwise);
|
|
10
|
+
// - an element that has an effect on theirs and none on ours, or that
|
|
11
|
+
// errors on ours, is CRITICAL;
|
|
12
|
+
// - a control of ours with no counterpart that does nothing is CRITICAL.
|
|
13
|
+
// The safety rules (real actions on a live site) are here too: what is never
|
|
14
|
+
// let through, and what is only recorded. And the hold check's result (a
|
|
15
|
+
// reload of our page painting their listing). Pure,
|
|
16
|
+
// unit-tested.
|
|
17
|
+
import { decide } from './owner.mjs';
|
|
18
|
+
import { WEIGHT, aggregate, scoreOf } from './compare.mjs';
|
|
19
|
+
|
|
20
|
+
/** Never let through, whatever the map says: checkout, payment, login and account pages (their path), payment providers (their host). */
|
|
21
|
+
export const NEVER = Object.freeze({
|
|
22
|
+
path: /(^|\/)(checkout|check-out|payment|payments|pay|login|log-in|logout|signin|sign-in|signup|sign-up|register|account|my-account|password|order|orders|kasse|pokladna|pokladňa|zahlung|betalen|afrekenen|inloggen|anmelden|prihlasenie|prihlaseni)(\/|$|[.?#-])/i,
|
|
23
|
+
host: /(^|\.)(stripe\.com|paypal\.com|adyen\.com|mollie\.com|klarna\.com|braintreegateway\.com|checkout\.com|gopay\.(cz|com)|payu\.(com|cz|pl)|comgate\.cz|thepay\.cz|besteron\.com)$/i,
|
|
24
|
+
});
|
|
25
|
+
/** Not sent unless the map says `"safe": true`: a POST (or any method but GET) to a cart, a compare list or a wishlist. */
|
|
26
|
+
export const UNSAFE = /(cart|basket|bag|compare|comparison|wishlist|wish-list|favou?rites?|winkelwagen|vergelijk|kosik|košík|kos[ií]k|merkliste|warenkorb|panier|carrello)/i;
|
|
27
|
+
/** Requests that are an action's own (not the assets a page loads). */
|
|
28
|
+
const ACTION_TYPES = new Set(['document', 'xhr', 'fetch', 'ping', 'eventsource', 'other']);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What happens to a request an action makes: `never` (aborted: checkout,
|
|
32
|
+
* payment, login), `intercept` (recorded, then aborted: compared, never
|
|
33
|
+
* sent) or `through` (sent). `safe`: the interaction's ("safe": true lets
|
|
34
|
+
* it through, false intercepts every request of the action; unset, a
|
|
35
|
+
* non-GET to a cart, a compare list or a wishlist is intercepted).
|
|
36
|
+
* @param {{ method: string, url: string, type?: string }} req @param {boolean | null} safe
|
|
37
|
+
* @returns {'never' | 'intercept' | 'through' | 'asset'}
|
|
38
|
+
*/
|
|
39
|
+
export const fateOf = (req, safe) => {
|
|
40
|
+
let u;
|
|
41
|
+
try {
|
|
42
|
+
u = new URL(req.url);
|
|
43
|
+
} catch {
|
|
44
|
+
return 'never';
|
|
45
|
+
}
|
|
46
|
+
if (NEVER.host.test(u.hostname) || NEVER.path.test(u.pathname)) return 'never';
|
|
47
|
+
if (req.type && !ACTION_TYPES.has(req.type)) return 'asset';
|
|
48
|
+
if (safe === true) return 'through';
|
|
49
|
+
if (safe === false) return 'intercept';
|
|
50
|
+
return req.method.toUpperCase() !== 'GET' && UNSAFE.test(`${u.pathname}${u.search}`) ? 'intercept' : 'through';
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The product's ids an action may carry (their tile's id, the catalog
|
|
55
|
+
* record's values, each one's last segment: `product/42` is `42` too), the
|
|
56
|
+
* values a URL or a body is read for.
|
|
57
|
+
* @param {(string | number | null | undefined)[]} values @param {any} [hit] the catalog record
|
|
58
|
+
*/
|
|
59
|
+
export const knownIds = (values, hit = null) => {
|
|
60
|
+
const out = new Set();
|
|
61
|
+
const add = (v) => {
|
|
62
|
+
if (v == null) return;
|
|
63
|
+
const s = String(v).trim();
|
|
64
|
+
if (!s || s.length > 64 || !/\d/.test(s)) return;
|
|
65
|
+
out.add(s);
|
|
66
|
+
const tail = s.split(/[/:#]/).pop();
|
|
67
|
+
if (tail && tail !== s && /\d/.test(tail)) out.add(tail);
|
|
68
|
+
};
|
|
69
|
+
for (const v of values) add(v);
|
|
70
|
+
for (const [k, v] of Object.entries(hit ?? {})) {
|
|
71
|
+
if (/url|image|price|title|desc/i.test(k)) continue;
|
|
72
|
+
for (const x of Array.isArray(v) ? v.slice(0, 10) : [v]) if (typeof x === 'string' || typeof x === 'number') add(x);
|
|
73
|
+
}
|
|
74
|
+
return [...out].sort();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A URL as a pattern (path segments and query values that are one of the
|
|
79
|
+
* product's ids `{id}`, other numbers `{n}`; the query by its names), and the
|
|
80
|
+
* ids it carries, in order: `/wishlist/add?id=42` -> `/wishlist/add?id={id}`.
|
|
81
|
+
* @param {string} url @param {string[]} ids
|
|
82
|
+
*/
|
|
83
|
+
export const patternOf = (url, ids = []) => {
|
|
84
|
+
let u;
|
|
85
|
+
try {
|
|
86
|
+
u = new URL(url);
|
|
87
|
+
} catch {
|
|
88
|
+
return { pattern: String(url), ids: [] };
|
|
89
|
+
}
|
|
90
|
+
const found = [];
|
|
91
|
+
const one = (v) => {
|
|
92
|
+
let d = v;
|
|
93
|
+
try {
|
|
94
|
+
d = decodeURIComponent(v);
|
|
95
|
+
} catch {}
|
|
96
|
+
if (ids.includes(d)) {
|
|
97
|
+
found.push(d);
|
|
98
|
+
return '{id}';
|
|
99
|
+
}
|
|
100
|
+
return /^\d+$/.test(d) || /^[0-9a-f]{16,}$/i.test(d) || /^[0-9a-f-]{32,36}$/i.test(d) ? '{n}' : v;
|
|
101
|
+
};
|
|
102
|
+
const path = u.pathname.split('/').map((s) => (s ? one(s) : s)).join('/');
|
|
103
|
+
const q = [...u.searchParams].map(([k, v]) => [k, one(v)]).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
104
|
+
const query = q.length ? `?${q.map(([k, v]) => `${k}=${v === '{id}' || v === '{n}' ? v : '…'}`).join('&')}` : '';
|
|
105
|
+
return { pattern: `${path}${query}`, ids: found };
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** The product's ids a request body carries (a JSON value, a form value), in order of appearance. @param {string | null | undefined} body @param {string[]} ids */
|
|
109
|
+
export const bodyIds = (body, ids = []) => {
|
|
110
|
+
if (!body) return [];
|
|
111
|
+
const s = String(body);
|
|
112
|
+
const hits = [];
|
|
113
|
+
for (const id of ids) {
|
|
114
|
+
const re = new RegExp(`(^|[^\\w-])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^\\w-])`);
|
|
115
|
+
const m = re.exec(s);
|
|
116
|
+
if (m) hits.push({ id, at: m.index });
|
|
117
|
+
}
|
|
118
|
+
// The longest id at a place (product/42 over 42): the one the body names.
|
|
119
|
+
hits.sort((a, b) => a.at - b.at || b.id.length - a.id.length);
|
|
120
|
+
const out = [];
|
|
121
|
+
let last = -1;
|
|
122
|
+
for (const h of hits) if (h.at > last) (out.push(h.id), (last = h.at + h.id.length));
|
|
123
|
+
return out;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** Whether a request is the shop's own (the page's host, its subdomains, its parent domain's), never Luigi's Box's. @param {string} page @param {string} url */
|
|
127
|
+
export const ownSite = (page, url) => {
|
|
128
|
+
try {
|
|
129
|
+
const [p, u] = [new URL(page).hostname, new URL(url).hostname];
|
|
130
|
+
if (/(^|\.)luigisbox\.(ai|com|tech)$/i.test(u)) return false;
|
|
131
|
+
const base = p.split('.').slice(-2).join('.');
|
|
132
|
+
return u === p || u === base || u.endsWith(`.${base}`);
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A navigation that stays on the page's own path (a filter, a sort, the
|
|
140
|
+
* next page: a full load on an MPA, pushState on ours): the page and the
|
|
141
|
+
* params it changed, by name (`(this page) +brand -page`), so our search
|
|
142
|
+
* page and their category listing compare alike (`names: false`: our search
|
|
143
|
+
* page against their category listing, whose params are named otherwise:
|
|
144
|
+
* `(this page) +param`). Null when it went elsewhere.
|
|
145
|
+
* @param {string} start @param {string} to @param {string[]} ids @param {{ names?: boolean }} [o]
|
|
146
|
+
*/
|
|
147
|
+
export const inPlace = (start, to, ids = [], { names = true } = {}) => {
|
|
148
|
+
try {
|
|
149
|
+
const [a, b] = [new URL(start), new URL(to)];
|
|
150
|
+
if (a.origin !== b.origin || a.pathname.replace(/\/+$/, '') !== b.pathname.replace(/\/+$/, '')) return null;
|
|
151
|
+
const changes = [];
|
|
152
|
+
const found = [];
|
|
153
|
+
for (const k of new Set([...a.searchParams.keys(), ...b.searchParams.keys()])) {
|
|
154
|
+
const [x, y] = [a.searchParams.getAll(k).join(), b.searchParams.getAll(k).join()];
|
|
155
|
+
if (x === y) continue;
|
|
156
|
+
changes.push(`${!a.searchParams.has(k) ? '+' : !b.searchParams.has(k) ? '-' : '~'}${names ? k : 'param'}`);
|
|
157
|
+
for (const v of b.searchParams.getAll(k)) if (ids.includes(v)) found.push(v);
|
|
158
|
+
}
|
|
159
|
+
return { pattern: `(this page)${changes.length ? ` ${changes.sort().join(' ')}` : ''}`, ids: found };
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* What an action did, from what the engine recorded on one side:
|
|
167
|
+
* `start` and `final` URLs, `navigations` (the main frame's), `requests` (the
|
|
168
|
+
* action's own: { method, url, type, body, status, failed, fate }), the
|
|
169
|
+
* watches before and after, the DOM changes after it (no navigation), the
|
|
170
|
+
* target's own state, an `error` (the action could not be taken, the page
|
|
171
|
+
* threw). The patterns follow the product's `ids`; `cross`: ours against
|
|
172
|
+
* another page of theirs (the params of a navigation in place by their kind).
|
|
173
|
+
* @param {any} raw @param {string[]} ids @param {{ cross?: boolean }} [o]
|
|
174
|
+
*/
|
|
175
|
+
export const outcomeOf = (raw, ids = [], { cross = false } = {}) => {
|
|
176
|
+
if (!raw) return null;
|
|
177
|
+
const same = (a, b) => {
|
|
178
|
+
try {
|
|
179
|
+
const [x, y] = [new URL(a), new URL(b)];
|
|
180
|
+
return x.origin === y.origin && x.pathname.replace(/\/+$/, '') === y.pathname.replace(/\/+$/, '') && x.search === y.search;
|
|
181
|
+
} catch {
|
|
182
|
+
return a === b;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
const docs = (raw.requests ?? []).filter((r) => r.type === 'document');
|
|
186
|
+
const blocked = docs.find((r) => r.fate === 'never' || r.fate === 'intercept');
|
|
187
|
+
// A document loaded (a reload, another page), or the URL changed in place (pushState): a navigation.
|
|
188
|
+
const moved = !same(raw.start, raw.final);
|
|
189
|
+
const kind = blocked ? (blocked.fate === 'never' ? 'blocked' : 'intercepted') : docs.length ? (moved ? 'page' : 'reload') : moved && (raw.navigations ?? 0) > 0 ? 'page' : 'none';
|
|
190
|
+
const target = blocked ? blocked.url : raw.final;
|
|
191
|
+
const at = kind === 'page' || kind === 'blocked' || kind === 'intercepted' ? inPlace(raw.start, target, ids, { names: !cross }) ?? patternOf(target, ids) : { pattern: null, ids: [] };
|
|
192
|
+
// The document it asked for first, when it ended elsewhere (a redirect back: /wishlist/add?id=42 -> the page again).
|
|
193
|
+
const first = docs[0] && !blocked && !same(docs[0].url, raw.final) ? patternOf(docs[0].url, ids) : null;
|
|
194
|
+
// `document`: whether the page was loaded again (reported; an MPA's full load and an in-place update alike are compared by their URL).
|
|
195
|
+
const nav = { kind, pattern: at.pattern, via: first?.pattern ?? null, ids: [...(first?.ids ?? []), ...at.ids], document: docs.length > 0 };
|
|
196
|
+
// The shop's own requests (its host and subdomains); Luma's API and analytics are our machinery, a tracker's beacon no outcome.
|
|
197
|
+
const requests = (raw.requests ?? [])
|
|
198
|
+
.filter((r) => r.fate !== 'asset' && !(r.type === 'document' && r.fate === 'through') && ownSite(raw.start, r.url))
|
|
199
|
+
.map((r) => {
|
|
200
|
+
const p = patternOf(r.url, ids);
|
|
201
|
+
return { method: r.method, pattern: p.pattern, ids: [...p.ids, ...bodyIds(r.body, ids)], fate: r.fate, status: r.status ?? null, failed: r.failed ?? null };
|
|
202
|
+
})
|
|
203
|
+
.sort((a, b) => (`${a.method} ${a.pattern}` < `${b.method} ${b.pattern}` ? -1 : 1));
|
|
204
|
+
const watches = {};
|
|
205
|
+
// A navigation not let through: the page stays as it was (what is shown is the browser's error page).
|
|
206
|
+
for (const [name, before] of Object.entries(raw.before?.watches ?? {})) {
|
|
207
|
+
const after = blocked ? before : (raw.after?.watches?.[name] ?? null);
|
|
208
|
+
watches[name] = { before, after, changed: JSON.stringify(before) !== JSON.stringify(after) };
|
|
209
|
+
}
|
|
210
|
+
// A request of the action that failed (not ours to abort): the element errs.
|
|
211
|
+
const failed = (raw.requests ?? []).find((r) => r.fate === 'through' && r.type !== 'document' && ownSite(raw.start, r.url) && (r.failed || (r.status != null && r.status >= 400)) && !/INTERNET_DISCONNECTED/.test(r.failed ?? ''));
|
|
212
|
+
// What the page throws once we aborted a request (a rejected fetch, the error page of a navigation not let through) is ours, not the element's.
|
|
213
|
+
const aborted = (raw.requests ?? []).some((r) => r.fate === 'intercept' || r.fate === 'never');
|
|
214
|
+
const error = raw.error ?? (!aborted && raw.pageErrors?.length ? `the page threw: ${raw.pageErrors[0]}` : failed ? `${failed.method} ${patternOf(failed.url, ids).pattern} ${failed.failed ? `failed (${failed.failed})` : `answered ${failed.status}`}` : null);
|
|
215
|
+
return {
|
|
216
|
+
error,
|
|
217
|
+
navigation: nav,
|
|
218
|
+
requests,
|
|
219
|
+
watches,
|
|
220
|
+
mutations: kind === 'none' ? (raw.after?.mutations ?? 0) - (raw.before?.mutations ?? 0) : null,
|
|
221
|
+
checked: { before: raw.before?.target?.checked ?? null, after: raw.after?.target?.checked ?? null },
|
|
222
|
+
missed: (raw.requests ?? []).filter((r) => /INTERNET_DISCONNECTED/.test(r.failed ?? '')).length,
|
|
223
|
+
};
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Whether an action did something a shopper sees or the shop keeps: it
|
|
228
|
+
* went to another page, a watched state changed, a request it made was
|
|
229
|
+
* recorded instead of sent (`intercept`: what it would have done), or,
|
|
230
|
+
* without a navigation, it changed the page (DOM changes). A reload that
|
|
231
|
+
* changes nothing watched is no effect (e.g. a link that sends another product's id).
|
|
232
|
+
* @param {ReturnType<typeof outcomeOf>} o
|
|
233
|
+
*/
|
|
234
|
+
export const effectOf = (o) => {
|
|
235
|
+
if (!o || o.error) return false;
|
|
236
|
+
if (o.navigation.kind === 'page' || o.navigation.kind === 'intercepted' || o.navigation.kind === 'blocked') return true;
|
|
237
|
+
if (o.requests.some((r) => r.fate === 'intercept' || r.fate === 'never')) return true;
|
|
238
|
+
if (Object.values(o.watches).some((w) => w.changed)) return true;
|
|
239
|
+
return o.navigation.kind === 'none' && (o.mutations ?? 0) > 0;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/** An outcome in a few words, for the report. @param {ReturnType<typeof outcomeOf>} o */
|
|
243
|
+
export const describe = (o) => {
|
|
244
|
+
if (!o) return 'not taken';
|
|
245
|
+
if (o.error) return `error: ${o.error}`;
|
|
246
|
+
const out = [];
|
|
247
|
+
const n = o.navigation;
|
|
248
|
+
const via = n.via ? ` (via ${n.via}${n.ids.length ? ` [${n.ids.join(', ')}]` : ''})` : '';
|
|
249
|
+
out.push(n.kind === 'none' ? 'no navigation' : n.kind === 'reload' ? `reloaded the page${via}` : n.kind === 'page' ? `went to ${n.pattern}${via}` : `${n.kind === 'blocked' ? 'blocked' : 'intercepted'} navigation to ${n.pattern}`);
|
|
250
|
+
for (const r of o.requests) out.push(`${r.method} ${r.pattern}${r.ids.length ? ` [${r.ids.join(', ')}]` : ''}${r.fate === 'intercept' ? ' (recorded, not sent)' : r.fate === 'never' ? ' (never sent)' : ''}`);
|
|
251
|
+
for (const [name, w] of Object.entries(o.watches)) out.push(`${name} ${w.changed ? `${show(w.before)} -> ${show(w.after)}` : `unchanged (${show(w.before)})`}`);
|
|
252
|
+
if (n.kind === 'none' && !Object.keys(o.watches).length) out.push(`${o.mutations ?? 0} DOM changes`);
|
|
253
|
+
return out.join('; ');
|
|
254
|
+
};
|
|
255
|
+
const show = (w) => (w == null ? 'none' : w.text != null && w.text !== '' ? JSON.stringify(w.text) : w.checked != null ? (w.checked ? 'checked' : 'unchecked') : w.value != null ? JSON.stringify(w.value) : `${w.count} found`);
|
|
256
|
+
const num = (w) => {
|
|
257
|
+
const m = /-?\d+(?:[.,]\d+)?/.exec(w?.text ?? '');
|
|
258
|
+
return m ? Number(m[0].replace(',', '.')) : null;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* One interaction, theirs against ours (outcomeOf on each side): its
|
|
263
|
+
* differences (part: `<component>.<part>`).
|
|
264
|
+
* @param {ReturnType<typeof outcomeOf>} a theirs @param {ReturnType<typeof outcomeOf>} b ours @param {{ part: string }} at
|
|
265
|
+
*/
|
|
266
|
+
export const compareOutcome = (a, b, { part }) => {
|
|
267
|
+
const out = { checks: 0, weight: 0, diffs: /** @type {any[]} */ ([]) };
|
|
268
|
+
const add = (prop, theirs, ours, delta, severity, extra = {}) => {
|
|
269
|
+
const weight = severity === 'critical' ? WEIGHT.critical : WEIGHT.check;
|
|
270
|
+
out.diffs.push({ part, prop, theirs, ours, delta, severity, group: 'behaviour', weight, ...extra });
|
|
271
|
+
out.weight += weight;
|
|
272
|
+
};
|
|
273
|
+
out.checks += WEIGHT.critical;
|
|
274
|
+
if (!a || !b) return out;
|
|
275
|
+
// Ours errs (the element missing, the click never lands, the page throws, its request fails): CRITICAL.
|
|
276
|
+
// Theirs errs too (their script throws in this browser, their element gone): nothing to compare it with.
|
|
277
|
+
if (a.error) return out;
|
|
278
|
+
if (b.error) {
|
|
279
|
+
add('error', describe(a), describe(b), `errors on ours: ${b.error}`, 'critical');
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
const [ea, eb] = [effectOf(a), effectOf(b)];
|
|
283
|
+
if (ea && !eb) add('effect', describe(a), describe(b), 'does something on theirs, nothing on ours', 'critical');
|
|
284
|
+
out.checks += WEIGHT.check;
|
|
285
|
+
const nav = (n) => `${n.kind}${n.pattern ? ` ${n.pattern}` : ''}${n.via ? ` via ${n.via}` : ''}`;
|
|
286
|
+
if (nav(a.navigation) !== nav(b.navigation)) add('navigation', nav(a.navigation), nav(b.navigation), 'another navigation', 'major');
|
|
287
|
+
else if (a.navigation.ids.join() !== b.navigation.ids.join()) add('navigation', `${nav(a.navigation)} [${a.navigation.ids.join(', ')}]`, `${nav(b.navigation)} [${b.navigation.ids.join(', ')}]`, "another of the product's ids in the URL", 'major');
|
|
288
|
+
out.checks += WEIGHT.check;
|
|
289
|
+
const sig = (r) => `${r.method} ${r.pattern}`;
|
|
290
|
+
const [sa, sb] = [a.requests.map(sig), b.requests.map(sig)];
|
|
291
|
+
const only = (x, y) => x.filter((s, i) => x.indexOf(s) === i && x.filter((t) => t === s).length > y.filter((t) => t === s).length);
|
|
292
|
+
const [lostR, extraR] = [only(sa, sb), only(sb, sa)];
|
|
293
|
+
if (lostR.length || extraR.length) add('request', sa.join(' | ') || 'none', sb.join(' | ') || 'none', [lostR.length ? `missing on ours: ${lostR.join(', ')}` : '', extraR.length ? `only on ours: ${extraR.join(', ')}` : ''].filter(Boolean).join('; '), 'major');
|
|
294
|
+
else
|
|
295
|
+
for (const [i, r] of a.requests.entries()) {
|
|
296
|
+
const o = b.requests[i];
|
|
297
|
+
if (o && r.ids.join() !== o.ids.join()) add('request', `${sig(r)} [${r.ids.join(', ') || 'no id'}]`, `${sig(o)} [${o.ids.join(', ') || 'no id'}]`, `another of the product's ids in ${sig(r)}`, 'major');
|
|
298
|
+
}
|
|
299
|
+
for (const [name, w] of Object.entries(a.watches)) {
|
|
300
|
+
const v = b.watches[name];
|
|
301
|
+
if (!v) continue;
|
|
302
|
+
out.checks += WEIGHT.check;
|
|
303
|
+
const moved = (x) => (num(x.before) != null && num(x.after) != null ? num(x.after) - num(x.before) : null);
|
|
304
|
+
const differs = w.changed !== v.changed || (moved(w) != null && moved(v) != null && moved(w) !== moved(v)) || (w.after?.checked ?? null) !== (v.after?.checked ?? null);
|
|
305
|
+
if (differs) add(`watch-${String(name).replace(/[^a-z0-9]+/gi, '-').replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()}`, `${name}: ${w.changed ? `${show(w.before)} -> ${show(w.after)}` : `unchanged (${show(w.before)})`}`, `${name}: ${v.changed ? `${show(v.before)} -> ${show(v.after)}` : `unchanged (${show(v.before)})`}`, `${name} ${v.changed ? 'changes otherwise' : 'does not change'} on ours`, 'major');
|
|
306
|
+
}
|
|
307
|
+
if (a.checked.after != null && b.checked.after != null) {
|
|
308
|
+
out.checks += WEIGHT.check;
|
|
309
|
+
if (a.checked.after !== b.checked.after) add('checked', a.checked.after ? 'checked' : 'unchecked', b.checked.after ? 'checked' : 'unchecked', 'another checked state after it', 'major');
|
|
310
|
+
}
|
|
311
|
+
return out;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Our controls without a counterpart, each probed (its requests recorded,
|
|
316
|
+
* never sent): one that does nothing is CRITICAL.
|
|
317
|
+
* @param {{ component: string, path: string, tag: string, label: string, outcome: ReturnType<typeof outcomeOf> }[]} stray
|
|
318
|
+
*/
|
|
319
|
+
export const compareStray = (stray) => {
|
|
320
|
+
const out = { checks: 0, weight: 0, diffs: /** @type {any[]} */ ([]) };
|
|
321
|
+
for (const s of stray) {
|
|
322
|
+
out.checks += WEIGHT.critical;
|
|
323
|
+
if (effectOf(s.outcome)) continue;
|
|
324
|
+
out.weight += WEIGHT.critical;
|
|
325
|
+
out.diffs.push({ part: `${s.component}.extra`, prop: 'dead', theirs: 'no such control', ours: `${s.tag} "${s.label}" (${s.path}): ${describe(s.outcome)}`, delta: 'a control only ours has, and it does nothing', severity: 'critical', group: 'behaviour', weight: WEIGHT.critical, hint: `${s.tag} "${s.label}" in our ${s.component} (${s.path}) has no counterpart on their page and does nothing: remove it from the integration's template, or make it work (and map it with its counterpart)` });
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The behaviour of one page as a result (compareComponent's shape):
|
|
332
|
+
* component `behaviour`, its differences decided by `behaviour.<component>.<part>`.
|
|
333
|
+
* @param {{ page: string, viewport: number }} at @param {{ checks: number, weight: number, diffs: any[] }} r @param {{ decided?: Record<string, any>, interactions?: any[] }} [o]
|
|
334
|
+
*/
|
|
335
|
+
export const behaviourResult = (at, r, { decided = {}, interactions = [] } = {}) => {
|
|
336
|
+
const diffs = decide(r.diffs.map((d) => ({ ...d, owner: 'template' })), { component: 'behaviour', decided });
|
|
337
|
+
const templateWeight = diffs.filter((d) => d.owner === 'template').reduce((s, d) => s + d.weight, 0);
|
|
338
|
+
const score = scoreOf(r);
|
|
339
|
+
const templateScore = scoreOf({ checks: r.checks, weight: templateWeight });
|
|
340
|
+
return { component: 'behaviour', page: at.page, viewport: at.viewport, state: null, score, templateScore, instances: r.checks ? [{ key: 'page', score, templateScore, checks: r.checks, weight: r.weight, pixels: null }] : [], diffs: aggregate([diffs]), interactions };
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The hold check (docs/PARITY.md, "The hold"): the frames of a reload of our
|
|
345
|
+
* page, the rules stored (the second load). A frame that painted their
|
|
346
|
+
* listing is CRITICAL: production, with the snippet first in <head>, never
|
|
347
|
+
* shows it. `snippet`: whether the snippet was on the page.
|
|
348
|
+
* @param {{ page: string, viewport: number }} at @param {{ n: number, theirs: boolean, ours: boolean }[] | null} frames @param {{ snippet?: boolean, decided?: Record<string, any> }} [o]
|
|
349
|
+
*/
|
|
350
|
+
export const holdResult = (at, frames, { snippet = true, decided = {} } = {}) => {
|
|
351
|
+
const painted = (frames ?? []).filter((f) => f.theirs).reduce((s, f) => s + f.n, 0);
|
|
352
|
+
// What the frames showed in order, repeats merged (['none', 'ours']; ['theirs', 'ours'] when it failed): repeatable, unlike the counts.
|
|
353
|
+
const states = [];
|
|
354
|
+
for (const f of frames ?? []) {
|
|
355
|
+
const s = f.theirs && f.ours ? 'both' : f.theirs ? 'theirs' : f.ours ? 'ours' : 'none';
|
|
356
|
+
if (states[states.length - 1] !== s) states.push(s);
|
|
357
|
+
}
|
|
358
|
+
const r = { checks: frames ? WEIGHT.critical : 0, weight: 0, diffs: /** @type {any[]} */ ([]) };
|
|
359
|
+
if (frames && painted) {
|
|
360
|
+
r.weight += WEIGHT.critical;
|
|
361
|
+
r.diffs.push({
|
|
362
|
+
part: 'listing',
|
|
363
|
+
prop: 'frames',
|
|
364
|
+
theirs: 'held (no frame of theirs)',
|
|
365
|
+
ours: `frames: ${states.join(' > ')}`,
|
|
366
|
+
delta: "a reload of our page painted their listing (the rules stored)",
|
|
367
|
+
severity: 'critical',
|
|
368
|
+
group: 'hold',
|
|
369
|
+
weight: WEIGHT.critical,
|
|
370
|
+
hint: snippet
|
|
371
|
+
? 'their listing showed before ours although the early-hold snippet was first in <head>: the rules the build stores (localStorage lbx:early) do not match this route, or its CSS does not hide their listing (src/core/early-rules.ts; the route\'s hide selector in the integration\'s config)'
|
|
372
|
+
: 'the early-hold snippet was not on the page: put snippet/early-hold.min.js first in <head> (README), before the loader',
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
const diffs = decide(r.diffs.map((d) => ({ ...d, owner: 'template' })), { component: 'hold', decided });
|
|
376
|
+
const templateWeight = diffs.filter((d) => d.owner === 'template').reduce((s, d) => s + d.weight, 0);
|
|
377
|
+
const score = scoreOf(r);
|
|
378
|
+
return { component: 'hold', page: at.page, viewport: at.viewport, state: null, score, templateScore: scoreOf({ checks: r.checks, weight: templateWeight }), instances: r.checks ? [{ key: 'reload', score, templateScore: score, checks: r.checks, weight: r.weight, pixels: null }] : [], diffs: aggregate([diffs]), frames: frames ? states : null };
|
|
379
|
+
};
|