@analitikisler/ai-tag-debugger 0.15.0 → 0.17.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/src/gtm.js ADDED
@@ -0,0 +1,298 @@
1
+ // Reads a GTM container export (Admin → Export container) to name the tag and trigger
2
+ // behind a hit. Matching is deterministic: no API call and no AI model.
3
+
4
+ import { canonicalEvent, isConsentCommand } from "./checks.js";
5
+ import { messages } from "./i18n.js";
6
+
7
+ // GTM's built-in "All Pages" trigger has no entry in the export's trigger list.
8
+ const BUILT_IN_TRIGGERS = { 2147479553: { name: "All Pages", type: "PAGEVIEW" } };
9
+ // The dataLayer events GTM raises for page triggers; the init script records them like any push.
10
+ const PAGE_EVENTS = { PAGEVIEW: "gtm.js", DOM_READY: "gtm.dom", WINDOW_LOADED: "gtm.load" };
11
+ // Triggers GTM evaluates from what happens in the page. Their events reach the dataLayer only
12
+ // while such a trigger is listening, so the dataLayer can confirm one but not rule it out.
13
+ const PAGE_ACTION_EVENTS = {
14
+ CLICK: "gtm.click", LINK_CLICK: "gtm.linkClick", FORM_SUBMIT: "gtm.formSubmit", FORM_SUBMISSION: "gtm.formSubmit", HISTORY_CHANGE: "gtm.historyChange",
15
+ ELEMENT_VISIBILITY: "gtm.elementVisibility", TIMER: "gtm.timer", SCROLL_DEPTH: "gtm.scrollDepth", JS_ERROR: "gtm.pageError", YOU_TUBE_VIDEO: "gtm.video",
16
+ };
17
+ // Older exports write the custom event filter's operators in short form.
18
+ const SHORT_OPS = { _eq: "EQUALS", _cn: "CONTAINS", _sw: "STARTS_WITH", _ew: "ENDS_WITH", _re: "MATCH_REGEX", _lt: "LESS", _le: "LESS_OR_EQUALS", _gt: "GREATER", _ge: "GREATER_OR_EQUALS", _css: "CSS_SELECTOR", _um: "URL_MATCHES" };
19
+ const PAGE_VARIABLES = { "Page URL": "URL", "Page Path": "PATH", "Page Hostname": "HOST" };
20
+ const EVENT_PARAMS = ["eventName", "event_name", "event", "eventType", "standardEventName", "customEventName", "eventTypeStandard", "eventTypeCustom"];
21
+
22
+ /** A tag parameter list as a flat { key: value } map; nested lists and maps are kept as JSON text. */
23
+ function params(list = []) {
24
+ const out = {};
25
+ for (const p of list) out[p.key] = p.value ?? (p.list || p.map ? JSON.stringify(p.list ?? p.map) : undefined);
26
+ return out;
27
+ }
28
+
29
+ /** Which platform a tag sends to, from its type or, for custom HTML and templates, from its code. */
30
+ function tagPlatform(tag, p) {
31
+ if (["gaawe", "gaawc", "googtag"].includes(tag.type)) return "ga4";
32
+ if (["awct", "sp", "gclidw"].includes(tag.type)) return "google_ads";
33
+ const text = `${tag.type} ${tag.name ?? ""} ${p.html ?? ""}`.toLowerCase();
34
+ if (/fbq\(|facebook|meta pixel|\bmeta\b/.test(text)) return "meta";
35
+ if (/ttq\.|tiktok/.test(text)) return "tiktok";
36
+ return null;
37
+ }
38
+
39
+ /** The event names a tag sends: its event parameter, a page_view for a GA4 config tag, or the names in fbq/ttq calls. */
40
+ function tagEvents(tag, p) {
41
+ if (tag.type === "gaawc" || tag.type === "googtag") return ["page_view"];
42
+ const names = EVENT_PARAMS.map((k) => p[k]).filter((v) => typeof v === "string" && v);
43
+ for (const m of String(p.html ?? "").matchAll(/(?:fbq|ttq)\s*(?:\(\s*['"](?:track|trackCustom)['"]\s*,|\.track\s*\()\s*['"]([^'"]+)['"]/g)) names.push(m[1]);
44
+ if (/fbq\s*\(\s*['"]init['"]/.test(p.html ?? "") || /ttq\.page\s*\(/.test(p.html ?? "")) names.push("PageView", "Pageview");
45
+ return names;
46
+ }
47
+
48
+ /** The custom event names a trigger fires on, from its "Event name equals …" filter. */
49
+ function triggerEvents(trigger) {
50
+ return (trigger?.customEventFilter ?? []).map((f) => params(f.parameter).arg1).filter(Boolean);
51
+ }
52
+
53
+ function describeTrigger(id, byId) {
54
+ const t = byId.get(String(id)) ?? BUILT_IN_TRIGGERS[id];
55
+ if (!t) return { id: String(id), name: `#${id}`, type: "unknown" };
56
+ const events = triggerEvents(t);
57
+ return { id: String(id), name: t.name, type: t.type, ...(events.length ? { event: events.join(", ") } : {}) };
58
+ }
59
+
60
+ /**
61
+ * The tags in a container export with their platform, events and triggers.
62
+ * @returns {{ id: string, name: string, type: string, platform: string|null, events: string[], paused: boolean, consent: string[], triggers: any[], blocking: any[] }[]}
63
+ */
64
+ export function containerTags(container) {
65
+ const v = container?.containerVersion ?? container ?? {};
66
+ const byId = new Map((v.trigger ?? []).map((t) => [String(t.triggerId), t]));
67
+ return (v.tag ?? []).map((tag) => {
68
+ const p = params(tag.parameter);
69
+ const triggers = (tag.firingTriggerId ?? []).map((id) => describeTrigger(id, byId));
70
+ let events = tagEvents(tag, p);
71
+ // An event name taken from a variable such as {{Event}} is whatever the trigger's custom event is.
72
+ if (events.some((e) => /^\{\{.+\}\}$/.test(e))) events = [...events.filter((e) => !/^\{\{.+\}\}$/.test(e)), ...triggers.flatMap((t) => (t.event ? t.event.split(", ") : []))];
73
+ const cs = tag.consentSettings;
74
+ return {
75
+ id: String(tag.tagId ?? ""),
76
+ name: tag.name,
77
+ type: tag.type,
78
+ platform: tagPlatform(tag, p),
79
+ events,
80
+ paused: tag.paused === true,
81
+ consent: cs?.consentStatus === "NEEDED" ? (cs.consentType?.list ?? []).map((x) => x.value).filter(Boolean) : [],
82
+ triggers,
83
+ blocking: (tag.blockingTriggerId ?? []).map((id) => describeTrigger(id, byId)),
84
+ };
85
+ });
86
+ }
87
+
88
+ /** The tags in the container that send an event to a platform. */
89
+ export function tagsForEvent(tags, platform, event) {
90
+ const want = canonicalEvent(event);
91
+ return tags.filter((t) => t.platform === platform && t.events.some((e) => canonicalEvent(e) === want));
92
+ }
93
+
94
+ /** The event name and data a push adds to GTM's data model: an object as it is, a gtag() call as its event and eventModel. */
95
+ function pushModel(value) {
96
+ if (Array.isArray(value)) return value[0] === "event" && typeof value[1] === "string" ? { event: value[1], data: { event: value[1], eventModel: value[2] ?? {} } } : null;
97
+ if (value && typeof value === "object") return { event: typeof value.event === "string" ? value.event : undefined, data: value };
98
+ return null;
99
+ }
100
+
101
+ /** Merges a push into the running data layer the way GTM's version 2 model does: objects and arrays merge key by key, so a value persists until it is overwritten or cleared with null. */
102
+ function merge(state, push) {
103
+ for (const [k, v] of Object.entries(push)) {
104
+ const same = v && typeof v === "object" && state[k] && typeof state[k] === "object" && Array.isArray(v) === Array.isArray(state[k]);
105
+ if (same) merge(state[k], v);
106
+ else state[k] = v && typeof v === "object" ? structuredClone(v) : v;
107
+ }
108
+ return state;
109
+ }
110
+
111
+ /** A value from the data layer by dot path, as a Data Layer Variable reads it; version 1 reads the key as it is. */
112
+ function readPath(state, name, version) {
113
+ if (String(version) === "1") return state[name];
114
+ return String(name).split(".").reduce((v, k) => (v && typeof v === "object" ? v[k] : undefined), state);
115
+ }
116
+
117
+ /** A part of the page address at the push, or undefined when it is not a valid address. */
118
+ function urlPart(url, component) {
119
+ try {
120
+ const u = new URL(url);
121
+ return { URL: u.href, PATH: u.pathname, HOST: u.hostname, PROTOCOL: u.protocol.replace(":", ""), PORT: u.port, FRAGMENT: u.hash.replace("#", ""), QUERY: u.search.replace("?", "") }[component];
122
+ } catch {
123
+ return undefined;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * The value of one GTM variable at a push: { value, field } when the dataLayer can give it, where `field` is how the
129
+ * report names it (a Data Layer Variable by its path), or { unknown: name } when only the page could tell.
130
+ */
131
+ function resolveVariable(name, ctx, variables) {
132
+ if (name === "_event" || name === "Event") return { value: ctx.event, field: "event" };
133
+ if (PAGE_VARIABLES[name]) return ctx.url ? { value: urlPart(ctx.url, PAGE_VARIABLES[name]), field: name } : { unknown: name };
134
+ const v = variables.find((x) => x.name === name);
135
+ const p = params(v?.parameter);
136
+ if (v?.type === "v" && p.name) return { value: readPath(ctx.state, p.name, p.dataLayerVersion ?? "2"), field: p.name };
137
+ if (v?.type === "c") return { value: p.value, field: name };
138
+ if (v?.type === "u" && ctx.url) return { value: urlPart(ctx.url, p.component ?? "URL"), field: name };
139
+ return { unknown: name };
140
+ }
141
+
142
+ /** A condition argument with its {{variables}} filled in; a lone reference keeps the value's type. */
143
+ function resolveArg(text, ctx, variables) {
144
+ const whole = /^\{\{(.+)\}\}$/.exec(String(text ?? ""));
145
+ if (whole) return resolveVariable(whole[1], ctx, variables);
146
+ let unknown = null;
147
+ const value = String(text ?? "").replace(/\{\{(.+?)\}\}/g, (_, n) => {
148
+ const r = resolveVariable(n, ctx, variables);
149
+ if (r.unknown) unknown = r.unknown;
150
+ return r.value ?? "";
151
+ });
152
+ return unknown ? { unknown } : { value, field: String(text ?? "") };
153
+ }
154
+
155
+ /** GTM's comparison for one condition type: true, false, or "unknown" for the ones only the page can answer. */
156
+ function compare(type, a, b, ignoreCase) {
157
+ const s = (v) => (ignoreCase ? String(v ?? "").toLowerCase() : String(v ?? ""));
158
+ const n = (v) => (v === "" || v === null || v === undefined || typeof v === "object" ? NaN : Number(v));
159
+ switch (type) {
160
+ case "EQUALS": return s(a) === s(b);
161
+ case "CONTAINS": return s(a).includes(s(b));
162
+ case "STARTS_WITH": return s(a).startsWith(s(b));
163
+ case "ENDS_WITH": return s(a).endsWith(s(b));
164
+ case "MATCH_REGEX":
165
+ try {
166
+ return new RegExp(String(b ?? ""), ignoreCase ? "i" : "").test(String(a ?? ""));
167
+ } catch {
168
+ return "unknown";
169
+ }
170
+ case "LESS": return n(a) < n(b);
171
+ case "LESS_OR_EQUALS": return n(a) <= n(b);
172
+ case "GREATER": return n(a) > n(b);
173
+ case "GREATER_OR_EQUALS": return n(a) >= n(b);
174
+ default: return "unknown";
175
+ }
176
+ }
177
+
178
+ /** A value as the reason shows it: numbers bare, text quoted, objects as JSON. */
179
+ const show = (v, m) => (v === undefined || v === null ? m.nothing : typeof v === "object" ? JSON.stringify(v) : typeof v === "string" && !/^-?\d+(\.\d+)?$/.test(v) ? `"${v}"` : String(v));
180
+
181
+ /** One trigger condition at a push: { ok: true | false | "unknown", reason }, with the reason the report shows when it is not met. */
182
+ function condition(c, ctx, variables, trigger, m) {
183
+ const type = SHORT_OPS[c.type] ?? c.type;
184
+ const p = params(c.parameter);
185
+ if (!m.ops[type]) return { ok: "unknown", reason: m.conditionType(trigger, type) };
186
+ const a = resolveArg(p.arg0, ctx, variables);
187
+ const b = resolveArg(p.arg1, ctx, variables);
188
+ const unknown = a.unknown ?? b.unknown;
189
+ if (unknown) return { ok: "unknown", reason: m.conditionUnknown(trigger, unknown) };
190
+ const negate = String(p.negate) === "true";
191
+ let ok = compare(type, a.value, b.value, String(p.ignore_case) === "true");
192
+ if (ok === "unknown") return { ok, reason: m.conditionType(trigger, type) };
193
+ if (negate) ok = !ok;
194
+ return { ok, reason: ok ? "" : m.condition(trigger, a.field, m.ops[type], show(b.value, m), show(a.value, m), negate) };
195
+ }
196
+
197
+ /**
198
+ * Whether a GTM trigger fired in a journey, judged from the recorded dataLayer pushes alone.
199
+ * @param {any} trigger A trigger from the container export.
200
+ * @param {{ value: any, url?: string }[]} pushes The run's dataLayerLog entries, in order.
201
+ * @param {any[]} [variables] The export's variable list, for conditions that read Data Layer Variables.
202
+ * @returns {{ fired: true | false | "unknown", reason: string, matched?: number }} `matched` is the index of the push it fired on.
203
+ */
204
+ export function evaluateTrigger(trigger, pushes, variables = [], lang = "en") {
205
+ const m = messages(lang).gtm;
206
+ const name = trigger?.name ?? "?";
207
+ const type = trigger?.type ?? "unknown";
208
+ const custom = type === "CUSTOM_EVENT";
209
+ const event = PAGE_EVENTS[type] ?? PAGE_ACTION_EVENTS[type];
210
+ if (!custom && !event) return { fired: "unknown", reason: m.unknownType(name, type) };
211
+ const state = {};
212
+ let seen = false;
213
+ let failed = null;
214
+ let unknown = null;
215
+ for (let i = 0; i < pushes.length; i++) {
216
+ const model = pushModel(pushes[i]?.value);
217
+ if (!model) continue;
218
+ merge(state, model.data);
219
+ const ctx = { event: model.event, state, url: pushes[i].url };
220
+ // The event conditions decide whether the push concerns this trigger at all; the filter decides whether it fires.
221
+ const events = custom ? (trigger.customEventFilter ?? []).map((c) => condition(c, ctx, variables, name, m)) : [{ ok: model.event === event }];
222
+ if (events.some((r) => r.ok === false)) continue;
223
+ const vague = events.find((r) => r.ok === "unknown");
224
+ if (vague) {
225
+ unknown ??= vague;
226
+ continue;
227
+ }
228
+ seen = true;
229
+ const result = (trigger.filter ?? []).map((c) => condition(c, ctx, variables, name, m)).find((r) => r.ok !== true);
230
+ if (!result) return { fired: true, reason: m.triggerFired(name, model.event ?? event), matched: i };
231
+ if (result.ok === "unknown") unknown ??= result;
232
+ else failed ??= result;
233
+ }
234
+ if (unknown) return { fired: "unknown", reason: unknown.reason };
235
+ if (failed) return { fired: false, reason: failed.reason };
236
+ if (PAGE_ACTION_EVENTS[type] && !seen) return { fired: "unknown", reason: m.pageTrigger(name, type.toLowerCase().replace(/_/g, " ")) };
237
+ if (PAGE_EVENTS[type] && !seen) return { fired: false, reason: m.noPageEvent(name, event) };
238
+ return { fired: false, reason: m.noPush(triggerEvents(trigger).join(", ") || event || name) };
239
+ }
240
+
241
+ /** The Consent Mode state after the pushes up to and including `upTo`; a type nobody set is not denied. */
242
+ function consentAt(pushes, upTo) {
243
+ const state = {};
244
+ for (const e of pushes.slice(0, upTo + 1)) if (isConsentCommand(e, "default") || isConsentCommand(e, "update")) Object.assign(state, e.value[2] ?? {});
245
+ return state;
246
+ }
247
+
248
+ /** Why one tag did or did not fire: its pause state, its triggers, its exceptions and its consent needs, in that order. */
249
+ function evaluateTag(tag, byId, pushes, variables, m, lang) {
250
+ if (tag.paused) return { fired: false, why: m.paused };
251
+ if (!tag.triggers.length) return { fired: false, why: m.noTrigger };
252
+ const raw = (t) => byId.get(String(t.id)) ?? (BUILT_IN_TRIGGERS[t.id] ? { ...BUILT_IN_TRIGGERS[t.id], triggerId: t.id } : null);
253
+ const evaluate = (t) => (raw(t) ? evaluateTrigger(raw(t), pushes, variables, lang) : { fired: "unknown", reason: m.missingTrigger(t.name) });
254
+ const results = tag.triggers.map(evaluate);
255
+ const hit = results.find((r) => r.fired === true);
256
+ if (!hit) {
257
+ const vague = results.find((r) => r.fired === "unknown");
258
+ return vague ? { fired: "unknown", why: vague.reason } : { fired: false, why: results.map((r) => r.reason).join("; ") };
259
+ }
260
+ // An exception only stops the tag when it fires on the same push as the firing trigger.
261
+ for (const b of tag.blocking) {
262
+ const r = evaluate(b);
263
+ if (r.fired === true && r.matched === hit.matched) return { fired: false, why: m.blocked(b.name) };
264
+ }
265
+ const consent = consentAt(pushes, hit.matched);
266
+ const denied = tag.consent.find((type) => consent[type] === "denied");
267
+ if (denied) return { fired: false, why: m.consentDenied(denied) };
268
+ return { fired: true, why: m.fired };
269
+ }
270
+
271
+ /**
272
+ * Adds `evaluation: { fired, why }` to each tag in `finding.gtm`, judged from the run's dataLayer pushes:
273
+ * whether the tag's triggers fired, whether an exception blocked it, and whether consent allowed it.
274
+ */
275
+ export function explainTags(finding, run, container, lang = "en") {
276
+ const m = messages(lang).gtm;
277
+ const v = container?.containerVersion ?? container ?? {};
278
+ const byId = new Map((v.trigger ?? []).map((t) => [String(t.triggerId), t]));
279
+ for (const tag of finding.gtm ?? []) tag.evaluation = evaluateTag(tag, byId, run?.dataLayerLog ?? [], v.variable ?? [], m, lang);
280
+ return finding;
281
+ }
282
+
283
+ /**
284
+ * Adds `gtm` to each finding about one platform event: the tags that send it, or an empty list when none does.
285
+ * With `runs`, each tag also says whether it should have fired in the finding's journey, and why not.
286
+ */
287
+ export function annotateFindings(findings, container, runs, { lang = "en" } = {}) {
288
+ if (!container) return findings;
289
+ const tags = containerTags(container);
290
+ if (!tags.length) return findings;
291
+ for (const f of findings) {
292
+ if (!f.platform || !f.event || f.severity === "ok") continue;
293
+ f.gtm = tagsForEvent(tags, f.platform, f.event).map(({ name, type, paused, consent, triggers, blocking }) => ({ name, type, paused, consent, triggers, blocking }));
294
+ const run = runs?.find((r) => r.journeyId === f.journey && r.viewport === f.viewport);
295
+ if (run) explainTags(f, run, container, lang);
296
+ }
297
+ return findings;
298
+ }
package/src/help.js CHANGED
@@ -13,6 +13,8 @@ Commands:
13
13
  run Audit what a site actually sends, and compare it to a plan
14
14
  replay Re-check saved sites offline, without AI
15
15
  mcp Start the MCP server for Claude, ChatGPT and other chat apps
16
+ doctor Check that Node, Chromium, an AI key and the data folder are ready
17
+ history Show how a site's saved runs trend: counts, open problems, flapping, journeys
16
18
 
17
19
  Examples:
18
20
  aitd plan --site https://example.com
@@ -40,12 +42,16 @@ Options:
40
42
  --lang <code> Report language: ${Object.keys(LANGUAGES).join(" or ")}
41
43
  --headed Show the browser window
42
44
  --fail-on <level> Exit 1 at this level: broken (default), risk, warning, never
45
+ --baseline <file> Known findings to accept; they don't count toward --fail-on
46
+ --update-baseline Write the baseline file after the run (creates it)
43
47
 
44
48
  AI: --provider <name> --model <id|cheap> (default: whichever API key is set)
45
49
 
46
50
  Advanced:
47
- --max-steps <n> --platforms <list> --viewport <name> --data-dir <dir>
48
- --scripted --no-ai --save-site <dir> --save-steps <file> (see the README)`;
51
+ --max-steps <n> --platforms <list> --viewport <name> --concurrency <n> --data-dir <dir>
52
+ --scripted --no-ai --save-site <dir> --save-steps <file> (see the README)
53
+ --scripted replays each journey's fixed steps; a journey without any repeats the steps
54
+ of its last completed run, which every run saves in its folder as steps.json.`;
49
55
 
50
56
  const PLAN = `Usage: aitd plan --site <url>
51
57
  aitd plan list --site <url>
@@ -99,7 +105,31 @@ Advanced:
99
105
  --reports <dir> Where finished audits write reports (default: ~/ai-tag-debugger-reports)
100
106
  --public-url <url> The HTTPS address of your tunnel or host, so audits can link to reports`;
101
107
 
102
- const HELP = { run: RUN, plan: PLAN, replay: REPLAY, mcp: MCP };
108
+ const DOCTOR = `Usage: aitd doctor [--site <url>] [--data-dir <dir>]
109
+
110
+ Check that this machine can run an audit: the Node version, Playwright's Chromium, whether
111
+ an AI provider key is set, and that the data folder is writable. Exits with code 1 when a
112
+ check an audit needs fails. A missing AI key is only reported: --no-ai and replay work without one.
113
+
114
+ Options:
115
+ --site <url> Also check that the site is an http(s) address whose host resolves (nothing is fetched)
116
+ --data-dir <dir> The folder plans and runs are saved in (default: aitd)`;
117
+
118
+ const HISTORY = `Usage: aitd history --site <url> [--limit <n>] [--json] [--data-dir <dir>]
119
+
120
+ Show the trend of a site's saved runs: one row per run with its problem counts and score, a
121
+ text sparkline, the problems open longest, the ones that come and go, and the journeys that do
122
+ not finish every time. Reads the runs in aitd/<domain>/runs/ and its history.json; no browser,
123
+ no AI, nothing is changed.
124
+
125
+ Options:
126
+ --site <url> The site (required)
127
+ --limit <n> How many of the newest runs to read (default 30)
128
+ --json Print the trend, the finding timeline and the journey stability as JSON
129
+ --lang <code> Language of the lines: ${Object.keys(LANGUAGES).join(" or ")}
130
+ --data-dir <dir> The folder plans and runs are saved in (default: aitd)`;
131
+
132
+ const HELP = { run: RUN, plan: PLAN, replay: REPLAY, mcp: MCP, doctor: DOCTOR, history: HISTORY };
103
133
 
104
134
  /** Help text for a command, or the top-level help when the command is unknown or missing. */
105
135
  export function helpText(command) {
package/src/history.js ADDED
@@ -0,0 +1,302 @@
1
+ // Run history: what the site's saved runs say over time. changes.js compares a run with the one
2
+ // before it; this module reads every run in the window and turns it into views the report, the CLI
3
+ // and the alert can show: a timeline per finding, a trend series for sparklines and the completion
4
+ // rate of every journey. Only a small summary of each run is kept, so thirty runs cost a few KB.
5
+ import { readFile, writeFile } from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { groupFindings } from "./checks.js";
8
+ import { LANGUAGES } from "./i18n.js";
9
+ import { PROVIDERS } from "./parsers.js";
10
+ import { listRunIds, runsDir, siteDir } from "./workspace.js";
11
+
12
+ /** The per-site index of run summaries, next to the plans and runs folders. */
13
+ export const HISTORY_FILE = "history.json";
14
+ export const historyFile = (dataDir, site) => path.join(siteDir(dataDir, site), HISTORY_FILE);
15
+
16
+ const INDEX_VERSION = 1;
17
+ const PROBLEMS = new Set(["broken", "risk", "warning"]);
18
+ const SEVERITY_RANK = { broken: 0, risk: 1, warning: 2 };
19
+ // Problems are weighed so one broken event outranks a few warnings when runs are compared.
20
+ const WEIGHT = { broken: 3, risk: 2, warning: 1 };
21
+ // "Recently" and the trend direction both look at the last few runs, not just the last one.
22
+ const RECENT = 3;
23
+
24
+ /**
25
+ * The identity of a finding across runs: the same line replay.js writes to site.json ("severity check
26
+ * journey viewport platform event"), so a replay and the history agree on what "the same finding" is.
27
+ * Kept inline rather than imported because replay.js loads the browser and the audit, and the report
28
+ * must stay importable without them. test/history.test.js pins the two to the same output.
29
+ */
30
+ export const findingKey = (f) => [f.severity, f.check, f.journey, f.viewport, f.platform ?? "-", f.event ?? "-"].join(" ");
31
+
32
+ /** The id's local time as "YYYY-MM-DDTHH:MM"; the seconds suffix of a same-minute id is dropped. */
33
+ export const dateOfId = (id) => {
34
+ const m = /^(\d{4}-\d{2}-\d{2})_(\d{2})(\d{2})/.exec(id);
35
+ return m ? `${m[1]}T${m[2]}:${m[3]}` : null;
36
+ };
37
+
38
+ /**
39
+ * The summary of one run that history keeps: its journeys, its counts and its problems, a few hundred
40
+ * bytes instead of a capture. Fields a capture may lack (older shapes, or ones audit.js does not write
41
+ * yet such as version and ai) come out null, never throw. Findings a baseline accepted are left out,
42
+ * as the counts and the verdict leave them out, so an accepted problem is not "still open" in the trend.
43
+ */
44
+ export function runSummary(id, capture = {}, { version = null, ai = null } = {}) {
45
+ const findings = Array.isArray(capture.findings) ? capture.findings : [];
46
+ const counts = { broken: 0, risk: 0, warning: 0, info: 0, ok: 0 };
47
+ for (const f of groupFindings(findings.filter((f) => f.severity in counts))) if (!f.accepted) counts[f.severity] += 1;
48
+ const seen = new Set();
49
+ const problems = [];
50
+ for (const f of findings) {
51
+ if (!PROBLEMS.has(f.severity) || !f.check || f.accepted === true) continue;
52
+ const key = findingKey(f);
53
+ if (seen.has(key)) continue;
54
+ seen.add(key);
55
+ problems.push({ key, check: f.check, severity: f.severity, platform: f.platform ?? null, event: f.event ?? null, journey: f.journey ?? null, viewport: f.viewport ?? null });
56
+ }
57
+ const journeys = (Array.isArray(capture.runs) ? capture.runs : [])
58
+ .filter((r) => r && r.journeyId)
59
+ .map((r) => ({ id: r.journeyId, viewport: r.viewport ?? null, completed: Boolean(r.completed), hits: Array.isArray(r.hits) ? r.hits.length : 0 }));
60
+ return { id, date: dateOfId(id), version: capture.version ?? version, ai: capture.ai ?? ai, journeys, counts, findings: problems };
61
+ }
62
+
63
+ const readJson = (file) => readFile(file, "utf8").then(JSON.parse).catch(() => null);
64
+
65
+ /** The summaries the index holds, by run id; empty when there is no index or it is unreadable. */
66
+ async function readIndex(dataDir, site) {
67
+ const index = await readJson(historyFile(dataDir, site));
68
+ const runs = index?.version === INDEX_VERSION && Array.isArray(index.runs) ? index.runs : [];
69
+ return new Map(runs.filter((r) => r && typeof r.id === "string").map((r) => [r.id, r]));
70
+ }
71
+
72
+ /**
73
+ * The site's most recent `limit` runs, oldest first and newest last, each as a runSummary. Runs with a
74
+ * missing or unreadable capture.json are skipped. A run already in history.json is taken from there;
75
+ * the others are read from their capture.json, one at a time so only one capture is ever in memory.
76
+ * Cost without an index: one read and parse per run (a capture is 100 KB to a few MB, roughly 1-10 ms
77
+ * per 100 KB), then the parsed object is dropped. With the index, one small file.
78
+ */
79
+ export async function loadHistory(dataDir, site, { limit = 30 } = {}) {
80
+ const ids = (await listRunIds(dataDir, site)).slice(-Math.max(0, limit));
81
+ const indexed = await readIndex(dataDir, site);
82
+ const out = [];
83
+ for (const id of ids) {
84
+ const known = indexed.get(id);
85
+ if (known) {
86
+ out.push(known);
87
+ continue;
88
+ }
89
+ const capture = await readJson(path.join(runsDir(dataDir, site), id, "capture.json"));
90
+ if (capture && typeof capture === "object") out.push(runSummary(id, capture));
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /**
96
+ * Adds one run to the site's history.json (or replaces its entry) so later reports and CLI calls do not
97
+ * re-read every capture. `run` is { id, capture, version?, ai? } or an existing runSummary. Entries whose
98
+ * run folder is gone are dropped. Never throws on a missing or corrupt index: it is rebuilt from the run.
99
+ */
100
+ export async function updateHistoryIndex(dataDir, site, run) {
101
+ const entry = run.capture ? runSummary(run.id, run.capture, { version: run.version ?? null, ai: run.ai ?? null }) : run;
102
+ const indexed = await readIndex(dataDir, site);
103
+ indexed.set(entry.id, entry);
104
+ const onDisk = new Set(await listRunIds(dataDir, site));
105
+ const runs = [...indexed.values()].filter((r) => r.id === entry.id || onDisk.has(r.id)).sort((a, b) => (a.id < b.id ? -1 : 1));
106
+ const file = historyFile(dataDir, site);
107
+ await writeFile(file, JSON.stringify({ version: INDEX_VERSION, site, updated: new Date().toISOString(), runs }, null, 2));
108
+ return { file, runs: runs.length };
109
+ }
110
+
111
+ /** Whether a run gives evidence about a journey: it ran and completed. An incomplete journey proves nothing by absence. */
112
+ const observed = (run) => new Set(run.journeys.filter((j) => j.completed).map((j) => `${j.id}|${j.viewport}`));
113
+
114
+ /**
115
+ * One entry per finding seen in the window, oldest open first:
116
+ * { key, check, severity, platform, event, journey, viewport, firstSeen, lastSeen, seenIn, openNow, openSince,
117
+ * flapping, fixedIn }. A run where the finding's journey did not complete says nothing: it is neither "seen"
118
+ * nor "gone", so a crashed checkout does not count as a fix. flapping: seen, gone, then seen again.
119
+ * fixedIn: the first run after lastSeen where the journey completed without it; null while open.
120
+ */
121
+ export function findingTimeline(history) {
122
+ const entries = new Map();
123
+ for (const run of history) {
124
+ for (const f of run.findings) {
125
+ if (!entries.has(f.key)) entries.set(f.key, { ...f, states: [] });
126
+ }
127
+ }
128
+ for (const run of history) {
129
+ const present = new Set(run.findings.map((f) => f.key));
130
+ const ok = observed(run);
131
+ for (const e of entries.values()) {
132
+ // A finding from an older capture without a journey has no completion to check: absence means gone.
133
+ const state = present.has(e.key) ? "seen" : !e.journey || ok.has(`${e.journey}|${e.viewport}`) ? "gone" : null;
134
+ e.states.push({ id: run.id, state });
135
+ }
136
+ }
137
+ const out = [];
138
+ for (const e of entries.values()) {
139
+ const { states, ...rest } = e;
140
+ const known = states.filter((s) => s.state);
141
+ const seen = known.filter((s) => s.state === "seen");
142
+ const firstSeen = seen[0].id;
143
+ const lastSeen = seen.at(-1).id;
144
+ const openNow = known.at(-1).state === "seen";
145
+ // Open since the start of the current unbroken streak, which is firstSeen unless it flapped.
146
+ let openSince = null;
147
+ for (let i = known.length - 1; i >= 0 && known[i].state === "seen"; i--) openSince = known[i].id;
148
+ const between = known.filter((s) => s.id > firstSeen && s.id < lastSeen);
149
+ const flapping = between.some((s) => s.state === "gone");
150
+ const fixedIn = openNow ? null : known.find((s) => s.id > lastSeen && s.state === "gone").id;
151
+ out.push({ ...rest, firstSeen, lastSeen, seenIn: seen.length, openNow, openSince, flapping, fixedIn });
152
+ }
153
+ return out.sort(byOpenLongest);
154
+ }
155
+
156
+ /** Open findings first, the one open longest on top; then the fixed ones, the most recently fixed on top. */
157
+ function byOpenLongest(a, b) {
158
+ if (a.openNow !== b.openNow) return a.openNow ? -1 : 1;
159
+ if (a.openNow) return a.openSince < b.openSince ? -1 : a.openSince > b.openSince ? 1 : bySeverity(a, b);
160
+ return a.fixedIn > b.fixedIn ? -1 : a.fixedIn < b.fixedIn ? 1 : bySeverity(a, b);
161
+ }
162
+
163
+ const bySeverity = (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
164
+
165
+ /**
166
+ * The same problem on several journeys or viewports as one entry with its places, as the report groups
167
+ * findings. The first entry in timeline order speaks for the group, so its dates are the longest open.
168
+ */
169
+ function grouped(entries) {
170
+ const out = new Map();
171
+ for (const { journey, viewport, ...e } of entries) {
172
+ const k = [e.severity, e.check, e.platform, e.event].join("|");
173
+ if (out.has(k)) out.get(k).places.push({ journey, viewport });
174
+ else out.set(k, { ...e, places: [{ journey, viewport }] });
175
+ }
176
+ return [...out.values()];
177
+ }
178
+
179
+ const score = (c) => c.broken * WEIGHT.broken + c.risk * WEIGHT.risk + c.warning * WEIGHT.warning;
180
+ const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;
181
+
182
+ /**
183
+ * The series for sparklines plus a summary of the window.
184
+ * series: per run { id, date, broken, risk, warning, score, complete }, oldest first. score is
185
+ * 3·broken + 2·risk + warning (lower is better); complete is false when a journey did not finish.
186
+ * direction: the mean score of the last 3 complete runs against the 3 complete runs before them
187
+ * ("improving" when lower, "worsening" when higher, else "flat"); with fewer than 4 complete runs the
188
+ * two halves are compared, and with fewer than 2 it is "flat". Incomplete runs are left out because a
189
+ * journey that stopped early hides findings rather than fixing them.
190
+ * openLongest: the 5 problems open longest; fixedRecently / newRecently: fixed or first seen in the
191
+ * last 3 runs; flapping: seen, gone and seen again in the window. Each list has one entry per problem
192
+ * with its `places` (journey and viewport, as the report groups findings) and its `runsOpen` count.
193
+ */
194
+ export function trend(history) {
195
+ const series = history.map((r) => ({ id: r.id, date: r.date, broken: r.counts.broken, risk: r.counts.risk, warning: r.counts.warning, score: score(r.counts), complete: r.journeys.every((j) => j.completed) }));
196
+ const timeline = findingTimeline(history);
197
+ const ids = history.map((r) => r.id);
198
+ const recentIds = new Set(ids.slice(-RECENT));
199
+ const withAge = (e) => ({ ...e, runsOpen: e.openNow ? ids.length - ids.indexOf(e.openSince) : 0 });
200
+ return {
201
+ runs: ids.length,
202
+ first: ids[0] ?? null,
203
+ last: ids.at(-1) ?? null,
204
+ series,
205
+ direction: direction(series.filter((p) => p.complete).map((p) => p.score)),
206
+ openLongest: grouped(timeline.filter((e) => e.openNow)).slice(0, 5).map(withAge),
207
+ fixedRecently: grouped(timeline.filter((e) => e.fixedIn && recentIds.has(e.fixedIn))).map(withAge),
208
+ newRecently: grouped(timeline.filter((e) => e.openNow && recentIds.has(e.firstSeen))).map(withAge),
209
+ flapping: grouped(timeline.filter((e) => e.flapping)).map(withAge),
210
+ };
211
+ }
212
+
213
+ function direction(scores) {
214
+ if (scores.length < 2) return "flat";
215
+ const n = Math.min(RECENT, Math.floor(scores.length / 2));
216
+ const recent = mean(scores.slice(-n));
217
+ const earlier = mean(scores.slice(-2 * n, -n));
218
+ return recent < earlier ? "improving" : recent > earlier ? "worsening" : "flat";
219
+ }
220
+
221
+ /**
222
+ * How reliably each journey finishes on this site, least reliable first: per journey and viewport
223
+ * { id, viewport, runs, completed, completionRate, lastFailure, avgHits }. A journey that keeps
224
+ * failing says the AI or the script cannot get through the site, not that the tracking is bad.
225
+ */
226
+ export function journeyStability(history) {
227
+ const out = new Map();
228
+ for (const run of history) {
229
+ for (const j of run.journeys) {
230
+ const k = `${j.id}|${j.viewport}`;
231
+ const s = out.get(k) ?? { id: j.id, viewport: j.viewport, runs: 0, completed: 0, lastFailure: null, hits: 0 };
232
+ s.runs += 1;
233
+ s.hits += j.hits;
234
+ if (j.completed) s.completed += 1;
235
+ else s.lastFailure = run.id;
236
+ out.set(k, s);
237
+ }
238
+ }
239
+ return [...out.values()]
240
+ .map(({ hits, ...s }) => ({ ...s, completionRate: s.runs ? s.completed / s.runs : 0, avgHits: s.runs ? Math.round((hits / s.runs) * 10) / 10 : 0 }))
241
+ .sort((a, b) => a.completionRate - b.completionRate || a.id.localeCompare(b.id) || String(a.viewport).localeCompare(String(b.viewport)));
242
+ }
243
+
244
+ const day = (id) => (id ? String(id).slice(0, 10) : "");
245
+ const platformLabel = (p) => (p ? PROVIDERS[p] ?? String(p) : "");
246
+ /** "GA4 purchase missing_params" or, for a finding without an event, the check name. */
247
+ const label = (e) => `${platformLabel(e.platform)} ${e.event ?? ""}`.trim().concat(` ${e.check}`).trim();
248
+ /** The strings of ui.history in the report's language; an unknown language falls back to English rather than throwing. */
249
+ const strings = (lang) => (LANGUAGES[lang] ?? LANGUAGES.en).ui.history;
250
+ /** "GA4 purchase missing_params in 4 places": the problem with its place count when it is seen in more than one. */
251
+ export const problemLabel = (e, lang = "en") => `${label(e)}${e.places?.length > 1 ? strings(lang).places(e.places.length) : ""}`;
252
+
253
+ const SPARK = "▁▂▃▄▅▆▇█";
254
+
255
+ /**
256
+ * The trend series as a text sparkline for the CLI and the alert: one character per run, the score
257
+ * mapped onto eight levels over 0..max, "·" for a run that did not complete (its lower counts are not
258
+ * a fix). The scale starts at 3 so one warning does not draw a full bar. Empty under two runs.
259
+ */
260
+ export function sparkline(t) {
261
+ const series = t?.series ?? [];
262
+ if (series.length < 2) return "";
263
+ const max = Math.max(3, ...series.map((p) => p.score));
264
+ return series.map((p) => (p.complete ? SPARK[Math.min(SPARK.length - 1, Math.round((p.score / max) * (SPARK.length - 1)))] : "·")).join("");
265
+ }
266
+
267
+ /**
268
+ * The trend as 2-3 plain lines for the CLI after a run and for the alert: the direction with the counts
269
+ * now and three runs ago, the findings open longest (up to 3), and the flapping ones when there are any.
270
+ * Pass the stability list to add a line on journeys that do not always finish.
271
+ */
272
+ export function historySummaryLines(t, lang = "en", stability = []) {
273
+ const s = strings(lang);
274
+ if (!t.runs) return [s.noRuns];
275
+ if (t.runs === 1) return [s.oneRun(t.last)];
276
+ const now = t.series.at(-1);
277
+ const was = t.series.at(-1 - RECENT);
278
+ const dir = s.direction[t.direction];
279
+ const lines = [was ? s.headline(t.runs, day(t.first), day(t.last), dir, s.counts(now), s.counts(was), RECENT) : s.headlineShort(t.runs, day(t.first), day(t.last), dir, s.counts(now))];
280
+ if (t.openLongest.length) lines.push(s.openLongest(t.openLongest.slice(0, 3).map((e) => s.since(problemLabel(e, lang), day(e.openSince), e.runsOpen))));
281
+ if (t.flapping.length) lines.push(s.flapping(t.flapping.slice(0, 3).map((e) => s.seenIn(problemLabel(e, lang), e.seenIn, t.runs))));
282
+ const unstable = stability.filter((j) => j.completionRate < 1);
283
+ if (unstable.length) lines.push(s.unstable(unstable.slice(0, 3).map((j) => `${j.id} (${j.viewport}) ${Math.round(j.completionRate * 100)}%`)));
284
+ return lines;
285
+ }
286
+
287
+ /**
288
+ * The trend as the alert shows it: the direction line with the text sparkline, the flapping line, and
289
+ * "still open after N runs" only when a problem has just reached a round number of runs (5, 10, 20, …),
290
+ * so the open-longest list does not repeat in every message and train readers to skip it.
291
+ */
292
+ export function historyAlertLines(t, lang = "en") {
293
+ const s = strings(lang);
294
+ if (!t || t.runs < 2) return [];
295
+ const lines = [`${historySummaryLines(t, lang)[0]} \`${sparkline(t)}\``];
296
+ if (t.flapping.length) lines.push(s.flapping(t.flapping.slice(0, 3).map((e) => s.seenIn(problemLabel(e, lang), e.seenIn, t.runs))));
297
+ // A round number of runs open (5, 10, 20, then every 10) is worth one line; every other week it is noise.
298
+ const milestone = (n) => [5, 10, 20].includes(n) || (n > 20 && n % 10 === 0);
299
+ const ages = [...new Set(t.openLongest.filter((e) => milestone(e.runsOpen)).map((e) => e.runsOpen))].sort((a, b) => b - a);
300
+ for (const n of ages) lines.push(s.stillOpen(n, t.openLongest.filter((e) => e.runsOpen === n).map((e) => problemLabel(e, lang))));
301
+ return lines;
302
+ }