@analitikisler/ai-tag-debugger 0.7.6 → 0.7.7

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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.7 (2026-10-14)
4
+
5
+ - A replay answers a request from the recording when the address only differs by a timestamp (`_=1791977697130`, `ts`, `timestamp`, `cb`, `nocache`). Magento sites reload the basket this way after "add to basket", so the replay never got the answer that makes the page send `add_to_cart`. Exact matches still come from the recording as before.
6
+ - A click found without a saved path no longer keeps its text when it looks like an email address or phone number.
7
+
3
8
  ## 0.7.6 (2026-10-14)
4
9
 
5
10
  - `replay` says when a saved click was found by its text rather than its saved place (`~` lines), since that may not be the element that was saved.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@analitikisler/ai-tag-debugger",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
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/browser.js CHANGED
@@ -202,6 +202,41 @@ function trackingBody(request) {
202
202
  * else a CSS path to the element, else its visible text. Refs only mean something in one run.
203
203
  */
204
204
  // Text that looks like an email address or phone number is not saved with a click.
205
+ /** Query parameters that only carry the time or a cache-buster, so they differ between a recording and its replay. */
206
+ const STAMP_PARAMS = /^(_|_t|ts|timestamp|cb|cachebust|nocache)$/i;
207
+ /** The address without its timestamp parameters. */
208
+ function unstamped(href) {
209
+ const url = new URL(href);
210
+ for (const name of [...url.searchParams.keys()]) if (STAMP_PARAMS.test(name)) url.searchParams.delete(name);
211
+ return url.href;
212
+ }
213
+ /**
214
+ * The GET answers in a recording whose address carries a timestamp, by address without it.
215
+ * Entries the recording has no body for (still loading when the page was left) are not included.
216
+ */
217
+ async function restampedAnswers(file) {
218
+ const answers = new Map();
219
+ answers.exact = new Set();
220
+ const har = JSON.parse(await readFile(file, "utf8"));
221
+ for (const e of har.log.entries) {
222
+ const { request, response } = e;
223
+ answers.exact.add(request.url);
224
+ if (request.method !== "GET" || response.status < 200 || response.status >= 300 || typeof response.content?.text !== "string") continue;
225
+ const key = unstamped(request.url);
226
+ if (key === request.url) continue;
227
+ const headers = {};
228
+ for (const h of response.headers ?? []) {
229
+ const name = h.name.toLowerCase();
230
+ if (["content-encoding", "content-length", "transfer-encoding"].includes(name)) continue;
231
+ headers[name] = name === "set-cookie" && headers[name] ? `${headers[name]}\n${h.value}` : h.value;
232
+ }
233
+ const body = response.content.encoding === "base64" ? Buffer.from(response.content.text, "base64") : response.content.text;
234
+ const entry = answers.get(key) ?? { queue: [] };
235
+ entry.queue.push({ status: response.status, headers, body });
236
+ answers.set(key, entry);
237
+ }
238
+ return answers;
239
+ }
205
240
  const savableText = (text) => (typeof text === "string" && text && !/@|\d[\d\s().+-]{6,}\d/.test(text) ? text : undefined);
206
241
 
207
242
  async function replayTarget(a, locator) {
@@ -228,7 +263,7 @@ async function replayTarget(a, locator) {
228
263
  const text = found?.text || a.text;
229
264
  // The text too, since a path through the page's layout breaks when a banner or overlay shifts it.
230
265
  if (found?.selector) return { selector: found.selector, ...(keepText && savableText(text) ? { text } : {}) };
231
- return text ? { text } : {};
266
+ return keepText && savableText(text) ? { text } : {};
232
267
  }
233
268
 
234
269
  // Images, fonts and video don't change what tracking sends, and are most of a recording's size.
@@ -421,6 +456,21 @@ export class AuditSession {
421
456
  await this.context.routeFromHAR(this.opts.replayHar, { notFound: "fallback" });
422
457
  // A saved redirect to a page the browser may not open fails here, as it did when saved. Left to the
423
458
  // recording, the redirect would be followed outside any handler, and its failure would end the run.
459
+ // A page asks again for what it asked when saved, with a new timestamp on the address. Registered
460
+ // after the recording, so it runs first and leaves every exact match to the recording.
461
+ const restamped = await restampedAnswers(this.opts.replayHar);
462
+ if (restamped.size) {
463
+ await this.context.route(
464
+ (url) => restamped.has(unstamped(url.href)),
465
+ (route) => {
466
+ const request = route.request();
467
+ const answers = restamped.get(unstamped(request.url()));
468
+ if (request.method() !== "GET" || restamped.exact.has(request.url())) return route.fallback().catch(() => {});
469
+ const answer = answers.queue.length > 1 ? answers.queue.shift() : answers.queue[0];
470
+ return route.fulfill(answer).catch(() => {});
471
+ },
472
+ );
473
+ }
424
474
  const refusedRedirects = await this.refusedRedirects(this.opts.replayHar);
425
475
  if (refusedRedirects.size) {
426
476
  await this.context.route((url) => refusedRedirects.has(url.href), (route) => route.abort("blockedbyclient").catch(() => {}));