@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/plan-html.js CHANGED
@@ -1,24 +1,67 @@
1
- import { CSS, VERSION, escapeHtml, logo, platformLabel } from "./report.js";
1
+ import { VERSION } from "./report-shared.js";
2
+ import { TOKENS, COMPONENTS, PRODUCT } from "./html/css.js";
3
+ import { chip, escapeHtml, logo, plat, platformLabel, sprite } from "./html/lib.js";
2
4
  import { LOGOS } from "./brand.js";
3
5
 
4
6
  const e = escapeHtml;
5
7
  const text = (v) => (v === undefined || v === null || v === "" ? `<span class="muted">–</span>` : e(v));
6
8
 
9
+ // Only what the plan page needs on top of the report's tokens and components: the top bar (a trimmed copy of the
10
+ // report shell's, which this page does not load, with no rail or verdict), the page column, and the event list. Colours come from tokens, so dark mode
11
+ // follows both selectors in TOKENS and nothing here names a colour.
7
12
  const PLAN_CSS = `
8
- .pl-event{padding:16px 20px}
13
+ .top{display:flex;align-items:center;gap:var(--s3);height:var(--top);padding:0 var(--s4);background:var(--card);border-bottom:1px solid var(--border);white-space:nowrap}
14
+ .top .brand{display:inline-flex;align-items:center;flex:none}
15
+ .top .l-mark{display:none}
16
+ .top-site{display:flex;align-items:baseline;gap:var(--s2);min-width:0;overflow:hidden}
17
+ .top-site b{font-weight:600;overflow:hidden;text-overflow:ellipsis}
18
+ .top-end{margin-left:auto;display:flex;align-items:center;gap:var(--s2)}
19
+ .byline{display:inline-flex;align-items:center;gap:var(--s2);font-size:12px;color:var(--muted)}
20
+ .logo{display:inline-flex}
21
+ .logo .l-dark{display:none}
22
+ html[data-theme="dark"] .logo .l-light{display:none}
23
+ html[data-theme="dark"] .logo .l-dark{display:inline}
24
+ @media (prefers-color-scheme:dark){html:not([data-theme="light"]) .logo .l-light{display:none}html:not([data-theme="light"]) .logo .l-dark{display:inline}}
25
+ .eyebrow{font:600 11px/1.4 var(--font-sans);text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}
26
+ .plan{max-width:1100px;margin:0 auto;padding:var(--s5) var(--s6) var(--s7)}
27
+ .plan-title{display:grid;gap:var(--s2);margin-bottom:var(--s5)}
28
+ .plan-title .chips{margin-top:var(--s1)}
29
+ .plan .card+.card{margin-top:var(--s4)}
30
+ .pl-card-h{padding:var(--s3) var(--s4);border-bottom:1px solid var(--border)}
31
+ .pl-card-d{margin-top:2px;font-size:12.5px;color:var(--muted)}
32
+ .pl-empty{padding:var(--s3) var(--s4)}
33
+ .pl-event{padding:var(--s4)}
9
34
  .pl-event+.pl-event{border-top:1px solid var(--border)}
10
- .pl-head{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
35
+ .pl-head{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s2)}
11
36
  .pl-head code{font-size:14px}
12
37
  .pl-desc{margin:6px 0 10px}
13
38
  .pl-trigger{font-size:12px;color:var(--muted);margin-bottom:10px}
14
39
  .pl-trigger b{font-weight:500;color:var(--fg)}
15
- .flag{display:inline-flex;align-items:center;font:500 12px/1 var(--font-sans);padding:4px 8px;border-radius:4px;background:var(--muted-bg);color:var(--muted)}
16
- .flag.on{background:var(--accent-bg);color:var(--accent-fg)}
17
- .flag.req{background:var(--warn-bg);color:var(--warn)}
18
- table.dt td,table.dt th{vertical-align:top}
40
+ .chip .plat{display:inline-flex;align-items:center;gap:5px}
41
+ .chip.on{--k:var(--accent);--k-fg:var(--accent-fg);background:var(--accent-bg)}
42
+ .chip.req{--k:var(--sev-warning);--k-fg:var(--sev-warning-fg);background:var(--sev-warning-bg)}
43
+ .pl-card-body{padding:var(--s4)}
44
+ .tbl td,.tbl th{vertical-align:top}
45
+ /* a narrow table scrolls inside its .tscroll instead of breaking names mid-word */
46
+ .plan .tbl{min-width:480px}
47
+ .plan .tbl td:first-child code{white-space:nowrap}
48
+ .footer{display:flex;align-items:center;gap:var(--s4);padding:var(--s4);border-top:1px solid var(--border);background:var(--card)}
49
+ .footer a{color:var(--fg)}
50
+ .logo-link{display:inline-flex}
51
+ .f-links{display:flex;flex-wrap:wrap;gap:var(--s2) var(--s4)}
52
+ .footer .f-meta{margin-left:auto}
53
+ @media (max-width:719.98px){
54
+ :root{--top:44px}
55
+ .top{gap:var(--s2);padding:0 var(--s3)}
56
+ .top .l-lockup,.top-site .eyebrow,.byline{display:none}
57
+ .top .l-mark{display:block}
58
+ .plan{padding:var(--s4) var(--s4) var(--s6)}
59
+ .footer{flex-wrap:wrap}
60
+ }
61
+ @media print{.top{display:none}.plan{padding:0}}
19
62
  `;
