@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/audit.js CHANGED
@@ -8,13 +8,21 @@ import path from "node:path";
8
8
  import { failedRun, runJourney } from "./agent.js";
9
9
  import { VIEWPORTS } from "./browser.js";
10
10
  import { checkRun, groupFindings, planCheck, summarize } from "./checks.js";
11
- import { explainFindings, renderCsv, renderHtml, renderMarkdown } from "./report.js";
11
+ import { explainFindings, renderCsv, renderHtml, renderMarkdown, verdictHeadline, VERSION } from "./report.js";
12
+ import { computeMetrics } from "./metrics.js";
13
+ import { journeyStability, loadHistory, trend as trendOf, updateHistoryIndex } from "./history.js";
14
+ import { buildSummary, renderJunit } from "./ci-output.js";
12
15
  import { LANGUAGES, messages } from "./i18n.js";
16
+ import { loadReportBrand, writePdf } from "./whitelabel.js";
13
17
  import { createProvider, alsoSet } from "./providers.js";
14
18
  import { filterFindings, JOURNEY_PRESETS, planRun } from "./journeys.js";
15
19
  import { resolveProviders } from "./parsers.js";
16
- import { SAVED_SITE_FILE, writeSavedSite } from "./replay.js";
20
+ import { annotateFindings } from "./gtm.js";
21
+ import { lastSteps, SAVED_SITE_FILE, savedSteps, STEPS_FILE, writeSavedSite } from "./replay.js";
17
22
  import { runConsentScenarios } from "./consent.js";
23
+ import { validateConfig, validatePlan } from "./schema.js";
24
+ import { applyBaseline } from "./baseline.js";
25
+ import { parseConcurrency, runPool, taggedLog } from "./pool.js";
18
26
 
19
27
  export async function readJson(file, baseDir = ".") {
20
28
  const full = path.resolve(baseDir, file);
@@ -25,58 +33,106 @@ export async function readJson(file, baseDir = ".") {
25
33
  }
26
34
  }
27
35
 
