@analitikisler/ai-tag-debugger 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.2 (2026-10-13)
4
+
5
+ - **GA4 and Google Ads hits sent through Google tag gateway are captured.** Sites using the gateway send GA4 to a short path on their own domain (`/<prefix>/ag/g/c`, `/<prefix>/ga/g/c`) and Google Ads to `/<prefix>/as/p/c/<id>/`. These were neither recognized nor answered locally, so their events were reported as missing and the audit's visits reached the site's real accounts. A `/g/c` path counts as GA4 only with a GA4 measurement id (`tid=G-…`).
6
+ - Google Tag Manager and gtag.js loaded from the site's own domain are labelled as such in the network timeline.
7
+ - Two more Google pings are answered locally instead of reaching Google: `www.google.com/measurement/conversion` and `google.<tld>/ads/ga-audiences`.
8
+ - Saved sites also leave out cookies named like a login or token (`auth_token`, `sid`, `csrftoken`, …) and cookies holding an email address, even when they aren't HttpOnly.
9
+ - Writing a saved site's recording can no longer stall a run: the page is left first, so what it is still loading is cancelled, and after 30 seconds the recording is skipped (the site is then not saved). The log says when the recording is being written. Reading the browser's cookies at the end of a journey has a time limit too.
10
+ - `replay` no longer crashes with "Target page, context or browser has been closed" when a request is still being answered as the browser closes.
11
+ - A saved journey whose start page could not be opened (a redirect to another site, a bot check) can be replayed; it stays unfinished with its saved note.
12
+ - When a site can't be saved, a `site.json` from an earlier save is removed so it can't be replayed against the new recordings, and `run` exits with code 1.
13
+
14
+ ## 0.7.1 (2026-10-13)
15
+
16
+ - A page whose script stops responding after a step can no longer stall a run. Reading the page now has a 1 minute limit too, and when it runs out the journey ends as incomplete with a note. Before, only the model call and the browser action itself had a limit.
17
+ - When a saved site's recording can't be cleaned, a warning now says so before the file is deleted.
18
+
19
+ Replays of saved sites are closer to the saved run, after the first save of 18 real shops:
20
+
21
+ - Scripted runs, and so replays, wait 3 seconds after the last step, so tags that send a moment after the last click (GA4 batches events) are captured as in an AI run.
22
+ - A step that failed in the saved run is tried again on replay without ending the journey there.
23
+ - A journey the AI could not finish (no cart, a bot check) stays unfinished on replay, so its missing events are not reported as broken.
24
+ - Cookies the server sets for the page's scripts, such as a country, are kept in the recording; HttpOnly cookies, where sessions and logins live, are still left out. Some sites don't render without them, for example a cookie banner that never appears.
25
+ - Images, fonts and video left out of the recording get an empty answer on replay instead of failing, since a failed image can stop a script.
26
+ - Requests that had no answer yet when the browser closed are left out of the recording. On replay they made the page that asked for them again never finish loading.
27
+ - A site whose recording is missing (the browser closed early) is not saved, and `replay` reports a saved site it can't replay and goes on with the next one instead of stopping.
28
+
3
29
  ## 0.7.0 (2026-10-12)
4
30
 
5
31
  Fixes from the first audit run across 18 real shops.
package/README.md CHANGED
@@ -45,7 +45,7 @@ Manual tag QA catches this, but it's tedious, so it rarely happens after every r
45
45
  | Personal data | An email address sent to an ad platform in plain text |
46
46
  | Unplanned events | Events firing that aren't in your measurement plan |
47
47
 
