@analitikisler/ai-tag-debugger 0.22.4 → 0.22.6

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/checks.js CHANGED
@@ -5,7 +5,7 @@ import { messages } from "./i18n.js";
5
5
  import { isOwnAddress } from "./redact.js";
6
6
  import { campaignParamsIn } from "./campaign.js";
7
7
  import { CONSENT_SIGNALS } from "./parsers.js";
8
- import { duplicateLoads, loadSources, scriptFailed, splitLayers } from "./containers.js";
8
+ import { containersNotRun, duplicateLoads, loadSources, scriptFailed, splitLayers } from "./containers.js";
9
9
  import { messageOutcomes, widgetSummaries, widgetsOf } from "./frames.js";
10
10
 
11
11
  const platformName = (p) => ({ meta: "Meta", tiktok: "TikTok" })[p] ?? p;
@@ -394,6 +394,23 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
394
394
  findings.push({ ...base, severity: "warning", check: "container_failed", step: stepLabel(sc.step), detail: d.containerFailed(sc.id, sc.host, String(sc.status)) });
395
395
  }
396
396
 
397
+ // A GTM container downloaded or asked for that never ran. Expected while a consent platform holds it back and the
398
+ // run never consented; otherwise none of its tags fired.
399
+ const consented = consentGrantedStep(run) !== null;
400
+ for (const c of containersNotRun(run)) {
401
+ findings.push(run.cmp?.vendor && !consented
402
+ ? { ...base, severity: "info", check: "container_held", step: stepLabel(c.step), detail: d.containerHeld(c.id, run.cmp.vendor) }
403
+ : { ...base, severity: "warning", check: "container_not_run", step: stepLabel(c.step), detail: d.containerNotRun(c.id, c.kind) });
404
+ }
405
+
406
+ // Logged in with the test account: GA4 hits from then on should carry the site's user ID.
407
+ if (Number.isInteger(run.loginStep)) {
408
+ const after = run.hits.filter((h) => h.platform === "ga4" && h.step > run.loginStep);
409
+ if (after.length && !after.some((h) => h.hasUserId)) {
410
+ findings.push({ ...base, severity: "info", check: "user_id_missing", platform: "ga4", step: stepLabel(after[0].step), detail: d.userIdMissing(after.length) });
411
+ }
412
+ }
413
+
397
414
  // Widgets that tracked on their own, left out of the plan checks: said once per widget, so nothing goes missing silently.
