@analitikisler/ai-tag-debugger 0.16.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/src/audit.js CHANGED
@@ -8,15 +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";
13
16
  import { loadReportBrand, writePdf } from "./whitelabel.js";
14
17
  import { createProvider, alsoSet } from "./providers.js";
15
18
  import { filterFindings, JOURNEY_PRESETS, planRun } from "./journeys.js";
16
19
  import { resolveProviders } from "./parsers.js";
17
20
  import { annotateFindings } from "./gtm.js";
18
- import { SAVED_SITE_FILE, writeSavedSite } from "./replay.js";
21
+ import { lastSteps, SAVED_SITE_FILE, savedSteps, STEPS_FILE, writeSavedSite } from "./replay.js";
19
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";
20
26
 
21
27
  export async function readJson(file, baseDir = ".") {
22
28
  const full = path.resolve(baseDir, file);
@@ -27,38 +33,67 @@ export async function readJson(file, baseDir = ".") {
27
33
  }
28
34
  }
29
35
 
30
- /** Reads a config file and everything it points to. */
31
- 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 } = {}) {
32
42
  const configPath = path.resolve(configFile);
33
43
  const configDir = path.dirname(configPath);
34
44
  const config = await readJson(configPath);
45
+ const { warnings } = validateConfig(config, { file: configFile });
35
46
  const plan = typeof config.plan === "string" ? await readJson(config.plan, configDir) : config.plan;
36
- 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);
37
49
  const gtmContainer = config.gtm_container ? await readJson(config.gtm_container, configDir) : null;
38
50
  const brand = await loadReportBrand(config.report, configDir);
39
- return { config, plan, gtmContainer, brand };
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 };
40
54
  }
41
55
 
42
56
 
43
57
  /**
44
- * 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.
45
60
  * previous: the site's last saved run (see previousRun), null when there is none, or undefined to skip
46
- * 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 }.
47
64
  */
48
- 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 }) {
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 }) {
49
66
  await mkdir(outDir, { recursive: true });
50
67
  const site = new URL(config.site).hostname;
51
68
  const title = messages(lang).ui.title(site);
52
69
  const generatedAt = new Date().toISOString().slice(0, 16).replace("T", " ") + " UTC";
53
70
  const check = against ? { plan_id: against.id ?? null, ...planCheck(plan, runs, findings) } : null;
54
71
  const changes = previous === undefined ? null : compareRuns(previous, { findings, planCheck: check, runs });
55
- const report = { title, site, ai, generatedAt, findings, explained, runs, plan, lang, branding, against: against?.label ?? null, planCheck: check, changes, planChanges, consentScenarios, brand };
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 };
56
86
  await writeFile(path.join(outDir, "report.md"), renderMarkdown(report));
57
87
  await writeFile(path.join(outDir, "report.html"), renderHtml(report));
58
88
  await writeFile(path.join(outDir, "timeline.csv"), renderCsv(report));
59
89
  if (check) await writeFile(path.join(outDir, "plan-check.json"), JSON.stringify(check, null, 2));
