@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,236 @@
|
|
|
1
|
+
// The reports of `yarn luma parity`: the terminal's (a scorecard, then per
|
|
2
|
+
// component and viewport the differences out of tolerance, theirs against
|
|
3
|
+
// ours, whole values), the HTML page (the same, with the crops side by side
|
|
4
|
+
// and the diff image) and the JSON. Pure functions (writeDeep aside), unit-tested.
|
|
5
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname } from 'node:path';
|
|
7
|
+
import { formatQueries } from './pairing.mjs';
|
|
8
|
+
|
|
9
|
+
/** Writes a report file, its folder made first (`--json out/parity.json` with no out/ yet). @param {string} file @param {string} text */
|
|
10
|
+
export const writeDeep = (file, text) => {
|
|
11
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
12
|
+
writeFileSync(file, text);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
16
|
+
const show = (v) => (typeof v === 'string' ? (v === '' ? '""' : v) : JSON.stringify(v));
|
|
17
|
+
|
|
18
|
+
/** The run as the JSON report holds it: `results` alone decides (two runs of one recording give the same `results`). */
|
|
19
|
+
export const toJson = (run, meta = {}) => ({
|
|
20
|
+
meta,
|
|
21
|
+
verdict: run.verdict,
|
|
22
|
+
todo: run.todo ?? [],
|
|
23
|
+
data: run.data ?? [],
|
|
24
|
+
decided: run.decided ?? [],
|
|
25
|
+
coverage: run.coverage ?? null,
|
|
26
|
+
pages: run.pages ?? [],
|
|
27
|
+
results: run.results,
|
|
28
|
+
aligned: run.aligned,
|
|
29
|
+
notes: run.notes,
|
|
30
|
+
stopped: run.stopped ?? null,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const SEV = { critical: 'CRITICAL', major: 'MAJOR', minor: 'minor' };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Per page, which of their pages it was compared against (the design
|
|
37
|
+
* source): the same page, or another (our search against their category
|
|
38
|
+
* listing), and whether their products were paired with ours.
|
|
39
|
+
* @param {{ page: string, theirs: string, ours: string, cross: boolean, paired: boolean }[]} pages
|
|
40
|
+
*/
|
|
41
|
+
export const formatPages = (pages = []) =>
|
|
42
|
+
pages.map((p) =>
|
|
43
|
+
p.cross
|
|
44
|
+
? `${p.page}: ours ${p.ours} against theirs ${p.theirs} (another page of theirs, the design source; ${p.paired ? 'their products paired with ours, the other texts by shape' : 'not paired: by structure only'})`
|
|
45
|
+
: `${p.page}: ${p.ours} on both sides${p.paired ? '' : ' (not paired)'}`,
|
|
46
|
+
);
|
|
47
|
+
/**
|
|
48
|
+
* Per page, how their products were asked for (the queries tried, each with
|
|
49
|
+
* what it found: parity/pairing.mjs), from its first viewport.
|
|
50
|
+
* @param {{ page: string, ids: number, requests: { found: number, asked: number, queries?: any[] }[] }[]} aligned
|
|
51
|
+
*/
|
|
52
|
+
export const formatPairing = (aligned = []) => {
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const a of aligned ?? []) {
|
|
56
|
+
if (seen.has(a.page)) continue;
|
|
57
|
+
seen.add(a.page);
|
|
58
|
+
const best = (a.requests ?? []).reduce((x, y) => (!x || y.found > x.found ? y : x), null);
|
|
59
|
+
out.push(best ? `${a.page}: their ${a.ids} products by id, ${best.found} paired${best.queries?.length ? `, asked with ${formatQueries(best.queries)}` : ''}` : `${a.page}: their ${a.ids} products, no request of ours to pair them with`);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
};
|
|
63
|
+
const at = (f) => `${f.viewports.map((v) => `${v}px`).join(', ')}`;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The top of the terminal report: the verdict, then the template's
|
|
67
|
+
* differences ranked (each with its selectors, both values and where to fix
|
|
68
|
+
* it), the data's for the client, the coverage. An agent fixes from this
|
|
69
|
+
* alone and runs again until it says CONVERGED.
|
|
70
|
+
* @param {any} run @param {{ color?: boolean, top?: number }} opts
|
|
71
|
+
*/
|
|
72
|
+
export const formatVerdict = (run, opts = {}) => {
|
|
73
|
+
const c = (code, s) => (opts.color ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
74
|
+
const v = run.verdict;
|
|
75
|
+
const out = [];
|
|
76
|
+
const cov = run.coverage;
|
|
77
|
+
const covLine = cov ? `coverage ${cov.items.filter((i) => i.status === 'mapped').length} mapped, ${cov.items.filter((i) => i.status === 'excluded').length} excluded, ${cov.missing.length} missing of ${cov.items.length}` : '';
|
|
78
|
+
const scores = v.templateScore == null ? 'nothing measured' : `template ${v.templateScore.toFixed(1)} (target ${v.target}), overall ${v.overall.toFixed(1)}`;
|
|
79
|
+
out.push(v.converged ? c('32;1', `CONVERGED: ${scores}; ${covLine}`) : c('31;1', `NOT CONVERGED: ${scores}; ${v.blocking ?? 0} CRITICAL/MAJOR template differences; ${covLine}`));
|
|
80
|
+
for (const r of v.converged ? [] : (v.reasons ?? [])) out.push(` - ${r}`);
|
|
81
|
+
const todo = run.todo ?? [];
|
|
82
|
+
const blocking = todo.filter((f) => f.severity !== 'minor');
|
|
83
|
+
const minor = todo.filter((f) => f.severity === 'minor');
|
|
84
|
+
const shownMinor = opts.top ? minor.slice(0, opts.top) : minor;
|
|
85
|
+
if (todo.length) {
|
|
86
|
+
out.push('', c('1', `TEMPLATE DIFFERENCES (yours to fix), by weight: ${blocking.length} CRITICAL/MAJOR, ${minor.length} minor`));
|
|
87
|
+
[...blocking, ...shownMinor].forEach((f, i) => {
|
|
88
|
+
const sev = f.severity === 'critical' ? c('31', SEV[f.severity]) : f.severity === 'major' ? c('33', SEV[f.severity]) : SEV[f.severity];
|
|
89
|
+
out.push(`${String(i + 1).padStart(3)}. ${sev} ${f.component}${f.part === 'root' ? '' : `.${f.part}`} ${f.prop}${f.also ? ` (and ${f.also.join(', ')}: currentColor)` : ''}: ${f.delta} (on ${f.page} at ${at(f)}; ${f.count} instance${f.count > 1 ? 's' : ''}, weight ${f.total})`);
|
|
90
|
+
if (f.selector) out.push(` selector: theirs ${f.selector.theirs ?? '-'} | ours ${f.selector.ours ?? '-'}`);
|
|
91
|
+
out.push(` theirs: ${show(f.theirs)}`);
|
|
92
|
+
out.push(` ours: ${show(f.ours)}`);
|
|
93
|
+
if (f.fix?.hint) out.push(` fix: ${f.fix.hint}`);
|
|
94
|
+
});
|
|
95
|
+
if (shownMinor.length < minor.length) out.push(` … ${minor.length - shownMinor.length} more minor (--all, or report.json "todo")`);
|
|
96
|
+
}
|
|
97
|
+
const data = run.data ?? [];
|
|
98
|
+
if (data.length) {
|
|
99
|
+
out.push('', c('1', `DATA DIFFERENCES (the catalog's or the API's, for the client): ${data.length}`));
|
|
100
|
+
for (const f of opts.top ? data.slice(0, opts.top * 2) : data) {
|
|
101
|
+
const ev = f.evidence ? `; the catalog's ${f.evidence.field}: ${JSON.stringify(f.evidence.catalog)}` : '';
|
|
102
|
+
out.push(` - ${f.component}${f.part === 'root' ? '' : `.${f.part}`} ${f.prop} (on ${f.page} at ${at(f)}): theirs ${show(f.theirs)}, ours ${show(f.ours)}${ev}${f.why && !f.evidence ? `; ${f.why}` : ''}`);
|
|
103
|
+
}
|
|
104
|
+
if (opts.top && data.length > opts.top * 2) out.push(` … ${data.length - opts.top * 2} more (--all, or report.json "data")`);
|
|
105
|
+
}
|
|
106
|
+
const decided = run.decided ?? [];
|
|
107
|
+
if (decided.length) {
|
|
108
|
+
out.push('', c('1', `DECIDED (recorded product decisions, "excluded" with a "decision"; not the template's): ${decided.length}`));
|
|
109
|
+
for (const f of opts.top ? decided.slice(0, opts.top) : decided) out.push(` - ${f.component}${f.part === 'root' ? '' : `.${f.part}`} ${f.prop} (on ${f.page} at ${at(f)}): theirs ${show(f.theirs)}, ours ${show(f.ours)}; ${f.why ?? ''}${f.decision ? ` [${f.decision}]` : ''}`);
|
|
110
|
+
}
|
|
111
|
+
if (cov) {
|
|
112
|
+
out.push('', c('1', `COVERAGE (mandatory components, docs/PARITY.md): ${covLine}`));
|
|
113
|
+
for (const i of cov.items) out.push(` ${i.status === 'missing' ? c('31', 'MISSING ') : i.status === 'excluded' ? 'excluded' : 'mapped '} ${i.id.padEnd(16)} ${i.status === 'mapped' ? i.by : i.status === 'excluded' ? `${i.reason}${i.decision ? ` (decision: ${[i.decision.decision, i.decision.by, i.decision.date].filter(Boolean).join(', ')})` : ''}` : 'map it (a component, or a part of one), or list it in "excluded" with a reason'}`);
|
|
114
|
+
}
|
|
115
|
+
return out.join('\n');
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Components x viewports: the score of each. */
|
|
119
|
+
export const scorecard = (results) => {
|
|
120
|
+
const widths = [...new Set(results.map((r) => r.viewport))].sort((a, b) => a - b);
|
|
121
|
+
const rows = new Map();
|
|
122
|
+
for (const r of results) {
|
|
123
|
+
const k = `${r.component} (${r.page})`;
|
|
124
|
+
if (!rows.has(k)) rows.set(k, {});
|
|
125
|
+
rows.get(k)[r.viewport] = r;
|
|
126
|
+
}
|
|
127
|
+
// `scores`: the overall score; `template`: the template's (the data's differences count as passed).
|
|
128
|
+
return { widths, rows: [...rows.entries()].map(([name, by]) => ({ name, scores: widths.map((w) => by[w]?.score ?? null), template: widths.map((w) => by[w]?.templateScore ?? by[w]?.score ?? null) })) };
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {{ results: any[], verdict: any, aligned: any[], notes: string[], stopped?: string | null }} run
|
|
133
|
+
* @param {{ color?: boolean, top?: number, title?: string, meta?: string[], thresholds?: any }} opts
|
|
134
|
+
*/
|
|
135
|
+
export const formatText = (run, opts = {}) => {
|
|
136
|
+
const c = (code, s) => (opts.color ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
137
|
+
const out = [];
|
|
138
|
+
out.push(c('1', opts.title ?? 'yarn luma parity'));
|
|
139
|
+
for (const m of opts.meta ?? []) out.push(` ${m}`);
|
|
140
|
+
const pages = formatPages(run.pages);
|
|
141
|
+
if (pages.length) out.push('', c('1', 'PAGES (ours, and the page of theirs it is compared against)'), ...pages.map((p) => ` ${p}`));
|
|
142
|
+
out.push(...formatPairing(run.aligned).map((p) => ` ${p}`));
|
|
143
|
+
if (run.stopped) out.push(c('31', `STOPPED: ${run.stopped}`));
|
|
144
|
+
out.push('', formatVerdict(run, opts), '', c('1', 'DETAILS (every component and viewport; template and overall scores)'));
|
|
145
|
+
const { widths, rows } = scorecard(run.results);
|
|
146
|
+
if (rows.length) {
|
|
147
|
+
const w0 = Math.max(9, ...rows.map((r) => r.name.length));
|
|
148
|
+
const cell = (t, o) => (t == null ? '-' : t === o || o == null ? t.toFixed(1) : `${t.toFixed(1)}/${o.toFixed(1)}`);
|
|
149
|
+
out.push('', `${'component'.padEnd(w0)} ${widths.map((w) => `${w}px`.padStart(12)).join('')} (the template score; /overall when the data's differences lower it)`);
|
|
150
|
+
for (const r of rows) out.push(`${r.name.padEnd(w0)} ${r.template.map((t, i) => cell(t, r.scores[i]).padStart(12)).join('')}`);
|
|
151
|
+
}
|
|
152
|
+
for (const r of run.results) {
|
|
153
|
+
const th = opts.thresholds?.components?.[r.component] ?? opts.thresholds?.score ?? 0;
|
|
154
|
+
const ts = r.templateScore ?? r.score;
|
|
155
|
+
const pg = (run.pages ?? []).find((x) => x.page === r.page);
|
|
156
|
+
const head = `${r.component} on ${r.page}${pg?.cross ? ` (against their ${pg.theirs})` : ''} at ${r.viewport}px: ${r.score == null ? 'not found on either side' : ts === r.score ? r.score.toFixed(1) : `template ${ts.toFixed(1)}, overall ${r.score.toFixed(1)}`}`;
|
|
157
|
+
const found = r.found ? ` (theirs ${r.found.theirs}, ours ${r.found.ours}, compared ${r.instances.length})` : '';
|
|
158
|
+
out.push('', `${ts != null && ts < th ? c('31', head) : c('1', head)}${found}`);
|
|
159
|
+
if (!r.instances.length) out.push(' nothing to compare: the component is not on one of the pages');
|
|
160
|
+
const list = opts.top ? r.diffs.slice(0, opts.top) : r.diffs;
|
|
161
|
+
for (const d of list) {
|
|
162
|
+
const sev = `${d.owner === 'data' ? 'DATA ' : ''}${d.severity === 'critical' ? c('31', 'CRITICAL ') : d.severity === 'minor' ? 'minor ' : ''}`;
|
|
163
|
+
const n = r.instances.length > 1 ? ` (${d.count} of ${r.instances.length})` : '';
|
|
164
|
+
out.push(` ${sev}${d.part} ${d.prop}: ${d.delta}${n}`);
|
|
165
|
+
out.push(` theirs: ${show(d.theirs)}`);
|
|
166
|
+
out.push(` ours: ${show(d.ours)}`);
|
|
167
|
+
}
|
|
168
|
+
if (opts.top && r.diffs.length > opts.top) out.push(` … ${r.diffs.length - opts.top} more (--all, or the HTML/JSON report)`);
|
|
169
|
+
}
|
|
170
|
+
for (const a of run.aligned ?? [])
|
|
171
|
+
for (const q of a.requests ?? [])
|
|
172
|
+
if (q.missing?.length) out.push('', c('33', `${a.page} ${a.viewport}px: ${q.missing.length} of ${q.asked} of their products not found by the API (${q.endpoint}${q.queries?.length ? `; asked with ${formatQueries(q.queries)}` : ''}): ${q.missing.join(', ')}`));
|
|
173
|
+
if (run.notes?.length) out.push('', ...run.notes.map((n) => c('33', `note: ${n}`)));
|
|
174
|
+
const v = run.verdict;
|
|
175
|
+
const conv = v.converged == null ? '' : v.converged ? c('32', '; CONVERGED') : c('31', '; NOT CONVERGED (the top of this report says what is left)');
|
|
176
|
+
out.push('', v.overall == null ? 'Nothing was measured.' : `${v.templateScore != null ? `Template ${v.templateScore.toFixed(1)} (target ${v.target}), overall` : 'Overall'} ${v.overall.toFixed(1)}; ${v.failing.length ? c('31', `${v.failing.length} under threshold: ${v.failing.map((f) => `${f.component}@${f.viewport} ${f.score} < ${f.threshold}`).join(', ')}`) : c('32', 'every component at or over its threshold')}${conv}`);
|
|
177
|
+
return out.join('\n');
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** The HTML report: scorecard, then per component and viewport the differences and the crops (theirs, ours, diff). */
|
|
181
|
+
export const formatHtml = (run, opts = {}) => {
|
|
182
|
+
const { widths, rows } = scorecard(run.results);
|
|
183
|
+
const cls = (s, th) => (s == null ? '' : s >= th ? 'ok' : 'bad');
|
|
184
|
+
const th = (comp) => opts.thresholds?.components?.[comp] ?? opts.thresholds?.score ?? 0;
|
|
185
|
+
const sections = run.results
|
|
186
|
+
.map((r) => {
|
|
187
|
+
const diffs = r.diffs
|
|
188
|
+
.map((d) => `<tr class="${d.severity}"><td>${esc(d.owner ?? 'template')}</td><td>${esc(d.part)}</td><td>${esc(d.prop)}</td><td><code>${esc(show(d.theirs))}</code></td><td><code>${esc(show(d.ours))}</code></td><td>${esc(d.delta)}</td><td>${d.count}/${r.instances.length}</td></tr>`)
|
|
189
|
+
.join('');
|
|
190
|
+
const crops = r.instances
|
|
191
|
+
.filter((i) => i.pixels?.images)
|
|
192
|
+
.map((i) => `<figure><figcaption>${esc(i.key)} · ${i.score == null ? '-' : i.score.toFixed(1)} · ${(i.pixels.ratio * 100).toFixed(2)}% pixels differ</figcaption><div class="crops"><img src="${esc(i.pixels.images.theirs)}" alt="theirs"><img src="${esc(i.pixels.images.ours)}" alt="ours"><img src="${esc(i.pixels.images.diff)}" alt="diff"></div></figure>`)
|
|
193
|
+
.join('');
|
|
194
|
+
const ts = r.templateScore ?? r.score;
|
|
195
|
+
return `<section><h2 class="${cls(ts, th(r.component))}">${esc(r.component)} on ${esc(r.page)}${r.against ? ` (theirs ${esc(r.against)})` : ''} at ${r.viewport}px: ${r.score == null ? 'not found on either side' : `template ${ts.toFixed(1)}, overall ${r.score.toFixed(1)}`}</h2>
|
|
196
|
+
${diffs ? `<table><thead><tr><th>owner</th><th>part</th><th>property</th><th>theirs</th><th>ours</th><th>difference</th><th>instances</th></tr></thead><tbody>${diffs}</tbody></table>` : '<p class="ok">No difference out of tolerance.</p>'}
|
|
197
|
+
${crops ? `<p class="legend">theirs · ours · diff</p>${crops}` : ''}</section>`;
|
|
198
|
+
})
|
|
199
|
+
.join('\n');
|
|
200
|
+
const card = `<table class="card"><thead><tr><th>component</th>${widths.map((w) => `<th>${w}px</th>`).join('')}</tr></thead><tbody>${rows
|
|
201
|
+
.map((r) => `<tr><td>${esc(r.name)}</td>${r.template.map((s, i) => `<td class="${cls(s, th(r.name.replace(/ \(.*$/, '')))}">${s == null ? '-' : s.toFixed(1)}${r.scores[i] != null && r.scores[i] !== s ? ` <small>/ ${r.scores[i].toFixed(1)}</small>` : ''}</td>`).join('')}</tr>`)
|
|
202
|
+
.join('')}</tbody></table>`;
|
|
203
|
+
const v = run.verdict;
|
|
204
|
+
const todo = (run.todo ?? [])
|
|
205
|
+
.map((f, i) => `<tr class="${f.severity}"><td>${i + 1}</td><td>${esc(f.severity)}</td><td>${esc(f.component)}${f.part === 'root' ? '' : `.${esc(f.part)}`}</td><td>${esc(f.prop)}</td><td><code>${esc(f.selector?.theirs ?? '')}</code><br><code>${esc(f.selector?.ours ?? '')}</code></td><td><code>${esc(show(f.theirs))}</code></td><td><code>${esc(show(f.ours))}</code></td><td>${esc(f.fix?.hint ?? '')}</td><td>${f.viewports.join(', ')}</td><td>${f.total}</td></tr>`)
|
|
206
|
+
.join('');
|
|
207
|
+
const data = (run.data ?? []).map((f) => `<tr><td>${esc(f.component)}${f.part === 'root' ? '' : `.${esc(f.part)}`}</td><td>${esc(f.prop)}</td><td><code>${esc(show(f.theirs))}</code></td><td><code>${esc(show(f.ours))}</code></td><td>${esc(f.evidence ? `the catalog's ${f.evidence.field}: ${JSON.stringify(f.evidence.catalog)}` : (f.why ?? ''))}</td><td>${f.viewports.join(', ')}</td></tr>`).join('');
|
|
208
|
+
const cov = run.coverage ? run.coverage.items.map((i) => `<tr class="${i.status === 'missing' ? 'critical' : ''}"><td>${esc(i.id)}</td><td>${esc(i.status)}</td><td>${esc(i.by ?? i.reason ?? 'map it, or list it in "excluded" with a reason')}</td></tr>`).join('') : '';
|
|
209
|
+
const top = `<p><strong class="${v.converged ? 'ok' : 'bad'}">${v.converged ? 'Converged' : 'Not converged'}</strong> · template ${v.templateScore == null ? '-' : v.templateScore.toFixed(1)} (target ${v.target ?? '-'}) · overall ${v.overall == null ? '-' : v.overall.toFixed(1)}</p>${(v.reasons ?? []).length && !v.converged ? `<ul>${v.reasons.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` : ''}
|
|
210
|
+
${todo ? `<h2>Template differences (to fix), by weight</h2><table><thead><tr><th>#</th><th>severity</th><th>where</th><th>property</th><th>selector (theirs, ours)</th><th>theirs</th><th>ours</th><th>fix</th><th>viewports</th><th>weight</th></tr></thead><tbody>${todo}</tbody></table>` : ''}
|
|
211
|
+
${data ? `<h2>Data differences (for the client)</h2><table><thead><tr><th>where</th><th>property</th><th>theirs</th><th>ours</th><th>evidence</th><th>viewports</th></tr></thead><tbody>${data}</tbody></table>` : ''}
|
|
212
|
+
${cov ? `<h2>Coverage</h2><table><tbody>${cov}</tbody></table>` : ''}`;
|
|
213
|
+
const notes = [...(run.stopped ? [`STOPPED: ${run.stopped}`] : []), ...(run.notes ?? [])].map((n) => `<li>${esc(n)}</li>`).join('');
|
|
214
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Parity report</title>
|
|
215
|
+
<style>
|
|
216
|
+
:root { --bg: #fff; --fg: #1d1d1f; --muted: #6b6b70; --line: #e3e3e8; --ok: #0a7a3a; --bad: #b3261e; --crit: #fdecea; }
|
|
217
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #161618; --fg: #ececf0; --muted: #a0a0a8; --line: #33333a; --ok: #5fd08a; --bad: #ff7b72; --crit: #3a1d1b; } }
|
|
218
|
+
body { background: var(--bg); color: var(--fg); font: 14px/1.45 system-ui, sans-serif; margin: 0 auto; padding: 16px; max-width: 1400px; }
|
|
219
|
+
h1 { font-size: 20px; } h2 { font-size: 16px; margin-top: 32px; } .ok { color: var(--ok); } .bad { color: var(--bad); }
|
|
220
|
+
table { border-collapse: collapse; width: 100%; margin: 8px 0; } th, td { border-bottom: 1px solid var(--line); padding: 4px 8px; text-align: left; vertical-align: top; }
|
|
221
|
+
td code { white-space: pre-wrap; word-break: break-all; } tr.critical td { background: var(--crit); } .card td:not(:first-child), .card th:not(:first-child) { text-align: right; }
|
|
222
|
+
.meta, .legend, figcaption { color: var(--muted); } .crops { display: flex; gap: 8px; align-items: flex-start; overflow-x: auto; } .crops img { border: 1px solid var(--line); max-width: 32%; height: auto; }
|
|
223
|
+
figure { margin: 12px 0; }
|
|
224
|
+
</style></head><body>
|
|
225
|
+
<h1>${esc(opts.title ?? 'Parity report')}</h1>
|
|
226
|
+
<p class="meta">${(opts.meta ?? []).map(esc).join('<br>')}</p>
|
|
227
|
+
${(run.pages ?? []).length ? `<h2>Pages (ours, and the page of theirs it is compared against)</h2><ul>${formatPages(run.pages).map((p) => `<li>${esc(p)}</li>`).join('')}</ul>` : ''}
|
|
228
|
+
${top}
|
|
229
|
+
<h2>Scores</h2>
|
|
230
|
+
<p><strong>Overall ${run.verdict.overall == null ? '-' : run.verdict.overall.toFixed(1)}</strong>${run.verdict.failing.length ? ` · <span class="bad">${run.verdict.failing.length} under threshold</span>` : ' · <span class="ok">at or over every threshold</span>'}</p>
|
|
231
|
+
${card}
|
|
232
|
+
${notes ? `<ul>${notes}</ul>` : ''}
|
|
233
|
+
${sections}
|
|
234
|
+
</body></html>
|
|
235
|
+
`;
|
|
236
|
+
};
|
package/parity/run.mjs
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// yarn luma parity: does the integration look exactly like the site it replaces?
|
|
3
|
+
// Run from the integration repository (Luma as the @luigisbox/luma package, or
|
|
4
|
+
// an older luma/ submodule):
|
|
5
|
+
//
|
|
6
|
+
// yarn luma parity (the integration's one `luma` script: bin/luma.mjs)
|
|
7
|
+
// yarn luma parity --record the live site once into parity/recording/ (pages, assets, API answers)
|
|
8
|
+
// yarn luma parity replays the recording: offline, the same numbers every run
|
|
9
|
+
// yarn luma parity --live the live site, nothing recorded
|
|
10
|
+
// yarn luma parity --init a draft map (integration/parity.draft.json) to confirm
|
|
11
|
+
//
|
|
12
|
+
// For every page and viewport of the map (integration/parity.json,
|
|
13
|
+
// parity/map.mjs), their page and ours (the same page with the build in it,
|
|
14
|
+
// its listing showing the products theirs shows), every mapped component
|
|
15
|
+
// measured property by property and as pixels (parity/engine.mjs), every
|
|
16
|
+
// difference the template's or the data's (parity/owner.mjs). The terminal's
|
|
17
|
+
// top section is the verdict and the template's differences ranked, each
|
|
18
|
+
// with where to fix it: an agent loops on `yarn luma parity --target <n>` until it
|
|
19
|
+
// converges. Exit code: 0 converged (the template score at or over --target,
|
|
20
|
+
// default 98; no CRITICAL or MAJOR template difference; every mandatory
|
|
21
|
+
// component mapped or excluded), 1 not yet, 2 nothing could be compared (or
|
|
22
|
+
// the run broke). docs/PARITY.md.
|
|
23
|
+
import { execFileSync } from 'node:child_process';
|
|
24
|
+
import { cpSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { tmpdir } from 'node:os';
|
|
26
|
+
import { dirname, extname, join, normalize as normPath, relative, resolve } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const LUMA = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
|
+
const { USAGE, devBundle, parseArgs } = await import('./args.mjs');
|
|
31
|
+
const { TARGET } = await import('./compare.mjs');
|
|
32
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
33
|
+
if (parsed.help) {
|
|
34
|
+
console.log(USAGE);
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
if (parsed.error) die(`${parsed.error}\n\n${USAGE}`);
|
|
38
|
+
const opt = parsed.opt;
|
|
39
|
+
|
|
40
|
+
function die(msg, code = 2) {
|
|
41
|
+
console.error(msg);
|
|
42
|
+
process.exit(code);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const SITE_DIR = [opt.siteDir, process.env.LBX_SITE_DIR, process.cwd(), dirname(LUMA)].filter(Boolean).map((d) => resolve(d)).find((d) => existsSync(join(d, 'integration')));
|
|
46
|
+
if (!SITE_DIR) die('No integration found: run yarn luma parity in the integration repository (the folder with integration/), or pass --site-dir.');
|
|
47
|
+
process.env.LBX_SITE_DIR = SITE_DIR;
|
|
48
|
+
|
|
49
|
+
for (const dep of ['playwright-core', 'pixelmatch', 'pngjs']) {
|
|
50
|
+
try {
|
|
51
|
+
await import(dep);
|
|
52
|
+
} catch {
|
|
53
|
+
die(`Luma's dependencies are not installed (${dep} is missing): run \`yarn install\`.`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const { chromium } = await import('playwright-core');
|
|
57
|
+
const { BLOCK_LUIGIS, BLOCK_OURS, BLOCK_THEIRS, normalize, recordedPages } = await import('./map.mjs');
|
|
58
|
+
const { Archive } = await import('./archive.mjs');
|
|
59
|
+
const { SNIPPET, holdInit } = await import('./hold.mjs');
|
|
60
|
+
const { runParity } = await import('./engine.mjs');
|
|
61
|
+
const { formatHtml, formatText, toJson, writeDeep } = await import('./report.mjs');
|
|
62
|
+
|
|
63
|
+
const MAP_FILE = resolve(SITE_DIR, opt.map || 'integration/parity.json');
|
|
64
|
+
let rawMap = null;
|
|
65
|
+
if (existsSync(MAP_FILE)) {
|
|
66
|
+
try {
|
|
67
|
+
rawMap = JSON.parse(readFileSync(MAP_FILE, 'utf8'));
|
|
68
|
+
} catch (e) {
|
|
69
|
+
die(`${relative(process.cwd(), MAP_FILE)} is not JSON: ${e.message}`);
|
|
70
|
+
}
|
|
71
|
+
} else if (opt.init == null) die(`No map at ${relative(process.cwd(), MAP_FILE)}: propose one with yarn luma parity --init (docs/PARITY.md).`);
|
|
72
|
+
|
|
73
|
+
const HOST = opt.host || process.env.LBX_HOST || rawMap?.host;
|
|
74
|
+
if (!HOST) die('No storefront host: "host" in the map, LBX_HOST or --host.');
|
|
75
|
+
const ORIGIN = `https://${HOST}`;
|
|
76
|
+
const ARCHIVE = resolve(SITE_DIR, opt.archive || 'parity/recording');
|
|
77
|
+
const DIST = join(SITE_DIR, 'dist');
|
|
78
|
+
// The dev server (--dev [url|port]): its origin and the integration's entry on it.
|
|
79
|
+
const DEV = opt.dev ? devBundle(opt.dev) : null;
|
|
80
|
+
const BUNDLE_PATH = '/__lbx/parity/';
|
|
81
|
+
const BUNDLE = DEV ? DEV.bundle : `${ORIGIN}${BUNDLE_PATH}luma-integration.js`;
|
|
82
|
+
|
|
83
|
+
if (!opt.dev && opt.build) {
|
|
84
|
+
process.stderr.write(`building ${relative(process.cwd(), SITE_DIR) || '.'} (yarn build)… `);
|
|
85
|
+
try {
|
|
86
|
+
execFileSync('yarn', ['build'], { cwd: SITE_DIR, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
87
|
+
process.stderr.write('done\n');
|
|
88
|
+
} catch (e) {
|
|
89
|
+
process.stderr.write('failed\n');
|
|
90
|
+
die(`yarn build failed:\n${e.stdout ?? ''}${e.stderr ?? ''}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!opt.dev && !existsSync(join(DIST, 'luma-integration.js'))) die(`${DIST}/luma-integration.js is missing: run yarn build (or drop --no-build).`);
|
|
94
|
+
|
|
95
|
+
// ------------------------------------------------------------------ the map
|
|
96
|
+
|
|
97
|
+
let map;
|
|
98
|
+
if (rawMap) {
|
|
99
|
+
const { map: m, errors } = normalize(rawMap);
|
|
100
|
+
if (errors.length) die(`${relative(process.cwd(), MAP_FILE)}:\n${errors.map((e) => ` ${e}`).join('\n')}`);
|
|
101
|
+
map = m;
|
|
102
|
+
if (opt.viewports) map.viewports = opt.viewports;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ------------------------------------------------------------------ browser and driver
|
|
106
|
+
|
|
107
|
+
const TYPES = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.map': 'application/json', '.svg': 'image/svg+xml', '.woff2': 'font/woff2' };
|
|
108
|
+
const mode = opt.init != null && opt.mode === 'replay' && !existsSync(join(ARCHIVE, 'index.json')) ? 'live' : opt.mode;
|
|
109
|
+
const archive = mode === 'live' ? null : new Archive(ARCHIVE, { mode: mode === 'record' ? 'record' : 'replay', merge: opt.only.length > 0, ignore: map?.archive.ignore ?? undefined, skip: (u) => u.startsWith(`${ORIGIN}${BUNDLE_PATH}`) || (!!DEV && u.startsWith(`${DEV.origin}/`)) });
|
|
110
|
+
const locale = map?.locale ?? rawMap?.locale ?? 'en-US';
|
|
111
|
+
const timezoneId = map?.timezone ?? rawMap?.timezone ?? 'UTC';
|
|
112
|
+
const ctxOpts = { viewport: { width: 1440, height: map?.height ?? 900 }, deviceScaleFactor: 1, locale, timezoneId, serviceWorkers: 'block', ignoreHTTPSErrors: !!opt.dev, colorScheme: 'light', reducedMotion: 'no-preference' };
|
|
113
|
+
|
|
114
|
+
let browser = null;
|
|
115
|
+
let persistent = null;
|
|
116
|
+
let profileCopy = '';
|
|
117
|
+
if (opt.profile) {
|
|
118
|
+
const src = resolve(opt.profile.replace(/^~(?=\/)/, process.env.HOME ?? '~'));
|
|
119
|
+
if (!existsSync(src)) die(`--profile ${src}: no such folder (yarn luma chrome keeps one per host under ~/.lbx-chrome/<host>)`);
|
|
120
|
+
profileCopy = mkdtempSync(join(tmpdir(), 'lbx-parity-profile-'));
|
|
121
|
+
cpSync(src, profileCopy, { recursive: true, filter: (p) => !/Singleton(Lock|Socket|Cookie)$|\/(Cache|Code Cache|GPUCache|Service Worker)(\/|$)/.test(p) });
|
|
122
|
+
// Chrome itself (the profile is Chrome's), its real keychain (the profile's cookies are encrypted with it),
|
|
123
|
+
// always headed (args.mjs: a profile is for a session a person sees).
|
|
124
|
+
persistent = await chromium.launchPersistentContext(profileCopy, { ...ctxOpts, channel: 'chrome', headless: !opt.headed, ignoreDefaultArgs: ['--use-mock-keychain'] });
|
|
125
|
+
} else {
|
|
126
|
+
const o = { headless: !opt.headed };
|
|
127
|
+
for (const t of [...(process.env.LBX_CHROMIUM_PATH ? [{ ...o, executablePath: process.env.LBX_CHROMIUM_PATH }] : []), { ...o, channel: 'chromium' }, { ...o, channel: 'chrome' }]) {
|
|
128
|
+
try {
|
|
129
|
+
browser = await chromium.launch(t);
|
|
130
|
+
break;
|
|
131
|
+
} catch {}
|
|
132
|
+
}
|
|
133
|
+
if (!browser) die('No Chromium to run: install Chrome, or `yarn playwright-core install chromium`, or set LBX_CHROMIUM_PATH.');
|
|
134
|
+
}
|
|
135
|
+
const contexts = {};
|
|
136
|
+
// One context per side and device (a phone width: isMobile, touch, a phone's user agent: engine.mjs deviceOf).
|
|
137
|
+
// A --profile is one context, a desktop's: its phone widths are a narrow desktop window (said in the notes).
|
|
138
|
+
const context = async (side, device = {}) => persistent ?? (contexts[`${side} ${JSON.stringify(device)}`] ??= await browser.newContext({ ...ctxOpts, ...device }));
|
|
139
|
+
let profilePhone = false;
|
|
140
|
+
let profileShared = false;
|
|
141
|
+
|
|
142
|
+
// The map's blocks (default: Luigi's Box scripts on ours, the platform's loader on theirs), and --block's vendor scripts on both.
|
|
143
|
+
const blocks = (side) => [...(map?.block?.[side] ?? (side === 'ours' ? BLOCK_OURS : BLOCK_THEIRS)), ...opt.block].map((s) => new RegExp(s));
|
|
144
|
+
let lastLoad = 0;
|
|
145
|
+
const driver = {
|
|
146
|
+
origin: ORIGIN,
|
|
147
|
+
/** Live: their site's pages at least gapMs apart (both sides load their page). */
|
|
148
|
+
wait: async () => {
|
|
149
|
+
if (mode === 'replay') return;
|
|
150
|
+
const gap = map?.gapMs ?? 2000;
|
|
151
|
+
const w = lastLoad + gap - Date.now();
|
|
152
|
+
if (w > 0) await new Promise((r) => setTimeout(r, w));
|
|
153
|
+
lastLoad = Date.now();
|
|
154
|
+
},
|
|
155
|
+
/**
|
|
156
|
+
* A fresh page on a side. `o.isolated`: in a context of its own (its own cookies and storage: an
|
|
157
|
+
* interaction's actions never reach the other pages), closed with the page; `o.scope`: its requests
|
|
158
|
+
* recorded and replayed under that scope (archive.mjs). Ours: Luma as a customer installs it, the
|
|
159
|
+
* early-hold snippet first in <head>, then the build (parity/hold.mjs; `o.snippet: false` leaves the
|
|
160
|
+
* snippet out).
|
|
161
|
+
*/
|
|
162
|
+
page: async (side, o = {}) => {
|
|
163
|
+
if (persistent && o.device?.isMobile) profilePhone = true;
|
|
164
|
+
const own = o.isolated && !persistent ? await browser.newContext({ ...ctxOpts, ...(o.device ?? {}) }) : null;
|
|
165
|
+
if (o.isolated && persistent) profileShared = true;
|
|
166
|
+
const ctx = own ?? (await context(side, o.device ?? {}));
|
|
167
|
+
const page = await ctx.newPage();
|
|
168
|
+
if (own) page.once('close', () => own.close().catch(() => {}));
|
|
169
|
+
if (archive && o.scope) archive.scope(page, o.scope);
|
|
170
|
+
if (archive) await archive.attach(page);
|
|
171
|
+
const bl = blocks(side);
|
|
172
|
+
if (bl.length) await page.route((u) => bl.some((re) => re.test(u.href)), (r) => r.abort('blockedbyclient'));
|
|
173
|
+
if (side === 'ours') {
|
|
174
|
+
await page.route(`${ORIGIN}${BUNDLE_PATH}**`, (route) => {
|
|
175
|
+
const url = new URL(route.request().url());
|
|
176
|
+
const file = normPath(join(DIST, decodeURIComponent(url.pathname.slice(BUNDLE_PATH.length))));
|
|
177
|
+
if (!file.startsWith(DIST) || !existsSync(file) || !statSync(file).isFile()) return route.fulfill({ status: 404, body: '' });
|
|
178
|
+
return route.fulfill({ status: 200, body: readFileSync(file), headers: { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream', 'cache-control': 'no-store' } });
|
|
179
|
+
});
|
|
180
|
+
// As a customer installs it: the early-hold snippet first in <head>, then the build (docs/PARITY.md, "The hold").
|
|
181
|
+
await page.addInitScript(holdInit, { origin: ORIGIN, bundle: BUNDLE, snippet: o.snippet === false ? '' : SNIPPET });
|
|
182
|
+
}
|
|
183
|
+
return page;
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const close = async () => {
|
|
188
|
+
await archive?.flush();
|
|
189
|
+
await persistent?.close().catch(() => {});
|
|
190
|
+
await browser?.close().catch(() => {});
|
|
191
|
+
if (profileCopy) rmSync(profileCopy, { recursive: true, force: true });
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// ------------------------------------------------------------------ --init
|
|
195
|
+
|
|
196
|
+
if (opt.init != null) {
|
|
197
|
+
const { initMap, initPages } = await import('./init.mjs');
|
|
198
|
+
const { loadConfig } = await import('../bin/config.mjs');
|
|
199
|
+
const F = (await loadConfig(SITE_DIR)).fixtures;
|
|
200
|
+
const pages = initPages({ paths: opt.paths, category: F.category ?? null, query: F.query ?? null, theirPlp: opt.theirPlp || null, ourSearch: opt.ourSearch || null });
|
|
201
|
+
const draft = await initMap({ driver, host: HOST, pages: Object.fromEntries(Object.entries(pages).filter(([, p]) => p)), query: F.query ?? 'a', input: F.input ?? rawMap?.input ?? null, block: [...BLOCK_LUIGIS, ...opt.block], log: (s) => process.stderr.write(`${s}\n`) }).finally(close);
|
|
202
|
+
const file = resolve(SITE_DIR, opt.init || 'integration/parity.draft.json');
|
|
203
|
+
if (file === MAP_FILE) die(`--init never overwrites the map (${relative(process.cwd(), MAP_FILE)}): give it another file.`);
|
|
204
|
+
writeDeep(file, `${JSON.stringify(draft, null, 2)}\n`);
|
|
205
|
+
console.log(`Draft map: ${relative(process.cwd(), file)}\nConfirm every selector (the guesses are listed in "confirm"), then move it to ${relative(process.cwd(), MAP_FILE)}.`);
|
|
206
|
+
process.exit(0);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ------------------------------------------------------------------ the run
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* For the fix hints (parity/hints.mjs): the integration's templates and
|
|
213
|
+
* styles (integration/**: *.ts, *.css), and Luma's built-in templates
|
|
214
|
+
* (src/…: the classes of the elements its markers name).
|
|
215
|
+
*/
|
|
216
|
+
function integrationFiles() {
|
|
217
|
+
const out = [];
|
|
218
|
+
const walk = (base, dir, keep) => {
|
|
219
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
220
|
+
const p = join(dir, e.name);
|
|
221
|
+
if (e.isDirectory()) walk(base, p, keep);
|
|
222
|
+
else if (keep(e.name)) out.push({ path: relative(base, p).split('\\').join('/'), text: readFileSync(p, 'utf8') });
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
walk(SITE_DIR, join(SITE_DIR, 'integration'), (n) => /\.(ts|css)$/.test(n));
|
|
226
|
+
walk(LUMA, join(LUMA, 'src/widgets'), (n) => /^template-.*\.ts$/.test(n));
|
|
227
|
+
walk(LUMA, join(LUMA, 'src/core'), (n) => n === 'image-tags.ts');
|
|
228
|
+
return out.sort((a, b) => (a.path < b.path ? -1 : 1));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const OUT = resolve(opt.out);
|
|
232
|
+
rmSync(OUT, { recursive: true, force: true });
|
|
233
|
+
let run;
|
|
234
|
+
try {
|
|
235
|
+
run = await runParity({ map, driver, out: OUT, only: opt.only, target: opt.target, files: integrationFiles(), log: (s) => process.stderr.write(` ${s}\n`) });
|
|
236
|
+
} catch (e) {
|
|
237
|
+
await close();
|
|
238
|
+
die(`the run broke: ${e.stack ?? e}`);
|
|
239
|
+
}
|
|
240
|
+
// Both paths of every page: ours, and the page of theirs it is compared against.
|
|
241
|
+
await archive?.save({ host: HOST, recorded: new Date().toISOString(), viewports: map.viewports, pages: recordedPages(map) });
|
|
242
|
+
await close();
|
|
243
|
+
|
|
244
|
+
const recorded = archive?.meta?.recorded;
|
|
245
|
+
if (profileShared) run.notes.push('--profile is one context: the interactions and the hold check ran in it (cookies and storage shared with the other pages); run without --profile for contexts of their own');
|
|
246
|
+
if (profilePhone) run.notes.push('--profile is one desktop context: the phone widths were measured as a narrow desktop window, not as a phone (no touch, no phone user agent); run without --profile for a phone\'s layout');
|
|
247
|
+
const meta = [
|
|
248
|
+
`${SITE_DIR} on ${ORIGIN}`,
|
|
249
|
+
mode === 'replay' ? `replay of ${relative(process.cwd(), ARCHIVE) || ARCHIVE}${recorded ? ` (recorded ${recorded.slice(0, 16).replace('T', ' ')})` : ''}` : mode === 'record' ? `live, recorded into ${relative(process.cwd(), ARCHIVE)}` : 'live, nothing recorded',
|
|
250
|
+
`viewports ${map.viewports.join(', ')} x ${map.height}, DPR 1, ${locale}, ${timezoneId}; build: ${DEV ? DEV.bundle : 'dist/'}`,
|
|
251
|
+
];
|
|
252
|
+
if (archive && mode === 'replay' && archive.stats.missed.length) {
|
|
253
|
+
const api = archive.stats.missed.filter((k) => /luigisbox\.ai\/discovery\//.test(k));
|
|
254
|
+
run.notes.push(`${archive.stats.missed.length} requests not in the recording were aborted (the site's analytics and beacons are expected here)`);
|
|
255
|
+
if (api.length) run.notes.push(`${api.length} API requests not in the recording: the build (or the map's ids) asks for other data than when it was recorded; re-record (yarn luma parity --record): ${api.slice(0, 3).join(' | ')}`);
|
|
256
|
+
}
|
|
257
|
+
const textOpts = { color: process.stdout.isTTY && !process.env.NO_COLOR, top: opt.all ? 0 : 12, title: `yarn luma parity: ${HOST}`, meta, thresholds: map.thresholds };
|
|
258
|
+
console.log(formatText(run, textOpts));
|
|
259
|
+
const json = toJson(run, { host: HOST, mode, viewports: map.viewports, target: opt.target, archive: archive ? { served: archive.stats.served, fuzzy: archive.stats.fuzzy, missed: archive.stats.missed.length } : null });
|
|
260
|
+
writeFileSync(join(OUT, 'report.json'), `${JSON.stringify(json, null, 1)}\n`);
|
|
261
|
+
writeFileSync(join(OUT, 'index.html'), formatHtml(run, { ...textOpts, color: false }));
|
|
262
|
+
// --json <file>: its folder made first.
|
|
263
|
+
if (opt.json) writeDeep(resolve(opt.json), `${JSON.stringify(json, null, 1)}\n`);
|
|
264
|
+
console.log(`\nReport: ${relative(process.cwd(), join(OUT, 'index.html'))} (JSON: ${relative(process.cwd(), join(OUT, 'report.json'))})`);
|
|
265
|
+
if (run.stopped) console.log("A bot challenge stopped the run; it is never worked around: run it on the client's own site with their knowledge, or ask them to allowlist the runner.");
|
|
266
|
+
if (run.verdict.exitCode === 1) console.log(`Not converged: fix the template differences at the top (report.json "todo", in order), map or exclude what coverage lists, then run yarn luma parity${opt.target !== TARGET ? ` --target ${opt.target}` : ''} again. The data differences ("data") are the client's.`);
|
|
267
|
+
process.exit(run.stopped && !run.results.length ? 2 : run.verdict.exitCode);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Luigi's Box early hold: the inline snippet a site puts first in <head>, next
|
|
3
|
+
* to the async LBX loader. Identical for every client (no ids, no rules): the
|
|
4
|
+
* rules come from localStorage, written by the bundle on earlier page views
|
|
5
|
+
* (src/core/early-rules.ts). Without them it does nothing.
|
|
6
|
+
*
|
|
7
|
+
* On a URL a stored route matches, before first paint: one <style> with that
|
|
8
|
+
* route's CSS (hides the host's listing, shows a loading bar), removed after
|
|
9
|
+
* the stored timeout unless the bundle claims it first (window.__lbx.early:
|
|
10
|
+
* claim(routeId) then drop()). window.__lbx is Luma's one global: the snippet
|
|
11
|
+
* makes it when it holds, the bundle adds its own to it (src/main.ts).
|
|
12
|
+
*
|
|
13
|
+
* Never blocks, never throws, makes no request. ES5 on purpose.
|
|
14
|
+
* Customers paste the minified form, snippet/early-hold.min.js (generated from
|
|
15
|
+
* this file, documented in README.md).
|
|
16
|
+
* Pasted copies stay on clients' pages: keep it backwards compatible.
|
|
17
|
+
*/
|
|
18
|
+
(function () {
|
|
19
|
+
try {
|
|
20
|
+
if (window.__lbx && window.__lbx.early) return;
|
|
21
|
+
var r = JSON.parse(localStorage.getItem('lbx:early') || 'null');
|
|
22
|
+
if (!r || r.v !== 1 || !(r.exp > Date.now())) return;
|
|
23
|
+
// Moved or injected late: never hide what the visitor has already seen.
|
|
24
|
+
if (performance.getEntriesByName && performance.getEntriesByName('first-contentful-paint').length) return;
|
|
25
|
+
var u = new URL(location.href);
|
|
26
|
+
for (var i = 0; i < r.routes.length; i++) {
|
|
27
|
+
var x = r.routes[i];
|
|
28
|
+
if (!new RegExp(x.path).test(u.pathname)) continue;
|
|
29
|
+
var ok = true;
|
|
30
|
+
for (var j = 0; x.need && j < x.need.length; j++) if (!(u.searchParams.get(x.need[j]) || '').trim()) ok = false;
|
|
31
|
+
if (ok && x.allow) {
|
|
32
|
+
var a = new RegExp(x.allow);
|
|
33
|
+
u.searchParams.forEach(function (v, n) {
|
|
34
|
+
if (v && !a.test(n)) ok = false;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (!ok) continue;
|
|
38
|
+
var s = document.createElement('style');
|
|
39
|
+
var me = document.currentScript;
|
|
40
|
+
if (me && me.nonce) s.nonce = me.nonce;
|
|
41
|
+
s.setAttribute('data-lbx', 'early');
|
|
42
|
+
s.textContent = x.css;
|
|
43
|
+
(document.head || document.documentElement).appendChild(s);
|
|
44
|
+
var drop = function () {
|
|
45
|
+
clearTimeout(t);
|
|
46
|
+
if (s.parentNode) s.parentNode.removeChild(s);
|
|
47
|
+
};
|
|
48
|
+
var t = setTimeout(drop, r.ms || 1500);
|
|
49
|
+
var L = (window.__lbx = window.__lbx || {});
|
|
50
|
+
L.early = {
|
|
51
|
+
t: performance.now(),
|
|
52
|
+
route: x.id,
|
|
53
|
+
snippet: 1,
|
|
54
|
+
claim: function (id) {
|
|
55
|
+
if (!s.parentNode || id !== x.id) return false;
|
|
56
|
+
clearTimeout(t);
|
|
57
|
+
return true;
|
|
58
|
+
},
|
|
59
|
+
drop: drop,
|
|
60
|
+
};
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
} catch (e) {}
|
|
64
|
+
})();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";(function(){try{if(window.__lbx&&window.__lbx.early)return;var n=JSON.parse(localStorage.getItem("lbx:early")||"null");if(!n||n.v!==1||!(n.exp>Date.now())||performance.getEntriesByName&&performance.getEntriesByName("first-contentful-paint").length)return;for(var o=new URL(location.href),i=0;i<n.routes.length;i++){var e=n.routes[i];if(new RegExp(e.path).test(o.pathname)){for(var r=!0,l=0;e.need&&l<e.need.length;l++)(o.searchParams.get(e.need[l])||"").trim()||(r=!1);if(r&&e.allow){var d=new RegExp(e.allow);o.searchParams.forEach(function(a,p){a&&!d.test(p)&&(r=!1)})}if(r){var t=document.createElement("style"),c=document.currentScript;c&&c.nonce&&(t.nonce=c.nonce),t.setAttribute("data-lbx","early"),t.textContent=e.css,(document.head||document.documentElement).appendChild(t);var f=function(){clearTimeout(u),t.parentNode&&t.parentNode.removeChild(t)},u=setTimeout(f,n.ms||1500),m=window.__lbx=window.__lbx||{};m.early={t:performance.now(),route:e.id,snippet:1,claim:function(a){return!t.parentNode||a!==e.id?!1:(clearTimeout(u),!0)},drop:f};return}}}}catch(a){}})();
|