20
63
 
21
- const flag = (label, cls = "") => `<span class="flag ${cls}">${e(label)}</span>`;
64
+ const flag = (label, cls = "") => `<span class="chip ${cls}">${e(label)}</span>`;
22
65
 
23
66
  function paramsTable(event) {
24
67
  const params = Object.entries(event.parameters ?? {});
@@ -27,13 +70,14 @@ function paramsTable(event) {
27
70
  const rows = params
28
71
  .map(([name, p]) => `<tr><td><code>${e(name)}</code>${p?.custom ? ` ${flag("custom")}` : ""}</td><td>${required.has(name) ? flag("required", "req") : `<span class="muted">optional</span>`}</td><td class="mono">${text(p?.example)}</td><td>${text(p?.description)}</td></tr>`)
29
72
  .join("");
30
- return `<div class="tscroll"><table class="dt"><thead><tr><th>Parameter</th><th>Required</th><th>Example</th><th>Description</th></tr></thead><tbody>${rows}</tbody></table></div>`;
73
+ return `<div class="tscroll"><table class="tbl"><thead><tr><th>Parameter</th><th>Required</th><th>Example</th><th>Description</th></tr></thead><tbody>${rows}</tbody></table></div>`;
31
74
  }
32
75
 
33
76
  function eventCard(event) {
34
77
  const trigger = event.trigger ?? {};
78
+ const platform = event.platform ?? "ga4";
35
79
  return `<div class="pl-event">
36
- <div class="pl-head"><span class="ptag">${e(platformLabel(event.platform ?? "ga4"))}</span><code>${e(event.name)}</code>${flag(event.custom ? "custom event" : "standard event", event.custom ? "on" : "")}</div>
80
+ <div class="pl-head">${chip("plat", platform, plat(platform, 13, platformLabel(platform)))}<code>${e(event.name)}</code>${flag(event.custom ? "custom event" : "standard event", event.custom ? "on" : "")}</div>
37
81
  <p class="pl-desc">${text(event.description)}</p>
38
82
  <p class="pl-trigger"><b>Trigger</b> · page: ${text(trigger.page)} · element: ${text(trigger.element)}</p>
39
83
  ${paramsTable(event)}
@@ -42,12 +86,12 @@ ${paramsTable(event)}
42
86
 
43
87
  function userPropertiesCard(props) {
44
88
  const body = props.length
45
- ? `<div class="tscroll"><table class="dt"><thead><tr><th>Name</th><th>Example</th><th>Description</th></tr></thead><tbody>${props
89
+ ? `<div class="pl-card-body"><div class="tscroll"><table class="tbl"><thead><tr><th>Name</th><th>Example</th><th>Description</th></tr></thead><tbody>${props
46
90
  .map((p) => (typeof p === "string" ? { name: p } : p))
47
91
  .map((p) => `<tr><td><code>${e(p.name)}</code></td><td class="mono">${text(p.example)}</td><td>${text(p.description)}</td></tr>`)
48
- .join("")}</tbody></table></div>`
49
- : `<p class="muted xs">No user properties.</p>`;
50
- return `<section class="card"><div class="card-h"><div class="card-t">User properties</div></div><div class="card-c">${body}</div></section>`;
92
+ .join("")}</tbody></table></div></div>`
93
+ : `<p class="pl-empty muted xs">No user properties.</p>`;
94
+ return `<section class="card"><div class="pl-card-h"><h2>User properties</h2></div>${body}</section>`;
51
95
  }
52
96
 
53
97
  /** A single self-contained, readable page for a measurement plan. Respects branding false like the report. */
@@ -58,19 +102,21 @@ export function renderPlanHtml(plan, { id = "", generatedAt = "", branding = tru
58
102
  const meta = [id && `Plan ${id}`, generatedAt, `${events.length} events (${custom} custom)`, `ai-tag-debugger ${VERSION}`].filter(Boolean).join(" · ");
59
103
  const consent = plan.consent_mode ? flag("Consent mode on", "on") : flag("Consent mode off");
60
104
  const footer = branding
61
- ? `<div class="wrap"><a href="https://analitikisler.com" class="logo-link" aria-label="analitikisler.com" rel="noopener noreferrer">${logo("stacked", "Analitik İşler", 56, "logo-s")}</a><div><div class="f-links"><a href="https://analitikisler.com" rel="noopener noreferrer">analitikisler.com</a><a href="https://github.com/analitikisler/ai-tag-debugger" rel="noopener noreferrer">GitHub</a></div></div><div class="f-meta">v${VERSION}</div></div>`
62
- : `<div class="wrap plain"><div class="f-meta">AI Tag Debugger</div><div class="f-meta">v${VERSION}</div></div>`;
105
+ ? `<a href="https://analitikisler.com" class="logo-link" aria-label="analitikisler.com" rel="noopener noreferrer">${logo("stacked", "Analitik İşler", 40, "logo-s")}</a><div class="f-links xs"><a href="https://analitikisler.com" rel="noopener noreferrer">analitikisler.com</a><a href="https://github.com/analitikisler/ai-tag-debugger" rel="noopener noreferrer">GitHub</a></div><p class="f-meta xs muted">v${VERSION}</p>`
106
+ : `<p class="xs">AI Tag Debugger</p><p class="f-meta xs muted">v${VERSION}</p>`;
107
+ const body = `<header class="top"><span class="brand">${logo("atd-lockup", "AI Tag Debugger", 22, "l-lockup")}<img class="l-mark" src="${LOGOS["atd-icon"]}" alt="AI Tag Debugger" height="24"></span><span class="top-site"><span class="eyebrow">Measurement plan</span>${plan.site ? `<b>${e(plan.site)}</b>` : ""}</span><div class="top-end">${branding ? `<a href="https://analitikisler.com" class="byline" rel="noopener noreferrer"><span>by</span>${logo("horizontal", "Analitik İşler", 16, "logo-h")}</a>` : ""}</div></header>
108
+ <main class="plan">
109
+ <div class="plan-title"><div class="eyebrow">Measurement plan</div><h1>${e(title)}</h1><div class="muted xs">${e(meta)}</div><div class="chips">${consent}</div></div>
110
+ <section class="card"><div class="pl-card-h"><h2>Events</h2><p class="pl-card-d">What should be tracked, where it fires and which parameters it carries.</p></div>${events.length ? events.map(eventCard).join("") : `<p class="pl-empty muted xs">No events.</p>`}</section>
111
+ ${userPropertiesCard(plan.user_properties ?? [])}
112
+ </main>
113
+ <footer class="footer">${footer}</footer>`;
63
114
  return `<!doctype html>
64
115
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
116
+ <meta name="color-scheme" content="light dark">
65
117
  <title>${e(title)}</title><link rel="icon" type="image/png" href="${LOGOS.favicon}">
66
- <style>${CSS}${PLAN_CSS}</style></head><body>
67
- <div class="topbar"><div class="wrap"><span class="product-logo">${logo("atd-lockup", "AI Tag Debugger", 26, "logo-atd")}</span>${branding ? `<a href="https://analitikisler.com" class="byline" rel="noopener noreferrer"><span>by</span>${logo("horizontal", "Analitik İşler", 18, "logo-h")}</a>` : ""}</div></div>
68
- <header class="wrap title"><div class="eyebrow">Measurement plan</div><h1>${e(title)}</h1><div class="meta">${e(meta)}</div></header>
69
- <main class="wrap" style="padding-top:24px;padding-bottom:64px"><div class="stack">
70
- <div>${consent}</div>
71
- <section class="card"><div class="card-h"><div class="card-t">Events</div><div class="card-d">What should be tracked, where it fires and which parameters it carries.</div></div><div class="card-c tight">${events.length ? events.map(eventCard).join("") : `<p class="muted xs" style="padding:12px">No events.</p>`}</div></section>
72
- ${userPropertiesCard(plan.user_properties ?? [])}
73
- </div></main>
74
- <footer class="footer">${footer}</footer>
118
+ <style>${TOKENS}\n${COMPONENTS}\n${PRODUCT}\n${PLAN_CSS}</style></head><body>
119
+ ${sprite(body)}
120
+ ${body}
75
121
  </body></html>`;
76
122
  }
package/src/plan.js CHANGED
@@ -2,9 +2,14 @@ import { mkdir, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { renderPlanHtml } from "./plan-html.js";
4
4
  import { PLAN_FILE, PLAN_HTML_FILE, newPlanDir } from "./workspace.js";
5
+ import { validatePlan } from "./schema.js";
5
6
 
6
- /** Saves a plan (measurement-plan.json and plan.html) in a new aitd/<domain>/plans/<id>/ folder and returns where. */
7
+ /**
8
+ * Saves a plan (measurement-plan.json and plan.html) in a new aitd/<domain>/plans/<id>/ folder and returns where.
9
+ * The plan is checked first, so nothing a later run would refuse gets saved.
10
+ */
7
11
  export async function savePlan(plan, { dataDir, site, now, branding = true } = {}) {
12
+ validatePlan(plan);
8
13
  const { id, dir } = await newPlanDir(dataDir, site, now);
9
14
  await mkdir(dir, { recursive: true });
10
15
  const file = path.join(dir, PLAN_FILE);
package/src/pool.js ADDED
@@ -0,0 +1,34 @@
1
+ // Runs independent jobs a few at a time. Each journey and consent scenario opens its own browser,
2
+ // so an audit with several journeys and viewports finishes sooner when they run side by side.
3
+ export const MAX_CONCURRENCY = 8;
4
+
5
+ /** The number of jobs to run at once, from --concurrency, the config or the MCP tool. Default 1, at most 8. */
6
+ export function parseConcurrency(value) {
7
+ if (value === undefined || value === null || value === "") return 1;
8
+ // Only a number or a numeric string counts: true would otherwise pass as 1.
9
+ const n = typeof value === "number" || typeof value === "string" ? Number(value) : NaN;
10
+ if (!(Number.isInteger(n) && n >= 1 && n <= MAX_CONCURRENCY)) throw new Error(`"concurrency" must be a whole number from 1 to ${MAX_CONCURRENCY} (--concurrency <n>, or "concurrency" in the config).`);
11
+ return n;
12
+ }
13
+
14
+ /**
15
+ * Runs worker(job, index) for every job, at most `limit` at a time, and returns the results in the jobs'
16
+ * order, not the order they finished in. A worker that throws rejects the pool, so callers keep each
17
+ * job's errors inside the worker. With a limit of 1 the jobs run strictly one after another.
18
+ */
19
+ export async function runPool(jobs, limit, worker) {
20
+ const results = new Array(jobs.length);
21
+ let next = 0;
22
+ // Each lane takes the next job as soon as its own is done, so the lanes stay busy whatever the job lengths.
23
+ const lane = async () => {
24
+ while (next < jobs.length) {
25
+ const i = next++;
26
+ results[i] = await worker(jobs[i], i);
27
+ }
28
+ };
29
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, jobs.length)) }, lane));
30
+ return results;
31
+ }
32
+
33
+ /** A log whose every line starts with a tag, so the lines of jobs running side by side can be told apart. */
34
+ export const taggedLog = (log, tag) => (message) => log(String(message).replace(/^(?=.)/gm, `${tag} `));
package/src/providers.js CHANGED
@@ -42,9 +42,10 @@ function hasApiKey(env) {
42
42
  }
43
43
 
44
44
  /**
45
- * @param {{ name?: string, model?: string, baseUrl?: string, client?: any }} opts
45
+ * @param {{ name?: string, model?: string, baseUrl?: string, client?: any, log?: (msg: string) => void, retry?: { attempts?: number, baseDelayMs?: number, sleep?: (ms: number) => Promise<void> } }} opts
46
46
  * baseUrl: for openai-compatible, e.g. https://api.deepseek.com or http://localhost:11434/v1.
47
47
  * client: an already constructed SDK client (used by tests).
48
+ * log: gets one line per retried model request. retry: overrides for withRetry (tests).
48
49
  */
49
50
  export async function createProvider(opts = {}, env = process.env) {
50
51
  // A bare client (tests, embedding) is an Anthropic client unless named otherwise.
@@ -66,39 +67,112 @@ async function buildProvider(name, opts, env) {
66
67
  if (!model) throw new Error("openai-compatible needs a model id: --model or OPENAI_COMPATIBLE_MODEL (\"cheap\" has no meaning here, since the API's models are unknown).");
67
68
  // Local servers such as Ollama need no key, but the SDK wants a non-empty one.
68
69
  const apiKey = env.OPENAI_COMPATIBLE_API_KEY || "not-needed";
69
- const client = opts.client ?? new (await import("openai")).default({ baseURL, apiKey });
70
+ const client = opts.client ?? new (await import("openai")).default(sdkOptions({ baseURL, apiKey }));
70
71
  return openaiProvider(client, model, { compatible: true, label: new URL(baseURL).host });
71
72
  }
72
73
  if (name === "vercel") {
73
- const client = opts.client ?? new (await import("openai")).default({ baseURL: VERCEL_GATEWAY, apiKey: env.AI_GATEWAY_API_KEY });
74
+ const client = opts.client ?? new (await import("openai")).default(sdkOptions({ baseURL: VERCEL_GATEWAY, apiKey: env.AI_GATEWAY_API_KEY }));
74
75
  return { ...openaiProvider(client, pickModel("vercel", opts.model), { compatible: true }), name: "vercel" };
75
76
  }
76
77
  if (name === "cloudflare") {
77
78
  const account = env.CLOUDFLARE_ACCOUNT_ID;
78
79
  if (!opts.client && !(account && env.CLOUDFLARE_API_TOKEN)) throw new Error("Cloudflare Workers AI needs CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID.");
79
- const client = opts.client ?? new (await import("openai")).default({ baseURL: `${CLOUDFLARE_API}/${account}/ai/v1`, apiKey: env.CLOUDFLARE_API_TOKEN });
80
+ const client = opts.client ?? new (await import("openai")).default(sdkOptions({ baseURL: `${CLOUDFLARE_API}/${account}/ai/v1`, apiKey: env.CLOUDFLARE_API_TOKEN }));
80
81
  return { ...openaiProvider(client, pickModel("cloudflare", opts.model), { compatible: true }), name: "cloudflare" };
81
82
  }
82
83
  const model = pickModel(name, opts.model);
83
84
  const gateway = gatewayOptions(name, env);
84
- const provider = await builtInProvider(name, model, gateway, opts.client, env);
85
+ const provider = await builtInProvider(name, model, gateway, opts, env);
85
86
  // Shown in the "Using ..." line so a run says whether its calls went through the gateway.
86
87
  if (gateway.baseURL && !opts.client) provider.via = "Cloudflare AI Gateway";
87
88
  return provider;
88
89
  }
89
90
 
90
- async function builtInProvider(name, model, gateway, client, env) {
91
- if (name === "anthropic") return anthropicProvider(client ?? new (await import("@anthropic-ai/sdk")).default(gateway), model);
92
- if (name === "openai") return openaiProvider(client ?? new (await import("openai")).default(gateway), model);
91
+ async function builtInProvider(name, model, gateway, { client, log, retry }, env) {
92
+ if (name === "anthropic") return anthropicProvider(client ?? new (await import("@anthropic-ai/sdk")).default(sdkOptions(gateway)), model);
93
+ if (name === "openai") return openaiProvider(client ?? new (await import("openai")).default(sdkOptions(gateway)), model);
93
94
  const { GoogleGenAI } = await import("@google/genai");
94
95
  const apiKey = env.GEMINI_API_KEY ?? env.GOOGLE_API_KEY;
96
+ // The Gemini SDK retries only when asked (httpOptions.retryOptions) and says nothing when it does,
97
+ // so its calls go through withRetry below instead, which logs each retry.
95
98
  const httpOptions = gateway.baseURL ? { httpOptions: { baseUrl: gateway.baseURL, headers: gateway.defaultHeaders } } : {};
96
- return geminiProvider(client ?? new GoogleGenAI({ ...(apiKey ? { apiKey } : {}), ...httpOptions }), model);
99
+ return geminiProvider(client ?? new GoogleGenAI({ ...(apiKey ? { apiKey } : {}), ...httpOptions }), model, { ...retry, log });
97
100
  }
98
101
 
99
102
  const VERCEL_GATEWAY = "https://ai-gateway.vercel.sh/v1";
100
103
  const CLOUDFLARE_API = "https://api.cloudflare.com/client/v4/accounts";
101
104
 
105
+ // --- Retries -----------------------------------------------------------------
106
+
107
+ /** How many times a failed model request is tried in all (the first try included). */
108
+ export const MODEL_ATTEMPTS = 3;
109
+ /** The wait before the first retry; each later one waits twice as long, give or take some jitter. */
110
+ export const RETRY_BASE_DELAY_MS = 2000;
111
+ /** The longest wait a retry-after header is allowed to ask for, so a run never hangs on one request. */
112
+ const RETRY_AFTER_MAX_MS = 60_000;
113
+ /** How long one request may take before it counts as failed. The SDKs' own 10 minutes would outlast the step. */
114
+ const MODEL_TIMEOUT_MS = 300_000;
115
+
116
+ /**
117
+ * The Anthropic and OpenAI SDKs retry on their own (408, 409, 429, 5xx, connection errors, with backoff
118
+ * and retry-after), so they only get the attempt count and a timeout; withRetry is not stacked on them.
119
+ */
120
+ function sdkOptions(extra = {}) {
121
+ // maxRetries counts retries, not attempts, so this gives the SDKs the same total as withRetry.
122
+ return { maxRetries: MODEL_ATTEMPTS - 1, timeout: MODEL_TIMEOUT_MS, ...extra };
123
+ }
124
+
125
+ const TRANSIENT_STATUSES = new Set([408, 409, 425, 429, 500, 502, 503, 504, 529]);
126
+ const TRANSIENT_MESSAGES = /overloaded|ECONNRESET|ETIMEDOUT|ECONNREFUSED|EAI_AGAIN|EPIPE|fetch failed|socket hang up|network error|terminated/i;
127
+
128
+ /**
129
+ * True for a failure a second try can get past: a rate limit, an overloaded or restarting API, a dropped
130
+ * connection. Bad requests, bad keys, missing models, refusals and schema errors fail at once.
131
+ */
132
+ export function isTransientModelError(err) {
133
+ const status = err?.status ?? err?.statusCode ?? err?.response?.status;
134
+ if (Number.isInteger(status)) return TRANSIENT_STATUSES.has(status);
135
+ return TRANSIENT_MESSAGES.test([err?.code, err?.message, err?.cause?.code, err?.cause?.message].filter(Boolean).join(" "));
136
+ }
137
+
138
+ /** The wait a retry-after header asks for, in milliseconds (seconds or an HTTP date), or null when there is none. */
139
+ export function retryAfterMs(err, now = Date.now()) {
140
+ const headers = err?.headers ?? err?.response?.headers;
141
+ const header = (name) => (typeof headers?.get === "function" ? headers.get(name) : headers?.[name]) ?? null;
142
+ const ms = Number(header("retry-after-ms"));
143
+ if (ms > 0) return Math.min(ms, RETRY_AFTER_MAX_MS);
144
+ const after = header("retry-after");
145
+ if (after == null || after === "") return null;
146
+ const seconds = Number(after);
147
+ const wait = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(after) - now;
148
+ return wait > 0 ? Math.min(wait, RETRY_AFTER_MAX_MS) : null;
149
+ }
150
+
151
+ /** What the log line says went wrong: the HTTP status when there is one, else the error code or message. */
152
+ const describeError = (err) => err?.status ?? err?.statusCode ?? err?.response?.status ?? err?.code ?? err?.cause?.code ?? String(err?.message ?? err).split("\n")[0].slice(0, 80);
153
+
154
+ /**
155
+ * Runs fn, trying again after a transient failure with exponential backoff (2s, 4s, 8s... with jitter),
156
+ * or after the wait a retry-after header asks for. Any other failure, or the last one, is thrown as is.
157
+ * @param {() => Promise<any>} fn
158
+ * @param {{ attempts?: number, baseDelayMs?: number, isTransient?: (err: any) => boolean, log?: (msg: string) => void, sleep?: (ms: number) => Promise<void>, random?: () => number }} opts
159
+ * sleep and random are injectable so tests run without waiting.
160
+ */
161
+ export async function withRetry(fn, { attempts = MODEL_ATTEMPTS, baseDelayMs = RETRY_BASE_DELAY_MS, isTransient = isTransientModelError, log = () => {}, sleep = wait, random = Math.random } = {}) {
162
+ for (let attempt = 1; ; attempt++) {
163
+ try {
164
+ return await fn();
165
+ } catch (err) {
166
+ if (attempt >= attempts || !isTransient(err)) throw err;
167
+ const delay = retryAfterMs(err) ?? Math.round(baseDelayMs * 2 ** (attempt - 1) * (1 + random() * 0.25));
168
+ log(` Model request failed (${describeError(err)}); retrying in ${Math.round(delay / 1000)}s (attempt ${attempt + 1} of ${attempts})`);
169
+ await sleep(delay);
170
+ }
171
+ }
172
+ }
173
+
174
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
175
+
102
176
  /** An openai-compatible API has no known cheap model, so `cheap` falls back to OPENAI_COMPATIBLE_MODEL. */
103
177
  function compatibleModel(model, env) {
104
178
  return (model === "cheap" ? undefined : model) ?? env.OPENAI_COMPATIBLE_MODEL;
@@ -160,6 +234,7 @@ function anthropicProvider(client, model) {
160
234
  return {
161
235
  name: "anthropic",
162
236
  model,
237
+ client, // exposed so the retry settings can be checked
163
238
  conversation({ system, tools, firstMessage }) {
164
239
  const messages = [{ role: "user", content: firstMessage }];
165
240
  const toolDefs = tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.schema }));
@@ -217,6 +292,7 @@ function openaiProvider(client, model, { compatible = false, label } = {}) {
217
292
  return {
218
293
  name: compatible ? `openai-compatible (${label})` : "openai",
219
294
  model,
295
+ client, // exposed so the retry settings can be checked
220
296
  conversation({ system, tools, firstMessage }) {
221
297
  const messages = [
222
298
  { role: "system", content: system },
@@ -275,10 +351,12 @@ function openaiProvider(client, model, { compatible = false, label } = {}) {
275
351
 
276
352
  // --- Google Gemini -----------------------------------------------------------
277
353
 
278
- function geminiProvider(ai, model) {
354
+ function geminiProvider(ai, model, retry = {}) {
355
+ const generate = (params) => withRetry(() => ai.models.generateContent(params), retry);
279
356
  return {
280
357
  name: "gemini",
281
358
  model,
359
+ client: ai,
282
360
  conversation({ system, tools, firstMessage }) {
283
361
  const contents = [{ role: "user", parts: [{ text: firstMessage }] }];
284
362
  const config = {
@@ -288,7 +366,7 @@ function geminiProvider(ai, model) {
288
366
  let pendingNames = new Map();
289
367
  return {
290
368
  async next() {
291
- const response = await ai.models.generateContent({ model, contents, config });
369
+ const response = await generate({ model, contents, config });
292
370
  const candidate = response.candidates?.[0];
293
371
  if (!candidate?.content) return { toolCalls: [], stop: candidate?.finishReason === "MAX_TOKENS" ? "max_tokens" : "refusal" };
294
372
  contents.push(candidate.content);
@@ -322,7 +400,7 @@ function geminiProvider(ai, model) {
322
400
  };
323
401
  },
324
402
  async json({ system, texts, schema }) {
325
- const response = await ai.models.generateContent({
403
+ const response = await generate({
326
404
  model,
327
405
  contents: [{ role: "user", parts: texts.map((text) => ({ text })) }],
328
406
  config: { systemInstruction: system, responseMimeType: "application/json", responseJsonSchema: schema },
package/src/replay.js CHANGED
@@ -5,12 +5,15 @@ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { harName } from "./agent.js";
7
7
  import { runAudit } from "./audit.js";
8
+ import { listRunIds, runsDir } from "./workspace.js";
8
9
 
9
10
  export const SAVED_SITE_FILE = "site.json";
11
+ /** The steps every run leaves in its folder, so the next run can repeat them without a model. */
12
+ export const STEPS_FILE = "steps.json";
10
13
 
11
14
  /** The steps a run took, as fixed steps a scripted run can repeat. */
12
15
  export function replayableJourney(run) {
13
- const steps = run.steps.filter((s) => s.index > 0);
16
+ const steps = (run.steps ?? []).filter((s) => s.index > 0);
14
17
  const start = steps[0]?.replay?.action === "goto" ? steps[0].replay.url : undefined;
15
18
  return {
16
19
  id: run.journeyId,
@@ -24,6 +27,30 @@ export function replayableJourney(run) {
24
27
  };
25
28
  }
26
29
 
30
+ /** What a run folder's steps.json holds: one entry per journey and viewport, leaving out journeys that took no repeatable step. */
31
+ export function savedSteps({ site, runs }) {
32
+ return { site, journeys: runs.map(replayableJourney).filter((j) => j.steps.length || j.start) };
33
+ }
34
+
35
+ /**
36
+ * The steps of the site's newest completed run of a journey, read from the steps.json each run leaves
37
+ * behind: { runId, viewport, start, steps } or null. The same viewport is preferred; when no run completed
38
+ * the journey on it, the newest completed run on another viewport is used, since most sites take the
39
+ * same path on both. Runs that did not complete the journey are skipped, since their steps led nowhere.
40
+ */
41
+ export async function lastSteps(dataDir, site, { journeyId, viewport }) {
42
+ let other = null;
43
+ for (const runId of (await listRunIds(dataDir, site)).reverse()) {
44
+ const saved = await readFile(path.join(runsDir(dataDir, site), runId, STEPS_FILE), "utf8").then(JSON.parse).catch(() => null);
45
+ const done = (saved?.journeys ?? []).filter((j) => j.id === journeyId && !j.incomplete && (j.steps?.length || j.start));
46
+ const pick = (j) => ({ runId, viewport: j.viewport, start: j.start, steps: j.steps ?? [] });
47
+ const same = done.find((j) => j.viewport === viewport);
48
+ if (same) return pick(same);
49
+ if (done.length && !other) other = pick(done[0]);
50
+ }
51
+ return other;
52
+ }
53
+
27
54
  /**
28
55
  * Writes the steps each journey took into journeys[].steps of a config file, so a later
29
56
  * --scripted --no-ai run repeats them without an AI model. Creates the file when it doesn't exist.
@@ -0,0 +1,123 @@
1
+ // What the Markdown and the HTML renderers share: the version, escaping, the grouped findings, platform names and
2
+ // order, the plan-check lines and the verdict sentence. src/report.js and src/html/*.js both import from here and
3
+ // this module imports neither, so the two renderers never import each other.
4
+ import { readFileSync } from "node:fs";
5
+ import { groupFindings } from "./checks.js";
6
+ import { messages } from "./i18n.js";
7
+ import { PROVIDERS } from "./parsers.js";
8
+
9
+ export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
10
+
11
+ const SEVERITIES = ["broken", "risk", "warning", "info"];
12
+
13
+ /** The summary as { headline, points, also_check }. Older plain-text summaries become a headline-only paragraph. */
14
+ function summaryParts(summary) {
15
+ if (!summary) return null;
16
+ if (typeof summary === "string") return { text: summary, points: [], also_check: [] };
17
+ return {
18
+ headline: String(summary.headline ?? ""),
19
+ points: (Array.isArray(summary.points) ? summary.points : []).filter((p) => p && p.text).map((p) => ({ severity: SEVERITIES.includes(p.severity) ? p.severity : "info", text: String(p.text) })),
20
+ also_check: (Array.isArray(summary.also_check) ? summary.also_check : []).map(String).filter(Boolean),
21
+ };
22
+ }
23
+
24
+ /** Fix steps of an explanation; older explanations had one "fix" string. */
25
+ const fixSteps = (e) => (Array.isArray(e?.fix_steps) && e.fix_steps.length ? e.fix_steps.map(String) : e?.fix ? [String(e.fix)] : []);
26
+
27
+ export const escapeHtml = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
28
+
29
+ function attachExplanations(findings, explained) {
30
+ const byIndex = new Map((explained?.explanations ?? []).map((e) => [e.finding, e]));
31
+ // A finding a baseline accepted carries `accepted: true`; the grouped card is accepted when every place it covers is.
32
+ const key = (f) => [f.severity, f.check, f.platform, f.event, f.detail].join("|");
33
+ const accepted = new Map();
34
+ for (const f of findings) if (f.severity !== "ok") accepted.set(key(f), (accepted.get(key(f)) ?? true) && f.accepted === true);
35
+ return groupFindings(findings).map((f, i) => ({ ...f, explanation: byIndex.get(i), ...(accepted.get(key(f)) ? { accepted: true } : {}) }));
36
+ }
37
+
38
+ const SEVERITY_RANK = { broken: 0, risk: 1, warning: 2 };
39
+
40
+ const seconds = (run, t) => `+${((t - run.startedAt) / 1000).toFixed(1)}s`;
41
+ const isConsentEntry = (value) => Array.isArray(value) && value[0] === "consent";
42
+
43
+ const routeList = (ch) => (ch?.routes ?? []).map((r) => `${r.journeyId} (${r.viewport})`).join(", ");
44
+
45
+ const isProblem = (f) => f.severity in SEVERITY_RANK;
46
+ const placeKey = (w) => `${w.journey}|${w.viewport}`;
47
+ const uniqPlaces = (where) => [...new Map(where.map((w) => [placeKey(w), w])).values()];
48
+ /** The problem the verdict is about: the one that costs the score most, else the most severe and widespread. */
49
+ function mdLead(rows, metrics) {
50
+ const top = metrics.score?.deductions?.[0];
51
+ const problems = rows.filter(isProblem);
52
+ return (top && problems.find((f) => f.check === top.check && f.severity === top.severity && (f.platform ?? null) === top.platform && (f.event ?? null) === top.event)) ?? problems[0] ?? null;
53
+ }
54
+
55
+ /** " on mobile" when the lead problem is on one viewport of several, " on checkout" for one journey of several, " on every journey" when it is everywhere. */
56
+ function mdScope(m, runs, lead) {
57
+ if (!lead) return "";
58
+ const places = uniqPlaces(lead.where);
59
+ if (runs.length > 1 && places.length === runs.length) return m.ui.md.everywhere;
60
+ const viewports = new Set(places.map((w) => w.viewport));
61
+ const journeys = new Set(places.map((w) => w.journey));
62
+ if (viewports.size === 1 && new Set(runs.map((r) => r.viewport)).size > 1) return m.ui.md.onViewport(String([...viewports][0]));
63
+ if (journeys.size === 1 && new Set(runs.map((r) => r.journeyId)).size > 1) return m.ui.md.onJourney(String([...journeys][0]));
64
+ return "";
65
+ }
66
+
67
+ /**
68
+ * The verdict sentence for the title, with no model, keyed the same way as metrics.verdict.headline. It names
69
+ * the lead problem and where it is ("purchase tracking is broken on mobile"): the counts sit next to the
70
+ * headline on every surface, so the count-based sentences in `verdict.headline` are left to the HTML verdict block.
71
+ */
72
+ function mdHeadline(m, runs, rows, metrics) {
73
+ const v = metrics.verdict;
74
+ const lead = mdLead(rows, metrics);
75
+ const what = lead ? (lead.event ? m.ui.md.tracking(String(lead.event)) : m.ui.md.setup(String(lead.check).replace(/_/g, " "))) : "";
76
+ const scope = mdScope(m, runs, lead);
77
+ const incomplete = runs.find((r) => !r.completed);
78
+ const ctx = { key: v.headline, what, scope, journey: incomplete ? String(incomplete.journeyId) : "", events: metrics.coverage?.checkable ?? 0, journeys: runs.length, counts: v.counts, lead };
79
+ const fallback = m.ui.md.headline[v.headline] ?? m.ui.md.headline.attention_nothing_tested;
80
+ if (v.headline === "fail_incomplete") return fallback(ctx.journey);
81
+ if (v.headline === "pass") return fallback(ctx.events, ctx.journeys);
82
+ if (v.headline === "attention_no_hits" || v.headline === "attention_nothing_tested") return fallback();
83
+ return fallback(what, scope);
84
+ }
85
+
86
+ /**
87
+ * The verdict as the CLI, the alert subject and the Markdown title say it: { status, statusText, headline,
88
+ * score }, in the report's language and with no Markdown in it. `metrics` is computeMetrics' result.
89
+ */
90
+ export function verdictHeadline({ findings = [], runs = [], metrics, lang = "en" }) {
91
+ const m = messages(lang);
92
+ const rows = groupFindings(findings).sort((a, b) => SEVERITY_ORDER_MD[a.severity] - SEVERITY_ORDER_MD[b.severity] || b.where.length - a.where.length);
93
+ return { status: metrics.verdict.status, statusText: m.verdict.status[metrics.verdict.status], headline: mdHeadline(m, runs, rows, metrics), score: metrics.score?.score ?? null };
94
+ }
95
+
96
+ const SEVERITY_ORDER_MD = { broken: 0, risk: 1, warning: 2, info: 3, ok: 4 };
97
+
98
+ const PLATFORM_ORDER = ["ga4", "google_ads", "meta", "tiktok", "x", "linkedin", "microsoft_ads", "pinterest", "snapchat", "criteo", "klaviyo", "adobe", "matomo", "piwik_pro", "clarity", "hotjar"];
99
+ export const platformLabel = (p) => PROVIDERS[p] ?? String(p).replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
100
+ const platformRank = (p) => (PLATFORM_ORDER.includes(p) ? PLATFORM_ORDER.indexOf(p) : PLATFORM_ORDER.length);
101
+
102
+ // Platform keys go into class names, ids and attribute selectors.
103
+ const safeKey = (k) => String(k ?? "").toLowerCase().replace(/[^a-z0-9_-]/g, "") || "other";
104
+ const sortPlatforms = (keys) => [...keys].sort((a, b) => platformRank(a) - platformRank(b) || platformLabel(a).localeCompare(platformLabel(b)));
105
+ const changeLabel = (c) => `${platformLabel(c.platform ?? "")} ${c.event ?? ""}`.trim() || c.check;
106
+ const coverageLines = (m, pc) => [
107
+ m.ui.coverageLine(pc.coverage),
108
+ ...sortPlatforms(Object.keys(pc.coverage.platforms)).map((p) => `${platformLabel(p)}: ${m.ui.coverageLine(pc.coverage.platforms[p])}`),
109
+ ];
110
+ const pcSort = (pc) => [...pc.events].sort((a, b) => BUCKET_ORDER.indexOf(a.bucket) - BUCKET_ORDER.indexOf(b.bucket) || platformRank(a.platform) - platformRank(b.platform));
111
+ const BUCKET_ORDER = ["missing", "missing_params", "wrong_values", "implemented", "unplanned", "not_checked"];
112
+
113
+ const planChangeItems = (pc) => [
114
+ ...pc.added.map((c) => `+ ${changeLabel(c)}`),
115
+ ...pc.removed.map((c) => `− ${changeLabel(c)}`),
116
+ ...pc.changed.map((c) => `~ ${changeLabel(c)}: ${c.what.join(", ")}`),
117
+ ];
118
+
119
+ export {
120
+ SEVERITIES, summaryParts, fixSteps, attachExplanations, SEVERITY_RANK, SEVERITY_ORDER_MD, seconds, isConsentEntry, routeList,
121
+ isProblem, placeKey, uniqPlaces, mdHeadline, PLATFORM_ORDER, platformRank, safeKey, sortPlatforms, changeLabel,
122
+ coverageLines, pcSort, BUCKET_ORDER, planChangeItems,
123
+ };