@analitikisler/ai-tag-debugger 0.17.0 → 0.19.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/CHANGELOG.md +17 -0
- package/README.md +16 -17
- package/README.tr.md +16 -17
- package/package.json +3 -3
- package/src/brand.js +17 -11
- package/src/browser.js +44 -8
- package/src/html/consent.js +152 -0
- package/src/html/css.js +1182 -0
- package/src/html/findings.js +493 -0
- package/src/html/journeys.js +449 -0
- package/src/html/lib.js +283 -0
- package/src/html/scope.js +350 -0
- package/src/html/script.js +300 -0
- package/src/html/shell.js +191 -0
- package/src/html/summary.js +288 -0
- package/src/i18n.js +385 -171
- package/src/metrics.js +9 -3
- package/src/plan-html.js +74 -27
- package/src/report-shared.js +123 -0
- package/src/report.js +24 -1505
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// The Coverage, Inventory and Run sections (three sections, one module). Every block is a collapsed card whose summary
|
|
2
|
+
// line carries the answer; Run's facts are an always-visible grid because they are that section's answer. The data
|
|
3
|
+
// handling (which runs check which platform, the funnel cells, the inventory's parties and cookies, the skipped
|
|
4
|
+
// vendors, the trend) is the 0.17 renderer's, so the numbers match the Markdown report and the CLI.
|
|
5
|
+
import { buildInventory } from "../inventory.js";
|
|
6
|
+
import { providerOf } from "../parsers.js";
|
|
7
|
+
import { siteOf } from "../redact.js";
|
|
8
|
+
import { VERSION, BUCKET_ORDER, coverageLines, pcSort, planChangeItems, routeList } from "../report-shared.js";
|
|
9
|
+
import {
|
|
10
|
+
PLATFORM_ART, badge, chip, detailHtml, esc, n, pathOf, platformLabel, safeKey, sectionBlock, sortPlatforms, use,
|
|
11
|
+
} from "./lib.js";
|
|
12
|
+
|
|
13
|
+
/* ---------- small markup helpers (candidates for lib.js) ---------- */
|
|
14
|
+
|
|
15
|
+
/** A platform chip: logo (or initials for a platform without artwork) and name. */
|
|
16
|
+
function pchip(key) {
|
|
17
|
+
const k = safeKey(key);
|
|
18
|
+
const label = platformLabel(key);
|
|
19
|
+
const art = PLATFORM_ART[k] ? use(`p-${k}`, 13) : `<span class="plat-fb s14" aria-hidden="true">${esc(label.split(/\s+/).slice(0, 2).map((w) => w[0]).join("").toUpperCase())}</span>`;
|
|
20
|
+
return chip("plat", k, `${art}${esc(label)}`);
|
|
21
|
+
}
|
|
22
|
+
/** A viewport chip, its label the journey when given; the viewport's name is read out with it. */
|
|
23
|
+
const vchip = (vp, label = vp) => chip("vp", vp, `${use(vp === "mobile" ? "ui-mobile" : "ui-desktop", 13)}${esc(label)}${label === vp ? "" : `<span class="sr"> · ${esc(vp)}</span>`}`);
|
|
24
|
+
/** A severity chip: glyph and word. label is HTML. */
|
|
25
|
+
const sevchip = (sev, label, cls = "") => `<span class="chip chip-sev ${sev}${cls ? ` ${cls}` : ""}">${use(`sev-${sev}`, 13)}${label}</span>`;
|
|
26
|
+
/** A neutral chip for a count or a label. html is HTML. */
|
|
27
|
+
const plain = (html, cls = "") => `<span class="chip plain${cls ? ` ${cls}` : ""}">${html}</span>`;
|
|
28
|
+
/** A glyph-only chip for a table cell; the word is for screen readers. */
|
|
29
|
+
const glyph = (sev, word) => `<span class="chip chip-sev ${sev} cv-g">${use(`sev-${sev}`, 13)}<span class="sr"> ${esc(word)}</span></span>`;
|
|
30
|
+
const code = (s) => `<code>${esc(s)}</code>`;
|
|
31
|
+
const bold = (x) => `<b>${esc(x)}</b>`;
|
|
32
|
+
const secs = (msValue) => `${(msValue / 1000).toFixed(1)} s`;
|
|
33
|
+
const ms = (v) => (v === null || v === undefined ? "–" : `${Math.round(v)} ms`);
|
|
34
|
+
const uniq = (xs) => [...new Set(xs)];
|
|
35
|
+
const hostOf = (u) => {
|
|
36
|
+
try { return new URL(u).hostname; } catch { return ""; }
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The providers that were checked: from the runs, or for older captures the platforms seen. */
|
|
40
|
+
function checkedProviders(runs) {
|
|
41
|
+
const chosen = new Set(runs.flatMap((r) => r.providers ?? []));
|
|
42
|
+
if (!chosen.size) for (const r of runs) for (const h of r.hits ?? []) chosen.add(h.platform);
|
|
43
|
+
return sortPlatforms(chosen);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Out-of-scope vendors and how many of their requests the audit skipped, most first. */
|
|
47
|
+
function skippedTotals(runs) {
|
|
48
|
+
const totals = {};
|
|
49
|
+
for (const r of runs) for (const counts of Object.values(r.skipped ?? {})) for (const [v, k] of Object.entries(counts)) totals[v] = (totals[v] ?? 0) + k;
|
|
50
|
+
return Object.entries(totals).sort((a, b) => b[1] - a[1]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const callProvider = (c) => safeKey(c.provider ?? providerOf(c.url)?.key ?? c.vendor);
|
|
54
|
+
|
|
55
|
+
/* ---------- Coverage ---------- */
|
|
56
|
+
|
|
57
|
+
const ST_SEV = { ok: "ok", params: "broken", missing: "warning" };
|
|
58
|
+
|
|
59
|
+
/** Each planned event × each run: sent with its parameters, sent without them, not sent, or not expected there. */
|
|
60
|
+
function coverageRows(plan, runs, metrics) {
|
|
61
|
+
const events = plan.events.map((e) => ({ ...e, platform: String(e.platform ?? "").toLowerCase() }));
|
|
62
|
+
const checks = (r, e) => !r.providers?.length || r.providers.includes(e.platform);
|
|
63
|
+
const taken = new Map();
|
|
64
|
+
return events.map((e) => {
|
|
65
|
+
const cells = runs.map((r, ri) => {
|
|
66
|
+
const row = metrics.funnel?.[ri]?.events.find((x) => x.platform === e.platform && x.event === e.name);
|
|
67
|
+
if (!row || !checks(r, e)) return { st: "na" };
|
|
68
|
+
if (row.status === "ok") return { st: "ok" };
|
|
69
|
+
if (row.status === "missing_params") return { st: "params", miss: row.missingParams ?? [] };
|
|
70
|
+
return { st: "missing" };
|
|
71
|
+
});
|
|
72
|
+
const sts = cells.map((c) => c.st);
|
|
73
|
+
const st = sts.includes("missing") ? "missing" : sts.includes("params") ? "params" : sts.includes("ok") ? "ok" : "na";
|
|
74
|
+
const base = `cov-${safeKey(e.platform)}-${safeKey(e.name)}`;
|
|
75
|
+
const k = (taken.get(base) ?? 0) + 1;
|
|
76
|
+
taken.set(base, k);
|
|
77
|
+
return { e, cells, st, unchecked: !runs.some((r) => checks(r, e)), miss: new Set(cells.flatMap((c) => c.miss ?? [])), id: k === 1 ? base : `${base}-${k}` };
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function coverageCard(ctx) {
|
|
82
|
+
const { m, plan, runs, metrics } = ctx;
|
|
83
|
+
const t = m.html.scope;
|
|
84
|
+
if (!plan?.events?.length || !runs.length) return `<p class="muted cv-none">${esc(m.ui.noPlan)}</p>`;
|
|
85
|
+
const rows = coverageRows(plan, runs, metrics);
|
|
86
|
+
const count = (st) => rows.filter((r) => r.st === st).length;
|
|
87
|
+
const nOk = count("ok"), nParams = count("params"), nMiss = count("missing");
|
|
88
|
+
const c = metrics.coverage;
|
|
89
|
+
const done = c?.counts.implemented ?? nOk;
|
|
90
|
+
const total = c?.checkable ?? rows.length;
|
|
91
|
+
const pct = c?.percent.implemented ?? null;
|
|
92
|
+
const notChecked = c?.counts.not_checked ?? 0;
|
|
93
|
+
const bar = `<span class="cv-bar" role="img" aria-label="${esc(t.barLabel(nOk, nParams, nMiss))}">${rows.map((r) => `<i class="${r.st}"></i>`).join("")}</span>`;
|
|
94
|
+
const platCounts = sortPlatforms(Object.keys(c?.platforms ?? {})).map((p) => `<span class="cv-pc">${pchip(p).replace(/<\/span>$/, ` <b>${c.platforms[p].counts.implemented}/${c.platforms[p].checkable}</b></span>`)}</span>`).join("");
|
|
95
|
+
const summary = `<span class="t">${esc(m.ui.planCoverage)}</span>${chip("sev", total && !done ? "broken" : nParams || nMiss ? "warning" : "ok", t.ofEvents(`<b>${done}</b>`, total))}${pct === null ? "" : plain(`<b>${pct}%</b>`, "mono")}${nParams ? sevchip("broken", esc(t.missingParams(nParams))) : ""}${nMiss ? sevchip("warning", esc(t.notSentCount(nMiss))) : ""}${notChecked ? plain(esc(t.notCheckedCount(notChecked))) : ""}${bar}<span class="end cv-pcs">${platCounts}</span>`;
|
|
96
|
+
|
|
97
|
+
const plats = sortPlatforms(uniq(rows.map((r) => r.e.platform)));
|
|
98
|
+
const stFilters = [["ok", m.ui.bucket.implemented, nOk], ["params", m.ui.bucket.missing_params, nParams], ["missing", m.html.notSent, nMiss]].filter((x) => x[2]);
|
|
99
|
+
const platBtn = (p) => `<button type="button" data-filter="plat=${safeKey(p)}" aria-pressed="false" class="chip-plat ${safeKey(p)}">${PLATFORM_ART[safeKey(p)] ? use(`p-${safeKey(p)}`, 14) : ""}${esc(platformLabel(p))} ${n(rows.filter((r) => r.e.platform === p).length)}</button>`;
|
|
100
|
+
const stBtn = ([k, label, cnt]) => `<button type="button" data-filter="st=${k}" aria-pressed="false" class="chip-sev ${ST_SEV[k]}">${use(`sev-${ST_SEV[k]}`, 14)}${esc(label)} ${n(cnt)}</button>`;
|
|
101
|
+
const filterbar = `<div class="filterbar" data-filterbar="#cov-rows"><span class="fl">${esc(m.ui.platform)}</span>${plats.map(platBtn).join("")}${stFilters.length > 1 ? `<span class="sep"></span><span class="fl">${esc(m.ui.status)}</span>${stFilters.map(stBtn).join("")}` : ""}<span class="cv-search">${use("ui-search", 14)}<input type="search" data-filter-search placeholder="${esc(t.searchEvents)}" aria-label="${esc(t.searchEvents)}"></span><p data-filter-count aria-live="polite"></p></div>`;
|
|
102
|
+
|
|
103
|
+
const cell = (cl) => {
|
|
104
|
+
if (cl.st === "na") return `<td class="cv-c muted">–<span class="sr"> ${esc(m.html.notExpected)}</span></td>`;
|
|
105
|
+
if (cl.st === "ok") return `<td class="cv-c">${glyph("ok", m.html.sent)}</td>`;
|
|
106
|
+
if (cl.st === "params") return `<td class="cv-c">${sevchip("broken", `${esc(m.html.paramsShort)}<span class="sr"> ${esc(m.html.params(cl.miss))}</span>`)}</td>`;
|
|
107
|
+
return `<td class="cv-c">${sevchip("warning", esc(m.html.notSent))}</td>`;
|
|
108
|
+
};
|
|
109
|
+
const body = rows.map(({ e, cells, st, unchecked, miss, id }) => {
|
|
110
|
+
const needs = e.required_params ?? [];
|
|
111
|
+
const text = `${e.platform} ${platformLabel(e.platform)} ${e.name} ${needs.join(" ")}`.toLowerCase();
|
|
112
|
+
const needsHtml = needs.length ? needs.map((k) => `<code${miss.has(k) ? ' class="cv-miss"' : ""}>${esc(k)}</code>`).join("") : `<span class="muted">–</span>`;
|
|
113
|
+
return `<tr id="${id}" data-plat="${safeKey(e.platform)}" data-st="${st}" data-text="${esc(text)}"><th scope="row"><span class="cv-ev">${pchip(e.platform)}${code(e.name)}${unchecked ? badge("muted", m.ui.notChecked) : ""}</span></th><td class="cv-needs">${needsHtml}</td>${cells.map(cell).join("")}</tr>`;
|
|
114
|
+
}).join("");
|
|
115
|
+
const key = `<p class="cv-key xs muted">${glyph("ok", m.html.sent)}<span>${esc(t.keyOk)}</span>${sevchip("broken", esc(m.html.paramsShort))}<span>${esc(t.keyParams)}</span>${sevchip("warning", esc(m.html.notSent))}<span>${esc(t.keyMissing)}</span><span>– ${esc(t.keyNotExpected)}</span></p>`;
|
|
116
|
+
const head = `<tr><th scope="col">${esc(m.ui.event)}</th><th scope="col">${esc(t.needs)}</th>${runs.map((r, ri) => `<th scope="col" class="cv-c"><a href="#j${ri}" class="cv-run">${vchip(r.viewport, r.journeyId)}</a></th>`).join("")}</tr>`;
|
|
117
|
+
return `<details class="card" id="cov-plan"><summary>${summary}</summary><div class="body">
|
|
118
|
+
${filterbar}
|
|
119
|
+
<div class="tscroll cv-scroll"><table class="tbl cv-tbl"><caption class="sr">${esc(m.ui.planCoverage)}</caption><thead>${head}</thead><tbody id="cov-rows">${body}</tbody></table></div>
|
|
120
|
+
${key}</div></details>`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const CONVERSION_CHECKS = ["value", "currency", "transactionId", "items", "duplicate", "parity", "eventId"];
|
|
124
|
+
|
|
125
|
+
/** One row per purchase seen, with each integrity check as a pass, fail or not-applicable cell. */
|
|
126
|
+
function conversionsCard(ctx) {
|
|
127
|
+
const { m, metrics, runs } = ctx;
|
|
128
|
+
const t = m.html.scope;
|
|
129
|
+
const c = metrics.conversions;
|
|
130
|
+
if (!c?.rows?.length) return "";
|
|
131
|
+
const nFail = c.total - c.passed;
|
|
132
|
+
const cellOf = (v) => (v === "pass" ? `<td class="cv-c">${glyph("ok", m.html.pass)}</td>` : v === "fail" ? `<td class="cv-c">${glyph("broken", m.html.fail)}</td>` : `<td class="cv-c muted">–<span class="sr"> ${esc(m.html.notApplicable)}</span></td>`);
|
|
133
|
+
const runLink = (r) => {
|
|
134
|
+
const ri = runs.findIndex((x) => x.journeyId === r.journey && x.viewport === r.viewport);
|
|
135
|
+
return ri < 0 ? vchip(r.viewport, r.journey) : `<a href="#j${ri}" class="cv-run">${vchip(r.viewport, r.journey)}</a>`;
|
|
136
|
+
};
|
|
137
|
+
const rows = c.rows.map((r) => `<tr><th scope="row">${runLink(r)}</th><td><span class="cv-ev">${pchip(r.platform)}${code(r.event)}</span></td><td class="mono xs">${r.transactionId ? esc(r.transactionId) : "–"}</td><td class="num">${r.value !== null && r.value !== undefined ? `${esc(String(r.value))}${r.currency ? ` ${esc(r.currency)}` : ""}` : sevchip("broken", esc(m.ui.missing))}</td><td class="num">${r.items ?? "–"}</td>${CONVERSION_CHECKS.map((k) => cellOf(r.checks?.[k])).join("")}</tr>`).join("");
|
|
138
|
+
const summary = `<span class="t">${esc(m.html.conversions)}</span>${sevchip(nFail ? "warning" : "ok", t.purchasesPass(`<b>${c.passed}</b>`, c.total))}${nFail ? sevchip("broken", esc(t.purchasesFail(nFail))) : ""}`;
|
|
139
|
+
return `<details class="card" id="cov-conversion"><summary>${summary}</summary><div class="body">
|
|
140
|
+
<p class="xs muted cv-lead">${esc(m.html.conversionsLead(c.passed, c.total))}</p>
|
|
141
|
+
<div class="tscroll"><table class="tbl cv-tbl cv-conv"><caption class="sr">${esc(m.html.conversions)}</caption><thead><tr><th scope="col">${esc(m.ui.journey)}</th><th scope="col">${esc(m.ui.event)}</th><th scope="col">${esc(m.html.orderId)}</th><th scope="col" class="num">${esc(m.html.value)}</th><th scope="col" class="num">${esc(m.ui.items)}</th>${CONVERSION_CHECKS.map((k) => `<th scope="col" class="cv-c">${esc(m.html.conversionChecks[k])}</th>`).join("")}</tr></thead><tbody>${rows}</tbody></table></div></div></details>`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const BUCKET_SEV = { implemented: "ok", missing: "broken", missing_params: "broken", wrong_values: "warning", unplanned: "info", not_checked: "muted" };
|
|
145
|
+
|
|
146
|
+
/** With --against: each planned event's bucket and what the site sent, then how the plan changed since the last one. */
|
|
147
|
+
function planVsSiteCard(ctx) {
|
|
148
|
+
const { m, planCheck: pc, planChanges } = ctx;
|
|
149
|
+
const [overall, ...perPlatform] = coverageLines(m, pc);
|
|
150
|
+
const counts = BUCKET_ORDER.map((b) => [b, pc.events.filter((e) => e.bucket === b).length]).filter(([, k]) => k);
|
|
151
|
+
const bchip = (b, label) => (BUCKET_SEV[b] === "muted" ? plain(label) : sevchip(BUCKET_SEV[b], label));
|
|
152
|
+
const summary = `<span class="t">${esc(m.ui.planVsSite)}</span>${counts.map(([b, k]) => bchip(b, `${esc(m.ui.bucket[b])} <b>${k}</b>`)).join("")}`;
|
|
153
|
+
const rows = pcSort(pc).map((e) => `<tr><th scope="row"><span class="cv-ev">${pchip(e.platform)}${code(e.event)}</span></th><td>${bchip(e.bucket, esc(m.ui.bucket[e.bucket]))}</td><td class="xs muted">${(e.details ?? []).map(detailHtml).join("<br>")}</td></tr>`).join("");
|
|
154
|
+
const items = planChanges ? planChangeItems(planChanges) : [];
|
|
155
|
+
const changes = planChanges ? `<h3 class="pv-h">${esc(m.html.planChanges)}</h3><p class="xs">${esc(m.ui.planChangesLine(planChanges))}</p>${items.length ? `<ul class="pv-changes xs">${items.map((i) => `<li>${esc(i)}</li>`).join("")}</ul>` : ""}` : "";
|
|
156
|
+
return `<details class="card" id="plan-vs-site"><summary>${summary}</summary><div class="body">
|
|
157
|
+
<p><strong>${esc(overall)}</strong></p>${perPlatform.length ? `<p class="muted xs">${perPlatform.map(esc).join("<br>")}</p>` : ""}
|
|
158
|
+
<div class="tscroll pv-scroll"><table class="tbl pv-tbl"><caption class="sr">${esc(m.ui.planVsSite)}</caption><thead><tr><th scope="col">${esc(m.ui.event)}</th><th scope="col">${esc(m.ui.bucketCol)}</th><th scope="col">${esc(m.html.detailsCol)}</th></tr></thead><tbody>${rows}</tbody></table></div>${changes}</div></details>`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function coverageSection(ctx) {
|
|
162
|
+
const { m, planCheck } = ctx;
|
|
163
|
+
return sectionBlock("coverage", esc(m.html.shell.sections.coverage), [planCheck ? planVsSiteCard(ctx) : "", coverageCard(ctx), conversionsCard(ctx)].filter(Boolean).join("\n"));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* ---------- Inventory ---------- */
|
|
167
|
+
|
|
168
|
+
/** Per platform (checked or seen): its requests by host and by page, the hosts' party, and the cookies tied to it. */
|
|
169
|
+
function inventoryLines(ctx) {
|
|
170
|
+
const { runs, tally } = ctx;
|
|
171
|
+
// A run's cookieLog holds the cookies each step set; older captures only kept the cookies at the end of the run.
|
|
172
|
+
const inv = buildInventory(runs.map((r) => (r.cookieLog?.length ? r : { ...r, cookieLog: r.cookies ?? [] })));
|
|
173
|
+
const cookiesOf = new Map(inv.vendors.map((v) => [v.vendor, v.cookies]));
|
|
174
|
+
const calls = runs.flatMap((r) => r.network ?? []);
|
|
175
|
+
const hitPlats = new Set(runs.flatMap((r) => (r.hits ?? []).map((h) => h.platform)));
|
|
176
|
+
const keys = sortPlatforms(uniq([...tally.vendors.checked, ...calls.map(callProvider), ...hitPlats]));
|
|
177
|
+
const lines = keys.map((p) => {
|
|
178
|
+
const mine = calls.filter((c) => callProvider(c) === p);
|
|
179
|
+
const pages = new Map();
|
|
180
|
+
const hosts = new Map();
|
|
181
|
+
for (const c of mine) {
|
|
182
|
+
if (c.pageUrl) pages.set(pathOf(c.pageUrl), (pages.get(pathOf(c.pageUrl)) ?? 0) + 1);
|
|
183
|
+
const h = hostOf(c.url) || String(c.url ?? "");
|
|
184
|
+
const e = hosts.get(h) ?? { n: 0, types: new Set(), first: 0, third: 0 };
|
|
185
|
+
e.n++;
|
|
186
|
+
if (c.type) e.types.add(c.type);
|
|
187
|
+
if (c.pageUrl) e[siteOf(h) === siteOf(hostOf(c.pageUrl)) ? "first" : "third"]++;
|
|
188
|
+
hosts.set(h, e);
|
|
189
|
+
}
|
|
190
|
+
const cookies = uniq(uniq(mine.map((c) => c.vendor).filter(Boolean)).flatMap((v) => cookiesOf.get(v) ?? [])).sort();
|
|
191
|
+
return { p, seen: mine.length > 0 || hitPlats.has(p), requests: mine.length, pages, hosts, cookies };
|
|
192
|
+
});
|
|
193
|
+
return { lines, otherCookies: inv.otherCookies };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function inventorySection(ctx) {
|
|
197
|
+
const { m, runs } = ctx;
|
|
198
|
+
const t = m.html.scope;
|
|
199
|
+
const { lines, otherCookies } = inventoryLines(ctx);
|
|
200
|
+
const skipped = skippedTotals(runs);
|
|
201
|
+
if (!lines.length && !skipped.length) return sectionBlock("inventory", esc(m.html.shell.sections.inventory), `<p class="muted">${esc(m.ui.noInventory)}</p>`);
|
|
202
|
+
const counts = (x) => `<span class="inv-m">${t.requests(x.requests, bold(x.requests))}</span><span class="inv-m">${t.pages(x.pages.size, bold(x.pages.size))}</span><span class="inv-m${x.cookies.length ? "" : " zero"}">${t.cookies(x.cookies.length, bold(x.cookies.length))}</span>`;
|
|
203
|
+
const out = lines.map((x) => {
|
|
204
|
+
const id = `inv-${safeKey(x.p)}`;
|
|
205
|
+
if (!x.seen) return `<li><div class="card inv-line" id="${id}" data-seen="no">${pchip(x.p)}${badge("info", m.html.checkedNotSeen)}</div></li>`;
|
|
206
|
+
const tid = (k) => `${id}-${k}`;
|
|
207
|
+
const tab = (k, label, c, sel) => `<button type="button" role="tab" id="${tid(`${k}-tab`)}" aria-controls="${tid(k)}" aria-selected="${sel}">${esc(label)} ${n(c)}</button>`;
|
|
208
|
+
const hosts = [...x.hosts].map(([h, v]) => `<li><code>${esc(h)}</code><span class="inv-n">${t.requests(v.n, bold(v.n))}</span>${v.first || v.third ? plain(esc(m.ui.party[v.first && v.third ? "both" : v.first ? "first" : "third"])) : ""}${[...v.types].map((ty) => plain(esc(ty), "mono")).join("")}</li>`).join("");
|
|
209
|
+
const pages = [...x.pages].map(([pg, k]) => `<li>${use("ui-page", 13)}<code>${esc(pg)}</code><span class="inv-n">${t.requests(k, bold(k))}</span></li>`).join("");
|
|
210
|
+
const none = `<p class="muted xs">${esc(t.noneSeen)}</p>`;
|
|
211
|
+
return `<li><details class="card inv" id="${id}" data-seen="yes"><summary>${pchip(x.p)}${sevchip("ok", esc(t.seen))}${counts(x)}</summary><div class="body">
|
|
212
|
+
<div class="tabs" data-tabs><div role="tablist">${tab("hosts", t.hosts, x.hosts.size, "true")}${tab("pages", m.ui.pagesCol, x.pages.size, "false")}${tab("cookies", m.ui.cookiesCol, x.cookies.length, "false")}</div>
|
|
213
|
+
<div role="tabpanel" id="${tid("hosts")}" data-label="${esc(t.hosts)}">${hosts ? `<ul class="inv-list">${hosts}</ul>` : none}</div>
|
|
214
|
+
<div role="tabpanel" id="${tid("pages")}" data-label="${esc(m.ui.pagesCol)}">${pages ? `<ul class="inv-list">${pages}</ul>` : none}</div>
|
|
215
|
+
<div role="tabpanel" id="${tid("cookies")}" data-label="${esc(m.ui.cookiesCol)}">${x.cookies.length ? `<ul class="inv-list">${x.cookies.map((ck) => `<li>${code(ck)}</li>`).join("")}</ul>` : none}</div></div></div></details></li>`;
|
|
216
|
+
});
|
|
217
|
+
const skippedN = skipped.reduce((a, [, k]) => a + k, 0);
|
|
218
|
+
if (skipped.length) {
|
|
219
|
+
out.push(`<li><details class="card inv" id="inv-out-of-scope"><summary><span class="t">${esc(m.ui.outOfScopeStatus)}</span>${plain(t.requests(skippedN, bold(skippedN)))}<span class="inv-m">${esc(skipped.slice(0, 3).map(([v]) => v).join(", "))}${skipped.length > 3 ? " …" : ""}</span></summary><div class="body"><ul class="inv-list">${skipped.map(([v, k]) => `<li><span>${esc(v)}</span>${badge("muted", m.ui.outOfScopeStatus)}<span class="inv-n">${t.requests(k, bold(k))}</span></li>`).join("")}</ul></div></details></li>`);
|
|
220
|
+
}
|
|
221
|
+
const silent = lines.filter((x) => !x.seen).map((x) => platformLabel(x.p));
|
|
222
|
+
const notes = [
|
|
223
|
+
silent.length ? esc(m.html.neverSeen(silent.join(", "))) : "",
|
|
224
|
+
otherCookies.length ? `${esc(m.ui.otherCookies)}: ${otherCookies.map((c) => `${code(c.name)} <span class="muted">(${esc(m.ui.party[c.party])})</span>`).join(", ")}` : "",
|
|
225
|
+
].filter(Boolean);
|
|
226
|
+
return sectionBlock("inventory", esc(m.html.shell.sections.inventory), `<ul class="inv-rows" aria-label="${esc(m.ui.inventoryTitle)}">${out.join("")}</ul>${notes.map((x) => `<p class="xs muted inv-note">${x}</p>`).join("")}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* ---------- Run ---------- */
|
|
230
|
+
|
|
231
|
+
const FILES = ["report.md", "timeline.csv", "capture.json", "steps.json", "junit.xml", "summary.json"];
|
|
232
|
+
const CAMPAIGN_PARAMS = ["utm_source", "gclid", "fbclid"];
|
|
233
|
+
|
|
234
|
+
/** The run's settings, for the reader who doubts a result: what was checked, how, and with which tool. */
|
|
235
|
+
function runFacts(ctx) {
|
|
236
|
+
const { m, runs, metrics, ai, plan, against, planChanges, consentScenarios, planCheck, site, title, generatedAt } = ctx;
|
|
237
|
+
const t = m.html.scope;
|
|
238
|
+
const fact = (label, html, cls = "") => `<div${cls ? ` class="${cls}"` : ""}><dt>${esc(label)}</dt><dd>${html}</dd></div>`;
|
|
239
|
+
const dash = `<span class="muted">–</span>`;
|
|
240
|
+
const provs = checkedProviders(runs);
|
|
241
|
+
const timingOf = (r, ri) => metrics.timing?.find((x) => x.journey === r.journeyId && x.viewport === r.viewport) ?? metrics.timing?.[ri];
|
|
242
|
+
const completed = runs.filter((r) => r.completed).length;
|
|
243
|
+
const runChip = (r, ri) => {
|
|
244
|
+
const d = timingOf(r, ri)?.durationMs;
|
|
245
|
+
const state = r.completed ? "completed" : "incomplete";
|
|
246
|
+
return `<a href="#j${ri}" class="chip chip-run ${state}">${use(r.completed ? "sev-ok" : "sev-warning", 13)}${esc(r.journeyId)} ${use(r.viewport === "mobile" ? "ui-mobile" : "ui-desktop", 13)}${esc(r.viewport)}${d === null || d === undefined ? "" : ` <b>${secs(d)}</b>`}${r.completed ? "" : `<span class="sr"> · ${esc(m.html.runIncomplete)}</span>`}</a>`;
|
|
247
|
+
};
|
|
248
|
+
const states = consentScenarios ? ["before", ...Object.keys(consentScenarios.states ?? {}).filter((s) => consentScenarios.states[s]?.after)] : [];
|
|
249
|
+
const consent = consentScenarios
|
|
250
|
+
? `${plain(esc(consentScenarios.cmp === "banner" ? m.html.cmpBanner : String(consentScenarios.cmp ?? "")))}${plain(esc(m.html.scenariosRun(states.length)))}`
|
|
251
|
+
: metrics.consent?.consentMode?.defaultPresent ? plain(esc(m.html.consentModeSeen)) : dash;
|
|
252
|
+
const planBits = [
|
|
253
|
+
plain(esc(plan?.events?.length ? m.ui.runPlanEvents(plan.events.length) : m.ui.runNoPlan)),
|
|
254
|
+
plan?.consent_mode ? plain(esc(m.html.consentMode)) : "",
|
|
255
|
+
against ? `<span class="xs">${esc(m.ui.checkedAgainst(against))}</span>` : "",
|
|
256
|
+
planChanges ? `<span class="xs">${planCheck ? `<a href="#plan-vs-site">${esc(m.ui.planChangesLine(planChanges))}</a>` : esc(m.ui.planChangesLine(planChanges))}</span>` : "",
|
|
257
|
+
].join("");
|
|
258
|
+
const skipped = skippedTotals(runs);
|
|
259
|
+
const skippedN = skipped.reduce((a, [, k]) => a + k, 0);
|
|
260
|
+
const files = [...FILES, ...(planCheck ? ["plan-check.json"] : [])];
|
|
261
|
+
const facts = [
|
|
262
|
+
fact(m.ui.site, plain(`<b>${esc(site ?? title)}</b>`, "mono")),
|
|
263
|
+
fact(t.generated, plain(`${use("ui-clock", 13)}${esc(generatedAt)}`, "mono")),
|
|
264
|
+
fact(m.ui.runVersion, `<code>${esc(VERSION)}</code>`),
|
|
265
|
+
fact(m.ui.runAi, plain(`${use("ui-ai", 13)}${esc(ai ? m.ui.runAiOn(ai.name, ai.model) : m.ui.runAiOff)}`)),
|
|
266
|
+
fact(m.ui.runJourneys, runs.length ? uniq(runs.map((r) => r.journeyId)).map((j) => plain(`${use("ui-journeys", 13)}${esc(j)}`)).join("") : dash),
|
|
267
|
+
fact(m.ui.runViewports, runs.length ? uniq(runs.map((r) => r.viewport)).map((v) => vchip(v)).join("") : dash),
|
|
268
|
+
fact(m.ui.runPlatforms, provs.length ? provs.map(pchip).join("") : dash),
|
|
269
|
+
fact(m.ui.runPlan, planBits),
|
|
270
|
+
fact(m.ui.consent, consent),
|
|
271
|
+
fact(m.html.campaignCheck, runs.some((r) => r.landing) ? CAMPAIGN_PARAMS.map(code).join("") : dash),
|
|
272
|
+
fact(m.ui.runLanguage, plain(esc(m.name))),
|
|
273
|
+
fact(t.runs, runs.length ? `${runs.map(runChip).join("")}<span class="xs muted">${esc(completed === runs.length ? m.html.runsAllCompleted(runs.length) : m.html.runsCompleted(completed, runs.length))}</span>` : dash, "wide"),
|
|
274
|
+
skippedN ? fact(m.ui.outOfScopeStatus, `<span class="xs">${esc(m.ui.skippedLine(skipped.map(([v]) => v).join(", "), skippedN))}</span>`, "wide") : "",
|
|
275
|
+
fact(t.files, files.map((f) => `<code class="file">${use("ui-page", 12)}${esc(f)}</code>`).join(""), "wide"),
|
|
276
|
+
].join("");
|
|
277
|
+
return `<dl class="run-facts">${facts}</dl>`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** How long each run took and how far behind each step its first hit came. */
|
|
281
|
+
function timingCard(ctx) {
|
|
282
|
+
const { m, metrics } = ctx;
|
|
283
|
+
const t = (metrics.timing ?? []).filter((r) => r.durationMs !== null && r.durationMs !== undefined);
|
|
284
|
+
if (!t.length) return "";
|
|
285
|
+
const total = t.reduce((a, r) => a + r.durationMs, 0);
|
|
286
|
+
const meds = t.map((r) => r.medianFirstHitMs).filter((x) => x !== null && x !== undefined).map(Math.round);
|
|
287
|
+
const maxD = Math.max(1, ...t.map((r) => r.durationMs));
|
|
288
|
+
const range = meds.length ? (Math.min(...meds) === Math.max(...meds) ? `${Math.min(...meds)}` : `${Math.min(...meds)}–${Math.max(...meds)}`) : "";
|
|
289
|
+
const rows = t.map((r) => `<tr><th scope="row">${vchip(r.viewport, r.journey)}</th><td class="num"><span class="tm-d"><span class="tm-bar ${safeKey(r.viewport)}" style="width:${Math.max(4, Math.round((r.durationMs / maxD) * 100))}%"></span></span>${secs(r.durationMs)}</td><td class="num">${ms(r.medianFirstHitMs)}</td><td>${r.slowest?.[0] ? `<span class="tm-s">${pchip(r.slowest[0].platform)}${code(r.slowest[0].event)}<span class="muted xs">${esc(m.ui.step)} ${esc(r.slowest[0].step)} · ${ms(r.slowest[0].ms)}</span></span>` : "–"}</td></tr>`).join("");
|
|
290
|
+
const summary = `<span class="t">${esc(m.html.timing)}</span>${plain(`${use("ui-clock", 13)}<b>${(total / 1000).toFixed(1)}</b> s`, "mono")}${range ? plain(m.html.scope.firstHit(`<b>${range}</b>`)) : ""}`;
|
|
291
|
+
return `<details class="card" id="run-timing"><summary>${summary}</summary><div class="body">
|
|
292
|
+
<div class="tscroll"><table class="tbl tm-tbl"><caption class="sr">${esc(m.html.timing)}</caption><thead><tr><th scope="col">${esc(m.ui.journey)}</th><th scope="col" class="num">${esc(m.html.duration)}</th><th scope="col" class="num">${esc(m.html.medianFirstHit)}</th><th scope="col">${esc(m.html.slowestHit)}</th></tr></thead><tbody>${rows}</tbody></table></div></div></details>`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const SPARK_SERIES = ["broken", "risk", "warning"];
|
|
296
|
+
const TREND_SEV = { improving: "ok", worsening: "broken", flat: "info" };
|
|
297
|
+
const dateOnly = (id) => String(id ?? "").slice(0, 10);
|
|
298
|
+
const problemKey = (f) => [f.severity, f.check, f.platform ?? "", f.event ?? ""].join("|");
|
|
299
|
+
|
|
300
|
+
/** Three count lines over the saved runs, evenly spaced by run; incomplete runs are hollow points. */
|
|
301
|
+
function sparklineSvg(m, t) {
|
|
302
|
+
const s = t.series;
|
|
303
|
+
const W = 280, H = 64, P = 6;
|
|
304
|
+
const max = Math.max(3, ...s.flatMap((p) => SPARK_SERIES.map((k) => p[k])));
|
|
305
|
+
const x = (i) => P + (i * (W - 2 * P)) / (s.length - 1);
|
|
306
|
+
const y = (v) => H - P - (v * (H - 2 * P)) / max;
|
|
307
|
+
const lines = SPARK_SERIES.map((k) => `<polyline class="sev-${k}" points="${s.map((p, i) => `${x(i).toFixed(1)},${y(p[k]).toFixed(1)}`).join(" ")}"/>`).join("");
|
|
308
|
+
const points = s.flatMap((p, i) => SPARK_SERIES.map((k) => `<circle class="sev-${k}${p.complete ? "" : " hollow"}" cx="${x(i).toFixed(1)}" cy="${y(p[k]).toFixed(1)}" r="${i === s.length - 1 ? 3.5 : 2.5}"><title>${esc(`${p.date ?? p.id} · ${p[k]} ${m.severity[k]}${p.complete ? "" : ` · ${m.html.runIncomplete}`}`)}</title></circle>`)).join("");
|
|
309
|
+
const label = SPARK_SERIES.map((k) => `${m.severity[k]}: ${s.map((p) => p[k]).join(", ")}`).join("; ");
|
|
310
|
+
return `<svg class="spark" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="${esc(label)}">${lines}${points}</svg>`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** One problem from the history: the current card's title when it is still open, else platform, event and check. */
|
|
314
|
+
function historyLine(ctx, e) {
|
|
315
|
+
const f = ctx.rows.find((r) => problemKey(r) === problemKey(e));
|
|
316
|
+
const text = f ? detailHtml(f.detail) : `${e.platform ? `${esc(platformLabel(e.platform))} ` : ""}${e.event ? `${code(e.event)} ` : ""}${esc(e.check)}`;
|
|
317
|
+
return f && ctx.ids.get(f) ? `<a href="#${ctx.ids.get(f)}">${text}</a>` : text;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The trend over the saved runs: a sparkline, the runs, a link to the diff, flapping problems and the ones open longest. */
|
|
321
|
+
function trendCard(ctx) {
|
|
322
|
+
const { m, trend: t, changes } = ctx;
|
|
323
|
+
const s = m.html.scope;
|
|
324
|
+
const dir = m.html.trendDirection[t.direction] ?? t.direction;
|
|
325
|
+
const summary = `<span class="t">${esc(m.html.trend)}</span>${sevchip(TREND_SEV[t.direction] ?? "info", esc(dir))}<span class="xs muted">${esc(m.html.trendSpan(t.runs, dateOnly(t.first), dateOnly(t.last)))}</span>`;
|
|
326
|
+
const legend = `<p class="xs muted spark-legend">${SPARK_SERIES.map((k) => `<span class="sev-${k}"><i></i>${esc(m.severity[k])}</span>`).join(" ")}</p>`;
|
|
327
|
+
const direction = `<p><strong>${esc(dir)}</strong> <span class="muted">${esc(m.html.trendSpan(t.runs, dateOnly(t.first), dateOnly(t.last)))}</span></p>`;
|
|
328
|
+
// The diff itself is the Findings section's "Changed since last run" card (#changes); the trend links to it.
|
|
329
|
+
const diff = changes?.previous ? `<p class="xs"><a href="#changes"><strong>${esc(m.ui.changesLine(changes))}</strong></a> <span class="muted">${esc(m.ui.changesLead(changes.previous, changes.mode))}</span></p>` : "";
|
|
330
|
+
const runsTable = `<div class="tscroll"><table class="tbl tr-tbl"><caption class="sr">${esc(m.html.trend)}</caption><thead><tr><th scope="col">${esc(s.runCol)}</th>${SPARK_SERIES.map((k) => `<th scope="col" class="num">${esc(m.severity[k])}</th>`).join("")}<th scope="col" class="num">${esc(s.problemPoints)}</th></tr></thead><tbody>${[...t.series].reverse().map((p) => `<tr><th scope="row"><span class="mono xs">${esc(p.id)}</span>${p.complete ? "" : ` ${chip("run", "incomplete", `${use("sev-warning", 13)}${esc(m.html.runIncomplete)}`)}`}</th>${SPARK_SERIES.map((k) => `<td class="num${p[k] ? "" : " dim"}">${esc(p[k])}</td>`).join("")}<td class="num">${esc(p.score ?? "–")}</td></tr>`).join("")}</tbody></table></div>`;
|
|
331
|
+
const list = (items, meta) => `<ul class="tr-list">${items.map((e) => `<li>${badge(e.severity, m.severity[e.severity])} <span>${historyLine(ctx, e)} <span class="muted">· ${esc(meta(e))}</span></span></li>`).join("")}</ul>`;
|
|
332
|
+
const flapping = t.flapping?.length ? `<h3 class="tr-h">${esc(m.html.flapping)}</h3>${list(t.flapping, (e) => m.html.seenIn(e.seenIn, t.runs))}` : "";
|
|
333
|
+
const open = t.openLongest?.length ? `<h3 class="tr-h">${esc(m.html.openLongest)}</h3>${list(t.openLongest, (e) => m.html.openSince(dateOnly(e.openSince), e.runsOpen, e.places.length))}` : "";
|
|
334
|
+
return `<details class="card" id="trend"><summary>${summary}</summary><div class="body tr-body">
|
|
335
|
+
<div class="tr-chart">${sparklineSvg(m, t)}${legend}${direction}${diff}</div>
|
|
336
|
+
${runsTable}${flapping}${open}</div></details>`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function runSection(ctx) {
|
|
340
|
+
const { m, trend, changes } = ctx;
|
|
341
|
+
// A different route can explain new or fixed findings, so it sits next to the run facts as in 0.17; the route
|
|
342
|
+
// pairs themselves are listed in the Findings section's "Changed since last run" card.
|
|
343
|
+
const route = changes?.routes?.length ? `<p class="meta"><a href="#changes">${esc(m.ui.routeChanged(routeList(changes)))}</a></p>` : "";
|
|
344
|
+
return sectionBlock("run", esc(m.html.shell.sections.run), [runFacts(ctx), route, timingCard(ctx), trend?.series?.length >= 2 ? trendCard(ctx) : ""].filter(Boolean).join("\n"));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** @param {import("./shell.js").Ctx} ctx */
|
|
348
|
+
export function render(ctx) {
|
|
349
|
+
return [coverageSection(ctx), inventorySection(ctx), runSection(ctx)].join("\n");
|
|
350
|
+
}
|