60
- 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));
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));
61
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));
62
97
  if (brand?.pdf) {
63
98
  try {
64
99
  await writePdf(reportPath, path.join(outDir, "report.pdf"));
@@ -67,28 +102,37 @@ export async function writeReports({ outDir, config, plan, runs, findings, expla
67
102
  console.error(`Could not write report.pdf: ${err.message}`);
68
103
  }
69
104
  }
70
- return { reportPath, changes };
105
+ return { reportPath, summaryPath, changes, metrics, verdict, trend, stability };
71
106
  }
72
107
 
73
108
  /**
74
109
  * @param {{ config: any, plan?: any, gtmContainer?: any, journeys?: string[], goals?: string[], events?: string[], viewports?: string[],
75
110
  * scripted?: boolean, ai?: boolean, provider?: string, model?: string, baseUrl?: string, maxSteps?: number, lang?: string, headed?: boolean,
76
- * outDir?: string, dataDir?: string, branding?: boolean, brand?: Awaited<ReturnType<typeof loadReportBrand>>, 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
77
112
  * journeys: config journey ids or ready-made journeys (purchase, add_to_cart, sign_up, lead, search).
78
113
  * goals: extra journeys described in plain language. events: only check these events.
79
114
  * providers: tracking providers to check (default: config "providers", else GA4, Google Ads, Meta, TikTok plus
80
115
  * any other known platform the site sends to).
81
116
  * saveSite: record the site's responses and the steps taken into this folder, for replaySite (see replay.js).
82
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.
83
120
  * against: { id, file } of the plan given with --against; opts.plan is then that plan. Used alone, the
84
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 }.
85
125
  */
86
126
  export async function runAudit(opts) {
87
127
  const { gtmContainer } = opts;
88
128
  let config = opts.config;
89
129
  const events = opts.events ?? [];
90
- if (!config.site) throw new Error('No site to audit. Pass --site or set "site" in the config.');
91
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.');
92
136
  const lang = opts.lang ?? config.language ?? "en";
93
137
  if (!LANGUAGES[lang]) throw new Error(`Language must be one of ${Object.keys(LANGUAGES).join(", ")}.`);
94
138
  const useAi = opts.ai !== false;
@@ -104,10 +148,21 @@ export async function runAudit(opts) {
104
148
  if (!Array.isArray(viewports)) throw new Error(`"viewports" must be a list, e.g. ["desktop", "mobile"]. Allowed values: ${allowed}.`);
105
149
  const unknown = viewports.filter((v) => !Object.hasOwn(VIEWPORTS, v));
106
150
  if (unknown.length) throw new Error(`Unknown viewport ${unknown.map((v) => `"${v}"`).join(", ")}. Use: ${allowed}.`);
151
+ const concurrency = parseConcurrency(opts.concurrency ?? config.concurrency);
107
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();
108
155
  if (opts.scripted) {
109
- const unscripted = journeys.filter((j) => !j.steps?.length && !j.incomplete).map((j) => j.id);
110
- 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.`);
111
166
  }
112
167
 
113
168
  if (against) {
@@ -119,33 +174,40 @@ export async function runAudit(opts) {
119
174
  }
120
175
  }
121
176
 
122
- 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;
123
178
  if (provider) log(`Using ${provider.name} (${provider.model})${provider.via ? ` via ${provider.via}` : ""}${alsoSet(provider.key)}`);
124
179
 
125
180
  if (opts.saveSite) await mkdir(opts.saveSite, { recursive: true });
126
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.
127
182
  const hints = provider && !opts.outDir && !opts.replaySite ? await routeHints(opts.dataDir, config.site).catch(() => new Map()) : new Map();
128
- const runs = [];
129
- for (const journey of journeys) {
130
- for (const viewport of viewports) {
131
- log(`\n▶ ${journey.id} (${viewport})`);
132
- const hint = hints.get(`${journey.id}|${viewport}`);
133
- const routeHint = hint && hint.goal === journey.goal ? hint.pages : undefined;
134
- if (routeHint) log(` Starting from the route of the last completed run (${routeHint.length} ${routeHint.length === 1 ? "page" : "pages"})`);
135
- // runJourney keeps its own errors in the run. This catches the rest, such as a browser that won't close,
136
- // so one journey can't cost the report of the others.
137
- 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));
138
- log(` ${run.completed ? "✓" : "✗"} ${run.note} · ${run.hits.length} tracking hits captured`);
139
- runs.push(run);
140
- }
141
- }
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
+ });
142
202
 
143
203
  // Consent scenarios need the live site, so a replay from a saved site skips them. "consent_scenarios": false turns them off.
144
204
  const consentScenarios = opts.replaySite || config.consent_scenarios === false
145
205
  ? null
146
- : 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));
147
207
 
148
- const findings = annotateFindings(filterFindings(runs.flatMap((run) => checkRun(plan, run, { lang })), events), gtmContainer);
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;
149
211
  let saveError = null;
150
212
  if (opts.saveSite) {
151
213
  try {
@@ -167,12 +229,14 @@ export async function runAudit(opts) {
167
229
  // Runs saved in the site's runs folder are compared with the one before; a run sent to --out is not.
168
230
  const previous = opts.outDir ? undefined : await previousRun(opts.dataDir, config.site);
169
231
  const outDir = path.resolve(opts.outDir ?? (await newRunDir(opts.dataDir, config.site)).dir);
170
- 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, brand: opts.brand ?? null });
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 } });
171
233
  if (config.notify && changes) {
172
- 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 });
173
235
  if (sent.length) log(`\nSent the changes since the last run to ${sent.join(" and ")}.`);
174
236
  for (const e of errors) log(`\nCould not send an alert. ${e}`);
175
237
  }
176
238
  const counts = summarize(groupFindings(findings));
177
- 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 } : {}) };
178
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
@@ -198,6 +198,8 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
198
198
  platform: hit.platform,
199
199
  event: hit.name,
200
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,
201
203
  detail: d.missingParams(hit.name, missing),
202
204
  evidence: JSON.stringify(hit.params),
203
205
  });
@@ -508,9 +510,10 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
508
510
  return findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
509
511
  }
510
512
 
513
+ /** Counts per severity. Findings a baseline accepted are left out, so the counts say what is new. */
511
514
  export function summarize(findings) {
512
515
  const counts = { broken: 0, risk: 0, warning: 0, info: 0, ok: 0 };
513
- for (const f of findings) counts[f.severity] += 1;
516
+ for (const f of findings) if (!f.accepted) counts[f.severity] += 1;
514
517
  return counts;
515
518
  }
516
519
 
@@ -523,8 +526,12 @@ export function groupFindings(findings) {
523
526
  for (const f of findings) {
524
527
  const k = f.severity === "ok" ? `ok|${f.journey}|${f.viewport}` : [f.severity, f.check, f.platform, f.event, f.detail].join("|");
525
528
  const where = { journey: f.journey, viewport: f.viewport, step: f.step };
526
- if (groups.has(k)) groups.get(k).where.push(where);
527
- 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 } : {}), 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] });
528
535
  }
529
536
  return [...groups.values()].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
530
537
  }
@@ -0,0 +1,104 @@
1
+ // Machine-readable outputs for CI: junit.xml, which GitHub Actions, GitLab and Jenkins show as test
2
+ // results, and summary.json, a small file a script can read without parsing capture.json.
3
+ import path from "node:path";
4
+ import { groupFindings, summarize } from "./checks.js";
5
+ import { messages } from "./i18n.js";
6
+ import { platformLabel } from "./report.js";
7
+
8
+ // The same order the CLI uses for --fail-on: a finding fails when its severity is at or above the level.
9
+ const LEVELS = ["broken", "risk", "warning", "info"];
10
+
11
+ /** Whether a finding counts as a failure under --fail-on. Accepted (baselined) findings never do. */
12
+ export function failsAt(finding, failOn = "broken") {
13
+ if (failOn === "never" || finding.accepted === true) return false;
14
+ const level = LEVELS.indexOf(finding.severity);
15
+ return level !== -1 && level <= LEVELS.indexOf(failOn);
16
+ }
17
+
18
+ /** How long a run took in seconds, from its start to its last step or hit; 0 when the run has no timing. */
19
+ export function runSeconds(run) {
20
+ if (typeof run?.startedAt !== "number") return 0;
21
+ const last = Math.max(...[...(run.steps ?? []), ...(run.hits ?? [])].map((x) => x?.t ?? 0));
22
+ return last > run.startedAt ? Math.round((last - run.startedAt) / 10) / 100 : 0;
23
+ }
24
+
25
+ /** The one-line name of a finding: platform and event when it has them, then the message. */
26
+ export function findingTitle(f) {
27
+ const what = [f.platform ? platformLabel(f.platform) : "", f.event ?? ""].filter(Boolean).join(" ");
28
+ const line = what ? `${what}: ${f.detail}` : f.detail ?? f.check;
29
+ return line.length > 120 ? `${line.slice(0, 119).trimEnd()}…` : line;
30
+ }
31
+
32
+ const escapeXml = (s) => String(s ?? "").replace(/[<>&"']/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&apos;" })[c]);
33
+ // Control characters are not allowed in XML 1.0 at all, so they are dropped rather than escaped.
34
+ // eslint-disable-next-line no-control-regex
35
+ const xmlText = (s) => escapeXml(String(s ?? "").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, ""));
36
+ const attrs = (o) => Object.entries(o).map(([k, v]) => ` ${k}="${xmlText(v)}"`).join("");
37
+
38
+ const belongsTo = (f, run) => f.journey === run.journeyId && f.viewport === run.viewport;
39
+
40
+ // A case is { xml, failed }: a failure carries the detail in its body, a pass keeps it in system-out.
41
+ const testcase = ({ classname, name, time = 0, failed, type, text, message = name }) => ({
42
+ failed,
43
+ xml: `<testcase${attrs({ classname, name, time })}>${failed ? `<failure${attrs({ message, type })}>${xmlText(text)}</failure>` : text ? `<system-out>${xmlText(text)}</system-out>` : ""}</testcase>`,
44
+ });
45
+
46
+ const findingCase = (f, failOn) => testcase({ classname: f.check, name: findingTitle(f), failed: failsAt(f, failOn), type: f.severity, text: f.detail });
47
+
48
+ function suite(name, cases, seconds) {
49
+ const failures = cases.filter((c) => c.failed).length;
50
+ return { failures, tests: cases.length, xml: ` <testsuite${attrs({ name, tests: cases.length, failures, errors: 0, skipped: 0, time: seconds })}>\n${cases.map((c) => ` ${c.xml}`).join("\n")}\n </testsuite>` };
51
+ }
52
+
53
+ /**
54
+ * JUnit XML: one suite per journey and viewport, one case per finding, plus a "journey completed" case
55
+ * that fails when the journey did not reach its goal. Findings not tied to a run go in a suite of their own.
56
+ */
57
+ export function renderJunit({ runs = [], findings = [], lang = "en", failOn = "broken", version = "", site = "" }) {
58
+ const m = messages(lang).ui;
59
+ const suites = runs.map((run) => {
60
+ // With --fail-on never the CLI exits 0 whatever happened, so the JUnit file must not fail the job on its own.
61
+ const journey = testcase({ classname: "journey", name: m.junitJourney, time: runSeconds(run), failed: failOn !== "never" && run.completed === false, type: "incomplete", text: run.note, message: run.note ?? "" });
62
+ return suite(`${run.journeyId} (${run.viewport})`, [journey, ...findings.filter((f) => belongsTo(f, run)).map((f) => findingCase(f, failOn))], runSeconds(run));
63
+ });
64
+ const loose = findings.filter((f) => !runs.some((run) => belongsTo(f, run)));
65
+ if (loose.length) suites.push(suite(m.junitSite, loose.map((f) => findingCase(f, failOn)), 0));
66
+ const totals = { tests: suites.reduce((n, s) => n + s.tests, 0), failures: suites.reduce((n, s) => n + s.failures, 0), time: runs.reduce((n, r) => n + runSeconds(r), 0) };
67
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuites${attrs({ name: `ai-tag-debugger ${site}`.trim(), ...(version ? { version } : {}), ...totals })}>\n${suites.map((s) => s.xml).join("\n")}\n</testsuites>\n`;
68
+ }
69
+
70
+ /** The run in one small object: counts, journeys, findings and where the other files are. */
71
+ export function buildSummary({ runs = [], findings = [], counts, plan = null, against = null, ai = null, lang = "en", reportPath, outDir, changes = null, version = "", site = "", metrics = null, trend = null }) {
72
+ // Only the run folder's name and file names relative to it: a summary uploaded as a CI artifact must not carry the runner's paths.
73
+ const dir = outDir ?? (reportPath ? path.dirname(reportPath) : ".");
74
+ const file = (name) => name;
75
+ return {
76
+ version,
77
+ site,
78
+ generatedAt: new Date().toISOString(),
79
+ lang,
80
+ ai: ai ? { name: ai.name, model: ai.model } : null,
81
+ plan: { id: against?.id ?? null, events: (plan?.events ?? []).map((e) => `${String(e.platform ?? "").toLowerCase()}:${e.name}`) },
82
+ journeys: runs.map((r) => ({ id: r.journeyId, viewport: r.viewport, completed: r.completed !== false, note: r.note ?? "", steps: r.steps?.length ?? 0, hits: r.hits?.length ?? 0 })),
83
+ counts: counts ?? summarize(groupFindings(findings)),
84
+ // The deterministic verdict (see src/metrics.js): its status, the headline key, and the data quality score.
85
+ verdict: metrics ? { status: metrics.verdict.status, headline: metrics.verdict.headline } : null,
86
+ score: metrics?.score?.score ?? null,
87
+ trend: trend ? { direction: trend.direction, runs: trend.runs } : null,
88
+ findings: findings.map((f) => ({
89
+ check: f.check,
90
+ severity: f.severity,
91
+ platform: f.platform ?? null,
92
+ event: f.event ?? null,
93
+ journey: f.journey ?? null,
94
+ viewport: f.viewport ?? null,
95
+ title: findingTitle(f),
96
+ message: f.detail ?? "",
97
+ ...(f.platform && f.event ? { key: `${f.platform}:${f.event}` } : {}),
98
+ ...(f.accepted === true ? { accepted: true } : {}),
99
+ })),
100
+ changes: changes ? { fixed: changes.fixed ?? [], new: changes.new ?? [] } : null,
101
+ run: path.basename(dir),
102
+ files: { html: file("report.html"), md: file("report.md"), csv: file("timeline.csv"), capture: file("capture.json"), ...(against ? { planCheck: file("plan-check.json") } : {}), junit: file("junit.xml"), summary: file("summary.json") },
103
+ };
104
+ }