@analitikisler/ai-tag-debugger 0.7.5 → 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,16 @@
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
+
8
+ ## 0.7.6 (2026-10-14)
9
+
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.
11
+ - Only clicks fall back to their text. Typing into a field or choosing an option uses its saved place only.
12
+ - A click's text is not saved when it looks like an email address or phone number.
13
+
3
14
  ## 0.7.5 (2026-10-14)
4
15
 
5
16
  - A saved click keeps the element's text as well as its path through the page. When a replay finds nothing visible at that path (a banner or overlay shifted the layout), it clicks the element with that text instead. Sites saved before 0.7.5 have no text to fall back on; save them again to use it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@analitikisler/ai-tag-debugger",
3
- "version": "0.7.5",
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
@@ -201,8 +201,48 @@ function trackingBody(request) {
201
201
  * How a scripted run can find the element an action used again: the selector it was given,
202
202
  * else a CSS path to the element, else its visible text. Refs only mean something in one run.
203
203
  */
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
+ }
240
+ const savableText = (text) => (typeof text === "string" && text && !/@|\d[\d\s().+-]{6,}\d/.test(text) ? text : undefined);
241
+
204
242
  async function replayTarget(a, locator) {
205
- if (a.selector) return { selector: a.selector, ...(a.text ? { text: a.text } : {}) };
243
+ // Only a click keeps its text: a field's text is what was typed in it, not what identifies it.
244
+ const keepText = a.action === "click";
245
+ if (a.selector) return { selector: a.selector, ...(keepText && savableText(a.text) ? { text: a.text } : {}) };
206
246
  const found = await locator
207
247
  .evaluate((el) => {
208
248
  const text = (el.innerText || el.value || el.getAttribute("aria-label") || "").replace(/\s+/g, " ").trim().slice(0, 80);
@@ -222,8 +262,8 @@ async function replayTarget(a, locator) {
222
262
  .catch(() => null);
223
263
  const text = found?.text || a.text;
224
264
  // The text too, since a path through the page's layout breaks when a banner or overlay shifts it.
225
- if (found?.selector) return { selector: found.selector, ...(text ? { text } : {}) };
226
- return text ? { text } : {};
265
+ if (found?.selector) return { selector: found.selector, ...(keepText && savableText(text) ? { text } : {}) };
266
+ return keepText && savableText(text) ? { text } : {};
227
267
  }
228
268
 
229
269
  // Images, fonts and video don't change what tracking sends, and are most of a recording's size.
@@ -416,6 +456,21 @@ export class AuditSession {
416
456
  await this.context.routeFromHAR(this.opts.replayHar, { notFound: "fallback" });
417
457
  // A saved redirect to a page the browser may not open fails here, as it did when saved. Left to the
418
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
+ }
419
474
  const refusedRedirects = await this.refusedRedirects(this.opts.replayHar);
420
475
  if (refusedRedirects.size) {
421
476
  await this.context.route((url) => refusedRedirects.has(url.href), (route) => route.abort("blockedbyclient").catch(() => {}));
@@ -741,13 +796,16 @@ export class AuditSession {
741
796
  case "fill":
742
797
  case "select": {
743
798
  let target;
744
- if (a.selector && typeof a.text === "string" && a.text.trim()) {
745
- // A saved step: its path first, then its text if the path finds nothing to click.
799
+ if (a.action === "click" && a.selector && typeof a.text === "string" && a.text.trim()) {
800
+ // A saved click: its path first, then its text if the path finds nothing to click.
746
801
  target = this.locatorFor(a);
747
802
  const shown = await target.waitFor({ state: "visible", timeout: 5000 }).then(() => true, () => false);
748
803
  if (!shown) {
749
804
  const found = await this.locateByText(a.text.trim());
750
- if (!found.error) target = found.locator;
805
+ if (!found.error) {
806
+ target = found.locator;
807
+ step.foundBy = "text"; // the replay summary says so, in case it is not the element saved
808
+ }
751
809
  }
752
810
  } else if (a.selector || Number.isInteger(a.ref)) target = this.locatorFor(a);
753
811
  else if (typeof a.text === "string" && a.text.trim()) {
package/src/cli.js CHANGED
@@ -125,6 +125,7 @@ async function main() {
125
125
  for (const k of r.extra) console.log(` + ${k}`);
126
126
  for (const k of r.missing) console.log(` - ${k}`);
127
127
  for (const note of r.incomplete) console.log(` ! ${note}`);
128
+ for (const note of r.foundByText) console.log(` ~ ${note}`);
128
129
  }
129
130
  if (changed && !values.update) process.exitCode = 1;
130
131
  return;
package/src/replay.js CHANGED
@@ -65,7 +65,7 @@ export async function writeSavedSite(dir, { config, plan, runs, findings }) {
65
65
  /**
66
66
  * Audits a saved site again, offline and without AI, and compares the findings with the expected ones.
67
67
  * update: make this replay's findings the expected ones.
68
- * @returns {Promise<{ site: string, missing: string[], extra: string[], reportPaths: string[], incomplete: string[] }>}
68
+ * @returns {Promise<{ site: string, missing: string[], extra: string[], reportPaths: string[], incomplete: string[], foundByText: string[] }>}
69
69
  */
70
70
  export async function replaySavedSite(dir, { update = false, outDir, log = () => {} } = {}) {
71
71
  const file = path.join(dir, SAVED_SITE_FILE);
@@ -79,6 +79,7 @@ export async function replaySavedSite(dir, { update = false, outDir, log = () =>
79
79
  const findings = [];
80
80
  const reportPaths = [];
81
81
  const incomplete = [];
82
+ const foundByText = [];
82
83
  for (const viewport of viewports) {
83
84
  const journeys = saved.journeys.filter((j) => j.viewport === viewport).map(({ viewport: _v, ...j }) => j);
84
85
  const out = path.resolve(outDir ?? path.join(dir, "last-replay"), viewports.length > 1 ? viewport : "");
@@ -98,11 +99,14 @@ export async function replaySavedSite(dir, { update = false, outDir, log = () =>
98
99
  // Journeys that were already unfinished when saved are compared through their findings only.
99
100
  const wasIncomplete = (r) => journeys.find((j) => j.id === r.journeyId)?.incomplete === r.note;
100
101
  incomplete.push(...result.runs.filter((r) => !r.completed && !wasIncomplete(r)).map((r) => `${r.journeyId} (${r.viewport}): ${r.note}`));
102
+ for (const r of result.runs) {
103
+ for (const s of r.steps.filter((s) => s.foundBy === "text")) foundByText.push(`${r.journeyId} (${r.viewport}): "${s.label}" was clicked by its text, since nothing was at its saved place.`);
104
+ }
101
105
  }
102
106
  const actual = findingKeys(findings);
103
107
  const { missing, extra } = compareKeys(saved.expected ?? [], actual);
104
108
  if (update && (missing.length || extra.length)) {
105
109
  await writeFile(file, JSON.stringify({ ...saved, expected: actual }, null, 2));
106
110
  }
107
- return { site: saved.config.site, missing, extra, reportPaths, incomplete };
111
+ return { site: saved.config.site, missing, extra, reportPaths, incomplete, foundByText };
108
112
  }