@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.
Files changed (145) hide show
  1. package/LICENSE.md +90 -0
  2. package/README.md +615 -2
  3. package/a11y/audit.mjs +735 -0
  4. package/a11y/axe.mjs +73 -0
  5. package/a11y/report.mjs +347 -0
  6. package/a11y/run.mjs +256 -0
  7. package/a11y/wcag.mjs +233 -0
  8. package/bin/config.mjs +60 -0
  9. package/bin/luma.mjs +99 -0
  10. package/bin/paths.mjs +11 -0
  11. package/bin/tools.mjs +43 -0
  12. package/build/baseline.d.mts +8 -0
  13. package/build/baseline.mjs +167 -0
  14. package/build/tooling.mjs +102 -0
  15. package/build/vite.mjs +507 -0
  16. package/chrome/block.mjs +8 -0
  17. package/chrome/run.mjs +150 -0
  18. package/docs/ACCESSIBILITY.md +501 -0
  19. package/docs/INTEGRATION-PERFORMANCE.md +213 -0
  20. package/docs/PARITY.md +946 -0
  21. package/footprint/model.mjs +184 -0
  22. package/footprint/run.mjs +331 -0
  23. package/package.json +58 -4
  24. package/parity/alignment.mjs +109 -0
  25. package/parity/archive.mjs +223 -0
  26. package/parity/args.mjs +137 -0
  27. package/parity/behaviour.mjs +379 -0
  28. package/parity/compare.mjs +985 -0
  29. package/parity/engine.mjs +848 -0
  30. package/parity/hints.mjs +132 -0
  31. package/parity/hold.mjs +54 -0
  32. package/parity/init.mjs +703 -0
  33. package/parity/map.mjs +640 -0
  34. package/parity/owner.mjs +170 -0
  35. package/parity/pairing.mjs +110 -0
  36. package/parity/pixels.mjs +33 -0
  37. package/parity/probe.mjs +1239 -0
  38. package/parity/report.mjs +236 -0
  39. package/parity/run.mjs +267 -0
  40. package/snippet/early-hold.js +64 -0
  41. package/snippet/early-hold.min.js +1 -0
  42. package/src/analytics/chain.ts +137 -0
  43. package/src/analytics/collect.ts +64 -0
  44. package/src/analytics/datalayer.ts +67 -0
  45. package/src/analytics/from-ga4.ts +68 -0
  46. package/src/analytics/from-luma.ts +21 -0
  47. package/src/analytics/index.ts +24 -0
  48. package/src/analytics/outbox.ts +118 -0
  49. package/src/analytics/page.ts +46 -0
  50. package/src/analytics/pageview.ts +82 -0
  51. package/src/analytics/ref.ts +19 -0
  52. package/src/analytics/setup.ts +41 -0
  53. package/src/analytics/start.ts +53 -0
  54. package/src/analytics/triggers.ts +239 -0
  55. package/src/analytics/user.ts +12 -0
  56. package/src/analytics/wire.ts +67 -0
  57. package/src/api/ai.ts +98 -0
  58. package/src/api/index.ts +87 -0
  59. package/src/api/token-key.ts +42 -0
  60. package/src/api/token.ts +312 -0
  61. package/src/core/a11y-keys.ts +60 -0
  62. package/src/core/a11y.ts +160 -0
  63. package/src/core/boot-state.ts +6 -0
  64. package/src/core/color.ts +42 -0
  65. package/src/core/context.ts +108 -0
  66. package/src/core/early-rules.ts +96 -0
  67. package/src/core/entries.ts +25 -0
  68. package/src/core/fold.ts +7 -0
  69. package/src/core/health.ts +26 -0
  70. package/src/core/hold.ts +74 -0
  71. package/src/core/i18n.ts +66 -0
  72. package/src/core/image-load.ts +102 -0
  73. package/src/core/image-tags.ts +26 -0
  74. package/src/core/images.ts +170 -0
  75. package/src/core/integration.ts +126 -0
  76. package/src/core/kept.ts +25 -0
  77. package/src/core/listings.ts +100 -0
  78. package/src/core/locales.ts +14 -0
  79. package/src/core/own.ts +49 -0
  80. package/src/core/page.ts +14 -0
  81. package/src/core/prefetch.ts +88 -0
  82. package/src/core/recommenders.ts +166 -0
  83. package/src/core/reconciler.ts +397 -0
  84. package/src/core/result.ts +73 -0
  85. package/src/core/routes.ts +115 -0
  86. package/src/core/scheduler.ts +83 -0
  87. package/src/core/sense.ts +107 -0
  88. package/src/core/speculation.ts +59 -0
  89. package/src/core/stash.ts +38 -0
  90. package/src/core/storage.ts +45 -0
  91. package/src/core/styles.ts +106 -0
  92. package/src/core/support.ts +12 -0
  93. package/src/core/takeover.ts +220 -0
  94. package/src/core/templates.ts +74 -0
  95. package/src/core/tiles.ts +137 -0
  96. package/src/core/turbo.ts +64 -0
  97. package/src/core/url.ts +164 -0
  98. package/src/core/widget-inits.ts +40 -0
  99. package/src/env.d.ts +3 -0
  100. package/src/lit.ts +11 -0
  101. package/src/locales/cs.json +94 -0
  102. package/src/locales/de.json +88 -0
  103. package/src/locales/en.json +88 -0
  104. package/src/main.ts +493 -0
  105. package/src/types.ts +778 -0
  106. package/src/widgets/autocomplete/index.ts +1387 -0
  107. package/src/widgets/autocomplete/style-autocomplete.css +266 -0
  108. package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
  109. package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
  110. package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
  111. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
  112. package/src/widgets/listing/element.ts +1772 -0
  113. package/src/widgets/listing/flow.ts +30 -0
  114. package/src/widgets/listing/instant.ts +133 -0
  115. package/src/widgets/listing/intent.ts +193 -0
  116. package/src/widgets/listing/query.ts +298 -0
  117. package/src/widgets/listing/stash.ts +97 -0
  118. package/src/widgets/listing/state.ts +131 -0
  119. package/src/widgets/listing/view.ts +257 -0
  120. package/src/widgets/plp/index.ts +11 -0
  121. package/src/widgets/plp/route.ts +263 -0
  122. package/src/widgets/recommender/index.ts +261 -0
  123. package/src/widgets/recommender/style-recommender.css +26 -0
  124. package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
  125. package/src/widgets/recommender/template-recommender-popup.ts +12 -0
  126. package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
  127. package/src/widgets/search/assets.ts +7 -0
  128. package/src/widgets/search/first.ts +15 -0
  129. package/src/widgets/search/form-url.ts +59 -0
  130. package/src/widgets/search/in-place.ts +226 -0
  131. package/src/widgets/search/index.ts +16 -0
  132. package/src/widgets/search/range.ts +260 -0
  133. package/src/widgets/search/route.ts +32 -0
  134. package/src/widgets/search/speculate.ts +202 -0
  135. package/src/widgets/search/style-search.css +65 -0
  136. package/src/widgets/search/template-search-box.ts +17 -0
  137. package/src/widgets/search/template-search-facets-default.ts +73 -0
  138. package/src/widgets/search/template-search-header.ts +14 -0
  139. package/src/widgets/search/template-search-pagination.ts +12 -0
  140. package/src/widgets/search/template-search-range-default.ts +48 -0
  141. package/src/widgets/search/template-search-result-default.ts +13 -0
  142. package/src/widgets/search/template-search-results-default.ts +8 -0
  143. package/tasks/model.mjs +116 -0
  144. package/tasks/run.mjs +305 -0
  145. package/tsconfig.json +14 -0