28
- /** Reads a config file and everything it points to. */
29
- export async function loadConfig(configFile) {
36
+ /**
37
+ * Reads a config file and everything it points to. The config and the plan are checked against their
38
+ * schema first, so a typo fails here with the field named; unknown keys are only warned about, through
39
+ * log (default: stderr). The result carries validated: true, which tells runAudit not to check again.
40
+ */
41
+ export async function loadConfig(configFile, { log = console.error } = {}) {
30
42
  const configPath = path.resolve(configFile);
31
43
  const configDir = path.dirname(configPath);
32
44
  const config = await readJson(configPath);
45
+ const { warnings } = validateConfig(config, { file: configFile });
33
46
  const plan = typeof config.plan === "string" ? await readJson(config.plan, configDir) : config.plan;
34
- if (plan && !Array.isArray(plan.events)) throw new Error('The "plan" needs an "events" list.');
47
+ if (plan) warnings.push(...validatePlan(plan, typeof config.plan === "string" ? { file: config.plan } : { file: configFile, at: "plan" }).warnings);
48
+ for (const w of warnings) log(w);
35
49
  const gtmContainer = config.gtm_container ? await readJson(config.gtm_container, configDir) : null;
36
- return { config, plan, gtmContainer };
50
+ const brand = await loadReportBrand(config.report, configDir);
51
+ // The baseline is only resolved here, not read: --update-baseline may create it on this run.
52
+ const baselineFile = typeof config.baseline === "string" ? path.resolve(configDir, config.baseline) : null;
53
+ return { config, plan, gtmContainer, brand, baselineFile, validated: true };
37
54
  }
38
55
 
39
56
 
40
57
  /**
41
- * Writes report.md, report.html, timeline.csv and capture.json, plus plan-check.json with --against.
58
+ * Writes report.md, report.html, timeline.csv, capture.json and steps.json, plus plan-check.json with --against,
59
+ * and junit.xml and summary.json for CI. failOn: the --fail-on level, so junit.xml fails the same findings the exit code does.
42
60
  * previous: the site's last saved run (see previousRun), null when there is none, or undefined to skip
43
- * the "Changed since last run" section. Returns { reportPath, changes }.
61
+ * the "Changed since last run" section. history: { dataDir } when the run is saved in the site's runs folder,
62
+ * so its history index is updated and the reports get the trend; null for --out and replays.
63
+ * Returns { reportPath, summaryPath, changes, metrics, verdict, trend, stability }.
44
64
  */
45
- export async function writeReports({ outDir, config, plan, runs, findings, explained, lang, ai = null, against = null, branding = config.branding !== false, previous = undefined, planChanges = null, consentScenarios = null }) {
65
+ export async function writeReports({ outDir, config, plan, runs, findings, explained, lang, ai = null, against = null, branding = config.branding !== false, previous = undefined, planChanges = null, consentScenarios = null, brand = null, failOn = "broken", history = null }) {
46
66
  await mkdir(outDir, { recursive: true });
47
67
  const site = new URL(config.site).hostname;
48
68
  const title = messages(lang).ui.title(site);
49
69
  const generatedAt = new Date().toISOString().slice(0, 16).replace("T", " ") + " UTC";
50
70
  const check = against ? { plan_id: against.id ?? null, ...planCheck(plan, runs, findings) } : null;
51
71
  const changes = previous === undefined ? null : compareRuns(previous, { findings, planCheck: check, runs });
52
- const report = { title, site, ai, generatedAt, findings, explained, runs, plan, lang, branding, against: against?.label ?? null, planCheck: check, changes, planChanges, consentScenarios };
72
+ const metrics = computeMetrics({ plan, runs, findings, consentScenarios });
73
+ const verdict = verdictHeadline({ findings, runs, metrics, lang });
74
+ // The capture goes first: the history index summarises it, and the reports then show the trend including this run.
75
+ await writeFile(path.join(outDir, "capture.json"), JSON.stringify({ version: VERSION, ai, config: { ...config, plan }, ...(against ? { against: { plan_id: against.id, file: against.file } } : {}), runs, findings, explained, metrics, ...(changes ? { changes } : {}), ...(planChanges ? { planChanges } : {}), ...(consentScenarios ? { consentScenarios } : {}) }, null, 2));
76
+ let trend = null;
77
+ let stability = null;
78
+ if (history) {
79
+ // A history that cannot be read or written costs the trend, never the report.
80
+ await updateHistoryIndex(history.dataDir, config.site, { id: path.basename(outDir), capture: { runs, findings }, version: VERSION, ai }).catch(() => null);
81
+ const saved = await loadHistory(history.dataDir, config.site).catch(() => []);
82
+ trend = trendOf(saved);
83
+ stability = journeyStability(saved);
84
+ }
85
+ const report = { title, site, ai, generatedAt, findings, explained, runs, plan, lang, branding, against: against?.label ?? null, planCheck: check, changes, planChanges, consentScenarios, brand, metrics, trend };
53
86
  await writeFile(path.join(outDir, "report.md"), renderMarkdown(report));
54
87
  await writeFile(path.join(outDir, "report.html"), renderHtml(report));
55
88
  await writeFile(path.join(outDir, "timeline.csv"), renderCsv(report));
56
89
  if (check) await writeFile(path.join(outDir, "plan-check.json"), JSON.stringify(check, null, 2));
57
- await writeFile(path.join(outDir, "capture.json"), JSON.stringify({ config: { ...config, plan }, ...(against ? { against: { plan_id: against.id, file: against.file } } : {}), runs, findings, explained, ...(changes ? { changes } : {}), ...(planChanges ? { planChanges } : {}), ...(consentScenarios ? { consentScenarios } : {}) }, null, 2));
58
- return { reportPath: path.join(outDir, "report.html"), changes };
90
+ // The steps taken, so the next --scripted run can repeat them without a model (see lastSteps). Typed values are
91
+ // stored as typed, since a replay has to type them again: journeys should use test data, and the run folder stays out of version control.
92
+ await writeFile(path.join(outDir, STEPS_FILE), JSON.stringify(savedSteps({ site: config.site, runs }), null, 2));
93
+ const reportPath = path.join(outDir, "report.html");
94
+ const summaryPath = path.join(outDir, "summary.json");
95
+ await writeFile(path.join(outDir, "junit.xml"), renderJunit({ runs, findings, lang, failOn, version: VERSION, site }));
96
+ await writeFile(summaryPath, JSON.stringify(buildSummary({ runs, findings, counts: summarize(groupFindings(findings)), plan, against, ai, lang, reportPath, outDir, changes, consentScenarios, version: VERSION, site, metrics, trend }), null, 2));
97
+ if (brand?.pdf) {
98
+ try {
99
+ await writePdf(reportPath, path.join(outDir, "report.pdf"));
100
+ } catch (err) {
101
+ // The HTML report is the main output; a PDF that can't be made is a warning, not a failed run.
102
+ console.error(`Could not write report.pdf: ${err.message}`);
103
+ }
104
+ }
105
+ return { reportPath, summaryPath, changes, metrics, verdict, trend, stability };
59
106
  }
60
107
 
61
108
  /**
62
109
  * @param {{ config: any, plan?: any, gtmContainer?: any, journeys?: string[], goals?: string[], events?: string[], viewports?: string[],
63
110
  * scripted?: boolean, ai?: boolean, provider?: string, model?: string, baseUrl?: string, maxSteps?: number, lang?: string, headed?: boolean,
64
- * outDir?: string, dataDir?: string, branding?: boolean, providers?: string[], saveSite?: string, replaySite?: string, log?: (m: string) => void }} opts
111
+ * outDir?: string, dataDir?: string, branding?: boolean, brand?: Awaited<ReturnType<typeof loadReportBrand>>, providers?: string[], saveSite?: string, replaySite?: string, concurrency?: number | string, log?: (m: string) => void }} opts
65
112
  * journeys: config journey ids or ready-made journeys (purchase, add_to_cart, sign_up, lead, search).
66
113
  * goals: extra journeys described in plain language. events: only check these events.
67
114
  * providers: tracking providers to check (default: config "providers", else GA4, Google Ads, Meta, TikTok plus
68
115
  * any other known platform the site sends to).
69
116
  * saveSite: record the site's responses and the steps taken into this folder, for replaySite (see replay.js).
70
117
  * replaySite: answer every request from a folder saved with saveSite, without the network.
118
+ * failOn: the --fail-on level (default "broken"), which decides what junit.xml marks as failed.
119
+ * concurrency: how many journeys (and consent scenarios) run at once, each in its own browser. Default 1, at most 8.
71
120
  * against: { id, file } of the plan given with --against; opts.plan is then that plan. Used alone, the
72
121
  * journeys come from the plan; with journeys or goals, only the plan events expected on them are checked.
122
+ * validated: true when config and plan came through loadConfig, which already checked them against the schema.
123
+ * baseline: a baseline read with readBaseline (see baseline.js). The findings it accepts are flagged
124
+ * accepted and left out of the counts; the result then has baseline: { accepted, partial, stale }.
73
125
  */
74
126
  export async function runAudit(opts) {
75
127
  const { gtmContainer } = opts;
76
128
  let config = opts.config;
77
129
  const events = opts.events ?? [];
78
- if (!config.site) throw new Error('No site to audit. Pass --site or set "site" in the config.');
79
130
  const log = opts.log ?? (() => {});
131
+ if (!opts.validated) {
132
+ const checked = [validateConfig(config), ...(opts.plan ? [validatePlan(opts.plan)] : [])];
133
+ for (const w of checked.flatMap((c) => c.warnings)) log(w);
134
+ }
135
+ if (!config.site) throw new Error('No site to audit. Pass --site or set "site" in the config.');
80
136
  const lang = opts.lang ?? config.language ?? "en";
81
137
  if (!LANGUAGES[lang]) throw new Error(`Language must be one of ${Object.keys(LANGUAGES).join(", ")}.`);
82
138
  const useAi = opts.ai !== false;
@@ -92,10 +148,21 @@ export async function runAudit(opts) {
92
148
  if (!Array.isArray(viewports)) throw new Error(`"viewports" must be a list, e.g. ["desktop", "mobile"]. Allowed values: ${allowed}.`);
93
149
  const unknown = viewports.filter((v) => !Object.hasOwn(VIEWPORTS, v));
94
150
  if (unknown.length) throw new Error(`Unknown viewport ${unknown.map((v) => `"${v}"`).join(", ")}. Use: ${allowed}.`);
151
+ const concurrency = parseConcurrency(opts.concurrency ?? config.concurrency);
95
152
  if (!journeys.length) throw new Error(`No journeys to run. A run needs one of:\n --journey <name> one of: ${Object.keys(JOURNEY_PRESETS).join(", ")} (or a journey id from your config)\n --goal "<text>" describe what to do in your own words\n --config <file> a config file with "journeys"\nNot sure what to test? "aitd plan --site ${config.site}" drafts a measurement plan.`);
153
+ // A journey without fixed steps of its own repeats the steps of the site's last completed run, which every run saves.
154
+ const replayed = new Map();
96
155
  if (opts.scripted) {
97
- const unscripted = journeys.filter((j) => !j.steps?.length && !j.incomplete).map((j) => j.id);
98
- if (unscripted.length) throw new Error(`Journeys without fixed steps can't be replayed with --scripted: ${unscripted.join(", ")}.`);
156
+ const unscripted = [];
157
+ for (const j of journeys.filter((j) => !j.steps?.length && !j.incomplete)) {
158
+ for (const viewport of viewports) {
159
+ // A saved-site replay stays offline: it never borrows steps from the runs folder.
160
+ const last = opts.replaySite ? null : await lastSteps(opts.dataDir, config.site, { journeyId: j.id, viewport }).catch(() => null);
161
+ if (last) replayed.set(`${j.id}|${viewport}`, last);
162
+ else if (!unscripted.includes(j.id)) unscripted.push(j.id);
163
+ }
164
+ }
165
+ if (unscripted.length) throw new Error(`Journeys without fixed steps can't be replayed with --scripted: ${unscripted.join(", ")}. Run them once with an AI model first; every completed run saves its steps.`);
99
166
  }
100
167
 
101
168
  if (against) {
@@ -107,33 +174,40 @@ export async function runAudit(opts) {
107
174
  }
108
175
  }
109
176
 
110
- const provider = useAi ? await createProvider({ name: opts.provider ?? config.provider, model: opts.model ?? config.model, baseUrl: opts.baseUrl ?? config.base_url }) : null;
177
+ const provider = useAi ? await createProvider({ name: opts.provider ?? config.provider, model: opts.model ?? config.model, baseUrl: opts.baseUrl ?? config.base_url, log }) : null;
111
178
  if (provider) log(`Using ${provider.name} (${provider.model})${provider.via ? ` via ${provider.via}` : ""}${alsoSet(provider.key)}`);
112
179
 
113
180
  if (opts.saveSite) await mkdir(opts.saveSite, { recursive: true });
114
181
  // Repeat runs start from the route the last completed run took. Not with --out or a replay, which have no history to learn from.
115
182
  const hints = provider && !opts.outDir && !opts.replaySite ? await routeHints(opts.dataDir, config.site).catch(() => new Map()) : new Map();
116
- const runs = [];
117
- for (const journey of journeys) {
118
- for (const viewport of viewports) {
119
- log(`\n▶ ${journey.id} (${viewport})`);
120
- const hint = hints.get(`${journey.id}|${viewport}`);
121
- const routeHint = hint && hint.goal === journey.goal ? hint.pages : undefined;
122
- if (routeHint) log(` Starting from the route of the last completed run (${routeHint.length} ${routeHint.length === 1 ? "page" : "pages"})`);
123
- // runJourney keeps its own errors in the run. This catches the rest, such as a browser that won't close,
124
- // so one journey can't cost the report of the others.
125
- const run = await runJourney(config, journey, viewport, { scripted: opts.scripted, provider, routeHint, maxSteps: opts.maxSteps, headed: opts.headed, lang, log, saveSite: opts.saveSite, replaySite: opts.replaySite }).catch((err) => failedRun(journey, viewport, err, config.providers));
126
- log(` ${run.completed ? "✓" : "✗"} ${run.note} · ${run.hits.length} tracking hits captured`);
127
- runs.push(run);
128
- }
129
- }
183
+ // Every journey × viewport pair has its own browser, so they can run side by side. The runs keep the pairs'
184
+ // order whatever finishes first: the report and the comparison with the last run count on it.
185
+ const jobs = journeys.flatMap((journey) => viewports.map((viewport) => ({ journey, viewport })));
186
+ const runs = await runPool(jobs, concurrency, async ({ journey, viewport }) => {
187
+ // Side by side, each job's lines start with its name, so the interleaved output stays readable.
188
+ const jobLog = concurrency > 1 ? taggedLog(log, `[${journey.id}/${viewport}]`) : log;
189
+ jobLog(`\n▶ ${journey.id} (${viewport})`);
190
+ const hint = hints.get(`${journey.id}|${viewport}`);
191
+ const routeHint = hint && hint.goal === journey.goal ? hint.pages : undefined;
192
+ if (routeHint) jobLog(` Starting from the route of the last completed run (${routeHint.length} ${routeHint.length === 1 ? "page" : "pages"})`);
193
+ const last = replayed.get(`${journey.id}|${viewport}`);
194
+ if (last) jobLog(` Replaying the steps of run ${last.runId}${last.viewport === viewport ? "" : ` (${last.viewport})`}`);
195
+ const fixed = last ? { ...journey, ...(last.start ? { start: last.start } : {}), steps: last.steps, stepsFrom: last.runId } : journey;
196
+ // runJourney keeps its own errors in the run. This catches the rest, such as a browser that won't close,
197
+ // so one journey can't cost the report of the others.
198
+ const run = await runJourney(config, fixed, viewport, { scripted: opts.scripted, provider, routeHint, maxSteps: opts.maxSteps, headed: opts.headed, lang, log: jobLog, saveSite: opts.saveSite, replaySite: opts.replaySite }).catch((err) => failedRun(journey, viewport, err, config.providers));
199
+ jobLog(` ${run.completed ? "✓" : "✗"} ${run.note} · ${run.hits.length} tracking hits captured`);
200
+ return run;
201
+ });
130
202
 
131
203
  // Consent scenarios need the live site, so a replay from a saved site skips them. "consent_scenarios": false turns them off.
132
204
  const consentScenarios = opts.replaySite || config.consent_scenarios === false
133
205
  ? null
134
- : await runConsentScenarios(config, { headed: opts.headed, log }).catch((err) => (log(` Consent scenarios failed: ${err.message}`), null));
206
+ : await runConsentScenarios(config, { headed: opts.headed, log, concurrency }).catch((err) => (log(` Consent scenarios failed: ${err.message}`), null));
135
207
 
136
- const findings = filterFindings(runs.flatMap((run) => checkRun(plan, run, { lang })), events);
208
+ const findings = annotateFindings(filterFindings(runs.flatMap((run) => checkRun(plan, run, { lang })), events), gtmContainer, runs, { lang });
209
+ // Applied before anything reads the findings, so the explanation, the reports, the alerts and the counts all skip accepted ones.
210
+ const applied = opts.baseline ? applyBaseline(findings, opts.baseline) : null;
137
211
  let saveError = null;
138
212
  if (opts.saveSite) {
139
213
  try {
@@ -155,12 +229,14 @@ export async function runAudit(opts) {
155
229
  // Runs saved in the site's runs folder are compared with the one before; a run sent to --out is not.
156
230
  const previous = opts.outDir ? undefined : await previousRun(opts.dataDir, config.site);
157
231
  const outDir = path.resolve(opts.outDir ?? (await newRunDir(opts.dataDir, config.site)).dir);
158
- const { reportPath, changes } = await writeReports({ outDir, config, plan, runs, findings, explained, lang, ai: provider ? { name: provider.name, model: provider.model } : null, against, branding: opts.branding ?? config.branding !== false, previous, planChanges: against ? await previousPlanChanges(opts.dataDir, config.site, against.id, plan).catch(() => null) : null, consentScenarios });
232
+ const { reportPath, summaryPath, changes, metrics, verdict, trend, stability } = await writeReports({ outDir, config, plan, runs, findings, explained, lang, ai: provider ? { name: provider.name, model: provider.model } : null, against, branding: opts.branding ?? config.branding !== false, previous, planChanges: against ? await previousPlanChanges(opts.dataDir, config.site, against.id, plan).catch(() => null) : null, consentScenarios, brand: opts.brand ?? null, failOn: opts.failOn, history: previous === undefined ? null : { dataDir: opts.dataDir } });
159
233
  if (config.notify && changes) {
160
- const { sent, errors } = await sendAlerts({ notify: config.notify, site: new URL(config.site).hostname, changes: { run: path.basename(outDir), ...changes }, lang, reportPath });
234
+ const { sent, errors } = await sendAlerts({ notify: config.notify, site: new URL(config.site).hostname, changes: { run: path.basename(outDir), ...changes }, lang, reportPath, verdict, trend });
161
235
  if (sent.length) log(`\nSent the changes since the last run to ${sent.join(" and ")}.`);
162
236
  for (const e of errors) log(`\nCould not send an alert. ${e}`);
163
237
  }
164
238
  const counts = summarize(groupFindings(findings));
165
- return { journeys, plan, runs, findings, explained, counts, reportPath, outDir, lang, consentScenarios, ...(against ? { againstId: against.id } : {}), ...(saveError ? { saveError } : {}) };
239
+ // Accepted counts every finding the baseline matched; a problem accepted in some places only is still counted, and reported as partial.
240
+ const baseline = applied ? { accepted: applied.accepted, partial: applied.partial, stale: applied.stale } : null;
241
+ return { journeys, plan, runs, findings, explained, counts, metrics, verdict, trend, stability, reportPath, summaryPath, outDir, lang, consentScenarios, ...(against ? { againstId: against.id } : {}), ...(baseline ? { baseline } : {}), ...(saveError ? { saveError } : {}) };
166
242
  }
@@ -0,0 +1,90 @@
1
+ // A baseline file: findings a team has looked at and accepted. A run with a baseline still
2
+ // lists them in the report, but they don't count toward the exit code, so a deploy fails only
3
+ // on new problems. Only each entry's key is matched; the other fields are there for whoever
4
+ // reads the file.
5
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import path from "node:path";
7
+
8
+ /**
9
+ * The key a finding is accepted by. It names what went wrong and where, and for missing
10
+ * parameters which ones, and leaves out everything that differs from run to run: the step a
11
+ * hit was seen on, timings, counts and the wording of the detail.
12
+ */
13
+ export function findingKey(f) {
14
+ return [f.check, f.platform ?? "-", f.event ?? "-", f.journey, f.viewport, f.params?.length ? f.params.join(",") : "-"].join(" ");
15
+ }
16
+
17
+ /** Reads a baseline file written by writeBaseline. A missing file throws with code ENOENT. */
18
+ export async function readBaseline(file) {
19
+ let baseline;
20
+ try {
21
+ baseline = JSON.parse(await readFile(file, "utf8"));
22
+ } catch (err) {
23
+ const wrapped = new Error(`Could not read the baseline ${file}: ${err.message}`);
24
+ wrapped.code = err.code;
25
+ throw wrapped;
26
+ }
27
+ if (!Array.isArray(baseline?.findings) || baseline.findings.some((b) => typeof b?.key !== "string")) {
28
+ throw new Error(`${file} is not a baseline: it needs a "findings" list with a "key" on each entry. Write one with --update-baseline.`);
29
+ }
30
+ return baseline;
31
+ }
32
+
33
+ /**
34
+ * Marks the findings the baseline accepts with accepted: true.
35
+ * @returns {{ accepted: number, partial: number, stale: string[] }} how many findings were accepted, how many
36
+ * problems are accepted in some of their places but not all (the report still counts those, see groupFindings),
37
+ * and the baseline keys no finding matched any more.
38
+ */
39
+ export function applyBaseline(findings, baseline) {
40
+ const keys = new Set(baseline.findings.map((b) => b.key));
41
+ const seen = new Set();
42
+ // Per problem, as groupFindings merges the places: how many of its findings the baseline accepts.
43
+ const problems = new Map();
44
+ let accepted = 0;
45
+ for (const f of findings) {
46
+ if (f.severity === "ok") continue;
47
+ const key = findingKey(f);
48
+ const problem = [f.severity, f.check, f.platform, f.event, f.detail].join("|");
49
+ const p = problems.get(problem) ?? { total: 0, accepted: 0 };
50
+ p.total += 1;
51
+ problems.set(problem, p);
52
+ if (!keys.has(key)) continue;
53
+ f.accepted = true;
54
+ accepted += 1;
55
+ p.accepted += 1;
56
+ seen.add(key);
57
+ }
58
+ const partial = [...problems.values()].filter((p) => p.accepted && p.accepted < p.total).length;
59
+ return { accepted, partial, stale: [...keys].filter((k) => !seen.has(k)) };
60
+ }
61
+
62
+ /**
63
+ * Writes every current finding except the ok ones as the baseline, creating the file when it
64
+ * doesn't exist. A note someone wrote on an entry that is still in the file is kept.
65
+ * @returns {Promise<number>} how many findings the file now holds.
66
+ */
67
+ export async function writeBaseline(file, { site, findings }) {
68
+ const notes = new Map();
69
+ const previous = await readBaseline(file).catch(() => null);
70
+ for (const b of previous?.findings ?? []) if (b.note) notes.set(b.key, b.note);
71
+ const entries = new Map();
72
+ for (const f of findings) {
73
+ if (f.severity === "ok") continue;
74
+ const key = findingKey(f);
75
+ if (entries.has(key)) continue;
76
+ entries.set(key, {
77
+ key,
78
+ check: f.check,
79
+ severity: f.severity,
80
+ ...(f.platform ? { platform: f.platform } : {}),
81
+ ...(f.event ? { event: f.event } : {}),
82
+ journey: f.journey,
83
+ viewport: f.viewport,
84
+ ...(notes.has(key) ? { note: notes.get(key) } : {}),
85
+ });
86
+ }
87
+ await mkdir(path.dirname(path.resolve(file)), { recursive: true });
88
+ await writeFile(file, `${JSON.stringify({ site, updated: new Date().toISOString(), findings: [...entries.values()] }, null, 2)}\n`);
89
+ return entries.size;
90
+ }
package/src/browser.js CHANGED
@@ -351,6 +351,21 @@ function pagePath(href) {
351
351
  }
352
352
  }
353
353
 
354
+ const NAVIGATION_RETRY_MS = 2000;
355
+
356
+ // Chromium network errors that a second try can get past: the connection dropped, the network changed,
357
+ // a lookup or the server took too long. Blocked, refused-by-proxy, certificate and protocol errors are not here.
358
+ const TRANSIENT_NET_ERRORS = /net::ERR_(CONNECTION_(RESET|CLOSED|ABORTED|REFUSED|TIMED_OUT)|TIMED_OUT|NETWORK_CHANGED|NAME_NOT_RESOLVED|NAME_RESOLUTION_FAILED|DNS_TIMED_OUT|INTERNET_DISCONNECTED|ADDRESS_UNREACHABLE|EMPTY_RESPONSE|SOCKET_NOT_CONNECTED|HTTP2_(PROTOCOL|SERVER_REFUSED_STREAM|PING_FAILED)|NETWORK_IO_SUSPENDED|SPDY_PROTOCOL_ERROR|QUIC_PROTOCOL_ERROR)\b/;
359
+
360
+ /** The net::ERR_* code or "timeout" from a Playwright navigation error, for the log line. */
361
+ const navigationErrorCode = (err) => String(err?.message ?? err).match(/net::ERR_[A-Z0-9_]+/)?.[0] ?? (err?.name === "TimeoutError" || /Timeout \d+ms exceeded/.test(String(err?.message)) ? "timeout" : "error");
362
+
363
+ /** True when a failed navigation is worth one more try: a transient network error or a timeout. */
364
+ export function isTransientNavigationError(err) {
365
+ const code = navigationErrorCode(err);
366
+ return code === "timeout" || TRANSIENT_NET_ERRORS.test(code);
367
+ }
368
+
354
369
  export class AuditSession {
355
370
  /**
356
371
  * @param {{ viewport: string, site?: string, allowedHosts?: string[], providers?: string[], keepBodies?: boolean, blockPrivate?: boolean | ((ip: string) => boolean), blockHits?: boolean, headless?: boolean }} opts
@@ -366,9 +381,11 @@ export class AuditSession {
366
381
  * replayHar: answer requests from a .har file saved with recordHar instead of the network; requests it lacks fail.
367
382
  * blockPrivate: refuse loopback, private and link-local addresses (used when the MCP server is reachable over HTTP).
368
383
  * All browser traffic then goes through the egress proxy. A function decides which addresses count as private (for tests).
384
+ * log: gets one line when a dropped navigation is tried again. navigationRetryMs: the wait before that (default 2 s).
369
385
  */
370
386
  constructor(opts) {
371
387
  this.opts = { blockHits: true, headless: true, ...opts };
388
+ this.log = this.opts.log ?? (() => {});
372
389
  this.scope = this.opts.site ? hostScope(this.opts.site, this.opts.allowedHosts) : null;
373
390
  this.isBlockedAddress = typeof this.opts.blockPrivate === "function" ? this.opts.blockPrivate : isPrivateAddress;
374
391
  this.isPrivate = this.opts.blockPrivate ? privateHostChecker(this.isBlockedAddress) : null;
@@ -635,6 +652,24 @@ export class AuditSession {
635
652
  return refused;
636
653
  }
637
654
 
655
+ /**
656
+ * Opens a page. When the network dropped the first attempt or it timed out, tries once more after a
657
+ * short wait, since one lost connection should not cost the journey. A navigation the guard refused
658
+ * (this.blocked, net::ERR_BLOCKED_BY_CLIENT) is never tried again: it would be refused again.
659
+ */
660
+ async navigate(url) {
661
+ const options = { waitUntil: "domcontentloaded", timeout: 30000 };
662
+ try {
663
+ return await this.page.goto(url, options);
664
+ } catch (err) {
665
+ if (this.blocked?.step === this.currentStep || !isTransientNavigationError(err)) throw err;
666
+ const delay = this.opts.navigationRetryMs ?? NAVIGATION_RETRY_MS;
667
+ this.log(` Opening ${url} failed (${navigationErrorCode(err)}); trying once more in ${Math.round(delay / 1000)}s`);
668
+ await new Promise((resolve) => setTimeout(resolve, delay));
669
+ return this.page.goto(url, options);
670
+ }
671
+ }
672
+
638
673
  /** Error message when the browser may not be on this URL, or null. */
639
674
  checkUrl(url) {
640
675
  if (url.startsWith("chrome-error:")) return null; // Chromium's own error page, e.g. after a blocked navigation
@@ -848,7 +883,7 @@ export class AuditSession {
848
883
  step.replay = { action: "goto", url: target };
849
884
  const refused = await this.checkUrl(target);
850
885
  if (refused) return refused;
851
- const response = await this.page.goto(target, { waitUntil: "domcontentloaded", timeout: 30000 });
886
+ const response = await this.navigate(target);
852
887
  if (!this.landing) {
853
888
  // The first page: the address asked for, each redirect on the way, and where it ended up.
854
889
  const chain = [];
package/src/changes.js CHANGED
@@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { messages } from "./i18n.js";
4
4
  import { platformLabel } from "./report.js";
5
+ import { ownerOf } from "./verify.js";
5
6
  import { PLAN_FILE, listPlanIds, listRunIds, plansDir, runsDir } from "./workspace.js";
6
7
 
7
8
  const PROBLEMS = new Set(["broken", "risk", "warning"]);
@@ -129,17 +130,22 @@ export async function sinceLastRun(dataDir, site) {
129
130
  return { run: latest.id, ...compareRuns(prev, { findings: latest.capture.findings ?? [], planCheck: latest.planCheck, runs: latest.capture.runs }) };
130
131
  }
131
132
 
132
- /** The diff as plain lines: a headline, then one line per fixed and new problem. */
133
+ /**
134
+ * The diff as plain lines: a headline, then one line per fixed and new problem. A fixed item says what
135
+ * it "was", since its text is the old state; a new one carries its severity (or plan bucket) and owner,
136
+ * so a lost purchase reads differently from an unplanned event.
137
+ */
133
138
  export function changesLines(ch, lang = "en") {
134
139
  const m = messages(lang);
135
140
  if (!ch.run) return [m.ui.noSavedRun];
136
141
  if (!ch.previous) return [m.ui.noPreviousRun];
137
142
  const label = (c) => `${platformLabel(c.platform ?? "")} ${c.event ?? ""}`.trim() || c.check;
138
143
  const why = (c, bucket) => (ch.mode === "findings" ? c.detail : m.ui.bucket[bucket]);
144
+ const tag = (c) => (ch.mode === "findings" ? m.severity[c.severity] ?? "" : m.ui.bucket[c.bucket]);
139
145
  return [
140
146
  `${m.ui.changesLine(ch)}. ${m.ui.changesLead(ch.previous, ch.mode)} (${ch.run}).`,
141
- ...ch.fixed.map((c) => `- ${m.ui.changeFixed}: ${label(c)} (${why(c, c.was)})`),
142
- ...ch.new.map((c) => `- ${m.ui.changeNew}: ${label(c)} (${why(c, c.bucket)})`),
147
+ ...ch.fixed.map((c) => `- ${m.ui.changeFixed}: ${label(c)} (${m.ui.history.fixedWas}: ${why(c, c.was)})`),
148
+ ...ch.new.map((c) => `- ${m.ui.changeNew}: ${[label(c), tag(c), ch.mode === "findings" ? c.detail : "", c.check ? m.ui.owners[ownerOf(c)] : ""].filter(Boolean).join(" · ")}`),
143
149
  ...(ch.routes?.length ? [m.ui.routeChanged(ch.routes.map((r) => `${r.journeyId} (${r.viewport})`).join(", "))] : []),
144
150
  ];
145
151
  }
package/src/checks.js CHANGED
@@ -24,6 +24,20 @@ const SEVERITY_ORDER = { broken: 0, risk: 1, warning: 2, info: 3, ok: 4 };
24
24
  * @typedef {{ severity: "broken"|"risk"|"warning"|"info"|"ok", check: string, journey: string, viewport: string, platform?: string, event?: string, step?: string, detail: string, evidence?: string }} Finding
25
25
  */
26
26
 
27
+ /** Hosts of server containers seen in the run's network calls, the site's own or Stape's. */
28
+ export function serverEndpoints(run) {
29
+ const hosts = new Set();
30
+ for (const c of run.network ?? []) {
31
+ let u;
32
+ try { u = new URL(c.url); } catch { continue; }
33
+ if (/(^|\.)(google-analytics\.com|analytics\.google\.com|googletagmanager\.com|google\.[a-z.]+|doubleclick\.net)$/.test(u.hostname)) continue;
34
+ const ga4Server = c.provider === "ga4" && u.pathname.endsWith("/g/collect");
35
+ const loader = (c.provider === "gtm" || c.provider === "ga4") && /\/(gtm\.js|gtag\/js)$/.test(u.pathname);
36
+ if (ga4Server || loader) hosts.add(u.hostname);
37
+ }
38
+ return [...hosts];
39
+ }
40
+
27
41
  const key = (platform, name) => `${platform}:${name}`;
28
42
 
29
43
  // Sites name events in their own way: Elevar's dl_add_to_cart, UA-style addToCart, Add To Cart.
@@ -184,6 +198,8 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
184
198
  platform: hit.platform,
185
199
  event: hit.name,
186
200
  step: stepLabel(hit.step),
201
+ // The parameter names are kept apart from the detail text, so a baseline can match them in any language.
202
+ params: missing,
187
203
  detail: d.missingParams(hit.name, missing),
188
204
  evidence: JSON.stringify(hit.params),
189
205
  });
@@ -287,7 +303,12 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
287
303
  for (const p of byPlatform) findings.push({ ...base, severity: "info", check: "server_side", platform: p.platform, detail: d.serverSide(p.platform, p.server, p.browser, unseenServer) });
288
304
  }
289
305
  const serverVendors = [...new Set((run.network ?? []).map((c) => c.provider).filter((p) => p === "stape" || p === "elevar"))];
290
- if (serverVendors.length || serverHits.length) {
306
+ // Server containers the browser talked to: GA4 hits on a non-Google host, and GTM or gtag.js
307
+ // loaded from one (a custom loader, which only a server container serves).
308
+ const endpoints = serverEndpoints(run);
309
+ if (endpoints.length && !serverHits.length) findings.push({ ...base, severity: "info", check: "server_endpoint", detail: d.serverEndpoint(endpoints) });
310
+ const serverSeen = serverVendors.length || serverHits.length || endpoints.length;
311
+ if (serverSeen) {
291
312
  const noId = new Map();
292
313
  for (const hit of run.hits) {
293
314
  if ((hit.platform === "meta" || hit.platform === "tiktok") && !hit.eventId && !noId.has(key(hit.platform, hit.name))) noId.set(key(hit.platform, hit.name), hit);
@@ -297,6 +318,39 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
297
318
  }
298
319
  }
299
320
 
321
+ // One event ID on two different events: the platform keeps the first and drops the other as its
322
+ // duplicate, so a real event is lost (usually an ID set once per page instead of once per event).
323
+ const byId = new Map();
324
+ for (const hit of run.hits) {
325
+ if (!hit.eventId) continue;
326
+ const k = `${hit.platform}|${hit.eventId}`;
327
+ byId.set(k, [...(byId.get(k) ?? []), hit]);
328
+ }
329
+ for (const [, hits] of byId) {
330
+ const events = [...new Set(hits.map((h) => `${h.step}|${canonicalEvent(h.name)}`))];
331
+ if (events.length < 2) continue;
332
+ const names = [...new Set(hits.map((h) => h.name))];
333
+ findings.push({ ...base, severity: "warning", check: "event_id_reused", platform: hits[0].platform, event: names.join(", "), step: stepLabel(hits[1].step), detail: d.eventIdReused(platformName(hits[0].platform), hits[0].eventId, names, events.length), evidence: hits.map((h) => h.url).join("\n") });
334
+ }
335
+
336
+ // With a server container, the Conversions API copy is usually built from the GA4 hit, so the
337
+ // GA4 hit has to carry the same event_id as the Meta pixel event for Meta to deduplicate them.
338
+ if (serverSeen) {
339
+ const ga4ToServer = run.hits.filter((h) => h.platform === "ga4" && h.delivery === "server");
340
+ const reported = new Set();
341
+ for (const m of run.hits.filter((h) => h.platform === "meta" && h.eventId)) {
342
+ const g = ga4ToServer.find((h) => h.step === m.step && canonicalEvent(h.name) === canonicalEvent(m.name));
343
+ if (!g) continue;
344
+ const gid = g.params?.event_id;
345
+ const k = key("meta", m.name);
346
+ if (reported.has(k) || (gid !== undefined && String(gid) === m.eventId)) continue;
347
+ reported.add(k);
348
+ findings.push(gid === undefined
349
+ ? { ...base, severity: "info", check: "event_id_not_forwarded", platform: "meta", event: m.name, step: stepLabel(m.step), detail: d.eventIdNotForwarded(m.name, g.name) }
350
+ : { ...base, severity: "warning", check: "event_id_mismatch", platform: "meta", event: m.name, step: stepLabel(m.step), detail: d.eventIdMismatch(m.name, m.eventId, g.name, gid), evidence: JSON.stringify({ meta: m.eventId, ga4: gid }) });
351
+ }
352
+ }
353
+
300
354
  // Campaign parameters (gclid, fbclid, UTM) on the landing address: they must survive redirects and reach the hits.
301
355
  const landing = run.landing;
302
356
  const sent = landing ? campaignParamsIn(landing.requested) : {};
@@ -456,9 +510,10 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
456
510
  return findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
457
511
  }
458
512
 
513
+ /** Counts per severity. Findings a baseline accepted are left out, so the counts say what is new. */
459
514
  export function summarize(findings) {
460
515
  const counts = { broken: 0, risk: 0, warning: 0, info: 0, ok: 0 };
461
- for (const f of findings) counts[f.severity] += 1;
516
+ for (const f of findings) if (!f.accepted) counts[f.severity] += 1;
462
517
  return counts;
463
518
  }
464
519
 
@@ -471,8 +526,12 @@ export function groupFindings(findings) {
471
526
  for (const f of findings) {
472
527
  const k = f.severity === "ok" ? `ok|${f.journey}|${f.viewport}` : [f.severity, f.check, f.platform, f.event, f.detail].join("|");
473
528
  const where = { journey: f.journey, viewport: f.viewport, step: f.step };
474
- if (groups.has(k)) groups.get(k).where.push(where);
475
- else groups.set(k, { severity: f.severity, check: f.check, platform: f.platform, event: f.event, detail: f.detail, evidence: f.evidence, where: [where] });
529
+ // A problem counts as accepted only when the baseline accepts it in every place it was seen.
530
+ if (groups.has(k)) {
531
+ const group = groups.get(k);
532
+ group.where.push(where);
533
+ if (!f.accepted) delete group.accepted;
534
+ } else groups.set(k, { severity: f.severity, check: f.check, platform: f.platform, event: f.event, detail: f.detail, evidence: f.evidence, ...(f.gtm ? { gtm: f.gtm } : {}), ...(f.accepted ? { accepted: true } : {}), where: [where] });
476
535
  }
477
536
  return [...groups.values()].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
478
537
  }