@analitikisler/ai-tag-debugger 0.7.0 → 0.7.1

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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.1 (2026-10-13)
4
+
5
+ - 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.
6
+ - When a saved site's recording can't be cleaned, a warning now says so before the file is deleted.
7
+
8
+ Replays of saved sites are closer to the saved run, after the first save of 18 real shops:
9
+
10
+ - 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.
11
+ - A step that failed in the saved run is tried again on replay without ending the journey there.
12
+ - 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.
13
+ - 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.
14
+ - 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.
15
+ - 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.
16
+ - 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.
17
+
3
18
  ## 0.7.0 (2026-10-12)
4
19
 
5
20
  Fixes from the first audit run across 18 real shops.
package/README.md CHANGED
@@ -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, session 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
@@ -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ı, oturum ç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.1",
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);
@@ -164,6 +170,10 @@ export async function runScriptedJourney(session, journey, opts = {}) {
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);
176
+ if (journey.incomplete) return { completed: false, note: journey.incomplete };
167
177
  return { completed: true, note: "All scripted steps completed." };
168
178
  }
169
179
 
package/src/audit.js CHANGED
@@ -92,8 +92,9 @@ export async function runAudit(opts) {
92
92
 
93
93
  const findings = filterFindings(runs.flatMap((run) => checkRun(plan, run, { lang })), events);
94
94
  if (opts.saveSite) {
95
- await writeSavedSite(opts.saveSite, { config, plan, runs, findings });
96
- log(`\nSaved the site for offline replay in ${opts.saveSite}`);
95
+ await writeSavedSite(opts.saveSite, { config, plan, runs, findings })
96
+ .then(() => log(`\nSaved the site for offline replay in ${opts.saveSite}`))
97
+ .catch((err) => log(`\nCould not save the site in ${opts.saveSite}: ${err.message}`));
97
98
  }
98
99
  let explained = null;
99
100
  if (provider) {
package/src/browser.js CHANGED
@@ -227,18 +227,32 @@ 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 live, are dropped. Replay
236
+ // applies only one Set-Cookie header, so the kept ones are joined into one, one cookie per line.
237
+ function keptHeaders(headers) {
238
+ const isSetCookie = (h) => /^set-cookie$/i.test(h.name);
239
+ const cookies = headers.filter(isSetCookie).flatMap((h) => h.value.split("\n")).filter((c) => !/;\s*httponly\s*(;|$)/i.test(c));
240
+ return [...headers.filter((h) => !isSetCookie(h)), ...(cookies.length ? [{ name: "Set-Cookie", value: cookies.join("\n") }] : [])];
241
+ }
242
+
230
243
  const SECRET_REQUEST_HEADER = /^(cookie|authorization|proxy-authorization)$/i;
231
244
 
232
245
  /**
233
246
  * 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.
247
+ * URLs can hold the user data redacted everywhere else), unanswered requests, requests with user data in their address, form bodies,
248
+ * the cookies the browser sent, HttpOnly cookies the server set, and authorization headers.
236
249
  */
237
-
238
250
  async function cleanRecording(file) {
239
251
  const har = JSON.parse(await readFile(file, "utf8"));
240
252
  har.log.entries = har.log.entries
241
253
  .filter((e) => !isCollectionEndpoint(e.request.url) && !MEDIA.test(e.response?.content?.mimeType ?? ""))
254
+ // A request that had no answer yet when the browser closed would never finish on replay either.
255
+ .filter((e) => e.response?.status > 0)
242
256
  // A request whose address holds user data (an email in a form GET, for example) is left out whole.
243
257
  .filter((e) => redactUrl(e.request.url).url === e.request.url)
244
258
  .map((e) => {
@@ -247,7 +261,7 @@ async function cleanRecording(file) {
247
261
  return {
248
262
  ...e,
249
263
  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)) },
264
+ response: { ...e.response, cookies: [], headers: keptHeaders(e.response.headers) },
251
265
  };
252
266
  });
253
267
  await writeFile(file, JSON.stringify(har));
@@ -376,7 +390,14 @@ export class AuditSession {
376
390
  // cancelled before its route handler runs, but its request event has already fired.
377
391
  this.context.on("request", onRequest);
378
392
  // 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" });
393
+ if (this.opts.replayHar) {
394
+ // Requests the recording lacks fail, except the media left out of it: those get an empty answer,
395
+ // since a failed image or icon can stop a script (a consent banner waiting for its logo, for example).
396
+ await this.context.route("**/*", (route) =>
397
+ isDroppedMedia(route.request()) ? route.fulfill({ status: 200, body: "" }) : route.abort(),
398
+ );
399
+ await this.context.routeFromHAR(this.opts.replayHar, { notFound: "fallback" });
400
+ }
380
401
  if (this.opts.blockHits) {
381
402
  await this.context.route((url) => isCollectionEndpoint(url.href), async (route) => {
382
403
  await route.fulfill({ status: 204, body: "" });
@@ -658,6 +679,13 @@ export class AuditSession {
658
679
  * @param {{ action: string, ref?: number, selector?: string, text?: string, value?: string, url?: string, reason?: string, accepts_consent?: boolean }} a
659
680
  */
660
681
  async act(a) {
682
+ const error = await this.perform(a);
683
+ // A scripted replay of this step may fail the same way, which must not end the journey there.
684
+ if (error) this.steps[this.currentStep].error = error;
685
+ return error;
686
+ }
687
+
688
+ async perform(a) {
661
689
  const describe = a.reason || `${a.action} ${a.selector ?? (a.ref != null ? `#${a.ref}` : a.text != null ? `"${a.text}"` : a.url ?? "")}`.trim();
662
690
  this.beginStep(describe, { action: a.action, acceptsConsent: !!a.accepts_consent });
663
691
  const step = this.steps[this.currentStep];
@@ -725,7 +753,12 @@ export class AuditSession {
725
753
  // Writes the recording. If the browser already went away, there is none to clean.
726
754
  const written = await this.context.close().then(() => true, () => false);
727
755
  // 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 }));
756
+ if (written) {
757
+ await cleanRecording(this.opts.recordHar).catch(async (err) => {
758
+ console.warn(`Could not clean the recording ${this.opts.recordHar}, so it was deleted: ${err.message}`);
759
+ await rm(this.opts.recordHar, { force: true });
760
+ });
761
+ }
729
762
  }
730
763
  await this.browser?.close();
731
764
  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" : ""}`}`);
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);