@analitikisler/ai-tag-debugger 0.17.0 → 0.18.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 +9 -0
- package/README.md +12 -13
- package/README.tr.md +12 -13
- package/package.json +2 -2
- package/src/html/consent.js +152 -0
- package/src/html/css.js +1180 -0
- package/src/html/findings.js +493 -0
- package/src/html/journeys.js +449 -0
- package/src/html/lib.js +272 -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 +380 -166
- package/src/metrics.js +9 -3
- package/src/plan-html.js +71 -25
- package/src/report-shared.js +123 -0
- package/src/report.js +23 -1504
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
// The Journeys section: journey tabs with a desktop/mobile switch, then per run a status line, the step list grouped by
|
|
2
|
+
// page and one step pane with Hits, Data Layer and Requests tabs. Generic markup only ([data-tabs], [data-inspector],
|
|
3
|
+
// [data-vp-switch], details.card), so shell.js drives it; the JOURNEYS script in script.js only keeps the viewport
|
|
4
|
+
// when the journey changes. Ids come from lib.js (stepAnchor, hitId, pushId, requestId) so Findings links land here.
|
|
5
|
+
import { canonicalEvent } from "../checks.js";
|
|
6
|
+
import { parseTrackingRequest, providerOf, PROVIDERS } from "../parsers.js";
|
|
7
|
+
import { decodeGa4Item, isEventParam, paramTip } from "../glossary.js";
|
|
8
|
+
import { SEVERITY_RANK, isConsentEntry } from "../report-shared.js";
|
|
9
|
+
import {
|
|
10
|
+
sectionBlock, use, plat, platIcon, badge, chip, n, esc, secs, pathOf, hostPath, safeKey, platformLabel, sortPlatforms, detailHtml,
|
|
11
|
+
whereTarget, missingParamRows, pageTitle, stepTitle, stepAnchor, hitsOf, pushesOf, requestsOf, hitId, pushId, requestId,
|
|
12
|
+
hitFinding,
|
|
13
|
+
} from "./lib.js";
|
|
14
|
+
|
|
15
|
+
const VP_ORDER = ["desktop", "mobile"];
|
|
16
|
+
const worstOf = (sevs) => sevs.filter((s) => s in SEVERITY_RANK).sort((a, b) => SEVERITY_RANK[a] - SEVERITY_RANK[b])[0] ?? null;
|
|
17
|
+
const rankOf = (sev) => SEVERITY_RANK[sev] ?? 9;
|
|
18
|
+
const trunc = (s, max = 40) => { const t = String(s ?? ""); return t.length > max ? `${t.slice(0, max - 1)}…` : t; };
|
|
19
|
+
const money = (v, c) => (v !== undefined && v !== null && v !== "" ? `${v}${c ? ` ${c}` : ""}` : null);
|
|
20
|
+
const cell = (v) => (v === null || v === undefined ? "" : typeof v === "object" ? JSON.stringify(v) : String(v));
|
|
21
|
+
const vpIcon = (vp) => (vp === "mobile" ? "ui-mobile" : "ui-desktop");
|
|
22
|
+
|
|
23
|
+
/* ---------- dataLayer pairing, moved from the 0.17 renderer: a hit shows the push and the request behind it ---------- */
|
|
24
|
+
|
|
25
|
+
// Pixel event names and their GA4 / dataLayer counterparts, so a Meta or TikTok hit can be shown next to the push behind it.
|
|
26
|
+
const EVENT_ALIASES = {
|
|
27
|
+
pageview: "pageview", viewcontent: "viewitem", addtocart: "addtocart", addtowishlist: "addtowishlist", initiatecheckout: "begincheckout",
|
|
28
|
+
addpaymentinfo: "addpaymentinfo", purchase: "purchase", completepayment: "purchase", placeanorder: "purchase", lead: "generatelead",
|
|
29
|
+
completeregistration: "signup", search: "search", contact: "generatelead", submitform: "generatelead", subscribe: "subscribe", starttrial: "starttrial",
|
|
30
|
+
viewbasket: "viewcart", tracktransaction: "purchase",
|
|
31
|
+
};
|
|
32
|
+
const normName = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
33
|
+
const eventKey = (s) => EVENT_ALIASES[normName(s)] ?? normName(s);
|
|
34
|
+
// GTM-style pushes are objects ({event: "add_to_cart"}); gtag.js records arrays (["event", "add_to_cart", {...}]).
|
|
35
|
+
const pushEvent = (value) => (Array.isArray(value) ? (value[0] === "event" ? value[1] : undefined) : value && typeof value === "object" ? value.event : undefined);
|
|
36
|
+
const pushText = (value) => (Array.isArray(value) && typeof value[0] === "string" ? `gtag(${value.map((v) => JSON.stringify(v, null, 2)).join(", ")})` : JSON.stringify(value, null, 2));
|
|
37
|
+
|
|
38
|
+
// Per run: step index -> event key -> the pushes with that event name. Built once so rendering stays linear.
|
|
39
|
+
const pushIndexCache = new WeakMap();
|
|
40
|
+
function pushIndex(run) {
|
|
41
|
+
let index = pushIndexCache.get(run);
|
|
42
|
+
if (!index) {
|
|
43
|
+
index = new Map();
|
|
44
|
+
for (const e of run.dataLayerLog ?? []) {
|
|
45
|
+
const name = pushEvent(e.value);
|
|
46
|
+
if (typeof name !== "string" || !eventKey(name)) continue;
|
|
47
|
+
if (!index.has(e.step)) index.set(e.step, new Map());
|
|
48
|
+
const byKey = index.get(e.step);
|
|
49
|
+
const key = eventKey(name);
|
|
50
|
+
if (!byKey.has(key)) byKey.set(key, []);
|
|
51
|
+
byKey.get(key).push(e);
|
|
52
|
+
}
|
|
53
|
+
pushIndexCache.set(run, index);
|
|
54
|
+
}
|
|
55
|
+
return index;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The push behind the hit. When the same event fires as often as it was pushed (duplicates), they are paired in order. */
|
|
59
|
+
function pushesForHit(run, hit) {
|
|
60
|
+
const pushes = pushIndex(run).get(hit.step)?.get(eventKey(hit.name)) ?? [];
|
|
61
|
+
const key = eventKey(hit.name);
|
|
62
|
+
const twins = run.hits.filter((h) => h.step === hit.step && h.platform === hit.platform && eventKey(h.name) === key);
|
|
63
|
+
return twins.length > 1 && pushes.length === twins.length ? [pushes[twins.indexOf(hit)]] : pushes;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The network request that carried the hit. Identical hits (duplicates) are paired with identical requests in order. */
|
|
67
|
+
function callsForHit(run, hit) {
|
|
68
|
+
const calls = (run.network ?? []).filter((c) => c.step === hit.step && c.url === hit.url);
|
|
69
|
+
const twins = run.hits.filter((h) => h.step === hit.step && h.url === hit.url);
|
|
70
|
+
return twins.length > 1 && calls.length === twins.length ? [calls[twins.indexOf(hit)]] : calls;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* ---------- request parameters, moved from the 0.17 renderer ---------- */
|
|
74
|
+
|
|
75
|
+
/** Nested JSON as dotted keys (properties.value); arrays stay JSON text. */
|
|
76
|
+
function flatten(obj, prefix = "", out = []) {
|
|
77
|
+
for (const [k, v] of Object.entries(obj ?? {})) {
|
|
78
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
79
|
+
if (v && typeof v === "object" && !Array.isArray(v) && prefix.split(".").length < 3) flatten(v, key, out);
|
|
80
|
+
else out.push([key, cell(v)]);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The parameter sets a request carried: one per event in a GA4 batch (query plus each body line), the query plus a flattened JSON body, or just the query. */
|
|
86
|
+
function paramSets(call) {
|
|
87
|
+
let u;
|
|
88
|
+
try {
|
|
89
|
+
u = new URL(call.url);
|
|
90
|
+
} catch {
|
|
91
|
+
return [[]];
|
|
92
|
+
}
|
|
93
|
+
const query = [...u.searchParams];
|
|
94
|
+
const body = call.postData;
|
|
95
|
+
if (!body) return [query];
|
|
96
|
+
const trimmed = body.trim();
|
|
97
|
+
if (trimmed.startsWith("{")) {
|
|
98
|
+
try {
|
|
99
|
+
return [[...query, ...flatten(JSON.parse(trimmed))]];
|
|
100
|
+
} catch { /* not JSON, or cut at the size limit */ }
|
|
101
|
+
}
|
|
102
|
+
if (/^[^\s{]*=/.test(trimmed)) {
|
|
103
|
+
return trimmed.split("\n").filter(Boolean).map((line) => {
|
|
104
|
+
const merged = new Map(query);
|
|
105
|
+
for (const [k, v] of new URLSearchParams(line)) merged.set(k, v);
|
|
106
|
+
return [...merged];
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return [query];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Parameter rows with a glossary tip: the "?" shows the tip on hover and takes no focus; screen readers read the hidden span. */
|
|
113
|
+
function paramRows(lang, entries) {
|
|
114
|
+
return entries.map(([k, v]) => {
|
|
115
|
+
const tip = paramTip(k, lang);
|
|
116
|
+
const isItem = /^pr\d+$/.test(k);
|
|
117
|
+
return `<tr${isItem ? ' class="pr"' : ""}><th scope="row"><span class="pk">${esc(k)}</span>${tip ? `<span class="q" aria-hidden="true" data-tip="${esc(tip)}">?</span><span class="sr">${esc(tip)}</span>` : ""}</th><td>${isItem ? `<span class="muted">${esc(tip ?? "")}</span>` : esc(v)}</td></tr>`;
|
|
118
|
+
}).join("");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* What a hit card shows as its parameters: the request's own parameter set (raw keys, with glossary tips), split
|
|
123
|
+
* into the event's parameters and the session, device, page and consent ones. A GA4 batch carries one set per
|
|
124
|
+
* event; the hit takes the set with its name. Without a captured request, the hit's parsed parameters.
|
|
125
|
+
*/
|
|
126
|
+
function hitParams(run, hit) {
|
|
127
|
+
const call = callsForHit(run, hit)[0] ?? null;
|
|
128
|
+
if (!call) return { call, batch: null, event: Object.entries(hit.params ?? {}).filter(([k]) => !(k === "items" && hit.items?.length)).map(([k, v]) => [k, cell(v)]), context: [] };
|
|
129
|
+
const sets = paramSets(call);
|
|
130
|
+
let i = 0;
|
|
131
|
+
if (sets.length > 1) {
|
|
132
|
+
const names = (parseTrackingRequest(call.url, call.postData ?? null) ?? []).map((h) => h.name);
|
|
133
|
+
i = Math.max(0, names.indexOf(hit.name));
|
|
134
|
+
}
|
|
135
|
+
const entries = sets[i] ?? [];
|
|
136
|
+
return { call, batch: sets.length > 1 ? [i + 1, sets.length] : null, event: entries.filter(([k]) => isEventParam(k)), context: entries.filter(([k]) => !isEventParam(k)) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* ---------- items: GA4 item strings and dataLayer item objects as one table ---------- */
|
|
140
|
+
|
|
141
|
+
const STANDARD_ITEM_KEYS = new Set(["item_id", "item_name", "affiliation", "coupon", "discount", "index", "item_brand", "item_category", "item_category2", "item_category3", "item_category4", "item_category5", "item_list_id", "item_list_name", "item_variant", "location_id", "price", "quantity", "promotion_id", "promotion_name", "creative_name", "creative_slot"]);
|
|
142
|
+
const ECOMMERCE_EVENTS = new Set(["view_item", "view_item_list", "select_item", "add_to_cart", "remove_from_cart", "view_cart", "begin_checkout", "add_shipping_info", "add_payment_info", "purchase", "refund", "add_to_wishlist", "view_promotion", "select_promotion"]);
|
|
143
|
+
const MAIN_ITEM_KEYS = ["item_id", "item_name", "price", "quantity"];
|
|
144
|
+
|
|
145
|
+
/** dataLayer ecommerce.items objects as { fields, custom } pairs, the shape decodeGa4Item gives. */
|
|
146
|
+
const objectItems = (list) =>
|
|
147
|
+
list.filter((it) => it && typeof it === "object").map((it) => {
|
|
148
|
+
const entries = Object.entries(it).map(([k, v]) => [k, cell(v)]);
|
|
149
|
+
return { fields: entries.filter(([k]) => STANDARD_ITEM_KEYS.has(k)), custom: entries.filter(([k]) => !STANDARD_ITEM_KEYS.has(k)) };
|
|
150
|
+
});
|
|
151
|
+
/** The items array of a GTM-style ({ecommerce: {items}}) or gtag.js-style (["event", name, {items}]) push. */
|
|
152
|
+
function pushItems(value) {
|
|
153
|
+
const items = Array.isArray(value) ? value[2]?.items : value?.ecommerce?.items ?? value?.items;
|
|
154
|
+
return Array.isArray(items) ? items : [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const kvList = (pairs) => pairs.map(([k, v]) => `<span class="jr-ikv"><code>${esc(k)}</code> ${esc(v === "" ? "–" : v)}</span>`).join("");
|
|
158
|
+
function itemsTable(m, t, items) {
|
|
159
|
+
const get = (it, k) => it.fields.find(([f]) => f === k)?.[1];
|
|
160
|
+
const rest = items.map((it) => it.fields.filter(([k]) => !MAIN_ITEM_KEYS.includes(k)));
|
|
161
|
+
const anyRest = rest.some((r) => r.length);
|
|
162
|
+
const anyCustom = items.some((it) => it.custom.length);
|
|
163
|
+
const head = `<tr><th scope="col" class="num">#</th><th scope="col">${esc(t.colId)}</th><th scope="col">${esc(t.colName)}</th><th scope="col" class="num">${esc(t.colPrice)}</th><th scope="col" class="num">${esc(t.colQty)}</th>${anyRest ? `<th scope="col">${esc(t.itemMore)}</th>` : ""}${anyCustom ? `<th scope="col">${esc(m.ui.customItemParams)}</th>` : ""}</tr>`;
|
|
164
|
+
const body = items.map((it, i) => `<tr><td class="num">${i + 1}</td><td class="mono">${esc(get(it, "item_id") ?? "")}</td><td>${esc(get(it, "item_name") ?? "")}</td><td class="num">${esc(get(it, "price") ?? "–")}</td><td class="num">${esc(get(it, "quantity") ?? "1")}</td>${anyRest ? `<td>${kvList(rest[i])}</td>` : ""}${anyCustom ? `<td>${kvList(it.custom)}</td>` : ""}</tr>`).join("");
|
|
165
|
+
return `<div class="jr-ts"><table class="tbl jr-it" aria-label="${esc(m.ui.items)}"><thead>${head}</thead><tbody>${body}</tbody></table></div>`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* ---------- chips ---------- */
|
|
169
|
+
|
|
170
|
+
const glyph = (m, sev, size = 13) => `<span class="jr-g sev-${sev}" title="${esc(m.severity[sev])}">${use(`sev-${sev}`, size)}<span class="sr"> ${esc(m.severity[sev])}</span></span>`;
|
|
171
|
+
const platChip = (key, size = 14) => chip("plat", key, plat(key, size));
|
|
172
|
+
const vpChip = (vp, label = vp) => chip("vp", vp, `${use(vpIcon(vp), 13)}${esc(label)}`);
|
|
173
|
+
/** One consent chip per signal: ✓ granted, ✕ denied; the full signal name for screen readers and in the title. */
|
|
174
|
+
function consentChip(t, key, v) {
|
|
175
|
+
const mark = v === "granted" ? "✓" : v === "denied" ? "✕" : esc(v);
|
|
176
|
+
return `<span class="chip chip-consent ${safeKey(v)}" title="${esc(`${key}: ${v}`)}"><span class="k" aria-hidden="true">${esc(t.signals[key] ?? key)}</span><span aria-hidden="true">${mark}</span><span class="sr">${esc(`${key} ${v}`)}</span></span>`;
|
|
177
|
+
}
|
|
178
|
+
const hitConsent = (t, c) => {
|
|
179
|
+
const keys = c ? ["ad_storage", "analytics_storage"].filter((k) => typeof c[k] === "string") : [];
|
|
180
|
+
return keys.length ? keys.map((k) => consentChip(t, k, c[k])).join("") : `<span class="chip chip-consent none" title="${esc(t.noSignalTitle)}">${esc(t.noSignal)}</span>`;
|
|
181
|
+
};
|
|
182
|
+
const missingChip = (m, k) => `<span class="chip chip-sev broken jr-miss"><span class="k">${esc(k)}</span>${esc(m.ui.missing)}</span>`;
|
|
183
|
+
const kv = (k, v) => `<span class="jr-kv">${k ? `<span class="k">${esc(k)}</span>` : ""}<span class="v">${esc(trunc(v))}</span></span>`;
|
|
184
|
+
|
|
185
|
+
/* ---------- severities: which steps and runs a finding points at ---------- */
|
|
186
|
+
|
|
187
|
+
/** The worst problem severity per step index of the run, from each row's places (whereTarget: repeated labels resolve to one step). */
|
|
188
|
+
function flaggedSteps(rows, runs, run) {
|
|
189
|
+
const out = new Map();
|
|
190
|
+
for (const f of rows) {
|
|
191
|
+
if (!(f.severity in SEVERITY_RANK)) continue;
|
|
192
|
+
for (const w of f.where) {
|
|
193
|
+
if (w.journey !== run.journeyId || w.viewport !== run.viewport) continue;
|
|
194
|
+
const step = whereTarget(runs, w, f)?.step;
|
|
195
|
+
if (step && rankOf(f.severity) < rankOf(out.get(step.index))) out.set(step.index, f.severity);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Everything a run's markup needs about its findings: the step severities, the run's worst, the step opened on load. */
|
|
202
|
+
function runState(ctx, rows, run) {
|
|
203
|
+
// Hits before the first listed step (browser.js's "start" step) get a step 0 pane of their own, only when there are
|
|
204
|
+
// any: simpler than folding them into step 1, since every id (stepAnchor, hitId) stays keyed by the hit's own step,
|
|
205
|
+
// so the plan line's link to j<r>-s0 and the hit numbering need no remapping.
|
|
206
|
+
const start = hitsOf(run, 0).length ? [{ ...(run.steps ?? []).find((s) => s.index === 0), index: 0, label: ctx.m.html.journeys.startStep }] : [];
|
|
207
|
+
const steps = [...start, ...(run.steps ?? []).filter((s) => s.index > 0)];
|
|
208
|
+
const flagged = flaggedSteps(rows, ctx.runs, run);
|
|
209
|
+
const sevOf = new Map(steps.map((s) => [s.index, worstOf([flagged.get(s.index), ...hitsOf(run, s.index).map((h) => hitFinding(rows, run, s, h)?.severity)])]));
|
|
210
|
+
// A run-level problem (journey incomplete, no consent default) has no step but still marks the run.
|
|
211
|
+
const runSev = worstOf([...sevOf.values(), ...rows.filter((f) => f.where.some((w) => w.journey === run.journeyId && w.viewport === run.viewport)).map((f) => f.severity)]);
|
|
212
|
+
const selected = steps.reduce((best, s) => (sevOf.get(s.index) && (!best || rankOf(sevOf.get(s.index)) < rankOf(sevOf.get(best.index))) ? s : best), null) ?? steps.find((s) => s.index > 0) ?? steps[0] ?? null;
|
|
213
|
+
return { steps, sevOf, runSev, selected };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/* ---------- step pane: hits ---------- */
|
|
217
|
+
|
|
218
|
+
/** The 1–3 parameters a practitioner scans first: missing required ones, value, items, the order id; a page title when there is nothing else. */
|
|
219
|
+
function keyBits(ctx, t, hit, flag) {
|
|
220
|
+
const { m } = ctx;
|
|
221
|
+
const p = hit.params ?? {};
|
|
222
|
+
const out = [];
|
|
223
|
+
if (flag?.check === "missing_params") for (const k of [...missingParamRows(ctx.plan, flag, p)].sort()) out.push(missingChip(m, k));
|
|
224
|
+
const mny = money(p.value, p.currency);
|
|
225
|
+
if (mny) out.push(kv("value", mny));
|
|
226
|
+
if (hit.items?.length) out.push(kv("items", hit.items.length));
|
|
227
|
+
else if (hit.platform === "ga4" && ECOMMERCE_EVENTS.has(hit.name)) out.push(`<span class="muted">${esc(m.ui.noItems)}</span>`);
|
|
228
|
+
if (p.transaction_id) out.push(kv("transaction_id", p.transaction_id));
|
|
229
|
+
if (hit.delivery === "server") out.push(`<span class="chip plain">${esc(m.ui.serverSideTag)}</span>`);
|
|
230
|
+
if (!out.length && p.page_title) out.push(kv("", p.page_title));
|
|
231
|
+
if (!out.length) return `<span class="muted">${esc(m.ui.noParameters)}</span>`;
|
|
232
|
+
return out.slice(0, 3).join("");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The readable form of a request: the URL with one parameter per line, then its body. */
|
|
236
|
+
function rawText(url, postData) {
|
|
237
|
+
let u = String(url ?? "");
|
|
238
|
+
try { u = decodeURIComponent(u); } catch { /* keep the encoded form */ }
|
|
239
|
+
return u.replace(/[?&]/g, "\n$&") + (postData ? `\n\n${postData}` : "");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function hitCard(ctx, t, rows, run, ri, step, hit, k) {
|
|
243
|
+
const { m, lang, ids } = ctx;
|
|
244
|
+
const flag = hitFinding(rows, run, step, hit);
|
|
245
|
+
const sev = flag?.severity;
|
|
246
|
+
const id = hitId(ri, step.index, k);
|
|
247
|
+
const { call, batch, event, context } = hitParams(run, hit);
|
|
248
|
+
const missing = flag?.check === "missing_params" ? [...missingParamRows(ctx.plan, flag, hit.params ?? {})].sort() : [];
|
|
249
|
+
const items = hit.items?.length ? hit.items.map(decodeGa4Item) : event.filter(([key]) => /^pr\d+$/.test(key)).map(([, v]) => decodeGa4Item(v));
|
|
250
|
+
const missRows = missing.map((key) => `<tr class="jr-mr"><th scope="row"><span class="pk">${esc(key)}</span></th><td>${chip("sev", "broken", esc(m.ui.missing))}</td></tr>`).join("");
|
|
251
|
+
const paramTable = event.length || missing.length
|
|
252
|
+
? `<table class="tbl jr-kvt" aria-label="${esc(m.ui.eventParams)}"><tbody>${paramRows(lang, event)}${missRows}</tbody></table>`
|
|
253
|
+
: `<p class="muted xs">${esc(m.ui.noParameters)}</p>`;
|
|
254
|
+
const ctxTable = context.length ? `<details class="card jr-ctx"><summary><span class="t">${esc(m.ui.contextParams)} ${n(context.length)}</span></summary><div class="body"><table class="tbl jr-kvt" aria-label="${esc(m.ui.contextParams)}"><tbody>${paramRows(lang, context)}</tbody></table></div></details>` : "";
|
|
255
|
+
const pushes = pushesForHit(run, hit);
|
|
256
|
+
const stepPushes = pushesOf(run, step.index);
|
|
257
|
+
const stepCalls = requestsOf(run, step.index);
|
|
258
|
+
const pushLine = pushes.length
|
|
259
|
+
? pushes.map((e) => `<a class="mono" href="#${pushId(ri, step.index, stepPushes.indexOf(e))}">${esc(pushEvent(e.value) ?? "")} · ${esc(secs(run, e.t))}</a>`).join("")
|
|
260
|
+
: `<span class="muted">${esc(m.ui.noMatchingPush)}</span>`;
|
|
261
|
+
const label = PROVIDERS[safeKey(hit.platform)] ?? platformLabel(hit.platform);
|
|
262
|
+
const calls = callsForHit(run, hit);
|
|
263
|
+
const reqLine = calls.length
|
|
264
|
+
? calls.map((c) => `<a class="mono" href="#${requestId(ri, step.index, stepCalls.indexOf(c))}">${esc(c.method ?? "")} ${esc(hostPath(c.url))} ${esc(c.status ?? "")}</a>${c.blocked ? `<span class="muted">${esc(m.html.blockedBy(label))}</span>` : ""}`).join("")
|
|
265
|
+
: `<span class="mono">${esc(hostPath(hit.url))}</span>`;
|
|
266
|
+
const card = flag ? ids.get(flag) : null;
|
|
267
|
+
return `<details class="card jr-hit${sev ? ` sev-${sev}` : ""}" id="${id}"${sev ? ` data-flagged="${sev}" data-print-open` : ""}><summary><span class="jr-c-p">${platChip(hit.platform)}</span><code class="jr-ev">${esc(hit.name)}</code><span class="jr-c-k">${keyBits(ctx, t, hit, flag)}</span><span class="jr-c-c">${hitConsent(t, hit.consent)}</span><span class="jr-c-s">${sev ? badge(sev, m.severity[sev]) : ""}</span><span class="jr-tm">${esc(secs(run, hit.t))}</span></summary>
|
|
268
|
+
<div class="body">${flag ? `<p class="jr-flag sev-${sev}">${use(`sev-${sev}`, 14)}<span>${detailHtml(flag.detail)}</span>${card ? `<a href="#${card}">${esc(m.html.findingLink)}</a>` : ""}</p>` : ""}
|
|
269
|
+
<div class="jr-hb"><div><h4 class="jr-h">${esc(m.ui.parameters)}${n(event.length + missing.length)}</h4>${batch ? `<p class="muted xs jr-batch">${esc(m.ui.inBatch(batch[0], batch[1]))}</p>` : ""}${paramTable}${ctxTable}</div>
|
|
270
|
+
${items.length ? `<div><h4 class="jr-h">${esc(m.ui.items)}${n(items.length)}</h4>${itemsTable(m, t, items)}</div>` : ""}
|
|
271
|
+
<div><h4 class="jr-h">${esc(m.ui.source)}</h4><ul class="jr-src"><li><span class="lbl">${esc(t.dataLayer)}</span>${pushLine}</li><li><span class="lbl">${esc(m.ui.request)}</span>${reqLine}</li></ul></div></div>
|
|
272
|
+
<details class="card jr-raw"><summary><span class="t">${esc(m.ui.rawRequest)}</span><span class="end mono xs muted">${esc(hostPath(call?.url ?? hit.url))}</span></summary><div class="body"><pre>${esc(rawText(call?.url ?? hit.url, call?.postData))}</pre></div></details>
|
|
273
|
+
</div></details>`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/* ---------- step pane: dataLayer ---------- */
|
|
277
|
+
|
|
278
|
+
function pushCard(ctx, t, run, ri, step, e, k) {
|
|
279
|
+
const { m } = ctx;
|
|
280
|
+
const v = e.value;
|
|
281
|
+
const name = isConsentEntry(v) ? `consent ${v[1]}` : pushEvent(v) ?? trunc(JSON.stringify(v) ?? String(v), 48);
|
|
282
|
+
const items = objectItems(pushItems(v));
|
|
283
|
+
let bits = "";
|
|
284
|
+
if (isConsentEntry(v) && v[2] && typeof v[2] === "object") bits = Object.entries(v[2]).filter(([, x]) => typeof x === "string").map(([s, x]) => consentChip(t, s, x)).join("");
|
|
285
|
+
else {
|
|
286
|
+
const ec = Array.isArray(v) ? v[2] : v?.ecommerce;
|
|
287
|
+
const out = [];
|
|
288
|
+
const mny = money(ec?.value, ec?.currency);
|
|
289
|
+
if (mny) out.push(kv("value", mny));
|
|
290
|
+
if (items.length) out.push(kv("items", items.length));
|
|
291
|
+
if (v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 1 && v.ecommerce === null) out.push(`<span class="muted">${esc(t.clear)}</span>`);
|
|
292
|
+
bits = out.join("");
|
|
293
|
+
}
|
|
294
|
+
return `<details class="card jr-push" id="${pushId(ri, step.index, k)}"><summary><code class="jr-ev">${esc(name)}</code><span class="jr-c-k">${bits}</span><span class="jr-tm">${esc(secs(run, e.t))}</span></summary>
|
|
295
|
+
<div class="body">${items.length ? `<h4 class="jr-h">${esc(m.ui.items)}${n(items.length)}</h4>${itemsTable(m, t, items)}` : ""}<pre>${esc(pushText(v))}</pre></div></details>`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/* ---------- step pane: requests ---------- */
|
|
299
|
+
|
|
300
|
+
const skippedOf = (run, si) => Object.entries(run.skipped?.[si] ?? {}).sort((a, b) => b[1] - a[1]);
|
|
301
|
+
|
|
302
|
+
function requestTable(ctx, t, run, ri, step, calls) {
|
|
303
|
+
const { m } = ctx;
|
|
304
|
+
const rows = calls.map((c, k) => {
|
|
305
|
+
const key = safeKey(c.provider ?? providerOf(c.url)?.key ?? c.vendor);
|
|
306
|
+
const label = PROVIDERS[key] ?? c.vendor ?? key;
|
|
307
|
+
const sets = paramSets(c);
|
|
308
|
+
const names = (parseTrackingRequest(c.url, c.postData ?? null) ?? []).map((h) => h.name);
|
|
309
|
+
const events = sets.map((_, i) => names[i] ?? (names.length === 1 ? names[0] : "")).map((name, i) => (name || sets.length > 1 ? `<span class="jr-rev">${name ? `<code>${esc(name)}</code>` : ""}${sets.length > 1 ? `<span class="muted">${esc(m.ui.inBatch(i + 1, sets.length))}</span>` : ""}</span>` : "")).join("");
|
|
310
|
+
const blocked = c.blocked ? ` <span class="chip" title="${esc(m.html.blockedBy(label))}">${esc(t.blocked)}<span class="sr"> ${esc(m.html.blockedBy(label))}</span></span>` : "";
|
|
311
|
+
return `<tr id="${requestId(ri, step.index, k)}"><td>${chip("plat", key, plat(key, 14, label))}</td><td class="mono">${esc(c.method ?? "")}</td><td>${events}</td><td class="mono jr-url" title="${esc(c.url)}"><span>${esc(hostPath(c.url))}</span></td><td><span class="jr-st-c"><span class="mono">${esc(c.status ?? "")}</span>${blocked}</span></td><td class="num">${esc(secs(run, c.t))}</td></tr>`;
|
|
312
|
+
}).join("");
|
|
313
|
+
return `<div class="tscroll"><table class="tbl jr-req" aria-label="${esc(m.ui.tabNetwork)}"><thead><tr><th scope="col">${esc(m.ui.platform)}</th><th scope="col">${esc(m.ui.method)}</th><th scope="col">${esc(m.ui.event)}</th><th scope="col">${esc(t.colUrl)}</th><th scope="col">${esc(m.ui.status)}</th><th scope="col" class="num">${esc(m.html.timeCol)}</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/* ---------- step pane ---------- */
|
|
317
|
+
|
|
318
|
+
/** What the step did, as the replay will do it: the action and the selector it targets. Typed values are never shown. */
|
|
319
|
+
function actionText(m, step) {
|
|
320
|
+
const r = step.replay;
|
|
321
|
+
const parts = [];
|
|
322
|
+
if (r?.action === "goto") parts.push(`goto ${pathOf(r.url)}`);
|
|
323
|
+
else if (r) parts.push([r.action, r.selector ?? (r.text ? `"${r.text}"` : "")].filter(Boolean).join(" "));
|
|
324
|
+
else if (step.action) parts.push(step.action);
|
|
325
|
+
if (step.forced) parts.push(m.ui.clickedThrough);
|
|
326
|
+
return parts;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The platform-parity line for a step: the events of this step whose platforms disagree (count, value, currency). */
|
|
330
|
+
function parityNote(m, run, step, parity) {
|
|
331
|
+
const events = new Set(hitsOf(run, step.index).map((h) => canonicalEvent(h.name)));
|
|
332
|
+
const lines = (parity?.shared ?? []).filter((s) => s.mismatch.length && events.has(s.event));
|
|
333
|
+
return lines.map((s) => {
|
|
334
|
+
const per = sortPlatforms(Object.keys(s.platforms)).map((k) => { const p = s.platforms[k]; return `<span class="jr-pp">${platChip(k, 13)}<code>${esc(p.names.join(" / "))}</code><span class="mono">${p.count}×${p.value !== null ? ` · ${esc(money(p.value, p.currency))}` : ""}</span></span>`; }).join("");
|
|
335
|
+
return `<p class="jr-par">${use("sev-warning", 14)}<span class="jr-par-h">${esc(m.html.parity)}</span>${per}<b>${esc(s.mismatch.map((x) => m.html.mismatch[x] ?? x).join(", "))}</b></p>`;
|
|
336
|
+
}).join("");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function stepPane(ctx, t, rows, run, ri, step, sev, selected) {
|
|
340
|
+
const { m } = ctx;
|
|
341
|
+
const id = stepAnchor(ri, step.index);
|
|
342
|
+
const hits = hitsOf(run, step.index);
|
|
343
|
+
const pushes = pushesOf(run, step.index);
|
|
344
|
+
const calls = requestsOf(run, step.index);
|
|
345
|
+
const skipped = skippedOf(run, step.index);
|
|
346
|
+
const skippedN = skipped.reduce((a, [, c]) => a + c, 0);
|
|
347
|
+
const meta = [step.pagePath ? pathOf(step.pagePath) : null, ...actionText(m, step), secs(run, step.t)].filter(Boolean).map((x) => `<span>${esc(x)}</span>`).join("");
|
|
348
|
+
const reqNotes = `${skippedN ? `<p class="muted xs jr-note-s">${esc(m.ui.skippedInStep(skipped.map(([v, c]) => `${v} ${c}`).join(", "), skippedN))}</p>` : ""}${calls.some((c) => c.bodyDropped) ? `<p class="muted xs jr-note-s">${esc(m.ui.bodyDropped)}</p>` : ""}`;
|
|
349
|
+
const tabs = [
|
|
350
|
+
["hits", esc(m.html.hitsCol), m.html.hitsInStep, hits.length, hits.length ? `<div class="jr-list">${hits.map((h, k) => hitCard(ctx, t, rows, run, ri, step, h, k)).join("")}</div>` : `<p class="muted xs jr-empty">${esc(m.ui.noHits)}</p>`],
|
|
351
|
+
["dl", esc(t.dataLayer), t.dataLayer, pushes.length, pushes.length ? `<div class="jr-list">${pushes.map((e, k) => pushCard(ctx, t, run, ri, step, e, k)).join("")}</div>` : `<p class="muted xs jr-empty">${esc(m.ui.noPushes)}</p>`],
|
|
352
|
+
["req", esc(m.ui.tabNetwork), m.ui.tabNetwork, calls.length, `${calls.length ? requestTable(ctx, t, run, ri, step, calls) : `<p class="muted xs jr-empty">${esc(m.ui.noRequests)}</p>`}${reqNotes}`],
|
|
353
|
+
];
|
|
354
|
+
// A tab with nothing in it is shown but disabled; the requests tab stays open when it only has the skipped-requests note.
|
|
355
|
+
const live = (k, c) => c > 0 || (k === "req" && Boolean(reqNotes));
|
|
356
|
+
const first = tabs.find(([k, , , c]) => live(k, c));
|
|
357
|
+
const body = first
|
|
358
|
+
? `<div class="tabs jr-tabs" data-tabs><div role="tablist" aria-label="${esc(t.stepOf(step.index))}">${tabs.map(([k, label, , c]) => `<button type="button" role="tab" aria-controls="${id}-${k}" aria-selected="${first[0] === k}"${live(k, c) ? "" : " disabled"}>${label} ${n(c)}</button>`).join("")}</div>
|
|
359
|
+
${tabs.map(([k, , dataLabel, c, html]) => `<div role="tabpanel" id="${id}-${k}" data-label="${esc(dataLabel)}"${live(k, c) ? "" : ' class="jr-none"'}>${html}</div>`).join("")}</div>`
|
|
360
|
+
: `<p class="muted xs jr-empty">${esc(t.nothing)}</p>`;
|
|
361
|
+
return `<article class="card jr-pane${sev ? ` sev-${sev}` : ""}" id="${id}" data-select-pane data-step="${step.index}"${sev ? ` data-flagged="${sev}"` : ""}${selected ? " data-selected" : ""} tabindex="-1" aria-labelledby="${id}-t">
|
|
362
|
+
<header class="jr-ph"><h3 id="${id}-t"><span class="jr-n">${step.index}</span><span>${esc(stepTitle(run, step))}</span></h3><span class="jr-ph-e">${sev ? badge(sev, m.severity[sev]) : ""}${vpChip(run.viewport)}</span><p class="jr-ph-m">${meta}</p></header>
|
|
363
|
+
${parityNote(m, run, step, ctx.metrics.parity?.[ri])}${body}
|
|
364
|
+
</article>`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/* ---------- one run: status line, plan line, step list, panes ---------- */
|
|
368
|
+
|
|
369
|
+
/** Expected versus observed for the run, as one line: the funnel sentence, then a chip per planned event that failed (three at most). */
|
|
370
|
+
function planLine(ctx, run, ri) {
|
|
371
|
+
const { m } = ctx;
|
|
372
|
+
const fu = ctx.metrics.funnel?.[ri];
|
|
373
|
+
if (!fu?.events?.length) return "";
|
|
374
|
+
const bad = fu.events.filter((r) => r.status !== "ok");
|
|
375
|
+
const chips = bad.slice(0, 3).map((r) => {
|
|
376
|
+
// The chip names the event; what is wrong with it is its title (and read out), so the line stays one line at 1280.
|
|
377
|
+
const why = r.status === "missing_params" ? m.html.params(r.missingParams) : m.html.notSent;
|
|
378
|
+
const label = `${platIcon(r.platform, 13)}<code>${esc(r.event)}</code><span class="sr"> ${esc(why)}</span>`;
|
|
379
|
+
if (r.step === null) return `<span class="chip chip-sev broken" title="${esc(why)}">${label}</span>`;
|
|
380
|
+
const k = hitsOf(run, r.step).findIndex((h) => safeKey(h.platform) === safeKey(r.platform) && String(h.name).toLowerCase() === String(r.event).toLowerCase());
|
|
381
|
+
return `<a class="chip chip-sev broken" href="#${k >= 0 ? hitId(ri, r.step, k) : stepAnchor(ri, r.step)}" title="${esc(why)}">${label}</a>`;
|
|
382
|
+
}).join("");
|
|
383
|
+
const more = bad.length > 3 ? `<span class="chip plain">+${bad.length - 3}</span>` : "";
|
|
384
|
+
// The chips come in plan order, so the first "not sent" one is where the funnel breaks; no sentence repeats it.
|
|
385
|
+
const lead = m.html.funnelLead(fu.observed, fu.expected, fu.ok);
|
|
386
|
+
return `<p class="jr-plan"><span class="jr-plan-h">${esc(m.html.funnel)}</span><span class="jr-plan-t">${use(bad.length ? "sev-broken" : "sev-ok", 13, bad.length ? "jr-bad" : "jr-good")}${esc(lead)}</span>${chips}${more}</p>`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function runPanel(ctx, t, rows, run, ri, state) {
|
|
390
|
+
const { m } = ctx;
|
|
391
|
+
const { steps, sevOf, selected } = state;
|
|
392
|
+
const plats = sortPlatforms(new Set((run.hits ?? []).map((h) => h.platform)));
|
|
393
|
+
const groups = [];
|
|
394
|
+
for (const s of steps) {
|
|
395
|
+
const key = s.pagePath ? pathOf(s.pagePath) : "";
|
|
396
|
+
if (!groups.length || groups[groups.length - 1].key !== key) groups.push({ key, title: pageTitle(run, s.index), steps: [] });
|
|
397
|
+
groups[groups.length - 1].steps.push(s);
|
|
398
|
+
}
|
|
399
|
+
const list = groups.map((g) => `<li class="jr-pg"><span class="jr-pg-i">${use("ui-page", 12)}</span><span class="jr-pg-t">${esc(g.title)}</span>${g.key ? `<code class="jr-pg-p">${esc(g.key)}</code>` : ""}</li>${g.steps.map((s) => {
|
|
400
|
+
const sev = sevOf.get(s.index);
|
|
401
|
+
const hs = hitsOf(run, s.index);
|
|
402
|
+
const id = stepAnchor(ri, s.index);
|
|
403
|
+
const counts = !plats.length ? '<span class="jr-hc"></span>' : plats.map((p) => { const c = hs.filter((h) => h.platform === p).length; return `<span class="jr-hc">${c ? `<span class="chip chip-plat ${safeKey(p)}" title="${esc(t.hitsOf(c, platformLabel(p)))}">${platIcon(p, 12)}${c}</span>` : ""}</span>`; }).join("");
|
|
404
|
+
return `<li class="jr-st${hs.length ? "" : " zero"}${sev ? ` sev-${sev}` : ""}"${sev ? ` data-flagged="${sev}"` : ""}><button type="button" data-select-target="#${id}" data-step="${s.index}" aria-controls="${id}"><span class="jr-n">${s.index}</span><span class="jr-t">${esc(stepTitle(run, s))}</span>${counts}<span class="jr-gs">${sev ? glyph(m, sev) : ""}</span></button></li>`;
|
|
405
|
+
}).join("")}`).join("");
|
|
406
|
+
const ms = ctx.metrics.timing?.[ri]?.durationMs ?? null;
|
|
407
|
+
const runChip = run.completed
|
|
408
|
+
? `<span title="${esc(run.note ?? "")}">${chip("run", "completed", `${use("sev-ok", 13)}${esc(m.ui.completed)}`)}</span>`
|
|
409
|
+
: chip("run", "incomplete", `${use("sev-warning", 13)}${esc(m.ui.notCompleted)}`);
|
|
410
|
+
const status = `<p class="jr-status"><span class="jr-runh">${vpChip(run.viewport, `${run.journeyId} · ${run.viewport}`)}</span>${runChip}<span class="jr-nums">${esc(t.stats(steps.filter((s) => s.index > 0).length, (run.hits ?? []).length, ms === null ? null : (ms / 1000).toFixed(1)))}</span>${run.goal ? `<span class="jr-goal" title="${esc(run.goal)}">${esc(run.goal)}</span>` : ""}</p>`;
|
|
411
|
+
// Why an incomplete run stopped is the first thing to read about it, so it gets its own line.
|
|
412
|
+
const note = !run.completed && run.note ? `<p class="jr-stop">${use("sev-warning", 14)}<span>${esc(run.note)}</span></p>` : "";
|
|
413
|
+
const inspector = steps.length
|
|
414
|
+
? `<div class="jr-insp" style="--jr-plats:${Math.max(1, plats.length)}">
|
|
415
|
+
<nav class="jr-steps" aria-label="${esc(t.steps)}"><ul data-select>${list}</ul></nav>
|
|
416
|
+
<div class="jr-panes">${steps.map((s) => stepPane(ctx, t, rows, run, ri, s, sevOf.get(s.index), s === selected)).join("")}</div>
|
|
417
|
+
</div>`
|
|
418
|
+
: `<p class="muted">${esc(m.ui.noSteps)}</p>`;
|
|
419
|
+
return `<div class="jr-run" id="j${ri}" data-inspector>
|
|
420
|
+
${status}${note}${planLine(ctx, run, ri)}
|
|
421
|
+
${inspector}</div>`;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** @param {import("./shell.js").Ctx} ctx */
|
|
425
|
+
export function render(ctx) {
|
|
426
|
+
const { m, runs } = ctx;
|
|
427
|
+
const t = m.html.journeys;
|
|
428
|
+
const heading = m.html.shell.sections.journeys;
|
|
429
|
+
if (!runs.length) return sectionBlock("journeys", heading, `<p class="muted">${esc(t.noRuns)}</p>`);
|
|
430
|
+
// Accepted rows are known and not counted (tally), so they do not flag steps or hits either.
|
|
431
|
+
const rows = ctx.rows.filter((f) => !f.accepted);
|
|
432
|
+
const states = runs.map((run) => runState(ctx, rows, run));
|
|
433
|
+
// The run opened on load is the one with the worst finding, earliest on ties (demo: checkout · mobile).
|
|
434
|
+
const openRun = runs.map((_, ri) => ri).sort((a, b) => rankOf(states[a].runSev) - rankOf(states[b].runSev) || a - b)[0];
|
|
435
|
+
const journeys = [...new Set(runs.map((r) => r.journeyId))];
|
|
436
|
+
const runsOf = (j) => runs.map((r, ri) => ({ r, ri })).filter((x) => x.r.journeyId === j).sort((a, b) => (VP_ORDER.indexOf(a.r.viewport) + 1 || 9) - (VP_ORDER.indexOf(b.r.viewport) + 1 || 9) || a.ri - b.ri);
|
|
437
|
+
const runGlyph = (ri, size) => (states[ri].runSev ? glyph(m, states[ri].runSev, size) : "");
|
|
438
|
+
const jtabs = journeys.map((j, ji) => `<button type="button" role="tab" aria-controls="jy-${ji}" aria-selected="${j === runs[openRun].journeyId}">${esc(j)}<span class="jr-gl">${runsOf(j).map(({ ri }) => runGlyph(ri, 12)).join("")}</span></button>`).join("");
|
|
439
|
+
const jpanels = journeys.map((j, ji) => {
|
|
440
|
+
const rs = runsOf(j);
|
|
441
|
+
// Each journey's switch starts on its worst run, so switching journeys never lands on a quiet run first.
|
|
442
|
+
const pick = rs.find((x) => x.ri === openRun) ?? [...rs].sort((a, b) => rankOf(states[a.ri].runSev) - rankOf(states[b.ri].runSev) || a.ri - b.ri)[0];
|
|
443
|
+
const vbtns = rs.map(({ r, ri }) => `<button type="button" class="jr-vpb ${safeKey(r.viewport)}" data-vp="${esc(r.viewport)}" data-vp-target="#j${ri}" aria-pressed="${ri === pick.ri}" title="${esc(r.viewport)}">${use(vpIcon(r.viewport), 14)}<span class="jr-vpl">${esc(r.viewport)}</span>${runGlyph(ri, 12)}</button>`).join("");
|
|
444
|
+
return `<div role="tabpanel" id="jy-${ji}" data-label="${esc(j)}"><div class="jr-vp" data-vp-switch role="group" aria-label="${esc(t.pickViewport)}">${vbtns}</div>
|
|
445
|
+
${rs.map(({ r, ri }) => runPanel(ctx, t, rows, r, ri, states[ri])).join("\n")}</div>`;
|
|
446
|
+
}).join("");
|
|
447
|
+
return sectionBlock("journeys", heading, `<div class="tabs jr-pick" data-tabs><div role="tablist" aria-label="${esc(m.ui.chooseJourney)}">${jtabs}</div>
|
|
448
|
+
${jpanels}</div>`);
|
|
449
|
+
}
|