@@ -0,0 +1,109 @@
1
+ // Alignment relations (docs/PARITY.md, "Alignment"): each component's box is
2
+ // compared relative to itself, so ours can match theirs element by element
3
+ // and still not line up across components (their sort right-aligned with
4
+ // the product grid, ours not). From their page, the edges their mapped
5
+ // components and key parts share within `px` (left, right, top or bottom
6
+ // edges equal, or centred on one another), and on ours the same required:
7
+ // a relation ours breaks is MAJOR, named by both elements and the offset.
8
+ // A required relation needs no theirs: our autocomplete panel against their
9
+ // visible search field (the wrapper that draws it), even when the panel is a
10
+ // house design of ours (`excluded` with a decision). Pure,
11
+ // unit-tested.
12
+ import { decide } from './owner.mjs';
13
+ import { WEIGHT, aggregate, round, scoreOf } from './compare.mjs';
14
+
15
+ export const EDGES = Object.freeze(['left', 'right', 'top', 'bottom', 'center-x', 'center-y']);
16
+ /** The search field's key among the boxes (edgesOf's `field` item). */
17
+ export const FIELD = 'search-field';
18
+
19
+ /** An edge of a box (page coordinates: { x, y, width, height }). @param {{ x: number, y: number, width: number, height: number }} b @param {string} e */
20
+ export const edgeOf = (b, e) =>
21
+ e === 'left' ? b.x : e === 'right' ? b.x + b.width : e === 'top' ? b.y : e === 'bottom' ? b.y + b.height : e === 'center-x' ? b.x + b.width / 2 : b.y + b.height / 2;
22
+
23
+ /** The component a box's key belongs to: `grid`, `facets.facetGroup` -> `facets`; the search field its own. @param {string} k */
24
+ const groupOf = (k) => (k === FIELD ? FIELD : k.split('.')[0]);
25
+
26
+ /**
27
+ * The relations their page holds: every pair of boxes of two different
28
+ * components (or a component and the search field) whose edge (left, right,
29
+ * top, bottom) is the same within `px`, or that are centred on one another
30
+ * (only when not both of that axis's edges are already equal). Sorted, so a
31
+ * run is repeatable. `ignore`: relations the map leaves out (`"grid~sort
32
+ * align-right"`, or `"grid~sort"` for every edge of the pair).
33
+ * @param {Record<string, { x: number, y: number, width: number, height: number } | null>} boxes
34
+ * @param {{ px?: number, ignore?: string[] }} [o]
35
+ * @returns {{ a: string, b: string, edge: string }[]}
36
+ */
37
+ export const relationsOf = (boxes, { px = 1, ignore = [] } = {}) => {
38
+ const keys = Object.keys(boxes).filter((k) => boxes[k] && boxes[k].width > 0 && boxes[k].height > 0).sort();
39
+ const out = [];
40
+ for (let i = 0; i < keys.length; i++)
41
+ for (let j = i + 1; j < keys.length; j++) {
42
+ const [a, b] = [keys[i], keys[j]];
43
+ if (groupOf(a) === groupOf(b)) continue;
44
+ const [A, B] = [boxes[a], boxes[b]];
45
+ const same = (e) => Math.abs(edgeOf(A, e) - edgeOf(B, e)) <= px;
46
+ const holds = EDGES.filter((e) => {
47
+ if (e === 'center-x') return !(same('left') && same('right')) && same(e);
48
+ if (e === 'center-y') return !(same('top') && same('bottom')) && same(e);
49
+ return same(e);
50
+ });
51
+ for (const edge of holds) {
52
+ const pair = `${a}~${b}`;
53
+ if (ignore.includes(pair) || ignore.includes(`${pair} align-${edge}`)) continue;
54
+ out.push({ a, b, edge });
55
+ }
56
+ }
57
+ return out;
58
+ };
59
+
60
+ /**
61
+ * The relations on ours: each of theirs (and the required ones) held within
62
+ * `px`, else MAJOR with both elements and the offset (ours' `a` edge minus
63
+ * `b`'s: + is right of it, or below). A relation whose boxes ours lacks is
64
+ * not compared (the element's presence is its component's check).
65
+ * @param {{ a: string, b: string, edge: string, required?: string }[]} relations
66
+ * @param {Record<string, { x: number, y: number, width: number, height: number } | null>} ours
67
+ * @param {{ px?: number, theirs?: Record<string, any> }} [o]
68
+ */
69
+ export const compareAlignment = (relations, ours, { px = 1, theirs = {} } = {}) => {
70
+ const out = { checks: 0, weight: 0, diffs: /** @type {any[]} */ ([]), relations: /** @type {string[]} */ ([]) };
71
+ for (const r of relations) {
72
+ const [A, B] = [ours[r.a], ours[r.b]];
73
+ if (!A || !B) continue;
74
+ out.checks += WEIGHT.check;
75
+ out.relations.push(`${r.a}.${r.edge} = ${r.b}.${r.edge}`);
76
+ const d = round(edgeOf(A, r.edge) - edgeOf(B, r.edge), 2);
77
+ if (Math.abs(d) <= px) continue;
78
+ const dir = r.edge === 'top' || r.edge === 'bottom' || r.edge === 'center-y' ? (d > 0 ? 'below' : 'above') : d > 0 ? 'right of' : 'left of';
79
+ const t = theirs[r.a] && theirs[r.b] ? round(edgeOf(theirs[r.a], r.edge) - edgeOf(theirs[r.b], r.edge), 2) : 0;
80
+ out.weight += WEIGHT.check;
81
+ out.diffs.push({
82
+ part: `${r.a}~${r.b}`,
83
+ prop: `align-${r.edge}`,
84
+ theirs: `${r.a}.${r.edge} = ${r.b}.${r.edge}${r.required ? ` (${r.required})` : t ? ` (${t > 0 ? '+' : ''}${t}px)` : ''}`,
85
+ ours: `${r.a}.${r.edge} is ${Math.abs(d)}px ${dir} ${r.b}.${r.edge}`,
86
+ delta: `${r.a}.${r.edge} ${d > 0 ? '+' : ''}${d}px off ${r.b}.${r.edge}`,
87
+ offset: d,
88
+ severity: 'major',
89
+ group: 'alignment',
90
+ weight: WEIGHT.check,
91
+ hint: `${r.a} and ${r.b} line up on their ${r.edge === 'center-x' || r.edge === 'center-y' ? 'centres' : `${r.edge} edges`} on their page; on ours ${r.a}'s is ${Math.abs(d)}px ${dir} ${r.b}'s: the margin, width or justify-content that places ${r.a} (or ${r.b}) in the integration's CSS`,
92
+ });
93
+ }
94
+ return out;
95
+ };
96
+
97
+ /**
98
+ * The alignment of one page as a result (compareComponent's shape):
99
+ * component `alignment` (`alignment@<state>`), its relations in `relations`.
100
+ * @param {{ page: string, viewport: number, state?: string | null }} at @param {ReturnType<typeof compareAlignment>} r @param {{ decided?: Record<string, any> }} [o]
101
+ */
102
+ export const alignmentResult = (at, r, { decided = {} } = {}) => {
103
+ const component = at.state ? `alignment@${at.state}` : 'alignment';
104
+ const diffs = decide(r.diffs.map((d) => ({ ...d, owner: 'template' })), { component: 'alignment', decided });
105
+ const templateWeight = diffs.filter((d) => d.owner === 'template').reduce((s, d) => s + d.weight, 0);
106
+ const score = scoreOf(r);
107
+ const templateScore = scoreOf({ checks: r.checks, weight: templateWeight });
108
+ return { component, page: at.page, viewport: at.viewport, state: at.state ?? null, score, templateScore, instances: r.checks ? [{ key: 'page', score, templateScore, checks: r.checks, weight: r.weight, pixels: null }] : [], diffs: aggregate([diffs]), relations: r.relations };
109
+ };
@@ -0,0 +1,223 @@
1
+ // Record and replay for `yarn luma parity`: the site's pages, their assets and
2
+ // the API answers, captured once into a local folder, so later runs replay
3
+ // them offline and byte for byte (CI-safe, and no load on the client's site).
4
+ //
5
+ // <dir>/index.json request key -> { status, headers, body (sha1) }, sorted
6
+ // <dir>/bodies/<sha1> response bodies, content-addressed (shared by every page)
7
+ //
8
+ // Recording listens to the browser's own responses (nothing is fetched
9
+ // beside the browser: no extra requests to the site). The key
10
+ // is the method, the URL without its volatile params (cache busters) and,
11
+ // for a POST, the body's hash. Replay answers every request from the index;
12
+ // a GET whose exact key is missing gets the recording of the same path with
13
+ // the most equal params (deterministic: ties go to the earliest recorded),
14
+ // anything else is aborted and counted. Luigi's Box auth is never recorded
15
+ // (a token, a proof of work): replay answers it with a fixed challenge and
16
+ // token. Cookies and auth headers are never stored.
17
+ //
18
+ // A page can be given a scope (`scope(page, tag)`: an interaction's page on
19
+ // one side, docs/PARITY.md "Behaviour"): its requests are recorded under
20
+ // `[tag] <key>`, the n-th of one key as `[tag] <key> ~n` (a page reloaded
21
+ // after a click: the same URL, another answer), and replayed from there in
22
+ // the same order, else from the plain recording. What an action changes on
23
+ // the server (a compare list, a cart) replays as it was recorded.
24
+ import { createHash } from 'node:crypto';
25
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+
28
+ export const VOLATILE = ['_', 'cb', 'cachebuster', 'ts', 'timestamp', 't', 'rnd', 'random', 'nocache', '_t', 'v', 'gclid', 'fbclid'];
29
+ const KEEP_HEADERS = /^(content-type|location|access-control-[a-z-]+|vary|content-language|link)$/i;
30
+ const AUTH = /^https:\/\/auth\.[a-z0-9-]+\.luigisbox\.ai\//;
31
+ export const sha1 = (b) => createHash('sha1').update(b).digest('hex');
32
+
33
+ /**
34
+ * The request key: method, URL without fragment and volatile params (the rest sorted), the POST body's hash.
35
+ * @param {string} method @param {string} url @param {string | Buffer | null} [body] @param {string[]} [ignore]
36
+ */
37
+ export const keyOf = (method, url, body = null, ignore = VOLATILE) => {
38
+ const u = new URL(url);
39
+ u.hash = '';
40
+ const params = [...u.searchParams].filter(([k]) => !ignore.includes(k)).sort(([a, x], [b, y]) => (a < b ? -1 : a > b ? 1 : x < y ? -1 : x > y ? 1 : 0));
41
+ u.search = new URLSearchParams(params).toString();
42
+ const m = method.toUpperCase();
43
+ return `${m} ${u.href}${m !== 'GET' && m !== 'HEAD' && body ? ` #${sha1(body).slice(0, 16)}` : ''}`;
44
+ };
45
+
46
+ export class Archive {
47
+ /**
48
+ * @param {string} dir
49
+ * @param {{ mode: 'record' | 'replay' | 'off', ignore?: string[], skip?: (url: string) => boolean, merge?: boolean }} opts
50
+ */
51
+ constructor(dir, opts) {
52
+ this.dir = dir;
53
+ this.mode = opts.mode;
54
+ this.ignore = opts.ignore ?? VOLATILE;
55
+ this.skip = opts.skip ?? (() => false);
56
+ /** @type {Record<string, { status: number, headers: Record<string, string>, body: string | null, n: number }>} */
57
+ this.entries = {};
58
+ this.meta = {};
59
+ this.pending = new Set();
60
+ /** Keys recorded in this session: the first response wins. */
61
+ this.seen = new Set();
62
+ this.stats = { served: 0, fuzzy: 0, missed: /** @type {string[]} */ ([]), recorded: 0 };
63
+ this.n = 0;
64
+ /** Pages' scopes (scope()), the scope and occurrence of each request recorded in one, the occurrences served. */
65
+ this.scopes = new WeakMap();
66
+ this.reqScope = new WeakMap();
67
+ this.counts = new Map();
68
+ if (opts.mode === 'replay') {
69
+ const file = join(dir, 'index.json');
70
+ if (!existsSync(file)) throw new Error(`no recording in ${dir}: record one first (yarn luma parity --record)`);
71
+ const idx = JSON.parse(readFileSync(file, 'utf8'));
72
+ this.entries = idx.entries ?? {};
73
+ this.meta = idx.meta ?? {};
74
+ this.byPath = new Map();
75
+ for (const [k, e] of Object.entries(this.entries)) {
76
+ if (k.startsWith('[')) continue;
77
+ const [m, href] = k.split(' ');
78
+ if (m !== 'GET') continue;
79
+ const u = new URL(href);
80
+ const p = `${u.origin}${u.pathname}`;
81
+ if (!this.byPath.has(p)) this.byPath.set(p, []);
82
+ this.byPath.get(p).push({ key: k, params: new URLSearchParams(u.search), n: e.n });
83
+ }
84
+ for (const l of this.byPath.values()) l.sort((a, b) => a.n - b.n);
85
+ }
86
+ if (opts.mode === 'record') {
87
+ // A partial recording (--only) adds to the one there; a whole one starts afresh.
88
+ const file = join(dir, 'index.json');
89
+ if (opts.merge && existsSync(file)) {
90
+ const idx = JSON.parse(readFileSync(file, 'utf8'));
91
+ this.entries = idx.entries ?? {};
92
+ this.meta = idx.meta ?? {};
93
+ this.n = Math.max(0, ...Object.values(this.entries).map((e) => e.n + 1));
94
+ } else rmSync(dir, { recursive: true, force: true });
95
+ mkdirSync(join(dir, 'bodies'), { recursive: true });
96
+ }
97
+ }
98
+
99
+ /** Recording: every response the page gets. Replay: every request answered from the recording. @param {import('playwright-core').Page} page */
100
+ async attach(page) {
101
+ if (this.mode === 'record') {
102
+ // A scoped page: each request's scope and occurrence, at the time it is made (responses come in any order).
103
+ page.on('request', (req) => {
104
+ const scope = this.scopes.get(page);
105
+ if (!scope) return;
106
+ const url = req.url();
107
+ if (!/^https?:/.test(url)) return;
108
+ const k = `[${scope}] ${keyOf(req.method(), url, req.postDataBuffer(), this.ignore)}`;
109
+ const n = (this.counts.get(k) ?? 0) + 1;
110
+ this.counts.set(k, n);
111
+ this.reqScope.set(req, n > 1 ? `${k} ~${n}` : k);
112
+ });
113
+ page.on('response', (res) => {
114
+ const p = this.record(res).catch(() => {});
115
+ this.pending.add(p);
116
+ p.finally(() => this.pending.delete(p));
117
+ });
118
+ } else if (this.mode === 'replay') {
119
+ await page.route('**/*', (route) => this.replay(route, page));
120
+ }
121
+ }
122
+
123
+ /**
124
+ * A scope for a page's requests from now on (null: none): recorded under it, replayed from it first.
125
+ * @param {import('playwright-core').Page} page @param {string | null} tag
126
+ */
127
+ scope(page, tag) {
128
+ if (tag) this.scopes.set(page, tag);
129
+ else this.scopes.delete(page);
130
+ }
131
+
132
+ /** @param {import('playwright-core').Response} res */
133
+ async record(res) {
134
+ const req = res.request();
135
+ const url = req.url();
136
+ if (!/^https?:/.test(url) || AUTH.test(url) || this.skip(url) || req.method() === 'OPTIONS') return;
137
+ const scoped = this.reqScope.get(req);
138
+ const key = scoped ?? keyOf(req.method(), url, req.postDataBuffer(), this.ignore);
139
+ if (this.seen.has(key)) return;
140
+ this.seen.add(key);
141
+ const status = res.status();
142
+ let body = null;
143
+ if (status < 300 || status >= 400) {
144
+ const buf = await res.body().catch(() => null);
145
+ if (buf == null) return;
146
+ body = sha1(buf);
147
+ const file = join(this.dir, 'bodies', body);
148
+ if (!existsSync(file)) writeFileSync(file, buf);
149
+ }
150
+ const headers = Object.fromEntries(Object.entries(await res.allHeaders().catch(() => res.headers())).filter(([k]) => KEEP_HEADERS.test(k)));
151
+ this.entries[key] = { status, headers, body, n: this.n++ };
152
+ this.stats.recorded++;
153
+ }
154
+
155
+ /** @param {import('playwright-core').Route} route @param {import('playwright-core').Page} [page] */
156
+ async replay(route, page) {
157
+ try {
158
+ return await this.answer(route, page);
159
+ } catch {
160
+ // A page gone meanwhile, an unreadable body: never a request left hanging.
161
+ return route.abort('failed').catch(() => {});
162
+ }
163
+ }
164
+
165
+ /** @param {import('playwright-core').Route} route @param {import('playwright-core').Page} [page] */
166
+ async answer(route, page) {
167
+ const req = route.request();
168
+ const url = req.url();
169
+ const origin = req.headers()['origin'];
170
+ const cors = origin ? { 'access-control-allow-origin': origin, 'access-control-allow-credentials': 'true', 'access-control-allow-headers': '*', 'access-control-allow-methods': 'GET, POST, OPTIONS' } : {};
171
+ if (req.method() === 'OPTIONS') return route.fulfill({ status: 204, headers: cors });
172
+ if (AUTH.test(url)) {
173
+ if (url.includes('/browser-challenge')) return route.fulfill({ status: 200, contentType: 'application/json', headers: cors, body: JSON.stringify({ nonce: 'parity', difficulty: 0 }) });
174
+ return route.fulfill({ status: 200, contentType: 'application/json', headers: cors, body: JSON.stringify({ access_token: 'parity-replay', expires_in: 86400, token_type: 'Bearer' }) });
175
+ }
176
+ const key = keyOf(req.method(), url, req.postDataBuffer(), this.ignore);
177
+ let e = null;
178
+ // A scoped page: the n-th answer of this key in its scope (or the last one before it), else the plain recording.
179
+ const scope = page ? this.scopes.get(page) : null;
180
+ if (scope) {
181
+ const k = `[${scope}] ${key}`;
182
+ const n = (this.counts.get(k) ?? 0) + 1;
183
+ this.counts.set(k, n);
184
+ for (let i = n; i >= 1 && !e; i--) e = this.entries[i > 1 ? `${k} ~${i}` : k] ?? null;
185
+ }
186
+ e ??= this.entries[key];
187
+ if (!e && req.method() === 'GET') {
188
+ const u = new URL(url);
189
+ const cands = this.byPath.get(`${u.origin}${u.pathname}`) ?? [];
190
+ let best = null;
191
+ let bestScore = -1;
192
+ for (const c of cands) {
193
+ let s = 0;
194
+ for (const [k, v] of u.searchParams) if (c.params.get(k) === v) s++;
195
+ if (s > bestScore) [best, bestScore] = [c, s];
196
+ }
197
+ if (best) {
198
+ e = this.entries[best.key];
199
+ this.stats.fuzzy++;
200
+ }
201
+ }
202
+ if (!e) {
203
+ this.stats.missed.push(key);
204
+ return route.abort('internetdisconnected');
205
+ }
206
+ this.stats.served++;
207
+ const body = e.body ? readFileSync(join(this.dir, 'bodies', e.body)) : Buffer.alloc(0);
208
+ return route.fulfill({ status: e.status, headers: { ...e.headers, ...cors }, body });
209
+ }
210
+
211
+ /** Waits for the bodies being written. */
212
+ async flush() {
213
+ while (this.pending.size) await Promise.all([...this.pending]);
214
+ }
215
+
216
+ /** Writes the index (sorted: a re-recording diffs cleanly). @param {Record<string, unknown>} meta */
217
+ async save(meta = {}) {
218
+ if (this.mode !== 'record') return;
219
+ await this.flush();
220
+ const entries = Object.fromEntries(Object.entries(this.entries).sort(([a], [b]) => (a < b ? -1 : 1)));
221
+ writeFileSync(join(this.dir, 'index.json'), `${JSON.stringify({ meta, entries }, null, 1)}\n`);
222
+ }
223
+ }
@@ -0,0 +1,137 @@
1
+ // The command line of `yarn luma parity` (parity/run.mjs): its options, parsed
2
+ // without side effects (unit-tested).
3
+ import { TARGET } from './compare.mjs';
4
+
5
+ export const USAGE = `usage: yarn luma parity [options]
6
+
7
+ (default) replay the recording (--archive): offline, deterministic
8
+ --target <n> the template score that ends the loop (default ${TARGET}): exit 0 only at or over it,
9
+ with no CRITICAL or MAJOR template difference left and every mandatory component
10
+ mapped or excluded; 1 otherwise; 2 nothing compared (or the run broke)
11
+ --record the live site, recorded into the archive (replaces it; with --only, adds to it)
12
+ --live the live site, nothing recorded
13
+ --init [file] propose a draft map from the site's pages (default integration/parity.draft.json)
14
+ --their-plp <path> with --init: their own search is disabled; our search page is drafted against this
15
+ category listing of theirs (its tile, grid, facets, sort, count and pager)
16
+ --our-search <path> with --init: our search page (default /search?q=<the fixtures' query>)
17
+ --map <file> the map (default integration/parity.json)
18
+ --archive <dir> the recording (default parity/recording)
19
+ --only <names> components to compare (comma-separated)
20
+ --viewports <list> widths, e.g. 390,1440 (default: the map's)
21
+ --out <dir> the report folder: index.html, report.json, crops (default parity-report)
22
+ --json <file> also write the JSON report there
23
+ --all every difference in the terminal (default: the first 12 per component and width)
24
+ --headed show the browser
25
+ --profile <dir> a Chrome profile you use on the site (runs headed; copied first: Chrome locks one in use)
26
+ --dev [url|port] the integration from the dev server (yarn dev) instead of building it: its URL or port
27
+ (default https://localhost:8069; a URL with a path is the entry itself)
28
+ --block <patterns> vendor scripts to block on both sides (comma-separated: hosts, or regular expressions
29
+ on the URL); --init also blocks every *.luigisbox.{tech,com,ai} script
30
+ --no-build use the integration's dist/ as it is
31
+ --host <name> the storefront host (default: the map's, LBX_HOST)
32
+ --site-dir <dir> the integration repository (default: the current directory, LBX_SITE_DIR)`;
33
+
34
+ /**
35
+ * The options, or why they are wrong (`error`), or `help`.
36
+ * @param {string[]} args process.argv.slice(2)
37
+ */
38
+ export const parseArgs = (args) => {
39
+ const opt = { mode: 'replay', init: /** @type {string | null} */ (null), map: '', archive: '', only: /** @type {string[]} */ ([]), viewports: /** @type {number[] | null} */ (null), out: 'parity-report', json: '', all: false, headed: false, profile: '', dev: /** @type {string | false} */ (false), block: /** @type {string[]} */ ([]), build: true, host: '', siteDir: '', paths: /** @type {string[]} */ ([]), theirPlp: '', ourSearch: '', target: TARGET };
40
+ for (let i = 0; i < args.length; i++) {
41
+ const a = args[i];
42
+ let missing = false;
43
+ const next = () => {
44
+ const v = args[++i];
45
+ if (v === undefined || v.startsWith('--')) {
46
+ missing = true;
47
+ return '';
48
+ }
49
+ return v;
50
+ };
51
+ if (a === '--help' || a === '-h') return { help: true, opt };
52
+ if (a === '--record') opt.mode = 'record';
53
+ else if (a === '--live') opt.mode = 'live';
54
+ else if (a === '--init') opt.init = args[i + 1] && !args[i + 1].startsWith('--') && /\.json$/.test(args[i + 1]) ? args[++i] : '';
55
+ else if (a === '--target') {
56
+ const v = next();
57
+ const n = Number(v);
58
+ if (!missing && (v.trim() === '' || !Number.isFinite(n) || n < 0 || n > 100)) return { error: `--target: a template score from 0 to 100 (got "${v}")`, opt };
59
+ opt.target = n;
60
+ } else if (a === '--their-plp' || a === '--our-search') {
61
+ const v = next();
62
+ if (!missing && !v.startsWith('/')) return { error: `${a}: a path starting with "/" (got "${v}")`, opt };
63
+ opt[a === '--their-plp' ? 'theirPlp' : 'ourSearch'] = v;
64
+ } else if (a === '--map') opt.map = next();
65
+ else if (a === '--archive') opt.archive = next();
66
+ else if (a === '--only') opt.only = next().split(',').map((s) => s.trim()).filter(Boolean);
67
+ else if (a === '--viewports') opt.viewports = next().split(',').map(Number);
68
+ else if (a === '--out') opt.out = next();
69
+ else if (a === '--json') opt.json = next();
70
+ else if (a === '--all') opt.all = true;
71
+ else if (a === '--headed') opt.headed = true;
72
+ else if (a === '--profile') opt.profile = next();
73
+ else if (a === '--dev') {
74
+ // The dev server's URL or port, when the next argument is one (else the default, https://localhost:8069).
75
+ const v = args[i + 1];
76
+ if (v && !v.startsWith('--') && !v.startsWith('/') && (/^\d{2,5}$/.test(v) || /^https?:\/\//.test(v) || /^[\w.-]+:\d{2,5}$/.test(v))) opt.dev = args[++i];
77
+ else opt.dev = 'https://localhost:8069';
78
+ if (!devBundle(opt.dev)) return { error: `--dev: the dev server's URL or port (got "${opt.dev}")`, opt };
79
+ } else if (a === '--block') {
80
+ for (const x of next().split(',').map((s) => s.trim()).filter(Boolean)) {
81
+ const re = blockPattern(x);
82
+ if (!re) return { error: `--block: "${x}" is neither a host nor a regular expression`, opt };
83
+ opt.block.push(re);
84
+ }
85
+ }
86
+ else if (a === '--no-build') opt.build = false;
87
+ else if (a === '--host') opt.host = next();
88
+ else if (a === '--site-dir') opt.siteDir = next();
89
+ else if (a.startsWith('/')) opt.paths.push(a);
90
+ else return { error: `unknown argument "${a}"`, opt };
91
+ if (missing) return { error: `${a} needs a value`, opt };
92
+ }
93
+ // A profile carries a site's cookies: it is for a session a person sees, never headless.
94
+ if (opt.profile) opt.headed = true;
95
+ if ((opt.theirPlp || opt.ourSearch) && opt.init == null) return { error: `${opt.theirPlp ? '--their-plp' : '--our-search'} drafts a map: use it with --init (the map's pages say it afterwards: docs/PARITY.md)`, opt };
96
+ return { opt };
97
+ };
98
+
99
+ /**
100
+ * The integration's entry on the dev server: `--dev` alone is
101
+ * https://localhost:8069; a port is https://localhost:<port>; a URL is that
102
+ * server (its path, when it has one, the entry itself: a server that serves
103
+ * it elsewhere). Null when it is neither.
104
+ * @param {string | false | null | undefined} dev
105
+ * @returns {{ origin: string, bundle: string } | null}
106
+ */
107
+ export const devBundle = (dev) => {
108
+ if (!dev) return null;
109
+ const v = String(dev);
110
+ const raw = /^\d{2,5}$/.test(v) ? `https://localhost:${v}` : /^[\w.-]+:\d{2,5}$/.test(v) ? `https://${v}` : v;
111
+ try {
112
+ const u = new URL(raw);
113
+ if (!/^https?:$/.test(u.protocol)) return null;
114
+ const port = Number(u.port || (u.protocol === 'https:' ? 443 : 80));
115
+ if (!(port > 0 && port < 65536)) return null;
116
+ return { origin: u.origin, bundle: u.pathname.length > 1 ? `${u.origin}${u.pathname}` : `${u.origin}/integration/__integration.ts` };
117
+ } catch {
118
+ return null;
119
+ }
120
+ };
121
+
122
+ /**
123
+ * A pattern of `--block` as a regular expression's source: a host
124
+ * (`cdn.vendor.com`: that host and its subdomains, any path), else the
125
+ * pattern itself when it is a valid regular expression; null otherwise.
126
+ * @param {string} x
127
+ */
128
+ export const blockPattern = (x) => {
129
+ const host = /^(\*\.)?([a-z0-9-]+\.)+[a-z]{2,}$/i.exec(x);
130
+ if (host) return `^https?://([a-z0-9-]+\\.)*${x.replace(/^\*\./, '').replace(/[.]/g, '\\.')}(:\\d+)?/`;
131
+ try {
132
+ new RegExp(x);
133
+ return x;
134
+ } catch {
135
+ return null;
136
+ }
137
+ };