@analitikisler/ai-tag-debugger 0.16.0 → 0.18.0
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 +31 -0
- package/README.md +128 -35
- package/README.tr.md +127 -36
- package/package.json +8 -1
- package/src/agent.js +11 -4
- package/src/audit.js +100 -36
- package/src/baseline.js +90 -0
- package/src/browser.js +36 -1
- package/src/changes.js +9 -3
- package/src/checks.js +10 -3
- package/src/ci-output.js +104 -0
- package/src/cli.js +84 -3
- package/src/consent.js +4 -2
- package/src/doctor.js +68 -0
- package/src/gtm.js +209 -3
- package/src/help.js +33 -3
- package/src/history.js +302 -0
- package/src/html/consent.js +152 -0
- package/src/html/css.js +1180 -0
- package/src/html/findings.js +493 -0
- package/src/html/journeys.js +449 -0
- package/src/html/lib.js +272 -0
- package/src/html/scope.js +350 -0
- package/src/html/script.js +300 -0
- package/src/html/shell.js +191 -0
- package/src/html/summary.js +288 -0
- package/src/i18n.js +1107 -141
- package/src/mcp.js +22 -4
- package/src/metrics.js +482 -0
- package/src/notify.js +21 -7
- package/src/plan-html.js +71 -25
- package/src/plan.js +6 -1
- package/src/pool.js +34 -0
- package/src/providers.js +90 -12
- package/src/replay.js +28 -1
- package/src/report-shared.js +123 -0
- package/src/report.js +289 -1018
- package/src/schema.js +190 -0
- package/src/sheet.js +11 -5
- package/src/whitelabel.js +35 -6
package/src/cli.js
CHANGED
|
@@ -5,7 +5,9 @@ import { parseArgs } from "node:util";
|
|
|
5
5
|
import { loadConfig, runAudit } from "./audit.js";
|
|
6
6
|
import { messages } from "./i18n.js";
|
|
7
7
|
import { helpText } from "./help.js";
|
|
8
|
+
import { validatePlan } from "./schema.js";
|
|
8
9
|
import { replaySavedSite, writeSavedSteps } from "./replay.js";
|
|
10
|
+
import { readBaseline, writeBaseline } from "./baseline.js";
|
|
9
11
|
import { savePlan } from "./plan.js";
|
|
10
12
|
import { DEFAULT_DATA_DIR } from "./workspace.js";
|
|
11
13
|
import { commandText, intentToArgs, isSentence, needsWhatToTest, translate } from "./ask.js";
|
|
@@ -25,6 +27,7 @@ async function main(args = process.argv.slice(2)) {
|
|
|
25
27
|
out: { type: "string" },
|
|
26
28
|
journey: { type: "string", multiple: true },
|
|
27
29
|
viewport: { type: "string", multiple: true },
|
|
30
|
+
concurrency: { type: "string" },
|
|
28
31
|
platforms: { type: "string", multiple: true },
|
|
29
32
|
providers: { type: "string", multiple: true },
|
|
30
33
|
"allow-host": { type: "string", multiple: true },
|
|
@@ -36,6 +39,8 @@ async function main(args = process.argv.slice(2)) {
|
|
|
36
39
|
"base-url": { type: "string" },
|
|
37
40
|
lang: { type: "string" },
|
|
38
41
|
"fail-on": { type: "string", default: "broken" },
|
|
42
|
+
baseline: { type: "string" },
|
|
43
|
+
"update-baseline": { type: "boolean", default: false },
|
|
39
44
|
headed: { type: "boolean", default: false },
|
|
40
45
|
"no-branding": { type: "boolean", default: false },
|
|
41
46
|
"keep-bodies": { type: "boolean", default: false },
|
|
@@ -53,6 +58,8 @@ async function main(args = process.argv.slice(2)) {
|
|
|
53
58
|
from: { type: "string" },
|
|
54
59
|
topic: { type: "string" },
|
|
55
60
|
format: { type: "string" },
|
|
61
|
+
limit: { type: "string" },
|
|
62
|
+
json: { type: "boolean", default: false },
|
|
56
63
|
force: { type: "boolean", default: false },
|
|
57
64
|
yes: { type: "boolean", short: "y", default: false },
|
|
58
65
|
help: { type: "boolean", short: "h", default: false },
|
|
@@ -131,6 +138,18 @@ async function main(args = process.argv.slice(2)) {
|
|
|
131
138
|
console.log(`✓ Saved plan ${id}: ${plan.events.length} events (${custom} custom) and ${plan.user_properties.length} user properties in ${rel.startsWith("..") ? dir : rel}`);
|
|
132
139
|
return;
|
|
133
140
|
}
|
|
141
|
+
if (command === "history" && !values.help) {
|
|
142
|
+
await showHistory(values);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (command === "doctor" && !values.help) {
|
|
146
|
+
const { doctorChecks } = await import("./doctor.js");
|
|
147
|
+
const checks = await doctorChecks({ site: values.site, dataDir: values["data-dir"] });
|
|
148
|
+
for (const c of checks) console.log(`${c.ok ? "✓" : "✗"} ${c.text}`);
|
|
149
|
+
// Only a check an audit cannot run without fails the command; a missing AI key is advice.
|
|
150
|
+
if (checks.some((c) => c.hard && !c.ok)) process.exitCode = 1;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
134
153
|
if (values.help || command !== "run" || (!values.config && !values.site && !values.against)) {
|
|
135
154
|
console.log(helpText(command));
|
|
136
155
|
process.exit(values.help ? 0 : 2);
|
|
@@ -161,6 +180,8 @@ async function main(args = process.argv.slice(2)) {
|
|
|
161
180
|
against = await resolvePlan(values.against, { dataDir: values["data-dir"] ?? DEFAULT_DATA_DIR, site: loaded.config.site });
|
|
162
181
|
const plan = await readJson(against.file);
|
|
163
182
|
if (!Array.isArray(plan?.events)) throw new Error(`${against.file} is not a plan: it needs an "events" list.`);
|
|
183
|
+
// loadConfig checked the config's own plan; the one --against names replaces it and gets the same check.
|
|
184
|
+
for (const w of validatePlan(plan, { file: against.file }).warnings) console.log(w);
|
|
164
185
|
if (!loaded.config.site) {
|
|
165
186
|
if (!plan.site) throw new Error(`${against.file} has no "site". Add --site <url>.`);
|
|
166
187
|
loaded.config = { ...loaded.config, site: plan.site };
|
|
@@ -178,6 +199,17 @@ async function main(args = process.argv.slice(2)) {
|
|
|
178
199
|
if (maxSteps !== undefined && !(Number.isInteger(maxSteps) && maxSteps > 0)) throw new Error("--max-steps must be a positive whole number.");
|
|
179
200
|
// run --out is a hidden alias kept for one minor version; --data-dir replaces it.
|
|
180
201
|
if (values.out) console.log("Note: run --out will be removed in a later version. Runs are saved in aitd/<domain>/runs/<id>/; use --data-dir <dir> to keep them elsewhere.");
|
|
202
|
+
const baselineFile = values.baseline ?? loaded.baselineFile ?? null;
|
|
203
|
+
if (values["update-baseline"] && !baselineFile) throw new Error('--update-baseline needs --baseline <file> or "baseline" in the config.');
|
|
204
|
+
// A baseline that doesn't exist yet is fine when this run writes it.
|
|
205
|
+
const baseline = baselineFile
|
|
206
|
+
? await readBaseline(baselineFile).catch((err) => {
|
|
207
|
+
if (values["update-baseline"] && err.code === "ENOENT") return null;
|
|
208
|
+
throw err;
|
|
209
|
+
})
|
|
210
|
+
: null;
|
|
211
|
+
// A baseline written for another site matches nothing of this one, so say so, but let the run go on.
|
|
212
|
+
if (baseline?.site && baseline.site !== loaded.config.site) console.log(`Warning: the baseline ${baselineFile} was written for ${baseline.site}, not ${loaded.config.site}. Its entries may not match this site.`);
|
|
181
213
|
const result = await runAudit({
|
|
182
214
|
...loaded,
|
|
183
215
|
journeys: values.journey,
|
|
@@ -185,6 +217,7 @@ async function main(args = process.argv.slice(2)) {
|
|
|
185
217
|
events: (values.events ?? []).flatMap((e) => e.split(",")).map((e) => e.trim()).filter(Boolean),
|
|
186
218
|
maxSteps,
|
|
187
219
|
viewports: values.viewport,
|
|
220
|
+
concurrency: values.concurrency,
|
|
188
221
|
providers: platforms.length ? platforms.flatMap((p) => p.split(",")) : undefined,
|
|
189
222
|
scripted: values.scripted,
|
|
190
223
|
ai: !values["no-ai"],
|
|
@@ -198,12 +231,27 @@ async function main(args = process.argv.slice(2)) {
|
|
|
198
231
|
dataDir: values["data-dir"],
|
|
199
232
|
saveSite: values["save-site"],
|
|
200
233
|
against,
|
|
234
|
+
failOn,
|
|
235
|
+
baseline,
|
|
201
236
|
log: (msg) => console.log(msg),
|
|
202
237
|
});
|
|
203
238
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
console.log(
|
|
239
|
+
const m = messages(result.lang);
|
|
240
|
+
console.log(`\n${m.ui.counts(result.counts)}`);
|
|
241
|
+
if (result.baseline) console.log(`${result.baseline.accepted} accepted (baseline ${baselineFile}), ${result.baseline.partial ? `${result.baseline.partial} ${result.baseline.partial === 1 ? "problem" : "problems"} accepted in some places only (still counted), ` : ""}${result.baseline.stale.length} stale baseline ${result.baseline.stale.length === 1 ? "entry" : "entries"}`);
|
|
242
|
+
if (result.verdict) console.log(m.ui.cli.verdict(result.verdict.statusText, result.verdict.headline, result.verdict.score));
|
|
243
|
+
// The trend needs two saved runs; with one there is nothing to say yet.
|
|
244
|
+
if (result.trend?.runs >= 2) {
|
|
245
|
+
const { historySummaryLines } = await import("./history.js");
|
|
246
|
+
for (const line of historySummaryLines(result.trend, result.lang, result.stability ?? []).slice(0, 3)) console.log(line);
|
|
247
|
+
}
|
|
248
|
+
const shown = (p) => (path.relative(process.cwd(), p).startsWith("..") ? p : path.relative(process.cwd(), p));
|
|
249
|
+
console.log(`Report: ${shown(result.reportPath)}`);
|
|
250
|
+
console.log(`Summary: ${shown(result.summaryPath)} (junit.xml next to it)`);
|
|
251
|
+
if (values["update-baseline"]) {
|
|
252
|
+
const n = await writeBaseline(baselineFile, { site: loaded.config.site, findings: result.findings });
|
|
253
|
+
console.log(`Baseline ${baselineFile} now holds ${n} ${n === 1 ? "finding" : "findings"}. Later runs with --baseline fail only on new ones.`);
|
|
254
|
+
}
|
|
207
255
|
|
|
208
256
|
if (!against && !values.out) await offerLearnedPlan(result, loaded.config.site, values);
|
|
209
257
|
|
|
@@ -224,6 +272,39 @@ async function main(args = process.argv.slice(2)) {
|
|
|
224
272
|
}
|
|
225
273
|
}
|
|
226
274
|
|
|
275
|
+
// aitd history --site <url>: the trend of the site's saved runs as a small table, the text sparkline and the summary lines.
|
|
276
|
+
async function showHistory(values) {
|
|
277
|
+
if (!values.site) throw new Error("history needs --site <url>: aitd history --site https://www.example.com");
|
|
278
|
+
const limit = values.limit ? Number(values.limit) : 30;
|
|
279
|
+
if (!(Number.isInteger(limit) && limit > 0)) throw new Error("--limit must be a positive whole number.");
|
|
280
|
+
const lang = values.lang ?? "en";
|
|
281
|
+
const m = messages(lang);
|
|
282
|
+
const { domainOf } = await import("./workspace.js");
|
|
283
|
+
domainOf(values.site);
|
|
284
|
+
const { findingTimeline, historySummaryLines, journeyStability, loadHistory, sparkline, trend } = await import("./history.js");
|
|
285
|
+
const history = await loadHistory(values["data-dir"] ?? DEFAULT_DATA_DIR, values.site, { limit });
|
|
286
|
+
const t = trend(history);
|
|
287
|
+
const stability = journeyStability(history);
|
|
288
|
+
if (values.json) {
|
|
289
|
+
console.log(JSON.stringify({ site: values.site, runs: history.length, trend: t, timeline: findingTimeline(history), stability }, null, 2));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (!history.length) {
|
|
293
|
+
console.log(m.ui.history.noRuns);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const cols = [m.ui.cli.runCol, m.severity.broken, m.severity.risk, m.severity.warning, m.ui.cli.scoreCol, m.ui.cli.completeCol];
|
|
297
|
+
const rows = t.series.map((p) => [p.id, p.broken, p.risk, p.warning, p.score, p.complete ? m.ui.md.yes : m.ui.md.no].map(String));
|
|
298
|
+
const widths = cols.map((c, i) => Math.max(c.length, ...rows.map((r) => r[i].length)));
|
|
299
|
+
const line = (cells) => cells.map((c, i) => (i ? c.padStart(widths[i]) : c.padEnd(widths[i]))).join(" ");
|
|
300
|
+
console.log(line(cols));
|
|
301
|
+
for (const r of rows) console.log(line(r));
|
|
302
|
+
console.log("");
|
|
303
|
+
const s = m.ui.history;
|
|
304
|
+
if (t.runs >= 2) console.log(s.sparkline(sparkline(t), s.direction[t.direction], t.runs, t.first.slice(0, 10), t.last.slice(0, 10)));
|
|
305
|
+
for (const l of historySummaryLines(t, lang, stability)) console.log(l);
|
|
306
|
+
}
|
|
307
|
+
|
|
227
308
|
// The first run of a site with no plan: offer what it already tracks as its plan.
|
|
228
309
|
async function offerLearnedPlan(result, site, values) {
|
|
229
310
|
const dataDir = values["data-dir"] ?? DEFAULT_DATA_DIR;
|
package/src/consent.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// (accept all, reject all, analytics only), to show which tags each choice lets through.
|
|
3
3
|
// Runs on its own with every audit when the page shows a consent banner.
|
|
4
4
|
import { AuditSession, CMP_BANNER_SELECTORS } from "./browser.js";
|
|
5
|
+
import { runPool } from "./pool.js";
|
|
5
6
|
|
|
6
7
|
export const CONSENT_STATES = ["accept", "reject", "analytics"];
|
|
7
8
|
// Tags that are advertising, so an "analytics only" choice should keep them quiet.
|
|
@@ -110,6 +111,7 @@ async function runState(config, state, opts) {
|
|
|
110
111
|
/**
|
|
111
112
|
* Runs every consent scenario on the site's start page. Returns null when the page has no consent
|
|
112
113
|
* banner the tool can answer (the first choice found nothing to click), so the report leaves the section out.
|
|
114
|
+
* opts.concurrency runs the other choices side by side, each in its own browser; default 1.
|
|
113
115
|
* @returns {Promise<null | { cmp: string, before: object, states: Record<string, { via: string|null, after?: object, error?: string }> }>}
|
|
114
116
|
*/
|
|
115
117
|
export async function runConsentScenarios(config, opts = {}) {
|
|
@@ -117,8 +119,8 @@ export async function runConsentScenarios(config, opts = {}) {
|
|
|
117
119
|
const first = await runState(config, "accept", opts);
|
|
118
120
|
if (!first.via) return null;
|
|
119
121
|
log(`\n▶ consent scenarios (${first.via})`);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
+
// The first choice shows whether there is a banner to answer at all; the others don't depend on each other.
|
|
123
|
+
const results = [first, ...(await runPool(CONSENT_STATES.slice(1), opts.concurrency ?? 1, (state) => runState(config, state, opts)))];
|
|
122
124
|
const states = {};
|
|
123
125
|
for (const r of results) {
|
|
124
126
|
states[r.state] = r.error ? { via: null, error: r.error } : { via: r.via, ...(r.after ? { after: r.after } : {}) };
|
package/src/doctor.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// `aitd doctor`: one line per thing an audit needs, so a broken setup is found before a run starts.
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { access, constants, stat } from "node:fs/promises";
|
|
4
|
+
import { lookup } from "node:dns/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { aiStatus } from "./providers.js";
|
|
7
|
+
import { DEFAULT_DATA_DIR } from "./workspace.js";
|
|
8
|
+
|
|
9
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
10
|
+
|
|
11
|
+
// The same as minNodeMajor() in scripts/setup.js, which the published package doesn't ship.
|
|
12
|
+
const minNodeMajor = (range) => Number(/(\d+)/.exec(range ?? "")?.[1] ?? 0);
|
|
13
|
+
|
|
14
|
+
/** The nearest existing directory at or above dir, since the data dir is only created by the first run. */
|
|
15
|
+
function existingAncestor(dir, exists) {
|
|
16
|
+
let d = dir;
|
|
17
|
+
while (!exists(d) && path.dirname(d) !== d) d = path.dirname(d);
|
|
18
|
+
return d;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Runs the checks and returns [{ ok, hard, text }]. A failed hard check means audits can't run;
|
|
23
|
+
* a soft one is advice. Everything that touches the machine can be injected, so tests need no
|
|
24
|
+
* browser or network.
|
|
25
|
+
*/
|
|
26
|
+
export async function doctorChecks({ site, dataDir = DEFAULT_DATA_DIR, nodeVersion = process.version, chromiumPath, env = process.env, exists = existsSync, writable = writableDir, resolve = lookup } = {}) {
|
|
27
|
+
const checks = [];
|
|
28
|
+
const range = pkg.engines?.node;
|
|
29
|
+
const nodeOk = Number(/^v?(\d+)/.exec(nodeVersion)?.[1] ?? 0) >= minNodeMajor(range);
|
|
30
|
+
checks.push({ ok: nodeOk, hard: true, text: nodeOk ? `Node ${nodeVersion} (needs ${range})` : `Node ${nodeVersion} is too old: this needs Node ${range}. Install a newer Node, for example with nvm: nvm install 22` });
|
|
31
|
+
|
|
32
|
+
const pw = pkg.dependencies.playwright;
|
|
33
|
+
chromiumPath ??= (await import("playwright")).chromium.executablePath();
|
|
34
|
+
const chromiumOk = Boolean(chromiumPath && exists(chromiumPath));
|
|
35
|
+
checks.push({ ok: chromiumOk, hard: true, text: chromiumOk ? `Playwright ${pw} with Chromium at ${chromiumPath}` : `Playwright ${pw} has no Chromium installed. Run: npx -y playwright@${pw} install chromium` });
|
|
36
|
+
|
|
37
|
+
// The same line `aitd` with no arguments prints. No key is not a failure: --no-ai and replay work without one.
|
|
38
|
+
const ai = aiStatus({}, env);
|
|
39
|
+
const aiOk = !ai.startsWith("AI: no key set");
|
|
40
|
+
checks.push({ ok: aiOk, hard: false, text: aiOk ? ai : `${ai}. Optional: --no-ai and replay work without a key` });
|
|
41
|
+
|
|
42
|
+
const dir = path.resolve(dataDir);
|
|
43
|
+
const probe = existingAncestor(dir, exists);
|
|
44
|
+
const dirOk = await writable(probe).then(() => true, () => false);
|
|
45
|
+
checks.push({ ok: dirOk, hard: true, text: dirOk ? `Data folder ${dir} is writable${probe === dir ? "" : " (it will be created)"}` : `Data folder ${dir} is not writable (checked ${probe}). Pick another with --data-dir <dir>` });
|
|
46
|
+
|
|
47
|
+
if (site !== undefined) checks.push(await siteCheck(site, resolve));
|
|
48
|
+
return checks;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Rejects unless d is a directory the process may write to: a data dir under a file can never be created, whatever the permissions say. */
|
|
52
|
+
async function writableDir(d) {
|
|
53
|
+
if (!(await stat(d)).isDirectory()) throw new Error(`${d} is not a directory`);
|
|
54
|
+
await access(d, constants.W_OK);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The site is an http(s) address whose host resolves. Nothing is fetched. */
|
|
58
|
+
async function siteCheck(site, resolve) {
|
|
59
|
+
let u;
|
|
60
|
+
try {
|
|
61
|
+
u = new URL(site);
|
|
62
|
+
} catch {
|
|
63
|
+
return { ok: false, hard: true, text: `"${site}" is not a full URL. Use the form https://www.example.com` };
|
|
64
|
+
}
|
|
65
|
+
if (!["http:", "https:"].includes(u.protocol)) return { ok: false, hard: true, text: `${site} is not an http or https address, so the browser can't open it` };
|
|
66
|
+
const found = await resolve(u.hostname).then(() => true, () => false);
|
|
67
|
+
return { ok: found, hard: true, text: found ? `${u.hostname} resolves` : `${u.hostname} does not resolve. Check the address, or your network and DNS` };
|
|
68
|
+
}
|
package/src/gtm.js
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
// Reads a GTM container export (Admin → Export container) to name the tag and trigger
|
|
2
2
|
// behind a hit. Matching is deterministic: no API call and no AI model.
|
|
3
3
|
|
|
4
|
-
import { canonicalEvent } from "./checks.js";
|
|
4
|
+
import { canonicalEvent, isConsentCommand } from "./checks.js";
|
|
5
|
+
import { messages } from "./i18n.js";
|
|
5
6
|
|
|
6
7
|
// GTM's built-in "All Pages" trigger has no entry in the export's trigger list.
|
|
7
8
|
const BUILT_IN_TRIGGERS = { 2147479553: { name: "All Pages", type: "PAGEVIEW" } };
|
|
9
|
+
// The dataLayer events GTM raises for page triggers; the init script records them like any push.
|
|
10
|
+
const PAGE_EVENTS = { PAGEVIEW: "gtm.js", DOM_READY: "gtm.dom", WINDOW_LOADED: "gtm.load" };
|
|
11
|
+
// Triggers GTM evaluates from what happens in the page. Their events reach the dataLayer only
|
|
12
|
+
// while such a trigger is listening, so the dataLayer can confirm one but not rule it out.
|
|
13
|
+
const PAGE_ACTION_EVENTS = {
|
|
14
|
+
CLICK: "gtm.click", LINK_CLICK: "gtm.linkClick", FORM_SUBMIT: "gtm.formSubmit", FORM_SUBMISSION: "gtm.formSubmit", HISTORY_CHANGE: "gtm.historyChange",
|
|
15
|
+
ELEMENT_VISIBILITY: "gtm.elementVisibility", TIMER: "gtm.timer", SCROLL_DEPTH: "gtm.scrollDepth", JS_ERROR: "gtm.pageError", YOU_TUBE_VIDEO: "gtm.video",
|
|
16
|
+
};
|
|
17
|
+
// Older exports write the custom event filter's operators in short form.
|
|
18
|
+
const SHORT_OPS = { _eq: "EQUALS", _cn: "CONTAINS", _sw: "STARTS_WITH", _ew: "ENDS_WITH", _re: "MATCH_REGEX", _lt: "LESS", _le: "LESS_OR_EQUALS", _gt: "GREATER", _ge: "GREATER_OR_EQUALS", _css: "CSS_SELECTOR", _um: "URL_MATCHES" };
|
|
19
|
+
const PAGE_VARIABLES = { "Page URL": "URL", "Page Path": "PATH", "Page Hostname": "HOST" };
|
|
8
20
|
const EVENT_PARAMS = ["eventName", "event_name", "event", "eventType", "standardEventName", "customEventName", "eventTypeStandard", "eventTypeCustom"];
|
|
9
21
|
|
|
10
22
|
/** A tag parameter list as a flat { key: value } map; nested lists and maps are kept as JSON text. */
|
|
@@ -79,14 +91,208 @@ export function tagsForEvent(tags, platform, event) {
|
|
|
79
91
|
return tags.filter((t) => t.platform === platform && t.events.some((e) => canonicalEvent(e) === want));
|
|
80
92
|
}
|
|
81
93
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
94
|
+
/** The event name and data a push adds to GTM's data model: an object as it is, a gtag() call as its event and eventModel. */
|
|
95
|
+
function pushModel(value) {
|
|
96
|
+
if (Array.isArray(value)) return value[0] === "event" && typeof value[1] === "string" ? { event: value[1], data: { event: value[1], eventModel: value[2] ?? {} } } : null;
|
|
97
|
+
if (value && typeof value === "object") return { event: typeof value.event === "string" ? value.event : undefined, data: value };
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Merges a push into the running data layer the way GTM's version 2 model does: objects and arrays merge key by key, so a value persists until it is overwritten or cleared with null. */
|
|
102
|
+
function merge(state, push) {
|
|
103
|
+
for (const [k, v] of Object.entries(push)) {
|
|
104
|
+
const same = v && typeof v === "object" && state[k] && typeof state[k] === "object" && Array.isArray(v) === Array.isArray(state[k]);
|
|
105
|
+
if (same) merge(state[k], v);
|
|
106
|
+
else state[k] = v && typeof v === "object" ? structuredClone(v) : v;
|
|
107
|
+
}
|
|
108
|
+
return state;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A value from the data layer by dot path, as a Data Layer Variable reads it; version 1 reads the key as it is. */
|
|
112
|
+
function readPath(state, name, version) {
|
|
113
|
+
if (String(version) === "1") return state[name];
|
|
114
|
+
return String(name).split(".").reduce((v, k) => (v && typeof v === "object" ? v[k] : undefined), state);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A part of the page address at the push, or undefined when it is not a valid address. */
|
|
118
|
+
function urlPart(url, component) {
|
|
119
|
+
try {
|
|
120
|
+
const u = new URL(url);
|
|
121
|
+
return { URL: u.href, PATH: u.pathname, HOST: u.hostname, PROTOCOL: u.protocol.replace(":", ""), PORT: u.port, FRAGMENT: u.hash.replace("#", ""), QUERY: u.search.replace("?", "") }[component];
|
|
122
|
+
} catch {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The value of one GTM variable at a push: { value, field } when the dataLayer can give it, where `field` is how the
|
|
129
|
+
* report names it (a Data Layer Variable by its path), or { unknown: name } when only the page could tell.
|
|
130
|
+
*/
|
|
131
|
+
function resolveVariable(name, ctx, variables) {
|
|
132
|
+
if (name === "_event" || name === "Event") return { value: ctx.event, field: "event" };
|
|
133
|
+
if (PAGE_VARIABLES[name]) return ctx.url ? { value: urlPart(ctx.url, PAGE_VARIABLES[name]), field: name } : { unknown: name };
|
|
134
|
+
const v = variables.find((x) => x.name === name);
|
|
135
|
+
const p = params(v?.parameter);
|
|
136
|
+
if (v?.type === "v" && p.name) return { value: readPath(ctx.state, p.name, p.dataLayerVersion ?? "2"), field: p.name };
|
|
137
|
+
if (v?.type === "c") return { value: p.value, field: name };
|
|
138
|
+
if (v?.type === "u" && ctx.url) return { value: urlPart(ctx.url, p.component ?? "URL"), field: name };
|
|
139
|
+
return { unknown: name };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A condition argument with its {{variables}} filled in; a lone reference keeps the value's type. */
|
|
143
|
+
function resolveArg(text, ctx, variables) {
|
|
144
|
+
const whole = /^\{\{(.+)\}\}$/.exec(String(text ?? ""));
|
|
145
|
+
if (whole) return resolveVariable(whole[1], ctx, variables);
|
|
146
|
+
let unknown = null;
|
|
147
|
+
const value = String(text ?? "").replace(/\{\{(.+?)\}\}/g, (_, n) => {
|
|
148
|
+
const r = resolveVariable(n, ctx, variables);
|
|
149
|
+
if (r.unknown) unknown = r.unknown;
|
|
150
|
+
return r.value ?? "";
|
|
151
|
+
});
|
|
152
|
+
return unknown ? { unknown } : { value, field: String(text ?? "") };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** GTM's comparison for one condition type: true, false, or "unknown" for the ones only the page can answer. */
|
|
156
|
+
function compare(type, a, b, ignoreCase) {
|
|
157
|
+
const s = (v) => (ignoreCase ? String(v ?? "").toLowerCase() : String(v ?? ""));
|
|
158
|
+
const n = (v) => (v === "" || v === null || v === undefined || typeof v === "object" ? NaN : Number(v));
|
|
159
|
+
switch (type) {
|
|
160
|
+
case "EQUALS": return s(a) === s(b);
|
|
161
|
+
case "CONTAINS": return s(a).includes(s(b));
|
|
162
|
+
case "STARTS_WITH": return s(a).startsWith(s(b));
|
|
163
|
+
case "ENDS_WITH": return s(a).endsWith(s(b));
|
|
164
|
+
case "MATCH_REGEX":
|
|
165
|
+
try {
|
|
166
|
+
return new RegExp(String(b ?? ""), ignoreCase ? "i" : "").test(String(a ?? ""));
|
|
167
|
+
} catch {
|
|
168
|
+
return "unknown";
|
|
169
|
+
}
|
|
170
|
+
case "LESS": return n(a) < n(b);
|
|
171
|
+
case "LESS_OR_EQUALS": return n(a) <= n(b);
|
|
172
|
+
case "GREATER": return n(a) > n(b);
|
|
173
|
+
case "GREATER_OR_EQUALS": return n(a) >= n(b);
|
|
174
|
+
default: return "unknown";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A value as the reason shows it: numbers bare, text quoted, objects as JSON. */
|
|
179
|
+
const show = (v, m) => (v === undefined || v === null ? m.nothing : typeof v === "object" ? JSON.stringify(v) : typeof v === "string" && !/^-?\d+(\.\d+)?$/.test(v) ? `"${v}"` : String(v));
|
|
180
|
+
|
|
181
|
+
/** One trigger condition at a push: { ok: true | false | "unknown", reason }, with the reason the report shows when it is not met. */
|
|
182
|
+
function condition(c, ctx, variables, trigger, m) {
|
|
183
|
+
const type = SHORT_OPS[c.type] ?? c.type;
|
|
184
|
+
const p = params(c.parameter);
|
|
185
|
+
if (!m.ops[type]) return { ok: "unknown", reason: m.conditionType(trigger, type) };
|
|
186
|
+
const a = resolveArg(p.arg0, ctx, variables);
|
|
187
|
+
const b = resolveArg(p.arg1, ctx, variables);
|
|
188
|
+
const unknown = a.unknown ?? b.unknown;
|
|
189
|
+
if (unknown) return { ok: "unknown", reason: m.conditionUnknown(trigger, unknown) };
|
|
190
|
+
const negate = String(p.negate) === "true";
|
|
191
|
+
let ok = compare(type, a.value, b.value, String(p.ignore_case) === "true");
|
|
192
|
+
if (ok === "unknown") return { ok, reason: m.conditionType(trigger, type) };
|
|
193
|
+
if (negate) ok = !ok;
|
|
194
|
+
return { ok, reason: ok ? "" : m.condition(trigger, a.field, m.ops[type], show(b.value, m), show(a.value, m), negate) };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Whether a GTM trigger fired in a journey, judged from the recorded dataLayer pushes alone.
|
|
199
|
+
* @param {any} trigger A trigger from the container export.
|
|
200
|
+
* @param {{ value: any, url?: string }[]} pushes The run's dataLayerLog entries, in order.
|
|
201
|
+
* @param {any[]} [variables] The export's variable list, for conditions that read Data Layer Variables.
|
|
202
|
+
* @returns {{ fired: true | false | "unknown", reason: string, matched?: number }} `matched` is the index of the push it fired on.
|
|
203
|
+
*/
|
|
204
|
+
export function evaluateTrigger(trigger, pushes, variables = [], lang = "en") {
|
|
205
|
+
const m = messages(lang).gtm;
|
|
206
|
+
const name = trigger?.name ?? "?";
|
|
207
|
+
const type = trigger?.type ?? "unknown";
|
|
208
|
+
const custom = type === "CUSTOM_EVENT";
|
|
209
|
+
const event = PAGE_EVENTS[type] ?? PAGE_ACTION_EVENTS[type];
|
|
210
|
+
if (!custom && !event) return { fired: "unknown", reason: m.unknownType(name, type) };
|
|
211
|
+
const state = {};
|
|
212
|
+
let seen = false;
|
|
213
|
+
let failed = null;
|
|
214
|
+
let unknown = null;
|
|
215
|
+
for (let i = 0; i < pushes.length; i++) {
|
|
216
|
+
const model = pushModel(pushes[i]?.value);
|
|
217
|
+
if (!model) continue;
|
|
218
|
+
merge(state, model.data);
|
|
219
|
+
const ctx = { event: model.event, state, url: pushes[i].url };
|
|
220
|
+
// The event conditions decide whether the push concerns this trigger at all; the filter decides whether it fires.
|
|
221
|
+
const events = custom ? (trigger.customEventFilter ?? []).map((c) => condition(c, ctx, variables, name, m)) : [{ ok: model.event === event }];
|
|
222
|
+
if (events.some((r) => r.ok === false)) continue;
|
|
223
|
+
const vague = events.find((r) => r.ok === "unknown");
|
|
224
|
+
if (vague) {
|
|
225
|
+
unknown ??= vague;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
seen = true;
|
|
229
|
+
const result = (trigger.filter ?? []).map((c) => condition(c, ctx, variables, name, m)).find((r) => r.ok !== true);
|
|
230
|
+
if (!result) return { fired: true, reason: m.triggerFired(name, model.event ?? event), matched: i };
|
|
231
|
+
if (result.ok === "unknown") unknown ??= result;
|
|
232
|
+
else failed ??= result;
|
|
233
|
+
}
|
|
234
|
+
if (unknown) return { fired: "unknown", reason: unknown.reason };
|
|
235
|
+
if (failed) return { fired: false, reason: failed.reason };
|
|
236
|
+
if (PAGE_ACTION_EVENTS[type] && !seen) return { fired: "unknown", reason: m.pageTrigger(name, type.toLowerCase().replace(/_/g, " ")) };
|
|
237
|
+
if (PAGE_EVENTS[type] && !seen) return { fired: false, reason: m.noPageEvent(name, event) };
|
|
238
|
+
return { fired: false, reason: m.noPush(triggerEvents(trigger).join(", ") || event || name) };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The Consent Mode state after the pushes up to and including `upTo`; a type nobody set is not denied. */
|
|
242
|
+
function consentAt(pushes, upTo) {
|
|
243
|
+
const state = {};
|
|
244
|
+
for (const e of pushes.slice(0, upTo + 1)) if (isConsentCommand(e, "default") || isConsentCommand(e, "update")) Object.assign(state, e.value[2] ?? {});
|
|
245
|
+
return state;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Why one tag did or did not fire: its pause state, its triggers, its exceptions and its consent needs, in that order. */
|
|
249
|
+
function evaluateTag(tag, byId, pushes, variables, m, lang) {
|
|
250
|
+
if (tag.paused) return { fired: false, why: m.paused };
|
|
251
|
+
if (!tag.triggers.length) return { fired: false, why: m.noTrigger };
|
|
252
|
+
const raw = (t) => byId.get(String(t.id)) ?? (BUILT_IN_TRIGGERS[t.id] ? { ...BUILT_IN_TRIGGERS[t.id], triggerId: t.id } : null);
|
|
253
|
+
const evaluate = (t) => (raw(t) ? evaluateTrigger(raw(t), pushes, variables, lang) : { fired: "unknown", reason: m.missingTrigger(t.name) });
|
|
254
|
+
const results = tag.triggers.map(evaluate);
|
|
255
|
+
const hit = results.find((r) => r.fired === true);
|
|
256
|
+
if (!hit) {
|
|
257
|
+
const vague = results.find((r) => r.fired === "unknown");
|
|
258
|
+
return vague ? { fired: "unknown", why: vague.reason } : { fired: false, why: results.map((r) => r.reason).join("; ") };
|
|
259
|
+
}
|
|
260
|
+
// An exception only stops the tag when it fires on the same push as the firing trigger.
|
|
261
|
+
for (const b of tag.blocking) {
|
|
262
|
+
const r = evaluate(b);
|
|
263
|
+
if (r.fired === true && r.matched === hit.matched) return { fired: false, why: m.blocked(b.name) };
|
|
264
|
+
}
|
|
265
|
+
const consent = consentAt(pushes, hit.matched);
|
|
266
|
+
const denied = tag.consent.find((type) => consent[type] === "denied");
|
|
267
|
+
if (denied) return { fired: false, why: m.consentDenied(denied) };
|
|
268
|
+
return { fired: true, why: m.fired };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Adds `evaluation: { fired, why }` to each tag in `finding.gtm`, judged from the run's dataLayer pushes:
|
|
273
|
+
* whether the tag's triggers fired, whether an exception blocked it, and whether consent allowed it.
|
|
274
|
+
*/
|
|
275
|
+
export function explainTags(finding, run, container, lang = "en") {
|
|
276
|
+
const m = messages(lang).gtm;
|
|
277
|
+
const v = container?.containerVersion ?? container ?? {};
|
|
278
|
+
const byId = new Map((v.trigger ?? []).map((t) => [String(t.triggerId), t]));
|
|
279
|
+
for (const tag of finding.gtm ?? []) tag.evaluation = evaluateTag(tag, byId, run?.dataLayerLog ?? [], v.variable ?? [], m, lang);
|
|
280
|
+
return finding;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Adds `gtm` to each finding about one platform event: the tags that send it, or an empty list when none does.
|
|
285
|
+
* With `runs`, each tag also says whether it should have fired in the finding's journey, and why not.
|
|
286
|
+
*/
|
|
287
|
+
export function annotateFindings(findings, container, runs, { lang = "en" } = {}) {
|
|
84
288
|
if (!container) return findings;
|
|
85
289
|
const tags = containerTags(container);
|
|
86
290
|
if (!tags.length) return findings;
|
|
87
291
|
for (const f of findings) {
|
|
88
292
|
if (!f.platform || !f.event || f.severity === "ok") continue;
|
|
89
293
|
f.gtm = tagsForEvent(tags, f.platform, f.event).map(({ name, type, paused, consent, triggers, blocking }) => ({ name, type, paused, consent, triggers, blocking }));
|
|
294
|
+
const run = runs?.find((r) => r.journeyId === f.journey && r.viewport === f.viewport);
|
|
295
|
+
if (run) explainTags(f, run, container, lang);
|
|
90
296
|
}
|
|
91
297
|
return findings;
|
|
92
298
|
}
|
package/src/help.js
CHANGED
|
@@ -13,6 +13,8 @@ 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
|
+
doctor Check that Node, Chromium, an AI key and the data folder are ready
|
|
17
|
+
history Show how a site's saved runs trend: counts, open problems, flapping, journeys
|
|
16
18
|
|
|
17
19
|
Examples:
|
|
18
20
|
aitd plan --site https://example.com
|
|
@@ -40,12 +42,16 @@ Options:
|
|
|
40
42
|
--lang <code> Report language: ${Object.keys(LANGUAGES).join(" or ")}
|
|
41
43
|
--headed Show the browser window
|
|
42
44
|
--fail-on <level> Exit 1 at this level: broken (default), risk, warning, never
|
|
45
|
+
--baseline <file> Known findings to accept; they don't count toward --fail-on
|
|
46
|
+
--update-baseline Write the baseline file after the run (creates it)
|
|
43
47
|
|
|
44
48
|
AI: --provider <name> --model <id|cheap> (default: whichever API key is set)
|
|
45
49
|
|
|
46
50
|
Advanced:
|
|
47
|
-
--max-steps <n> --platforms <list> --viewport <name> --data-dir <dir>
|
|
48
|
-
--scripted --no-ai --save-site <dir> --save-steps <file> (see the README)
|
|
51
|
+
--max-steps <n> --platforms <list> --viewport <name> --concurrency <n> --data-dir <dir>
|
|
52
|
+
--scripted --no-ai --save-site <dir> --save-steps <file> (see the README)
|
|
53
|
+
--scripted replays each journey's fixed steps; a journey without any repeats the steps
|
|
54
|
+
of its last completed run, which every run saves in its folder as steps.json.`;
|
|
49
55
|
|
|
50
56
|
const PLAN = `Usage: aitd plan --site <url>
|
|
51
57
|
aitd plan list --site <url>
|
|
@@ -99,7 +105,31 @@ Advanced:
|
|
|
99
105
|
--reports <dir> Where finished audits write reports (default: ~/ai-tag-debugger-reports)
|
|
100
106
|
--public-url <url> The HTTPS address of your tunnel or host, so audits can link to reports`;
|
|
101
107
|
|
|
102
|
-
const
|
|
108
|
+
const DOCTOR = `Usage: aitd doctor [--site <url>] [--data-dir <dir>]
|
|
109
|
+
|
|
110
|
+
Check that this machine can run an audit: the Node version, Playwright's Chromium, whether
|
|
111
|
+
an AI provider key is set, and that the data folder is writable. Exits with code 1 when a
|
|
112
|
+
check an audit needs fails. A missing AI key is only reported: --no-ai and replay work without one.
|
|
113
|
+
|
|
114
|
+
Options:
|
|
115
|
+
--site <url> Also check that the site is an http(s) address whose host resolves (nothing is fetched)
|
|
116
|
+
--data-dir <dir> The folder plans and runs are saved in (default: aitd)`;
|
|
117
|
+
|
|
118
|
+
const HISTORY = `Usage: aitd history --site <url> [--limit <n>] [--json] [--data-dir <dir>]
|
|
119
|
+
|
|
120
|
+
Show the trend of a site's saved runs: one row per run with its problem counts and score, a
|
|
121
|
+
text sparkline, the problems open longest, the ones that come and go, and the journeys that do
|
|
122
|
+
not finish every time. Reads the runs in aitd/<domain>/runs/ and its history.json; no browser,
|
|
123
|
+
no AI, nothing is changed.
|
|
124
|
+
|
|
125
|
+
Options:
|
|
126
|
+
--site <url> The site (required)
|
|
127
|
+
--limit <n> How many of the newest runs to read (default 30)
|
|
128
|
+
--json Print the trend, the finding timeline and the journey stability as JSON
|
|
129
|
+
--lang <code> Language of the lines: ${Object.keys(LANGUAGES).join(" or ")}
|
|
130
|
+
--data-dir <dir> The folder plans and runs are saved in (default: aitd)`;
|
|
131
|
+
|
|
132
|
+
const HELP = { run: RUN, plan: PLAN, replay: REPLAY, mcp: MCP, doctor: DOCTOR, history: HISTORY };
|
|
103
133
|
|
|
104
134
|
/** Help text for a command, or the top-level help when the command is unknown or missing. */
|
|
105
135
|
export function helpText(command) {
|