48
- Platforms: GA4 (including server-side GTM endpoints), Google Ads conversions, Meta Pixel, TikTok Pixel, and basic X Pixel events.
48
+ Platforms: GA4 (including server-side GTM endpoints and Google tag gateway paths on the site's own domain), Google Ads conversions, Meta Pixel, TikTok Pixel, and basic X Pixel events.
49
49
 
50
50
  ### Choose the providers to check
51
51
 
@@ -180,7 +180,7 @@ npx @analitikisler/ai-tag-debugger replay saved/*
180
180
  - warning duplicate purchase desktop ga4 page_view
181
181
  ```
182
182
 
183
- The first run's findings become the expected ones. When a change is right, for example a false alarm that is fixed, `replay --update` makes the new findings the expected ones. Use it to check a new version of the tool against sites you know, or a site change against its last good run. A saved site is usually a few MB. It leaves out tracking requests, images and video, cookies, authorization headers, form data, and requests with an email or other user data in their address. It still holds the site's pages and scripts, so keep sites you don't own out of public repositories.
183
+ The first run's findings become the expected ones. When a change is right, for example a false alarm that is fixed, `replay --update` makes the new findings the expected ones. Use it to check a new version of the tool against sites you know, or a site change against its last good run. A saved site is usually a few MB. It leaves out tracking requests, images and video, HttpOnly and login cookies and the cookies the browser sent, authorization headers, form data, and requests with an email or other user data in their address. It still holds the site's pages and scripts, so keep sites you don't own out of public repositories.
184
184
 
185
185
  ## Configure your site
186
186
 
package/README.tr.md CHANGED
@@ -45,7 +45,7 @@ Tag'leri elle test etmek bunu yakalar, ama zahmetli olduğu için her sürümden
45
45
  | Kişisel veri | Bir e-posta adresi reklam platformuna açık metin olarak gönderildi |
46
46
  | Planda olmayan event'ler | Ölçümleme planınızda bulunmayan event'ler gönderiliyor |
47
47
 
48
- Platformlar: GA4 (server-side GTM adresleri dahil), Google Ads dönüşümleri, Meta Pixel, TikTok Pixel ve temel X Pixel event'leri.
48
+ Platformlar: GA4 (server-side GTM adresleri ve sitenin kendi alan adındaki Google tag gateway yolları dahil), Google Ads dönüşümleri, Meta Pixel, TikTok Pixel ve temel X Pixel event'leri.
49
49
 
50
50
  ### Kontrol edilecek platformları seçin
51
51
 
@@ -182,7 +182,7 @@ npx @analitikisler/ai-tag-debugger replay saved/*
182
182
  - warning duplicate purchase desktop ga4 page_view
183
183
  ```
184
184
 
185
- İlk çalıştırmanın bulguları beklenen bulgular olur. Bir değişiklik doğruysa (örneğin düzeltilmiş bir yanlış alarm), `replay --update` yeni bulguları beklenen bulgular yapar. Aracın yeni bir sürümünü bildiğiniz sitelerle ya da bir site değişikliğini son sağlıklı çalıştırmayla karşılaştırmak için kullanın. Kaydedilmiş bir site genellikle birkaç MB'tır. İzleme isteklerini, görselleri ve videoları, çerezleri, yetkilendirme başlıklarını, form verilerini ve adresinde e-posta ya da başka kullanıcı verisi olan istekleri içermez. Yine de sitenin sayfalarını ve betiklerini içerdiği için size ait olmayan siteleri herkese açık depolara koymayın.
185
+ İlk çalıştırmanın bulguları beklenen bulgular olur. Bir değişiklik doğruysa (örneğin düzeltilmiş bir yanlış alarm), `replay --update` yeni bulguları beklenen bulgular yapar. Aracın yeni bir sürümünü bildiğiniz sitelerle ya da bir site değişikliğini son sağlıklı çalıştırmayla karşılaştırmak için kullanın. Kaydedilmiş bir site genellikle birkaç MB'tır. İzleme isteklerini, görselleri ve videoları, HttpOnly ve oturum açma çerezlerini ve tarayıcının gönderdiği çerezleri, yetkilendirme başlıklarını, form verilerini ve adresinde e-posta ya da başka kullanıcı verisi olan istekleri içermez. Yine de sitenin sayfalarını ve betiklerini içerdiği için size ait olmayan siteleri herkese açık depolara koymayın.
186
186
 
187
187
  ## Sitenizi ayarlayın
188
188
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@analitikisler/ai-tag-debugger",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Open-source AI agent that QA-tests your website's analytics, ad and consent tracking (GA4, GTM, Google Ads, Meta, TikTok). Bring your own model (Claude, OpenAI, Gemini) or use it from your chat app over MCP.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -88,9 +88,11 @@ export function validateAction(input) {
88
88
  return null;
89
89
  }
90
90
 
91
- // One hung page or model request must not stall a whole run.
92
- const MODEL_TIMEOUT_MS = 180_000;
93
- const ACTION_TIMEOUT_MS = 120_000;
91
+ // One hung page or model request must not stall a whole run. A page whose script never
92
+ // stops (an endless loop after a click, for example) can't be read, so reading it has a limit too.
93
+ const TIMEOUTS = { model: 180_000, action: 120_000, page: 60_000 };
94
+
95
+ const LAST_STEP_WAIT_MS = 3000;
94
96
 
95
97
  class StepTimeout extends Error {}
96
98
 
@@ -105,14 +107,18 @@ function withTimeout(promise, ms, message) {
105
107
  * Lets the AI model drive the browser through one journey.
106
108
  * @param {AuditSession} session
107
109
  * @param {{ id: string, goal: string }} journey
108
- * @param {{ provider?: any, providerName?: string, client?: any, model?: string, maxSteps?: number, lang?: string, log?: (msg: string) => void }} opts
110
+ * @param {{ provider?: any, providerName?: string, client?: any, model?: string, maxSteps?: number, lang?: string, log?: (msg: string) => void, timeouts?: { model?: number, action?: number, page?: number } }} opts
111
+ * timeouts: milliseconds for one model answer, one browser action and one read of the page.
109
112
  */
110
113
  export async function runAgentJourney(session, journey, opts = {}) {
111
114
  const provider = opts.provider ?? (await createProvider({ name: opts.providerName, model: opts.model, client: opts.client }));
112
115
  const maxSteps = opts.maxSteps ?? 30;
113
116
  const log = opts.log ?? (() => {});
117
+ const limit = { ...TIMEOUTS, ...opts.timeouts };
118
+ const seconds = (ms) => (ms % 60_000 ? `${Math.round(ms / 1000)} seconds` : ms === 60_000 ? "a minute" : `${ms / 60_000} minutes`);
119
+ const readPage = () => withTimeout(session.snapshot(), limit.page, `The page stopped responding: it could not be read for ${seconds(limit.page)}.`);
114
120
 
115
- const first = await session.snapshot();
121
+ const first = await readPage();
116
122
  const chat = provider.conversation({
117
123
  system: systemPrompt(opts.lang ?? "en"),
118
124
  tools: TOOLS,
@@ -120,7 +126,7 @@ export async function runAgentJourney(session, journey, opts = {}) {
120
126
  });
121
127
 
122
128
  for (let turn = 0; turn < maxSteps; turn++) {
123
- const { toolCalls, stop } = await withTimeout(chat.next(), MODEL_TIMEOUT_MS, "The model did not answer within 3 minutes.");
129
+ const { toolCalls, stop } = await withTimeout(chat.next(), limit.model, `The model did not answer within ${seconds(limit.model)}.`);
124
130
  if (stop === "refusal") return { completed: false, note: "The model declined to continue this journey." };
125
131
  if (toolCalls.length === 0) {
126
132
  if (stop === "max_tokens") return { completed: false, note: "The model response was cut off." };
@@ -146,8 +152,8 @@ export async function runAgentJourney(session, journey, opts = {}) {
146
152
  continue;
147
153
  }
148
154
  log(` ${call.input.reason}`);
149
- const error = await withTimeout(session.act(call.input), ACTION_TIMEOUT_MS, "The browser action did not finish within 2 minutes.");
150
- results.push({ id: call.id, content: pageView(await session.snapshot(), error) });
155
+ const error = await withTimeout(session.act(call.input), limit.action, `The browser action did not finish within ${seconds(limit.action)}.`);
156
+ results.push({ id: call.id, content: pageView(await readPage(), error) });
151
157
  }
152
158
  if (finished) return finished;
153
159
  chat.addToolResults(results);
@@ -158,12 +164,16 @@ export async function runAgentJourney(session, journey, opts = {}) {
158
164
  /** Replays fixed steps from the config, without calling an AI model. */
159
165
  export async function runScriptedJourney(session, journey, opts = {}) {
160
166
  const log = opts.log ?? (() => {});
161
- if (!journey.steps?.length) throw new Error(`Journey "${journey.id}" has no "steps" for --scripted mode.`);
162
- for (const step of journey.steps) {
167
+ if (!journey.steps?.length && !journey.incomplete) throw new Error(`Journey "${journey.id}" has no "steps" for --scripted mode.`);
168
+ for (const step of journey.steps ?? []) {
163
169
  log(` ${step.reason ?? `${step.action} ${step.selector ?? step.url ?? ""}`}`);
164
170
  const error = await session.act(step);
165
171
  if (error && !step.optional) return { completed: false, note: `Step "${step.reason ?? step.action}" failed: ${error}` };
166
172
  }
173
+ // Tags often send a moment after the last click (GA4 batches events). An AI run gives them that time
174
+ // while the model answers; a scripted run would otherwise close the browser first.
175
+ await session.page.waitForTimeout(LAST_STEP_WAIT_MS).catch(() => {});
176
+ if (journey.incomplete) return { completed: false, note: journey.incomplete };
167
177
  return { completed: true, note: "All scripted steps completed." };
168
178
  }
169
179
 
@@ -184,7 +194,8 @@ export async function runJourney(config, journey, viewport, opts = {}) {
184
194
  await session.start();
185
195
  const startUrl = new URL(journey.start ?? "/", config.site).href;
186
196
  const startError = await session.act({ action: "goto", url: startUrl, reason: `Open ${startUrl}` });
187
- if (startError) return { ...collect(session, journey, viewport), completed: false, note: `Could not open the start page: ${startError}` };
197
+ // A replayed journey that was unfinished when saved keeps its saved note, however it fails this time.
198
+ if (startError) return { ...collect(session, journey, viewport), completed: false, note: journey.incomplete ?? `Could not open the start page: ${startError}` };
188
199
 
189
200
  const outcome = await (opts.scripted ? runScriptedJourney(session, journey, opts) : runAgentJourney(session, journey, opts)).catch((err) => {
190
201
  if (err instanceof StepTimeout) return { completed: false, note: err.message };
@@ -192,6 +203,7 @@ export async function runJourney(config, journey, viewport, opts = {}) {
192
203
  });
193
204
  return { ...collect(session, journey, viewport), ...outcome, cookies: await session.cookies() };
194
205
  } finally {
206
+ if (opts.saveSite) opts.log?.(" Writing the recording…");
195
207
  await session.close();
196
208
  }
197
209
  }
package/src/audit.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // A full audit from a config file: run every journey, check, explain, write the reports.
2
2
  // Shared by the CLI and the MCP server's run_audit tool.
3
- import { readFile, mkdir, writeFile } from "node:fs/promises";
3
+ import { readFile, mkdir, rm, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { runJourney } from "./agent.js";
6
6
  import { checkRun, groupFindings, summarize } from "./checks.js";
@@ -9,7 +9,7 @@ import { LANGUAGES, messages } from "./i18n.js";
9
9
  import { createProvider } from "./providers.js";
10
10
  import { filterFindings, planRun } from "./journeys.js";
11
11
  import { resolveProviders } from "./parsers.js";
12
- import { writeSavedSite } from "./replay.js";
12
+ import { SAVED_SITE_FILE, writeSavedSite } from "./replay.js";
13
13
 
14
14
  export async function readJson(file, baseDir = ".") {
15
15
  const full = path.resolve(baseDir, file);
@@ -72,7 +72,7 @@ export async function runAudit(opts) {
72
72
  const viewports = opts.viewports?.length ? opts.viewports : config.viewports ?? ["desktop"];
73
73
  if (!journeys.length) throw new Error("No journeys to run. Pick one with --journey (e.g. purchase) or describe one with --goal.");
74
74
  if (opts.scripted) {
75
- const unscripted = journeys.filter((j) => !j.steps?.length).map((j) => j.id);
75
+ const unscripted = journeys.filter((j) => !j.steps?.length && !j.incomplete).map((j) => j.id);
76
76
  if (unscripted.length) throw new Error(`Journeys without fixed steps can't be replayed with --scripted: ${unscripted.join(", ")}.`);
77
77
  }
78
78
 
@@ -91,9 +91,17 @@ export async function runAudit(opts) {
91
91
  }
92
92
 
93
93
  const findings = filterFindings(runs.flatMap((run) => checkRun(plan, run, { lang })), events);
94
+ let saveError = null;
94
95
  if (opts.saveSite) {
95
- await writeSavedSite(opts.saveSite, { config, plan, runs, findings });
96
- log(`\nSaved the site for offline replay in ${opts.saveSite}`);
96
+ try {
97
+ await writeSavedSite(opts.saveSite, { config, plan, runs, findings });
98
+ log(`\nSaved the site for offline replay in ${opts.saveSite}`);
99
+ } catch (err) {
100
+ // A site.json from an earlier save would no longer match the recordings next to it.
101
+ await rm(path.join(opts.saveSite, SAVED_SITE_FILE), { force: true });
102
+ saveError = err.message;
103
+ log(`\nCould not save the site in ${opts.saveSite}: ${err.message}`);
104
+ }
97
105
  }
98
106
  let explained = null;
99
107
  if (provider) {
@@ -104,5 +112,5 @@ export async function runAudit(opts) {
104
112
  const outDir = path.resolve(opts.outDir ?? defaultOutDir());
105
113
  const reportPath = await writeReports({ outDir, config, plan, runs, findings, explained, lang, branding: opts.branding ?? config.branding !== false });
106
114
  const counts = summarize(groupFindings(findings));
107
- return { runs, findings, explained, counts, reportPath, outDir, lang };
115
+ return { runs, findings, explained, counts, reportPath, outDir, lang, ...(saveError ? { saveError } : {}) };
108
116
  }
package/src/browser.js CHANGED
@@ -227,18 +227,41 @@ async function replayTarget(a, locator) {
227
227
  // Images, fonts and video don't change what tracking sends, and are most of a recording's size.
228
228
  const MEDIA = /^(image|font|video|audio)\//;
229
229
 
230
+ const MEDIA_FILE = /\.(png|jpe?g|gif|webp|avif|svg|ico|bmp|woff2?|ttf|otf|eot|mp4|webm|mov|mp3|m4a|ogg|wav)$/i;
231
+ const isDroppedMedia = (request) =>
232
+ ["image", "font", "media"].includes(request.resourceType()) || MEDIA_FILE.test(new URL(request.url()).pathname);
233
+
234
+ // Cookies the server sets for the page's scripts (a country or language, for example) are kept, since
235
+ // sites often need them to render. HttpOnly ones, where sessions and logins usually live, are dropped,
236
+ // and so are ones named or holding like a login. Replay applies only one Set-Cookie header, so the
237
+ // kept ones are joined into one, one cookie per line.
238
+ const LOGIN_COOKIE = /sess|sid|token|auth|jwt|login|csrf|xsrf|remember/i;
239
+ function keptHeaders(headers) {
240
+ const isSetCookie = (h) => /^set-cookie$/i.test(h.name);
241
+ const isPrivate = (cookie) => {
242
+ const [pair] = cookie.split(";");
243
+ const name = pair.slice(0, pair.indexOf("=")).trim();
244
+ return /;\s*httponly\s*(;|$)/i.test(cookie) || LOGIN_COOKIE.test(name) || /@|%40/.test(pair);
245
+ };
246
+ const cookies = headers.filter(isSetCookie).flatMap((h) => h.value.split("\n")).filter((c) => !isPrivate(c));
247
+ return [...headers.filter((h) => !isSetCookie(h)), ...(cookies.length ? [{ name: "Set-Cookie", value: cookies.join("\n") }] : [])];
248
+ }
249
+
250
+ const CLOSE_TIMEOUT_MS = 30_000;
251
+
230
252
  const SECRET_REQUEST_HEADER = /^(cookie|authorization|proxy-authorization)$/i;
231
253
 
232
254
  /**
233
255
  * Drops images, fonts, video and tracking requests from a recording (they are answered locally on replay, and their
234
- * URLs can hold the user data redacted everywhere else), requests with user data in their address, form bodies,
235
- * cookies both ways and authorization headers.
256
+ * URLs can hold the user data redacted everywhere else), unanswered requests, requests with user data in their address, form bodies,
257
+ * the cookies the browser sent, HttpOnly cookies the server set, and authorization headers.
236
258
  */
237
-
238
259
  async function cleanRecording(file) {
239
260
  const har = JSON.parse(await readFile(file, "utf8"));
240
261
  har.log.entries = har.log.entries
241
262
  .filter((e) => !isCollectionEndpoint(e.request.url) && !MEDIA.test(e.response?.content?.mimeType ?? ""))
263
+ // A request that had no answer yet when the browser closed would never finish on replay either.
264
+ .filter((e) => e.response?.status > 0)
242
265
  // A request whose address holds user data (an email in a form GET, for example) is left out whole.
243
266
  .filter((e) => redactUrl(e.request.url).url === e.request.url)
244
267
  .map((e) => {
@@ -247,7 +270,7 @@ async function cleanRecording(file) {
247
270
  return {
248
271
  ...e,
249
272
  request: { ...request, cookies: [], headers: request.headers.filter((h) => !SECRET_REQUEST_HEADER.test(h.name)) },
250
- response: { ...e.response, cookies: [], headers: e.response.headers.filter((h) => !/^set-cookie$/i.test(h.name)) },
273
+ response: { ...e.response, cookies: [], headers: keptHeaders(e.response.headers) },
251
274
  };
252
275
  });
253
276
  await writeFile(file, JSON.stringify(har));
@@ -262,6 +285,7 @@ export class AuditSession {
262
285
  * others are still answered locally, but only counted, per step, in this.skipped.
263
286
  * site: navigation is limited to this site's host and its subdomains, plus allowedHosts.
264
287
  * recordHar: save every response the site sends into this .har file, so the journey can be replayed offline later.
288
+ * closeTimeoutMs: how long close() waits for the recording to be written (default 30 s) before skipping it.
265
289
  * replayHar: answer requests from a .har file saved with recordHar instead of the network; requests it lacks fail.
266
290
  * blockPrivate: refuse loopback, private and link-local addresses (used when the MCP server is reachable over HTTP).
267
291
  * All browser traffic then goes through the egress proxy. A function decides which addresses count as private (for tests).
@@ -376,7 +400,14 @@ export class AuditSession {
376
400
  // cancelled before its route handler runs, but its request event has already fired.
377
401
  this.context.on("request", onRequest);
378
402
  // Registered first, so the routes below run before it and tracking requests are still answered locally.
379
- if (this.opts.replayHar) await this.context.routeFromHAR(this.opts.replayHar, { notFound: "abort" });
403
+ if (this.opts.replayHar) {
404
+ // Requests the recording lacks fail, except the media left out of it: those get an empty answer,
405
+ // since a failed image or icon can stop a script (a consent banner waiting for its logo, for example).
406
+ await this.context.route("**/*", (route) =>
407
+ (isDroppedMedia(route.request()) ? route.fulfill({ status: 200, body: "" }) : route.abort()).catch(() => {}),
408
+ );
409
+ await this.context.routeFromHAR(this.opts.replayHar, { notFound: "fallback" });
410
+ }
380
411
  if (this.opts.blockHits) {
381
412
  await this.context.route((url) => isCollectionEndpoint(url.href), async (route) => {
382
413
  await route.fulfill({ status: 204, body: "" });
@@ -658,6 +689,13 @@ export class AuditSession {
658
689
  * @param {{ action: string, ref?: number, selector?: string, text?: string, value?: string, url?: string, reason?: string, accepts_consent?: boolean }} a
659
690
  */
660
691
  async act(a) {
692
+ const error = await this.perform(a);
693
+ // A scripted replay of this step may fail the same way, which must not end the journey there.
694
+ if (error) this.steps[this.currentStep].error = error;
695
+ return error;
696
+ }
697
+
698
+ async perform(a) {
661
699
  const describe = a.reason || `${a.action} ${a.selector ?? (a.ref != null ? `#${a.ref}` : a.text != null ? `"${a.text}"` : a.url ?? "")}`.trim();
662
700
  this.beginStep(describe, { action: a.action, acceptsConsent: !!a.accepts_consent });
663
701
  const step = this.steps[this.currentStep];
@@ -665,9 +703,9 @@ export class AuditSession {
665
703
  switch (a.action) {
666
704
  case "goto": {
667
705
  const target = new URL(a.url, this.page.url() === "about:blank" ? undefined : this.page.url()).href;
706
+ step.replay = { action: "goto", url: target };
668
707
  const refused = await this.checkUrl(target);
669
708
  if (refused) return refused;
670
- step.replay = { action: "goto", url: target };
671
709
  await this.page.goto(target, { waitUntil: "domcontentloaded", timeout: 30000 });
672
710
  break;
673
711
  }
@@ -717,15 +755,46 @@ export class AuditSession {
717
755
  }
718
756
 
719
757
  async cookies() {
720
- return (await this.context.cookies()).map((c) => ({ name: c.name, domain: c.domain }));
758
+ let timer;
759
+ const tooLong = new Promise((resolve) => (timer = setTimeout(() => resolve([]), 10_000)));
760
+ const cookies = await Promise.race([this.context.cookies().catch(() => []), tooLong]);
761
+ clearTimeout(timer);
762
+ return cookies.map((c) => ({ name: c.name, domain: c.domain }));
721
763
  }
722
764
 
723
765
  async close() {
766
+ // Leaving the page first cancels what it is still loading (the recording would otherwise wait for it),
767
+ // while tracking requests sent on the way out are still answered locally.
768
+ if (this.opts.recordHar) await this.page?.goto("about:blank", { timeout: 5000 }).catch(() => {});
769
+ // Requests still in flight then get no route callback that would throw once the browser is gone.
770
+ // A page stuck in a script never answers, so this waits at most 5 seconds.
771
+ let unrouteTimer;
772
+ await Promise.race([
773
+ this.context?.unrouteAll({ behavior: "ignoreErrors" }).catch(() => {}),
774
+ new Promise((resolve) => (unrouteTimer = setTimeout(resolve, 5000))),
775
+ ]);
776
+ clearTimeout(unrouteTimer);
724
777
  if (this.opts.recordHar && this.context) {
725
- // Writes the recording. If the browser already went away, there is none to clean.
726
- const written = await this.context.close().then(() => true, () => false);
778
+ // Writes the recording. If the browser already went away, or writing it takes too long, there is
779
+ // none to clean: closing the browser below then ends the run's wait.
780
+ let timer;
781
+ const limit = this.opts.closeTimeoutMs ?? CLOSE_TIMEOUT_MS;
782
+ const tooLong = new Promise((resolve) => (timer = setTimeout(() => resolve("timeout"), limit)));
783
+ const closed = await Promise.race([this.context.close().then(() => "closed", () => "gone"), tooLong]);
784
+ clearTimeout(timer);
785
+ if (closed === "timeout") {
786
+ console.warn(`Writing the recording ${this.opts.recordHar} took over ${Math.round(limit / 1000)} seconds, so it was skipped.`);
787
+ await this.browser?.close().catch(() => {});
788
+ await rm(this.opts.recordHar, { force: true }); // a partly written file
789
+ }
790
+ const written = closed === "closed";
727
791
  // A recording that could not be cleaned may hold cookies or form data, so it is deleted.
728
- if (written) await cleanRecording(this.opts.recordHar).catch(() => rm(this.opts.recordHar, { force: true }));
792
+ if (written) {
793
+ await cleanRecording(this.opts.recordHar).catch(async (err) => {
794
+ console.warn(`Could not clean the recording ${this.opts.recordHar}, so it was deleted: ${err.message}`);
795
+ await rm(this.opts.recordHar, { force: true });
796
+ });
797
+ }
729
798
  }
730
799
  await this.browser?.close();
731
800
  await this.egress?.close();
package/src/cli.js CHANGED
@@ -113,7 +113,12 @@ async function main() {
113
113
  if (!dirs.length) throw new Error("replay needs the folder of at least one site saved with --save-site.");
114
114
  let changed = false;
115
115
  for (const dir of dirs) {
116
- const r = await replaySavedSite(dir, { update: values.update, outDir: dirs.length === 1 ? values.out : undefined });
116
+ const r = await replaySavedSite(dir, { update: values.update, outDir: dirs.length === 1 ? values.out : undefined }).catch((err) => {
117
+ console.log(`✗ ${dir}: could not replay: ${err.message.split("\n")[0]}`);
118
+ changed = true;
119
+ return null;
120
+ });
121
+ if (!r) continue;
117
122
  const same = !r.missing.length && !r.extra.length;
118
123
  changed ||= !same;
119
124
  console.log(`${same ? "✓" : values.update ? "↻" : "✗"} ${r.site} (${dir}): ${same ? "same findings" : `${r.extra.length} new, ${r.missing.length} gone${values.update ? ", saved as expected" : ""}`}`);
@@ -175,6 +180,7 @@ async function main() {
175
180
  const threshold = levels.indexOf(failOn);
176
181
  if (levels.slice(0, threshold + 1).some((l) => result.counts[l] > 0)) process.exitCode = 1;
177
182
  }
183
+ if (result.saveError) process.exitCode = 1;
178
184
  }
179
185
 
180
186
  main().catch((err) => {
package/src/parsers.js CHANGED
@@ -29,12 +29,19 @@ function ga4Consent(searchParams) {
29
29
  // Only www.google.<tld> and doubleclick.net: analytics.google.com is a primary GA4 endpoint.
30
30
  const GOOGLE_COMPANION_HOST = /^(www\.)?google\.[a-z]{2,3}(\.[a-z]{2})?$|(^|\.)doubleclick\.net$/;
31
31
 
32
+ /**
33
+ * Google tag gateway serves Google's endpoints from a path on the site's own domain, with
34
+ * short names: /<prefix>/ag/g/c or /<prefix>/ga/g/c for GA4's /g/collect. Only accepted with a
35
+ * GA4 measurement id, since "/g/c" alone is too common a path.
36
+ */
37
+ const isGa4Path = (u) => u.pathname.endsWith("/g/collect") || (/\/g\/c$/.test(u.pathname) && /^G-[A-Z0-9]+$/.test(u.searchParams.get("tid") ?? ""));
38
+
32
39
  /** @returns {Hit[] | null} */
33
40
  export function parseGa4(url, postData) {
34
41
  const u = new URL(url);
35
42
  // Matched on the GA4 protocol rather than the host, so server-side GTM
36
43
  // endpoints on first-party domains are captured too.
37
- if (!u.pathname.endsWith("/g/collect") || u.searchParams.get("v") !== "2") return null;
44
+ if (!isGa4Path(u) || u.searchParams.get("v") !== "2") return null;
38
45
  // gtag.js resends a hit to www.google.com/g/collect when the first send seems lost, and
39
46
  // copies some to doubleclick.net. GA4 doesn't count these twice, so they aren't hits.
40
47
  // (Primary hits can carry gaf too, e.g. gaf=2, so the host decides, not the parameter.)
@@ -74,11 +81,14 @@ export function parseMeta(url) {
74
81
  /** @returns {Hit[] | null} */
75
82
  export function parseGoogleAds(url) {
76
83
  const u = new URL(url);
84
+ // Google tag gateway serves /pagead/conversion/<id>/ as /<prefix>/as/p/c/<id>/ on the site's own domain.
85
+ const gateway = u.pathname.match(/\/as\/p\/c\/(\d+)\/?$/);
77
86
  const isAds =
78
87
  (u.hostname === "www.googleadservices.com" && u.pathname.startsWith("/pagead/conversion/")) ||
79
- (/(^|\.)google\.[a-z.]+$/.test(u.hostname) && u.pathname.startsWith("/pagead/1p-conversion/"));
88
+ (/(^|\.)google\.[a-z.]+$/.test(u.hostname) && u.pathname.startsWith("/pagead/1p-conversion/")) ||
89
+ (gateway && u.searchParams.has("cv"));
80
90
  if (!isAds) return null;
81
- const conversionId = u.pathname.split("/").filter(Boolean)[2];
91
+ const conversionId = gateway ? gateway[1] : u.pathname.split("/").filter(Boolean)[2];
82
92
  const params = {};
83
93
  for (const key of ["label", "value", "currency_code", "oid"]) {
84
94
  if (u.searchParams.has(key)) params[key === "currency_code" ? "currency" : key === "oid" ? "transaction_id" : key] = u.searchParams.get(key);
@@ -136,7 +146,7 @@ export function parseTrackingRequest(url, postData) {
136
146
  // hits: Google Ads remarketing and consent pings, and the other common ad pixels.
137
147
  const OTHER_BEACONS = [
138
148
  [/(^|\.)(doubleclick\.net|googleadservices\.com|google\.[a-z.]+)$/, /^\/pagead\/(viewthroughconversion|1p-user-list|landing)\//],
139
- [/(^|\.)google\.[a-z.]+$/, /^\/rmkt\/collect\//],
149
+ [/(^|\.)google\.[a-z.]+$/, /^\/(rmkt\/collect\/|measurement\/conversion|ads\/ga-audiences)/],
140
150
  [GOOGLE_COMPANION_HOST, /\/g\/collect$/],
141
151
  [/(^|\.)merchant-center-analytics\.goog$/, /^\/mc\/collect/],
142
152
  // Google tag pings on any host: consent mode (ccm), the Google tag's own /g/s/collect, and
@@ -217,8 +227,12 @@ export function providerOf(url) {
217
227
  if (/(^|\.)(doubleclick\.net|google\.[a-z.]+)$/.test(host) && /^\/(pagead|ccm)\//.test(u.pathname)) return of("google_ads");
218
228
  if (/(^|\.)doubleclick\.net$/.test(host)) return of("doubleclick");
219
229
  for (const [pattern, key] of HOSTS) if (pattern.test(host)) return of(key);
220
- if (u.pathname.endsWith("/g/collect")) return of("ga4");
221
- if (u.pathname.startsWith("/pagead/")) return of("google_ads");
230
+ if (isGa4Path(u)) return of("ga4");
231
+ if (u.pathname.startsWith("/pagead/") || /\/as\/p\/c\/\d+\/?$/.test(u.pathname)) return of("google_ads");
232
+ // Google Tag Manager and gtag.js served from the site's own domain (server-side GTM, Google tag gateway).
233
+ const id = u.searchParams.get("id") ?? "";
234
+ if (/^GTM-[A-Z0-9]+$/.test(id)) return of("gtm");
235
+ if (/\/gtag\/js$/.test(u.pathname) && id.startsWith("G-")) return of("ga4");
222
236
  return null;
223
237
  }
224
238
 
package/src/replay.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // Saved sites: the responses a site sent during an audit and the steps taken, so a later
2
2
  // version of the tool can audit the same pages offline and show what it now reports
3
3
  // differently. Replays need no network and no AI model, so they cost nothing.
4
- import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
+ import { harName } from "./agent.js";
6
7
  import { runAudit } from "./audit.js";
7
8
 
8
9
  export const SAVED_SITE_FILE = "site.json";
@@ -15,9 +16,11 @@ export function replayableJourney(run) {
15
16
  id: run.journeyId,
16
17
  viewport: run.viewport,
17
18
  ...(start ? { start } : {}),
19
+ // A journey the AI could not finish (no cart, a bot check) is still incomplete when its steps replay.
20
+ ...(run.completed ? {} : { incomplete: run.note }),
18
21
  steps: (start ? steps.slice(1) : steps)
19
22
  .filter((s) => s.replay)
20
- .map((s) => ({ ...s.replay, reason: s.label, ...(s.acceptsConsent ? { accepts_consent: true } : {}) })),
23
+ .map((s) => ({ ...s.replay, reason: s.label, ...(s.acceptsConsent ? { accepts_consent: true } : {}), ...(s.error ? { optional: true } : {}) })),
21
24
  };
22
25
  }
23
26
 
@@ -41,6 +44,12 @@ export function compareKeys(expected, actual) {
41
44
  /** Writes site.json next to the recorded responses. The findings of this run become the expected ones. */
42
45
  export async function writeSavedSite(dir, { config, plan, runs, findings }) {
43
46
  await mkdir(dir, { recursive: true });
47
+ for (const run of runs) {
48
+ const har = path.join(dir, harName(run.journeyId, run.viewport));
49
+ if (!(await stat(har).catch(() => null))) {
50
+ throw new Error(`The recording of ${run.journeyId} (${run.viewport}) is missing, probably because the browser closed early. Save the site again.`);
51
+ }
52
+ }
44
53
  const { journeys: _journeys, plan: _plan, gtm_container: _gtm, ...site } = config;
45
54
  const saved = {
46
55
  version: 1,
@@ -86,7 +95,9 @@ export async function replaySavedSite(dir, { update = false, outDir, log = () =>
86
95
  });
87
96
  findings.push(...result.findings);
88
97
  reportPaths.push(result.reportPath);
89
- incomplete.push(...result.runs.filter((r) => !r.completed).map((r) => `${r.journeyId} (${r.viewport}): ${r.note}`));
98
+ // Journeys that were already unfinished when saved are compared through their findings only.
99
+ const wasIncomplete = (r) => journeys.find((j) => j.id === r.journeyId)?.incomplete === r.note;
100
+ incomplete.push(...result.runs.filter((r) => !r.completed && !wasIncomplete(r)).map((r) => `${r.journeyId} (${r.viewport}): ${r.note}`));
90
101
  }
91
102
  const actual = findingKeys(findings);
92
103
  const { missing, extra } = compareKeys(saved.expected ?? [], actual);