398
415
  for (const w of widgetsOf(run.widgetHits ?? [])) {
399
416
  const first = (run.widgetHits ?? []).find((h) => h.frame.origin === w.origin);
package/src/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from "node:fs";
3
+ import { chmod, mkdir } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { parseArgs } from "node:util";
5
6
  import { loadConfig, runAudit } from "./audit.js";
@@ -10,10 +11,48 @@ import { replaySavedSite, writeSavedSteps } from "./replay.js";
10
11
  import { readBaseline, writeBaseline } from "./baseline.js";
11
12
  import { savePlan } from "./plan.js";
12
13
  import { DEFAULT_DATA_DIR } from "./workspace.js";
14
+ import { resolveSessionFile, sessionFileFor } from "./login.js";
13
15
  import { commandText, intentToArgs, isSentence, needsWhatToTest, translate } from "./ask.js";
14
16
 
15
17
  const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
16
18
 
19
+ /**
20
+ * `aitd login`: opens the site in a visible browser for the user to log in by hand (a CAPTCHA, two-factor login or
21
+ * single sign-on are fine), then saves the session (cookies and storage) for runs to start from with --session saved.
22
+ * The file holds a live login, so only its owner may read it.
23
+ */
24
+ async function saveLoginSession(values) {
25
+ if (!values.site) throw new Error("login needs --site <url>: aitd login --site https://www.example.com");
26
+ try {
27
+ new URL(values.site);
28
+ } catch {
29
+ throw new Error(`"${values.site}" is not a full URL. Use e.g. https://www.example.com`);
30
+ }
31
+ const file = path.resolve(values.save ?? sessionFileFor(values.site, values["data-dir"] ?? DEFAULT_DATA_DIR));
32
+ const { AuditSession } = await import("./browser.js");
33
+ const session = new AuditSession({ viewport: "desktop", site: values.site, allowedHosts: values["allow-host"], headless: false, login: null });
34
+ await session.start();
35
+ try {
36
+ await session.act({ action: "goto", url: values.site });
37
+ console.log("Log in to the site in the browser window. When you are logged in, come back here and press Enter (Ctrl+C cancels).");
38
+ console.log(`The browser stays on ${session.scope.hosts.join(", ")}; for a login on another host (single sign-on), add --allow-host <host>.`);
39
+ await new Promise((resolve) => {
40
+ process.stdin.resume();
41
+ process.stdin.once("data", () => {
42
+ process.stdin.pause();
43
+ resolve();
44
+ });
45
+ });
46
+ await mkdir(path.dirname(file), { recursive: true });
47
+ await session.context.storageState({ path: file });
48
+ await chmod(file, 0o600);
49
+ console.log(`Saved the logged-in session to ${file}. It holds a live login: keep it private.`);
50
+ console.log(`Start a run from it with: aitd run --site ${values.site} --session ${values.save ? file : "saved"} --journey <id>`);
51
+ } finally {
52
+ await session.close();
53
+ }
54
+ }
55
+
17
56
  async function main(args = process.argv.slice(2)) {
18
57
  const { values, positionals } = parseArgs({
19
58
  args,
@@ -31,6 +70,8 @@ async function main(args = process.argv.slice(2)) {
31
70
  platforms: { type: "string", multiple: true },
32
71
  providers: { type: "string", multiple: true },
33
72
  "allow-host": { type: "string", multiple: true },
73
+ session: { type: "string" },
74
+ save: { type: "string" },
34
75
  "allow-private-network": { type: "boolean", default: false },
35
76
  "allow-live": { type: "boolean", default: false },
36
77
  scripted: { type: "boolean", default: false },
@@ -83,6 +124,10 @@ async function main(args = process.argv.slice(2)) {
83
124
  await startMcp({ http: values.http, port: values.port ? Number(values.port) : undefined, host: values.host, token: values.token, reportsDir: values.reports, publicUrl: values["public-url"], allowPrivateNetwork: values["allow-private-network"], allowLive: values["allow-live"] });
84
125
  return;
85
126
  }
127
+ if (command === "login" && !values.help) {
128
+ await saveLoginSession(values);
129
+ return;
130
+ }
86
131
  if (command === "replay" && !values.help) {
87
132
  const dirs = positionals.slice(1);
88
133
  if (!dirs.length) throw new Error("replay needs the folder of at least one site saved with --save-site.");
@@ -191,6 +236,9 @@ async function main(args = process.argv.slice(2)) {
191
236
  }
192
237
  if (values["keep-bodies"]) loaded.config = { ...loaded.config, keep_bodies: true };
193
238
  if (values["allow-host"]?.length) loaded.config = { ...loaded.config, allowed_hosts: [...(loaded.config.allowed_hosts ?? []), ...values["allow-host"]] };
239
+ // A session saved with `aitd login` ("saved") or a file: every journey starts logged in.
240
+ const sessionArg = values.session ?? loaded.config.session_file;
241
+ if (sessionArg) loaded.config = { ...loaded.config, session_file: resolveSessionFile(sessionArg, { site: loaded.config.site, dataDir: values["data-dir"] ?? DEFAULT_DATA_DIR }) };
194
242
  try {
195
243
  new URL(loaded.config.site);
196
244
  } catch {
package/src/consent.js CHANGED
@@ -184,7 +184,7 @@ export function scenarioLanding(config, campaign) {
184
184
  */
185
185
  async function runState(config, state, opts) {
186
186
  const session = new AuditSession({
187
- viewport: "desktop", site: config.site, allowedHosts: config.allowed_hosts, providers: config.providers,
187
+ viewport: "desktop", site: config.site, allowedHosts: config.allowed_hosts, storageState: config.session_file, providers: config.providers,
188
188
  blockHits: config.block_hits !== false, headless: !opts.headed, ...(opts.blockPrivate ? { blockPrivate: opts.blockPrivate } : {}),
189
189
  });
190
190
  const landing = scenarioLanding(config, opts.campaign);
package/src/containers.js CHANGED
@@ -7,6 +7,9 @@
7
7
  // Container and destination IDs gtm.js and gtag/js are loaded with.
8
8
  const CONTAINER_ID = /^(GTM|G|GT|AW|DC|MC|HA)-[A-Z0-9]+$/;
9
9
 
10
+ /** Whether a string is a container or Google tag ID: GTM-…, G-…, GT-…, AW-…, DC-… and the like. */
11
+ export const isContainerId = (id) => CONTAINER_ID.test(String(id));
12
+
10
13
  /**
11
14
  * The container a script URL loads, or null: its ID, gtm or gtag, the dataLayer it reads (`l`), the host it
12
15
  * came from (Google's, or the site's own through the Google tag gateway or a server container) and the GTM
@@ -148,13 +151,16 @@ export function loadSources(run) {
148
151
 
149
152
  /**
150
153
  * One row per container and source across runs, in the order first seen: { id, script, layer, served, host,
151
- * environment?, kind, by?, frame?, version?, bytes?, transferBytes?, ms?, failed?, pages, journeys }. frame is the
152
- * iframe it loaded in; version to failed are its script's answer (see scriptFacts), from the first load in the row.
154
+ * environment?, kind, by?, frame?, version?, bytes?, transferBytes?, ms?, failed?, ran?, pages, journeys }. frame is
155
+ * the iframe it loaded in; version to failed are its script's answer (see scriptFacts), from the first load in the
156
+ * row; ran says whether Google's registry held it on some step (left out for captures that didn't record it).
157
+ * Containers the page asked for without the tool seeing a script added are rows too (see unseenContainers).
153
158
  */
154
159
  export function containerRows(runs) {
155
160
  const rows = new Map();
156
161
  for (const run of runs) {
157
- for (const l of loadSources(run)) {
162
+ const ran = Array.isArray(run.containersRunning) ? new Set(run.containersRunning.map((c) => c.id)) : null;
163
+ for (const l of [...loadSources(run), ...unseenContainers(run)]) {
158
164
  const k = [l.id, l.kind, l.by ?? "", l.host, l.layer, l.environment ?? "", l.frame?.origin ?? ""].join("|");
159
165
  const row = rows.get(k) ?? {
160
166
  id: l.id, script: l.script, layer: l.layer, served: l.served, host: l.host,
@@ -165,12 +171,63 @@ export function containerRows(runs) {
165
171
  };
166
172
  if (l.page) row.pages.add(l.page);
167
173
  row.journeys.add(`${run.journeyId} (${run.viewport})`);
174
+ // Whether it ran (Google's registry held it) in any run that recorded the registry; unknown for older captures.
175
+ if (ran) row.ran = Boolean(row.ran) || ran.has(l.id);
168
176
  rows.set(k, row);
169
177
  }
170
178
  }
171
179
  return [...rows.values()].map((r) => ({ ...r, pages: [...r.pages].sort(), journeys: [...r.journeys] }));
172
180
  }
173
181
 
182
+ /**
183
+ * Containers a run met without seeing a script added for them: a script the page requested or preloaded, or an ID
184
+ * that ran (Google's registry held it), with no load recorded. kind "preload": only a <link rel="preload"> asked for
185
+ * it, which downloads a script without running it; "unseen": something added it in a way the tool couldn't follow,
186
+ * as when a consent platform's auto-blocking replaces the browser's own functions. One per container and host.
187
+ */
188
+ export function unseenContainers(run) {
189
+ const loads = run.containerLoads ?? [];
190
+ const loaded = (id, host) => loads.some((l) => l.id === id && (!host || l.host === host));
191
+ const preloads = run.containerPreloads ?? [];
192
+ const out = new Map();
193
+ // The page a step was on: the preload's own, else the step's (see pagePath in browser.js).
194
+ const pageAt = (step, page) => page ?? run.steps?.[step]?.pagePath ?? undefined;
195
+ const add = (id, host, kind, step, facts = {}, page) => {
196
+ const k = `${id}|${host ?? ""}`;
197
+ if (out.has(k) || loaded(id, host)) return;
198
+ const at = pageAt(step, page);
199
+ out.set(k, {
200
+ id, script: id.startsWith("GTM-") ? "gtm" : "gtag", layer: null,
201
+ ...(host ? { host, served: /(^|\.)googletagmanager\.com$/.test(host) ? "google" : "site" } : {}),
202
+ kind, step, ...(at ? { page: at } : {}), ...facts,
203
+ });
204
+ };
205
+ for (const sc of run.containerScripts ?? []) {
206
+ const pre = preloads.find((p) => p.id === sc.id && p.host === sc.host);
207
+ add(sc.id, sc.host, pre ? "preload" : "unseen", pre?.step ?? sc.step, scriptFacts(sc), pre?.page);
208
+ }
209
+ for (const p of preloads) add(p.id, p.host, "preload", p.step, {}, p.page);
210
+ for (const c of run.containersRunning ?? []) {
211
+ if (!loaded(c.id) && ![...out.values()].some((x) => x.id === c.id)) add(c.id, null, "unseen", c.step);
212
+ }
213
+ return [...out.values()];
214
+ }
215
+
216
+ /**
217
+ * GTM containers a run downloaded or asked for that never ran: Google's registry never held them on any step. Only
218
+ * GTM containers, whose registry entry is certain; [] for captures that didn't record the registry.
219
+ */
220
+ export function containersNotRun(run) {
221
+ if (!Array.isArray(run.containersRunning)) return [];
222
+ const ran = new Set(run.containersRunning.map((c) => c.id));
223
+ const seen = new Map();
224
+ for (const l of [...loadSources(run), ...unseenContainers(run)]) {
225
+ if (!l.id.startsWith("GTM-") || ran.has(l.id) || l.failed || l.frame?.party === "third" || seen.has(l.id)) continue;
226
+ seen.set(l.id, { id: l.id, kind: l.kind, step: l.step ?? 0 });
227
+ }
228
+ return [...seen.values()];
229
+ }
230
+
174
231
  /** The containers loaded more than once into one page, as [{ id, loads }] with the loads in order. */
175
232
  export function duplicateLoads(run) {
176
233
  const byDoc = new Map();
package/src/help.js CHANGED
@@ -13,6 +13,7 @@ Commands:
13
13
  run Audit what a site actually sends, and compare it to a plan
14
14
  replay Re-check saved sites offline, without AI
15
15
  mcp Start the MCP server for Claude, ChatGPT and other chat apps
16
+ login Log in to a site by hand once and save the session for runs to start from
16
17
  doctor Check that Node, Chromium, an AI key and the data folder are ready
17
18
  history Show how a site's saved runs trend: counts, open problems, flapping, journeys
18
19
 
@@ -41,6 +42,7 @@ Options:
41
42
  --events <names> Only check these events, e.g. purchase,add_to_cart
42
43
  --lang <code> Report language: ${Object.keys(LANGUAGES).join(" or ")}
43
44
  --headed Show the browser window
45
+ --session <file> Start logged in: saved (the session aitd login saved) or a session file
44
46
  --fail-on <level> Exit 1 at this level: broken (default), risk, warning, never
45
47
  --baseline <file> Known findings to accept; they don't count toward --fail-on
46
48
  --update-baseline Write the baseline file after the run (creates it)
@@ -106,6 +108,23 @@ Advanced:
106
108
  --reports <dir> Where finished audits write reports (default: ~/ai-tag-debugger-reports)
107
109
  --public-url <url> The HTTPS address of your tunnel or host, so audits can link to reports`;
108
110
 
111
+ const LOGIN = `Usage: aitd login --site <url> [--save <file>] [--allow-host <host>] [--data-dir <dir>]
112
+
113
+ Open the site in a browser window, log in by hand (a CAPTCHA, a code sent to your phone or
114
+ single sign-on are fine), then press Enter in the terminal: the logged-in session (cookies and
115
+ storage) is saved to aitd/<domain>/session.json. Runs start from it with --session saved.
116
+ The file holds a live login: keep it private, and save it again when the login expires.
117
+
118
+ For journeys that can log in by themselves, set a test account instead:
119
+ AITD_LOGIN_EMAIL (or AITD_LOGIN_USERNAME) and AITD_LOGIN_PASSWORD. The model sees only
120
+ the placeholders {{login.email}} and {{login.password}}; the browser types the real values.
121
+
122
+ Options:
123
+ --site <url> The site to log in to (required)
124
+ --save <file> Where to save the session (default: aitd/<domain>/session.json)
125
+ --allow-host <host> Another host the login passes through, such as a single sign-on page (repeatable)
126
+ --data-dir <dir> The folder plans and runs are saved in (default: aitd)`;
127
+
109
128
  const DOCTOR = `Usage: aitd doctor [--site <url>] [--data-dir <dir>]
110
129
 
111
130
  Check that this machine can run an audit: the Node version, Playwright's Chromium, whether
@@ -130,7 +149,7 @@ Options:
130
149
  --lang <code> Language of the lines: ${Object.keys(LANGUAGES).join(" or ")}
131
150
  --data-dir <dir> The folder plans and runs are saved in (default: aitd)`;
132
151
 
133
- const HELP = { run: RUN, plan: PLAN, replay: REPLAY, mcp: MCP, doctor: DOCTOR, history: HISTORY };
152
+ const HELP = { run: RUN, plan: PLAN, replay: REPLAY, mcp: MCP, login: LOGIN, doctor: DOCTOR, history: HISTORY };
134
153
 
135
154
  /** Help text for a command, or the top-level help when the command is unknown or missing. */
136
155
  export function helpText(command) {
package/src/html/scope.js CHANGED
@@ -202,7 +202,7 @@ function containersCard(ctx) {
202
202
  const rows = containerRows(runs);
203
203
  if (!rows.length) return null;
204
204
  const c = m.ui.containers;
205
- const items = rows.map((r) => `<li>${code(r.id)}${plain(esc(c.script[r.script]))}<span>${esc(c.kinds[r.kind](r.by))}</span>${plain(`${esc(m.ui.layerCol)} ${code(r.layer)}`)}${plain(esc(r.served === "google" ? c.served.google : `${c.served.site} (${r.host})`))}${r.version ? plain(esc(c.version(r.version)), "mono") : ""}${r.bytes ? plain(esc(c.size(r.bytes)), "mono") : ""}${r.ms != null ? plain(esc(c.loadTime(r.ms))) : ""}${r.failed ? badge("broken", c.failed(r.failed)) : ""}${r.frame ? plain(esc(c.iframe(r.frame.origin, r.frame.party))) : ""}${r.environment ? badge("warning", c.environment(r.environment)) : ""}<span class="inv-n">${m.html.scope.pages(r.pages.length, bold(r.pages.length))}</span></li>`).join("");
205
+ const items = rows.map((r) => `<li>${code(r.id)}${plain(esc(c.script[r.script]))}<span>${esc(c.kinds[r.kind](r.by))}</span>${r.ran === false ? badge("broken", c.neverRan) : ""}${r.layer ? plain(`${esc(m.ui.layerCol)} ${code(r.layer)}`) : ""}${r.served ? plain(esc(r.served === "google" ? c.served.google : `${c.served.site} (${r.host})`)) : ""}${r.version ? plain(esc(c.version(r.version)), "mono") : ""}${r.bytes ? plain(esc(c.size(r.bytes)), "mono") : ""}${r.ms != null ? plain(esc(c.loadTime(r.ms))) : ""}${r.failed ? badge("broken", c.failed(r.failed)) : ""}${r.frame ? plain(esc(c.iframe(r.frame.origin, r.frame.party))) : ""}${r.environment ? badge("warning", c.environment(r.environment)) : ""}<span class="inv-n">${m.html.scope.pages(r.pages.length, bold(r.pages.length))}</span></li>`).join("");
206
206
  const ids = uniq(rows.map((r) => r.id));
207
207
  return `<li><details class="card inv" id="inv-containers"><summary><span class="t">${esc(m.ui.containersTitle)}</span>${plain(n(ids.length))}<span class="inv-m">${esc(ids.slice(0, 3).join(", "))}${ids.length > 3 ? " …" : ""}</span></summary><div class="body"><p class="xs muted">${esc(m.ui.containersIntro)}</p><ul class="inv-list">${items}</ul></div></details></li>`;
208
208
  }
package/src/i18n.js CHANGED
@@ -244,6 +244,7 @@ const en = {
244
244
  size: (bytes) => `${(bytes / 1024).toFixed(1)} KB`,
245
245
  loadTime: (ms) => `loaded in ${ms} ms`,
246
246
  failed: (status) => (status === "failed" ? "failed to load" : `failed to load (HTTP ${status})`),
247
+ neverRan: "never ran",
247
248
  kinds: {
248
249
  html: () => "A script tag in the page's HTML",
249
250
  inline: () => "Inline code in the page (the snippet)",
@@ -254,6 +255,8 @@ const en = {
254
255
  config: (by) => `A gtag('config') command, loaded by ${by}`,
255
256
  send_to: (by) => `An event's send_to with no config, loaded by ${by}`,
256
257
  destination: (by) => `A destination of the Google tag ${by}`,
258
+ preload: () => "Only a preload hint in the page (<link rel=\"preload\">): downloaded, never added as a script",
259
+ unseen: () => "Not seen being added: a script on the page may have replaced the browser's own functions, as consent platforms that block scripts do",
257
260
  },
258
261
  },
259
262
  provider: "Provider",
@@ -385,6 +388,9 @@ const en = {
385
388
  widgetTracking: (origin, platforms) => `An embedded widget from ${origin} sent its own tracking: ${platforms.map((p) => `${label(p.platform)}${p.ids.length ? ` (${p.ids.join(", ")})` : ""} ${p.events.map(q).join(", ")}`).join("; ")}. It goes to accounts the site's own tags don't use, so it was left out of the plan, coverage and duplicate checks; the consent and personal data checks still include it.`,
386
389
  iframeContainer: (id, origin, parent) => `${id} runs in an iframe from ${origin} embedded in ${parent}. The iframe has its own dataLayer: events and consent commands pushed in the page never reach it, so it needs its own.`,
387
390
  containerFailed: (id, host, status) => `The ${id} script from ${host} ${status === "failed" ? "failed to load" : `was answered with HTTP ${status}`}, so none of its tags ran on that page.`,
391
+ userIdMissing: (n) => `After logging in with the test account, ${n} GA4 ${n === 1 ? "hit went" : "hits went"} out without a user ID (uid). GA4 can't tie the visit to the account, so reports by user and journeys across devices miss it.`,
392
+ containerNotRun: (id, kind) => `${id} ${kind === "preload" ? "was downloaded because of a preload hint in the page, but nothing added it as a script, and it" : "was loaded but"} never ran on any page of this run: Google's own registry never held it, so none of its tags fired.`,
393
+ containerHeld: (id, vendor) => `${id} never ran, which is expected while ${vendor} holds it back until the visitor consents. This run never consented, so its tags weren't checked.`,
388
394
  widgetEventsUnshared: (origin, names) => `An embedded widget from ${origin} pushed ${names.map(q).join(", ")} to its own dataLayer, but none of its hits to the site's accounts carried ${names.length === 1 ? "it" : "them"}. The page's tags can't read a widget's dataLayer, so ${names.length === 1 ? "this event reaches" : "these events reach"} only the widget's own tracking.`,
389
395
  iframeMessageUnhandled: (origin, name, party) => `${party === "site" ? "An iframe on the site" : "An embedded widget"} from ${origin} posted ${q(name)} to the page, but the page pushed nothing to its dataLayer and sent no hit in the 3 seconds after, nor ${q(name)} later. If it is a conversion, the page isn't relaying it to its tags.`,
390
396
  consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
@@ -449,6 +455,9 @@ const en = {
449
455
  widget_tracking: "Nothing is wrong with the site's tracking: the widget's own hits were kept out of the plan checks so they can't pass for the site's.",
450
456
  iframe_container: "Tags in the iframe miss what the page pushes, so conversions completed in it can go untracked or be sent without the visitor's consent.",
451
457
  container_failed: "None of the container's tags ran: page views, conversions and consent signals it sends were all lost on that page.",
458
+ user_id_missing: "Logged-in visits can't be tied to their accounts in GA4, so user-level reports and cross-device journeys undercount them.",
459
+ container_not_run: "None of the container's tags fired: page views, conversions and remarketing were all lost wherever it never ran.",
460
+ container_held: "The container's tags weren't checked on this run, since it waited for a consent the run never gave.",
452
461
  widget_events_unshared: "Conversions completed in the widget, such as a booking, a payment or a form, can be missing from the site's analytics and ad accounts.",
453
462
  iframe_message_unhandled: "A conversion completed in the iframe, such as a form sent or a booking made, can go untracked although the iframe told the page about it.",
454
463
  campaign_params: "Campaign parameters survived the landing redirect and reached the hits, so paid clicks are credited to their campaigns.",
@@ -500,6 +509,9 @@ const en = {
500
509
  widget_tracking: "Nothing to fix for the plan. If the widget sets cookies or sends data before consent, the consent findings name it; ask its vendor how to hold it back.",
501
510
  iframe_container: "Push the events and the consent state inside the iframe as well (or pass them in with postMessage), or track the iframe's steps from the page.",
502
511
  container_failed: "Check the container ID and the address in the snippet; for a server container or the Google tag gateway, check that it serves gtm.js and gtag/js.",
512
+ user_id_missing: "Once the visitor is logged in, set user_id on the Google tag (the user_id field of the GA4 Google tag in GTM, or gtag('config', ID, { user_id })) to your own account ID, never an email.",
513
+ container_not_run: "Check that something actually adds the container's script, the snippet or the framework component that loads it (a preload alone runs nothing); look for script errors that stop the page's own code, and for a consent platform that never releases it.",
514
+ container_held: "Run the journey with consent accepted, or the consent scenarios, to check the container's tags.",
503
515
  widget_events_unshared: "If the site needs these events, have the widget send them to the site's accounts (most booking and form widgets take a GA4 or GTM ID), or pass them to the page with postMessage and push them to the page's dataLayer.",
504
516
  iframe_message_unhandled: "Add a message listener on the page that checks event.origin and pushes the event to the dataLayer, so a GTM trigger can fire on it; or have the iframe send the event to the site's accounts itself.",
505
517
  campaign_params: "Nothing to fix. Check the redirects again when the landing pages change.",
@@ -551,6 +563,9 @@ const en = {
551
563
  widget_tracking: "hygiene",
552
564
  iframe_container: "hygiene",
553
565
  container_failed: "money",
566
+ user_id_missing: "hygiene",
567
+ container_not_run: "money",
568
+ container_held: "hygiene",
554
569
  widget_events_unshared: "money",
555
570
  iframe_message_unhandled: "money",
556
571
  campaign_params: "money",
@@ -1113,6 +1128,7 @@ const tr = {
1113
1128
  size: (bytes) => `${(bytes / 1024).toFixed(1).replace(".", ",")} KB`,
1114
1129
  loadTime: (ms) => `${ms} ms'de yüklendi`,
1115
1130
  failed: (status) => (status === "failed" ? "yüklenemedi" : `yüklenemedi (HTTP ${status})`),
1131
+ neverRan: "hiç çalışmadı",
1116
1132
  kinds: {
1117
1133
  html: () => "Sayfanın HTML'indeki bir script etiketi",
1118
1134
  inline: () => "Sayfadaki satır içi kod (snippet)",
@@ -1123,6 +1139,8 @@ const tr = {
1123
1139
  config: (by) => `Bir gtag('config') komutu, ${by} yükledi`,
1124
1140
  send_to: (by) => `config olmadan bir olayın send_to değeri, ${by} yükledi`,
1125
1141
  destination: (by) => `${by} Google etiketinin bir hedefi`,
1142
+ preload: () => "Sayfada yalnızca bir ön yükleme ipucu (<link rel=\"preload\">): indirildi, hiç betik olarak eklenmedi",
1143
+ unseen: () => "Eklendiği görülmedi: sayfadaki bir betik tarayıcının kendi işlevlerini değiştirmiş olabilir; betikleri engelleyen onay platformlarının yaptığı gibi",
1126
1144
  },
1127
1145
  },
1128
1146
  provider: "Sağlayıcı",
@@ -1250,6 +1268,9 @@ const tr = {
1250
1268
  widgetTracking: (origin, platforms) => `${origin} kaynaklı gömülü bir widget kendi izlemesini gönderdi: ${platforms.map((p) => `${label(p.platform)}${p.ids.length ? ` (${p.ids.join(", ")})` : ""} ${p.events.map(q).join(", ")}`).join("; ")}. Bunlar sitenin kendi etiketlerinin kullanmadığı hesaplara gidiyor; bu yüzden plan, kapsam ve tekrar kontrollerinin dışında tutuldu, onay ve kişisel veri kontrolleri ise onları yine içeriyor.`,
1251
1269
  iframeContainer: (id, origin, parent) => `${id}, ${parent} içine gömülü ${origin} kaynaklı bir iframe içinde çalışıyor. iframe'in kendi dataLayer'ı var: sayfada gönderilen olaylar ve onay komutları ona hiç ulaşmaz, bu yüzden kendi olaylarına ve onayına ihtiyacı var.`,
1252
1270
  containerFailed: (id, host, status) => `${host} adresinden gelen ${id} betiği ${status === "failed" ? "yüklenemedi" : `HTTP ${status} ile yanıtlandı`}; bu yüzden o sayfada etiketlerinin hiçbiri çalışmadı.`,
1271
+ userIdMissing: (n) => `Test hesabıyla giriş yapıldıktan sonra ${n} GA4 isteği kullanıcı kimliği (uid) olmadan gönderildi. GA4 ziyareti hesaba bağlayamıyor; bu yüzden kullanıcıya göre raporlar ve cihazlar arası yolculuklar onu kaçırıyor.`,
1272
+ containerNotRun: (id, kind) => `${id} ${kind === "preload" ? "sayfadaki bir ön yükleme ipucu yüzünden indirildi, ama hiçbir şey onu betik olarak eklemedi ve" : "yüklendi ama"} bu çalıştırmanın hiçbir sayfasında çalışmadı: Google'ın kendi kaydı onu hiç tutmadı, bu yüzden etiketlerinin hiçbiri çalışmadı.`,
1273
+ containerHeld: (id, vendor) => `${id} hiç çalışmadı; ${vendor} onu ziyaretçi onay verene kadar beklettiği sürece bu beklenen bir durum. Bu çalıştırmada onay hiç verilmedi, bu yüzden etiketleri kontrol edilmedi.`,
1253
1274
  widgetEventsUnshared: (origin, names) => `${origin} kaynaklı gömülü bir widget kendi dataLayer'ına ${names.map(q).join(", ")} gönderdi, ancak sitenin hesaplarına giden isteklerinin hiçbiri ${names.length === 1 ? "onu" : "onları"} taşımadı. Sayfanın etiketleri bir widget'ın dataLayer'ını okuyamaz; bu yüzden ${names.length === 1 ? "bu olay" : "bu olaylar"} yalnızca widget'ın kendi izlemesine ulaşıyor.`,
1254
1275
  iframeMessageUnhandled: (origin, name, party) => `${origin} kaynaklı ${party === "site" ? "sitedeki bir iframe" : "gömülü bir widget"} sayfaya ${q(name)} gönderdi, ancak sayfa ardından 3 saniye içinde dataLayer'a hiçbir şey göndermedi ve istek yollamadı, sonradan ${q(name)} da göndermedi. Bu bir dönüşümse sayfa onu etiketlerine iletmiyor.`,
1255
1276
  consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
@@ -1313,6 +1334,9 @@ const tr = {
1313
1334
  widget_tracking: "Sitenin izlemesinde bir sorun yok: widget'ın kendi istekleri sitenin isteklerinin yerine geçmesin diye plan kontrollerinin dışında tutuldu.",
1314
1335
  iframe_container: "iframe içindeki etiketler sayfanın gönderdiklerini görmez; içinde tamamlanan dönüşümler izlenmeyebilir ya da ziyaretçinin onayı olmadan gönderilebilir.",
1315
1336
  container_failed: "Container'ın etiketlerinin hiçbiri çalışmadı: gönderdiği sayfa görüntülemeleri, dönüşümler ve onay sinyalleri o sayfada kayboldu.",
1337
+ user_id_missing: "Giriş yapılmış ziyaretler GA4'te hesaplarına bağlanamıyor; kullanıcı düzeyindeki raporlar ve cihazlar arası yolculuklar onları eksik sayıyor.",
1338
+ container_not_run: "Container'ın etiketlerinin hiçbiri çalışmadı: hiç çalışmadığı her yerde sayfa görüntülemeleri, dönüşümler ve yeniden pazarlama kayboldu.",
1339
+ container_held: "Container onay beklediği ve bu çalıştırma onay vermediği için etiketleri kontrol edilmedi.",
1316
1340
  widget_events_unshared: "Widget içinde tamamlanan dönüşümler, örneğin bir rezervasyon, ödeme ya da form, sitenin analitik ve reklam hesaplarında eksik kalabilir.",
1317
1341
  iframe_message_unhandled: "iframe içinde tamamlanan bir dönüşüm (gönderilen bir form, yapılan bir rezervasyon), iframe sayfaya bildirdiği hâlde izlenmeyebilir.",
1318
1342
  campaign_params: "Kampanya parametreleri açılış yönlendirmesinden geçip isteklere ulaştı; ücretli tıklamalar kendi kampanyalarına yazılıyor.",
@@ -1362,6 +1386,9 @@ const tr = {
1362
1386
  widget_tracking: "Plan için düzeltilecek bir şey yok. Widget onaydan önce çerez yazıyor ya da veri gönderiyorsa onay bulguları onu adlandırır; nasıl durdurulacağını sağlayıcısına sorun.",
1363
1387
  iframe_container: "Olayları ve onay durumunu iframe içinde de gönderin (ya da postMessage ile iletin) veya iframe'deki adımları sayfadan izleyin.",
1364
1388
  container_failed: "Snippet'teki container kimliğini ve adresi kontrol edin; sunucu container'ı ya da Google etiket ağ geçidi için gtm.js ve gtag/js sunduğunu kontrol edin.",
1389
+ user_id_missing: "Ziyaretçi giriş yaptıktan sonra Google etiketinde user_id'yi kendi hesap kimliğinize ayarlayın (GTM'deki GA4 Google etiketinin user_id alanı ya da gtag('config', ID, { user_id })); asla bir e-posta kullanmayın.",
1390
+ container_not_run: "Container betiğini gerçekten bir şeyin eklediğini kontrol edin: snippet ya da onu yükleyen çatı bileşeni (yalnızca ön yükleme hiçbir şeyi çalıştırmaz); sayfanın kendi kodunu durduran betik hatalarına ve onu hiç serbest bırakmayan bir onay platformuna bakın.",
1391
+ container_held: "Container'ın etiketlerini kontrol etmek için yolculuğu onay kabul edilmiş olarak ya da onay senaryolarıyla çalıştırın.",
1365
1392
  widget_events_unshared: "Site bu olaylara ihtiyaç duyuyorsa widget'ın onları sitenin hesaplarına göndermesini sağlayın (çoğu rezervasyon ve form widget'ı bir GA4 ya da GTM kimliği kabul eder) ya da onları postMessage ile sayfaya iletip sayfanın dataLayer'ına gönderin.",
1366
1393
  iframe_message_unhandled: "Sayfaya event.origin'i kontrol eden ve olayı dataLayer'a gönderen bir message dinleyicisi ekleyin, böylece bir GTM tetikleyicisi onunla çalışabilir; ya da iframe'in olayı sitenin hesaplarına kendisi göndermesini sağlayın.",
1367
1394
  campaign_params: "Düzeltilecek bir şey yok. Açılış sayfaları değiştiğinde yönlendirmeleri yeniden kontrol edin.",
@@ -1412,6 +1439,9 @@ const tr = {
1412
1439
  widget_tracking: "hygiene",
1413
1440
  iframe_container: "hygiene",
1414
1441
  container_failed: "money",
1442
+ user_id_missing: "hygiene",
1443
+ container_not_run: "money",
1444
+ container_held: "hygiene",
1415
1445
  widget_events_unshared: "money",
1416
1446
  iframe_message_unhandled: "money",
1417
1447
  campaign_params: "money",
package/src/journeys.js CHANGED
@@ -39,6 +39,12 @@ export const JOURNEY_PRESETS = {
39
39
  { platform: "meta", name: "Lead" },
40
40
  ],
41
41
  },
42
+ login: {
43
+ goal: "Accept cookies, open the login or sign-in page, and log in with the test account: fill {{login.email}} (or {{login.username}} where the site asks for a username) and {{login.password}}, then submit. Stop when the site shows you are logged in.",
44
+ events: [
45
+ { platform: "ga4", name: "login" },
46
+ ],
47
+ },
42
48
  search: {
43
49
  goal: "Accept cookies, use the site search to search for a common product or topic, and open the first result.",
44
50
  events: [
@@ -52,7 +58,7 @@ export const JOURNEY_PRESETS = {
52
58
  },
53
59
  };
54
60
 
55
- const ALIASES = { checkout: "purchase", order: "purchase", cart: "add_to_cart", signup: "sign_up", decline: "decline_cookies", reject: "decline_cookies", reject_cookies: "decline_cookies", consent: "decline_cookies", register: "sign_up", registration: "sign_up", contact: "lead", form: "lead" };
61
+ const ALIASES = { signin: "login", sign_in: "login", log_in: "login", checkout: "purchase", order: "purchase", cart: "add_to_cart", signup: "sign_up", decline: "decline_cookies", reject: "decline_cookies", reject_cookies: "decline_cookies", consent: "decline_cookies", register: "sign_up", registration: "sign_up", contact: "lead", form: "lead" };
56
62
 
57
63
  /** The preset for a name like "purchase" or "signup", or null. */
58
64
  export function presetFor(name) {
@@ -168,6 +174,7 @@ const PRESET_BY_EVENT = [
168
174
  ["purchase", ["purchase", "add_payment_info", "add_shipping_info", "begin_checkout"]],
169
175
  ["add_to_cart", ["add_to_cart", "view_cart"]],
170
176
  ["sign_up", ["sign_up"]],
177
+ ["login", ["login"]],
171
178
  ["lead", ["generate_lead"]],
172
179
  ["search", ["search", "view_search_results"]],
173
180
  ];
package/src/login.js ADDED
@@ -0,0 +1,91 @@
1
+ // A test account for journeys that log in. The email (or username) and password come from environment variables on
2
+ // the computer running the tool; the model only ever sees placeholders such as {{login.password}}, the browser fills
3
+ // in the real values (on the audited site's pages only, see AuditSession.perform), and every text the tool records
4
+ // or shows has them turned back into their placeholders.
5
+
6
+ import { existsSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { domainOf } from "./workspace.js";
9
+
10
+ export const LOGIN_ENV = { email: "AITD_LOGIN_EMAIL", username: "AITD_LOGIN_USERNAME", password: "AITD_LOGIN_PASSWORD" };
11
+
12
+ const PLACEHOLDER = /\{\{\s*login\.(email|username|password)\s*\}\}/g;
13
+
14
+ /**
15
+ * The test account the environment sets, as { email, username, password }, or null when none is set. username is
16
+ * AITD_LOGIN_USERNAME, else the email, for sites that ask for a username; email is the same the other way round.
17
+ */
18
+ export function loginFromEnv(env = process.env) {
19
+ const email = env[LOGIN_ENV.email]?.trim() || "";
20
+ const username = env[LOGIN_ENV.username]?.trim() || "";
21
+ const password = env[LOGIN_ENV.password] ?? "";
22
+ if (!email && !username && !password) return null;
23
+ return { email: email || username, username: username || email, password };
24
+ }
25
+
26
+ /** Whether a value asks for the test account: it holds {{login.email}}, {{login.username}} or {{login.password}}. */
27
+ export const hasLoginPlaceholder = (value) => typeof value === "string" && new RegExp(PLACEHOLDER.source).test(value);
28
+
29
+ /**
30
+ * The value with its placeholders replaced by the test account's. Throws an error with code NO_LOGIN, naming the
31
+ * environment variables to set, when a placeholder asks for something the environment doesn't hold.
32
+ */
33
+ export function fillLoginValue(value, login) {
34
+ return String(value).replace(PLACEHOLDER, (_, field) => {
35
+ const v = login?.[field];
36
+ if (!v) {
37
+ const name = field === "password" ? LOGIN_ENV.password : `${LOGIN_ENV.email} (or ${LOGIN_ENV.username})`;
38
+ const err = new Error(`No test account is set up for {{login.${field}}}: set ${name} on the computer running ai-tag-debugger. Without one, stop and report that the journey needs a login.`);
39
+ err.code = "NO_LOGIN";
40
+ throw err;
41
+ }
42
+ return v;
43
+ });
44
+ }
45
+
46
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
47
+
48
+ /**
49
+ * A function that turns the test account's values back into their placeholders wherever they appear in a text, as
50
+ * typed or URL-encoded. Values shorter than 3 characters are left alone, since they would match ordinary text.
51
+ * Identity when there is no test account.
52
+ */
53
+ export function loginScrubber(login) {
54
+ if (!login) return (text) => text;
55
+ const pairs = [];
56
+ const taken = new Set();
57
+ // The password first: an email inside a password must not leave the rest of the password behind. A username that is
58
+ // the email (the default) reads as {{login.email}}.
59
+ for (const [field, value] of [["password", login.password], ["email", login.email], ["username", login.username]]) {
60
+ if (!value || value.length < 3 || taken.has(value)) continue;
61
+ taken.add(value);
62
+ for (const form of new Set([value, encodeURIComponent(value), encodeURIComponent(value).replace(/%20/g, "+")])) pairs.push([form, `{{login.${field}}}`]);
63
+ }
64
+ if (!pairs.length) return (text) => text;
65
+ const re = new RegExp(pairs.map(([v]) => escape(v)).sort((a, b) => b.length - a.length).join("|"), "g");
66
+ const to = new Map(pairs);
67
+ return (text) => (typeof text === "string" && text ? text.replace(re, (m) => to.get(m) ?? m) : text);
68
+ }
69
+
70
+ /** A copy of a value with every string in it scrubbed: for hits' parameters, dataLayer pushes and the like. */
71
+ export function scrubDeep(value, scrub) {
72
+ if (typeof value === "string") return scrub(value);
73
+ if (Array.isArray(value)) return value.map((v) => scrubDeep(v, scrub));
74
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, scrubDeep(v, scrub)]));
75
+ return value;
76
+ }
77
+
78
+ /** Where `aitd login` saves a site's logged-in session by default: aitd/<domain>/session.json, a gitignored folder. */
79
+ export function sessionFileFor(site, dataDir) {
80
+ return path.join(dataDir, domainOf(site), "session.json");
81
+ }
82
+
83
+ /**
84
+ * The session file a run should start from: "saved" for the one `aitd login` saved for the site, else a path.
85
+ * Throws when the file doesn't exist, with the command that saves one.
86
+ */
87
+ export function resolveSessionFile(value, { site, dataDir }) {
88
+ const file = value === "saved" ? sessionFileFor(site, dataDir) : path.resolve(String(value));
89
+ if (!existsSync(file)) throw new Error(`No saved login session at ${file}. Save one with: aitd login --site ${site}`);
90
+ return file;
91
+ }
package/src/mcp.js CHANGED
@@ -33,7 +33,8 @@ import { decodeGcd, DEFAULT_PROVIDERS, googleConsent, isCollectionEndpoint, pars
33
33
  import { hitParams } from "./hit-params.js";
34
34
  import { CAMPAIGN_PARAMS, campaignParamsIn, withCampaignParams } from "./campaign.js";
35
35
  import { cmpFacts } from "./cmp.js";
36
- import { containerRows, loadSources } from "./containers.js";
36
+ import { resolveSessionFile } from "./login.js";
37
+ import { containerRows, containersNotRun, loadSources, unseenContainers } from "./containers.js";
37
38
  import { isEventMessage, isWidgetHit, messageOutcomes, pushedEvent, siteIds, splitRun, widgetSummaries } from "./frames.js";
38
39
  import { consentMatrixMarkdown, consentTable, runConsentScenarios } from "./consent.js";
39
40
  import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
@@ -244,7 +245,7 @@ export function hitDetail(audit, step, index) {
244
245
  function containerJson(l) {
245
246
  return {
246
247
  id: l.id, source: l.kind, ...(l.by ? { by: l.by } : {}),
247
- ...(l.layer !== "dataLayer" ? { data_layer: l.layer } : {}),
248
+ ...(l.layer && l.layer !== "dataLayer" ? { data_layer: l.layer } : {}),
248
249
  ...(l.served === "site" ? { served_from: l.host } : {}),
249
250
  ...(l.environment ? { gtm_environment: l.environment } : {}),
250
251
  ...(l.frame ? { iframe: { origin: l.frame.origin, party: l.frame.party } } : {}),
@@ -253,6 +254,7 @@ function containerJson(l) {
253
254
  ...(l.transferBytes ? { transfer_bytes: l.transferBytes } : {}),
254
255
  ...(l.ms != null ? { load_ms: l.ms } : {}),
255
256
  ...(l.failed ? { failed: l.failed } : {}),
257
+ ...(typeof l.ran === "boolean" ? { ran: l.ran } : {}),
256
258
  ...(l.pages ? { pages: l.pages } : {}),
257
259
  };
258
260
  }
@@ -265,7 +267,7 @@ export function containerLines(runs) {
265
267
  return [
266
268
  "Google containers (how each reached the page):",
267
269
  ...rows.map((r) => {
268
- const extra = [scriptCell(c, r), r.layer !== "dataLayer" && `dataLayer ${r.layer}`, r.served === "site" && `served from ${r.host}`, r.environment && c.environment(r.environment), r.frame && c.iframe(r.frame.origin, r.frame.party), `${r.pages.length} ${r.pages.length === 1 ? "page" : "pages"}`].filter(Boolean);
270
+ const extra = [scriptCell(c, r), r.layer && r.layer !== "dataLayer" && `dataLayer ${r.layer}`, r.served === "site" && `served from ${r.host}`, r.environment && c.environment(r.environment), r.frame && c.iframe(r.frame.origin, r.frame.party), `${r.pages.length} ${r.pages.length === 1 ? "page" : "pages"}`].filter(Boolean);
269
271
  return ` ${r.id} (${c.script[r.script]}): ${c.kinds[r.kind](r.by)}; ${extra.join(", ")}`;
270
272
  }),
271
273
  ];
@@ -440,10 +442,14 @@ export function buildServer(opts) {
440
442
  .optional()
441
443
  .describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")} plus any other known platform the site sends to. When given, requests to the others are still answered locally, but they are only counted, not shown or checked.`),
442
444
  campaign: campaignSchema().describe(`Open the first page the way an ad click does, so finish_audit checks that the campaign parameters survive redirects and reach the hits. ${CAMPAIGN_DOC}`),
445
+ session: z
446
+ .enum(["saved"])
447
+ .optional()
448
+ .describe("Start logged in, from the session saved for this site with `aitd login --site <url>` on the computer running this server. For sites whose login needs a person (a CAPTCHA or a code). Local servers only."),
443
449
  live: z.boolean().optional().describe("Default false: tracking requests are answered locally and never reach the site's accounts. true lets them through to the real analytics and ad accounts, to check them in GA4 Realtime or DebugView, Meta Events Manager or Tag Assistant: the test traffic then shows in the site's reports. Only when the user asks for it."),
444
450
  },
445
451
  },
446
- async ({ site, journey, viewport, allowed_hosts, providers, campaign = true, live = false }) => {
452
+ async ({ site, journey, viewport, allowed_hosts, providers, campaign = true, live = false, session: savedSession }) => {
447
453
  let url;
448
454
  try {
449
455
  url = new URL(site);
@@ -460,7 +466,10 @@ export function buildServer(opts) {
460
466
  let session;
461
467
  try {
462
468
  if (live && !opts.allowLive) return fail("Live audits are off on this server: it was started without --allow-live. Leave live out to audit with tracking requests answered locally.");
463
- session = new AuditSession({ viewport: viewport ?? "desktop", site: url.href, allowedHosts: allowed_hosts, providers, blockPrivate: opts.blockPrivate, blockHits: !live });
469
+ // A saved login lives on the computer running the server; over HTTP, a chat elsewhere must not use it.
470
+ if (savedSession && !opts.allowFiles) return fail("A saved login session can only be used on a local server. Leave session out, or log in with the test account during the journey.");
471
+ const storageState = savedSession ? resolveSessionFile("saved", { site: url.href, dataDir: opts.dataDir ?? DEFAULT_DATA_DIR }) : undefined;
472
+ session = new AuditSession({ viewport: viewport ?? "desktop", site: url.href, allowedHosts: allowed_hosts, providers, blockPrivate: opts.blockPrivate, blockHits: !live, storageState });
464
473
  } catch (err) {
465
474
  return fail(err.message);
466
475
  }
@@ -482,7 +491,8 @@ export function buildServer(opts) {
482
491
  const sent = campaignParamsIn(start);
483
492
  const opened = Object.keys(sent).length ? `Opened like an ad click with ${Object.entries(sent).map(([k, v]) => `${k}=${v}`).join(", ")}; finish_audit checks that they survive redirects and reach the hits.\n` : "";
484
493
  const liveLine = live ? `${LIVE_NOTE}\n` : "";
485
- return text(`audit_id: ${id}\n${liveLine}${opened}${goal}\n${page}\n\n${stepCapture(audit, session.currentStep)}`);
494
+ const accountLine = `${session.login ? "A test account is set up: to log in, fill {{login.email}} (or {{login.username}}) and {{login.password}}, written exactly like that; the browser types the real values." : "No test account is set up on this computer (AITD_LOGIN_EMAIL and AITD_LOGIN_PASSWORD), so a journey that needs a login stops at it."}${savedSession ? " The browser started from the saved login session." : ""}\n`;
495
+ return text(`audit_id: ${id}\n${liveLine}${accountLine}${opened}${goal}\n${page}\n\n${stepCapture(audit, session.currentStep)}`);
486
496
  },
487
497
  );
488
498
 
@@ -575,10 +585,20 @@ export function buildServer(opts) {
575
585
  },
576
586
  async ({ audit_id }) => {
577
587
  const { session } = getAudit(audit_id);
578
- const loads = loadSources({ containerLoads: session.containerLoads ?? [], containerScripts: session.containerScripts, dataLayerLog: session.dataLayerLog });
579
- if (!loads.length) return text("No GTM container or Google tag script has loaded on this audit's pages yet.");
588
+ const run = {
589
+ containerLoads: session.containerLoads ?? [], containerScripts: session.containerScripts, dataLayerLog: session.dataLayerLog,
590
+ containerPreloads: [...(session.containerPreloads?.values() ?? [])],
591
+ containersRunning: [...(session.containersRunning ?? [])].map(([id, step]) => ({ id, step })),
592
+ };
593
+ const loads = loadSources(run);
594
+ const unseen = unseenContainers(run);
595
+ const notRun = containersNotRun(run);
596
+ if (!loads.length && !unseen.length) return text("No GTM container or Google tag script has loaded on this audit's pages yet.");
597
+ const c = messages("en").ui.containers;
580
598
  const lines = loads.map((l) => `Step ${l.step} ("${session.steps[l.step]?.label ?? ""}"), ${l.page ?? "?"}:\n${describeLoad(l)}`);
581
- return text(`${loads.length} container ${loads.length === 1 ? "load" : "loads"}:\n${lines.join("\n")}\n\nAs JSON: ${JSON.stringify(loads.map((l) => ({ step: l.step, page: l.page, ...containerJson(l) })))}`);
599
+ if (unseen.length) lines.push("Asked for without a script being seen added:", ...unseen.map((u) => ` ${u.id} (${c.script[u.script]}), step ${u.step}: ${c.kinds[u.kind]()}`));
600
+ if (notRun.length) lines.push(`Never ran so far (Google's registry never held ${notRun.length === 1 ? "it" : "them"}): ${notRun.map((x) => x.id).join(", ")}. If a consent platform holds GTM back, accept consent to see it run.`);
601
+ return text(`${loads.length} container ${loads.length === 1 ? "load" : "loads"}:\n${lines.join("\n")}\n\nAs JSON: ${JSON.stringify([...loads, ...unseen].map((l) => ({ step: l.step, page: l.page, ...containerJson(l), ...(l.id.startsWith("GTM-") ? { ran: run.containersRunning.some((r) => r.id === l.id) } : {}) })))}`);
582
602
  },
583
603
  );
584
604