@analitikisler/ai-tag-debugger 0.9.0 → 0.11.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 +27 -0
- package/README.md +195 -46
- package/README.tr.md +334 -185
- package/package.json +5 -2
- package/src/agent.js +1 -1
- package/src/ask.js +123 -0
- package/src/audit.js +35 -13
- package/src/changes.js +66 -0
- package/src/checks.js +56 -1
- package/src/cli.js +176 -74
- package/src/discover.js +185 -0
- package/src/help.js +107 -0
- package/src/i18n.js +24 -0
- package/src/journeys.js +87 -8
- package/src/mcp.js +105 -11
- package/src/plan-html.js +76 -0
- package/src/plan.js +14 -0
- package/src/providers.js +1 -1
- package/src/report.js +59 -10
- package/src/sheet.js +321 -0
- package/src/workspace.js +77 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@analitikisler/ai-tag-debugger",
|
|
3
3
|
"mcpName": "io.github.analitikisler/ai-tag-debugger",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.11.0",
|
|
5
5
|
"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.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"setup": "node scripts/setup.js",
|
|
19
19
|
"test": "node --test test/*.test.js",
|
|
20
20
|
"demo:serve": "node examples/serve.js",
|
|
21
|
-
"demo": "node src/cli.js run --config examples/demo.config.json --scripted --no-ai
|
|
21
|
+
"demo": "node src/cli.js run --config examples/demo.config.json --scripted --no-ai"
|
|
22
22
|
},
|
|
23
23
|
"engines": {
|
|
24
24
|
"node": ">=20"
|
|
@@ -57,5 +57,8 @@
|
|
|
57
57
|
"openai": "^7.30.1",
|
|
58
58
|
"playwright": "1.56.1",
|
|
59
59
|
"zod": "^4.6.5"
|
|
60
|
+
},
|
|
61
|
+
"optionalDependencies": {
|
|
62
|
+
"fflate": "^0.8.3"
|
|
60
63
|
}
|
|
61
64
|
}
|
package/src/agent.js
CHANGED
|
@@ -95,7 +95,7 @@ const TIMEOUTS = { model: 180_000, action: 120_000, page: 60_000 };
|
|
|
95
95
|
|
|
96
96
|
const LAST_STEP_WAIT_MS = 3000;
|
|
97
97
|
|
|
98
|
-
class StepTimeout extends Error {}
|
|
98
|
+
export class StepTimeout extends Error {}
|
|
99
99
|
|
|
100
100
|
/** The promise's value, or a StepTimeout with this message once ms have passed. */
|
|
101
101
|
function withTimeout(promise, ms, message) {
|
package/src/ask.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Turns a plain sentence like "check my GA tags" into an aitd command.
|
|
2
|
+
// The AI provider fills a fixed intent schema, so it can never invent options;
|
|
3
|
+
// without an AI key a small keyword matcher handles the common phrases.
|
|
4
|
+
import { PROVIDERS as PLATFORMS } from "./parsers.js";
|
|
5
|
+
|
|
6
|
+
const PLATFORM_KEYS = Object.keys(PLATFORMS);
|
|
7
|
+
// The same values as plan --topic (TOPICS in discover.js, kept in step by a test).
|
|
8
|
+
export const TOPIC_KEYS = ["custom-events", "ecommerce", "forms", "consent"];
|
|
9
|
+
|
|
10
|
+
export const INTENT_SCHEMA = {
|
|
11
|
+
type: "object",
|
|
12
|
+
additionalProperties: false,
|
|
13
|
+
required: ["action", "site", "against", "platforms", "goal", "journey", "topic", "question"],
|
|
14
|
+
properties: {
|
|
15
|
+
action: { type: "string", enum: ["plan", "run", "report", "unclear"], description: "plan drafts what a site should track; run audits what the site sends; report re-reads an existing report; unclear when you cannot tell plan from run." },
|
|
16
|
+
site: { type: ["string", "null"], description: "The site's domain or URL if the sentence names one." },
|
|
17
|
+
against: { type: ["string", "null"], enum: ["plan", "latest", null], description: "run only: compare with the saved plan, or with the latest run (\"what changed\", \"since the last run\", \"after the deploy\")." },
|
|
18
|
+
platforms: { type: "array", items: { type: "string", enum: PLATFORM_KEYS }, description: "Only the tracking platforms the sentence names; empty for all." },
|
|
19
|
+
goal: { type: ["string", "null"], description: "run only: a short user journey to try, in plain words, e.g. \"submit the contact form\"." },
|
|
20
|
+
journey: { type: ["string", "null"], enum: ["purchase", null], description: "run only: purchase when the sentence is about the purchase event or revenue." },
|
|
21
|
+
topic: { type: ["string", "null"], enum: [...TOPIC_KEYS, null], description: "plan only: limit the plan to one kind of event when the sentence asks about it." },
|
|
22
|
+
question: { type: ["string", "null"], description: "One short question to ask back when the sentence is ambiguous; otherwise null." },
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const SYSTEM = `You translate a request about website tracking (analytics, ads, consent tags) into options for the aitd command line tool. Fill the schema with only what the sentence says; leave the rest null or empty. Use action "unclear" and a short question when you cannot tell whether the user wants a tracking plan or an audit of the live site.`;
|
|
27
|
+
|
|
28
|
+
const has = (s, re) => re.test(s);
|
|
29
|
+
|
|
30
|
+
/** The intent for a sentence from keywords alone, for when no AI key is set. */
|
|
31
|
+
export function matchKeywords(sentence) {
|
|
32
|
+
const s = ` ${sentence.toLowerCase()} `;
|
|
33
|
+
const intent = { action: null, site: null, against: null, platforms: [], goal: null, journey: null, topic: null, question: null };
|
|
34
|
+
const site = sentence.match(/\b(?:https?:\/\/)?((?:[a-z0-9-]+\.)+[a-z]{2,})(?:\/\S*)?/i);
|
|
35
|
+
if (site) intent.site = site[1].toLowerCase();
|
|
36
|
+
|
|
37
|
+
if (has(s, /google analytics|\bga4?\b/)) intent.platforms.push("ga4");
|
|
38
|
+
if (has(s, /google ads|adwords/)) intent.platforms.push("google_ads");
|
|
39
|
+
if (has(s, /\bmeta\b|facebook/)) intent.platforms.push("meta");
|
|
40
|
+
if (has(s, /tiktok/)) intent.platforms.push("tiktok");
|
|
41
|
+
|
|
42
|
+
if (has(s, /only the (errors|failures)|from the (last|latest) report/)) intent.action = "report";
|
|
43
|
+
else if (has(s, /tracking plan|make a plan|should .* track|does my site need|events .* need/)) intent.action = "plan";
|
|
44
|
+
else {
|
|
45
|
+
if (has(s, /since (the )?last run|what changed|\bbreak|\bbroke|deploy/)) intent.against = "latest";
|
|
46
|
+
else if (has(s, /(against|in) the plan/)) intent.against = "plan";
|
|
47
|
+
if (has(s, /revenue|purchase event/)) intent.journey = "purchase";
|
|
48
|
+
else if (has(s, /newsletter/)) intent.goal = "sign up for the newsletter";
|
|
49
|
+
else if (has(s, /accept(ing)? (the )?cookies|consent/)) intent.goal = "browse without accepting cookies";
|
|
50
|
+
else if (has(s, /contact form/)) intent.goal = "submit the contact form";
|
|
51
|
+
else if (has(s, /checkout/)) intent.goal = "reach checkout";
|
|
52
|
+
if (intent.platforms.length || intent.against || intent.goal || intent.journey || has(s, /\b(check|test|audit|firing|fire|working)\b/)) intent.action = "run";
|
|
53
|
+
}
|
|
54
|
+
if (intent.action === "plan") {
|
|
55
|
+
if (has(s, /custom events?/)) intent.topic = "custom-events";
|
|
56
|
+
else if (has(s, /ecommerce|e-commerce|online shop|\bshop\b|\bstore\b/)) intent.topic = "ecommerce";
|
|
57
|
+
else if (has(s, /\bforms?\b/)) intent.topic = "forms";
|
|
58
|
+
else if (has(s, /consent|cookies?/)) intent.topic = "consent";
|
|
59
|
+
}
|
|
60
|
+
if (!intent.action) {
|
|
61
|
+
intent.action = "unclear";
|
|
62
|
+
intent.question = "Do you want a tracking plan or a check of what the site sends? (plan/run)";
|
|
63
|
+
}
|
|
64
|
+
return intent;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Asks the provider for the intent of a sentence; falls back to keywords without one. */
|
|
68
|
+
export async function translate(sentence, { provider } = {}) {
|
|
69
|
+
if (!provider) return { intent: matchKeywords(sentence), by: "keywords" };
|
|
70
|
+
const result = await provider.json({ system: SYSTEM, texts: [sentence], schema: INTENT_SCHEMA });
|
|
71
|
+
if (result.error) return { intent: matchKeywords(sentence), by: "keywords" };
|
|
72
|
+
return { intent: normalize(result.value), by: "ai" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Keeps only schema values, so an odd reply can never become an option. */
|
|
76
|
+
export function normalize(v = {}) {
|
|
77
|
+
const str = (x) => (typeof x === "string" && x.trim() ? x.trim() : null);
|
|
78
|
+
return {
|
|
79
|
+
action: ["plan", "run", "report", "unclear"].includes(v.action) ? v.action : "unclear",
|
|
80
|
+
site: str(v.site),
|
|
81
|
+
against: ["plan", "latest"].includes(v.against) ? v.against : null,
|
|
82
|
+
platforms: [...new Set((Array.isArray(v.platforms) ? v.platforms : []).filter((p) => PLATFORM_KEYS.includes(p)))],
|
|
83
|
+
goal: str(v.goal),
|
|
84
|
+
journey: v.journey === "purchase" ? "purchase" : null,
|
|
85
|
+
topic: TOPIC_KEYS.includes(v.topic) ? v.topic : null,
|
|
86
|
+
question: str(v.question),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const siteUrl = (site) => (/^https?:\/\//i.test(site) ? site : `https://${site}`);
|
|
91
|
+
|
|
92
|
+
/** The aitd arguments for an intent, or { question } when one more answer is needed. */
|
|
93
|
+
export function intentToArgs(intent) {
|
|
94
|
+
if (intent.action === "unclear") return { question: intent.question ?? "Do you want a tracking plan or a check of what the site sends? (plan/run)" };
|
|
95
|
+
if (intent.action === "report") return { unsupported: "Filtering an existing report is not available yet. Open the latest report in aitd/<domain>/runs/." };
|
|
96
|
+
const args = [intent.action];
|
|
97
|
+
if (intent.site) args.push("--site", siteUrl(intent.site));
|
|
98
|
+
if (intent.action === "plan" && intent.topic) args.push("--topic", intent.topic);
|
|
99
|
+
if (intent.action === "run") {
|
|
100
|
+
// --against takes a saved plan. Every run already reports what changed since the previous
|
|
101
|
+
// run, so "since the last run" needs no option of its own.
|
|
102
|
+
if (intent.against === "plan") args.push("--against", "latest");
|
|
103
|
+
if (intent.platforms.length) args.push("--platforms", intent.platforms.join(","));
|
|
104
|
+
if (intent.journey) args.push("--journey", intent.journey);
|
|
105
|
+
if (intent.goal) args.push("--goal", intent.goal);
|
|
106
|
+
}
|
|
107
|
+
return { args };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** True when a run has nothing to test yet: no plan to check against, no journey, no goal. */
|
|
111
|
+
export function needsWhatToTest(args) {
|
|
112
|
+
return args[0] === "run" && !["--against", "--journey", "--goal"].some((o) => args.includes(o));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The command as the user would type it. */
|
|
116
|
+
export function commandText(args) {
|
|
117
|
+
return ["aitd", ...args.map((a) => (/^[\w.,:/@=-]+$/.test(a) ? a : `"${a.replace(/(["\\$`])/g, "\\$1")}"`))].join(" ");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** True when the arguments are one sentence rather than a command. */
|
|
121
|
+
export function isSentence(positionals) {
|
|
122
|
+
return positionals.length === 1 && /\s/.test(positionals[0].trim());
|
|
123
|
+
}
|
package/src/audit.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// A full audit from a config file: run every journey, check, explain, write the reports.
|
|
2
2
|
// Shared by the CLI and the MCP server's run_audit tool.
|
|
3
|
+
import { newRunDir } from "./workspace.js";
|
|
4
|
+
import { compareRuns, previousRun } from "./changes.js";
|
|
3
5
|
import { readFile, mkdir, rm, writeFile } from "node:fs/promises";
|
|
4
6
|
import path from "node:path";
|
|
5
7
|
import { failedRun, runJourney } from "./agent.js";
|
|
6
8
|
import { VIEWPORTS } from "./browser.js";
|
|
7
|
-
import { checkRun, groupFindings, summarize } from "./checks.js";
|
|
9
|
+
import { checkRun, groupFindings, planCheck, summarize } from "./checks.js";
|
|
8
10
|
import { explainFindings, renderCsv, renderHtml, renderMarkdown } from "./report.js";
|
|
9
11
|
import { LANGUAGES, messages } from "./i18n.js";
|
|
10
12
|
import { createProvider } from "./providers.js";
|
|
11
|
-
import { filterFindings, planRun } from "./journeys.js";
|
|
13
|
+
import { filterFindings, JOURNEY_PRESETS, planRun } from "./journeys.js";
|
|
12
14
|
import { resolveProviders } from "./parsers.js";
|
|
13
15
|
import { SAVED_SITE_FILE, writeSavedSite } from "./replay.js";
|
|
14
16
|
|
|
@@ -32,30 +34,38 @@ export async function loadConfig(configFile) {
|
|
|
32
34
|
return { config, plan, gtmContainer };
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
export const defaultOutDir = () => path.resolve("reports", new Date().toISOString().replace(/[:.]/g, "-"));
|
|
36
37
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Writes report.md, report.html, timeline.csv and capture.json, plus plan-check.json with --against.
|
|
40
|
+
* previous: the site's last saved run (see previousRun), null when there is none, or undefined to skip
|
|
41
|
+
* the "Changed since last run" section.
|
|
42
|
+
*/
|
|
43
|
+
export async function writeReports({ outDir, config, plan, runs, findings, explained, lang, against = null, branding = config.branding !== false, previous = undefined }) {
|
|
39
44
|
await mkdir(outDir, { recursive: true });
|
|
40
45
|
const title = messages(lang).ui.title(new URL(config.site).hostname);
|
|
41
46
|
const generatedAt = new Date().toISOString().slice(0, 16).replace("T", " ") + " UTC";
|
|
42
|
-
const
|
|
47
|
+
const check = against ? { plan_id: against.id ?? null, ...planCheck(plan, runs, findings) } : null;
|
|
48
|
+
const changes = previous === undefined ? null : compareRuns(previous, { findings, planCheck: check });
|
|
49
|
+
const report = { title, generatedAt, findings, explained, runs, plan, lang, branding, against: against?.label ?? null, planCheck: check, changes };
|
|
43
50
|
await writeFile(path.join(outDir, "report.md"), renderMarkdown(report));
|
|
44
51
|
await writeFile(path.join(outDir, "report.html"), renderHtml(report));
|
|
45
52
|
await writeFile(path.join(outDir, "timeline.csv"), renderCsv(report));
|
|
46
|
-
await writeFile(path.join(outDir, "
|
|
53
|
+
if (check) await writeFile(path.join(outDir, "plan-check.json"), JSON.stringify(check, null, 2));
|
|
54
|
+
await writeFile(path.join(outDir, "capture.json"), JSON.stringify({ config: { ...config, plan }, ...(against ? { against: { plan_id: against.id, file: against.file } } : {}), runs, findings, explained, ...(changes ? { changes } : {}) }, null, 2));
|
|
47
55
|
return path.join(outDir, "report.html");
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
/**
|
|
51
59
|
* @param {{ config: any, plan?: any, gtmContainer?: any, journeys?: string[], goals?: string[], events?: string[], viewports?: string[],
|
|
52
60
|
* scripted?: boolean, ai?: boolean, provider?: string, model?: string, baseUrl?: string, maxSteps?: number, lang?: string, headed?: boolean,
|
|
53
|
-
* outDir?: string, branding?: boolean, providers?: string[], saveSite?: string, replaySite?: string, log?: (m: string) => void }} opts
|
|
61
|
+
* outDir?: string, dataDir?: string, branding?: boolean, providers?: string[], saveSite?: string, replaySite?: string, log?: (m: string) => void }} opts
|
|
54
62
|
* journeys: config journey ids or ready-made journeys (purchase, add_to_cart, sign_up, lead, search).
|
|
55
63
|
* goals: extra journeys described in plain language. events: only check these events.
|
|
56
64
|
* providers: tracking providers to check (default: config "providers", else GA4, Google Ads, Meta, TikTok).
|
|
57
65
|
* saveSite: record the site's responses and the steps taken into this folder, for replaySite (see replay.js).
|
|
58
66
|
* replaySite: answer every request from a folder saved with saveSite, without the network.
|
|
67
|
+
* against: { id, file } of the plan given with --against; opts.plan is then that plan. Used alone, the
|
|
68
|
+
* journeys come from the plan; with journeys or goals, only the plan events expected on them are checked.
|
|
59
69
|
*/
|
|
60
70
|
export async function runAudit(opts) {
|
|
61
71
|
const { gtmContainer } = opts;
|
|
@@ -68,19 +78,29 @@ export async function runAudit(opts) {
|
|
|
68
78
|
const useAi = opts.ai !== false;
|
|
69
79
|
if (!useAi && !opts.scripted) throw new Error("Without an AI model, journeys must be replayed from their fixed steps (scripted).");
|
|
70
80
|
|
|
71
|
-
const
|
|
81
|
+
const against = opts.against ? { ...opts.against, label: opts.against.id ?? path.relative(process.cwd(), opts.against.file) } : null;
|
|
82
|
+
const { journeys, plan } = planRun({ config, plan: opts.plan, picks: opts.journeys, goals: opts.goals, events, against: Boolean(against) });
|
|
72
83
|
config = { ...config, providers: resolveProviders(opts.providers ?? config.providers, plan) };
|
|
73
84
|
const viewports = opts.viewports?.length ? opts.viewports : config.viewports ?? ["desktop"];
|
|
74
85
|
const allowed = Object.keys(VIEWPORTS).join(", ");
|
|
75
86
|
if (!Array.isArray(viewports)) throw new Error(`"viewports" must be a list, e.g. ["desktop", "mobile"]. Allowed values: ${allowed}.`);
|
|
76
87
|
const unknown = viewports.filter((v) => !Object.hasOwn(VIEWPORTS, v));
|
|
77
88
|
if (unknown.length) throw new Error(`Unknown viewport ${unknown.map((v) => `"${v}"`).join(", ")}. Use: ${allowed}.`);
|
|
78
|
-
if (!journeys.length) throw new Error(
|
|
89
|
+
if (!journeys.length) throw new Error(`No journeys to run. A run needs one of:\n --journey <name> one of: ${Object.keys(JOURNEY_PRESETS).join(", ")} (or a journey id from your config)\n --goal "<text>" describe what to do in your own words\n --config <file> a config file with "journeys"\nNot sure what to test? "aitd plan --site ${config.site}" drafts a measurement plan.`);
|
|
79
90
|
if (opts.scripted) {
|
|
80
91
|
const unscripted = journeys.filter((j) => !j.steps?.length && !j.incomplete).map((j) => j.id);
|
|
81
92
|
if (unscripted.length) throw new Error(`Journeys without fixed steps can't be replayed with --scripted: ${unscripted.join(", ")}.`);
|
|
82
93
|
}
|
|
83
94
|
|
|
95
|
+
if (against) {
|
|
96
|
+
const notChecked = plan.events.filter((e) => Array.isArray(e.journeys) && !e.journeys.some((j) => journeys.some((jj) => jj.id === j))).length;
|
|
97
|
+
log(`Checking against plan ${against.label}: ${journeys.length} ${journeys.length === 1 ? "journey" : "journeys"}${notChecked ? `, ${notChecked} planned ${notChecked === 1 ? "event" : "events"} not checked in this run` : ""}`);
|
|
98
|
+
for (const j of journeys) {
|
|
99
|
+
const expected = plan.events.filter((e) => !e.journeys || e.journeys.includes(j.id)).map((e) => e.name);
|
|
100
|
+
log(` - ${j.id}: ${j.goal ?? "fixed steps"}${expected.length ? ` [${[...new Set(expected)].join(", ")}]` : ""}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
84
104
|
const provider = useAi ? await createProvider({ name: opts.provider ?? config.provider, model: opts.model ?? config.model, baseUrl: opts.baseUrl ?? config.base_url }) : null;
|
|
85
105
|
if (provider) log(`Using ${provider.name} (${provider.model})`);
|
|
86
106
|
|
|
@@ -116,8 +136,10 @@ export async function runAudit(opts) {
|
|
|
116
136
|
explained = await explainFindings({ findings, plan, runs, gtmContainer, provider, lang, log });
|
|
117
137
|
}
|
|
118
138
|
|
|
119
|
-
|
|
120
|
-
const
|
|
139
|
+
// Runs saved in the site's runs folder are compared with the one before; a run sent to --out is not.
|
|
140
|
+
const previous = opts.outDir ? undefined : await previousRun(opts.dataDir, config.site);
|
|
141
|
+
const outDir = path.resolve(opts.outDir ?? (await newRunDir(opts.dataDir, config.site)).dir);
|
|
142
|
+
const reportPath = await writeReports({ outDir, config, plan, runs, findings, explained, lang, against, branding: opts.branding ?? config.branding !== false, previous });
|
|
121
143
|
const counts = summarize(groupFindings(findings));
|
|
122
|
-
return { journeys, plan, runs, findings, explained, counts, reportPath, outDir, lang, ...(saveError ? { saveError } : {}) };
|
|
144
|
+
return { journeys, plan, runs, findings, explained, counts, reportPath, outDir, lang, ...(against ? { againstId: against.id } : {}), ...(saveError ? { saveError } : {}) };
|
|
123
145
|
}
|
package/src/changes.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { listRunIds, runsDir } from "./workspace.js";
|
|
4
|
+
|
|
5
|
+
const PROBLEMS = new Set(["broken", "risk", "warning"]);
|
|
6
|
+
const PLAN_PROBLEMS = new Set(["missing", "missing_params", "wrong_values"]);
|
|
7
|
+
|
|
8
|
+
const readJson = (file) => readFile(file, "utf8").then(JSON.parse).catch(() => null);
|
|
9
|
+
|
|
10
|
+
/** The site's newest saved run before this one, with its capture and plan check, or null. */
|
|
11
|
+
export async function previousRun(dataDir, site, before = null) {
|
|
12
|
+
const ids = (await listRunIds(dataDir, site)).filter((id) => !before || id < before);
|
|
13
|
+
for (const id of ids.reverse()) {
|
|
14
|
+
const dir = path.join(runsDir(dataDir, site), id);
|
|
15
|
+
const capture = await readJson(path.join(dir, "capture.json"));
|
|
16
|
+
if (capture) return { id, capture, planCheck: await readJson(path.join(dir, "plan-check.json")) };
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One entry per problem, keyed so the same problem matches across runs whatever its wording. */
|
|
22
|
+
function findingProblems(findings = []) {
|
|
23
|
+
const out = new Map();
|
|
24
|
+
for (const f of findings) {
|
|
25
|
+
if (!PROBLEMS.has(f.severity)) continue;
|
|
26
|
+
const k = [f.check, f.platform ?? "", f.event ?? ""].join("|");
|
|
27
|
+
if (!out.has(k)) out.set(k, { check: f.check, platform: f.platform ?? null, event: f.event ?? null, severity: f.severity, detail: f.detail });
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function planProblems(pc) {
|
|
33
|
+
const out = new Map();
|
|
34
|
+
for (const e of pc?.events ?? []) out.set(`${e.platform}|${e.event}`, { platform: e.platform, event: e.event, bucket: e.bucket });
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What changed since the previous run of the same site. Compares plan coverage when both runs were
|
|
40
|
+
* checked against a plan, and the health findings otherwise.
|
|
41
|
+
* Returns { previous: null } when there is no earlier run, else
|
|
42
|
+
* { previous, mode: "plan" | "findings", fixed: [...], new: [...] }.
|
|
43
|
+
*/
|
|
44
|
+
export function compareRuns(prev, { findings, planCheck = null }) {
|
|
45
|
+
if (!prev) return { previous: null };
|
|
46
|
+
if (planCheck && prev.planCheck) {
|
|
47
|
+
const before = planProblems(prev.planCheck);
|
|
48
|
+
const now = planProblems(planCheck);
|
|
49
|
+
const fixed = [];
|
|
50
|
+
const added = [];
|
|
51
|
+
for (const [k, e] of now) {
|
|
52
|
+
const was = before.get(k)?.bucket;
|
|
53
|
+
if (e.bucket === "implemented" && PLAN_PROBLEMS.has(was)) fixed.push({ ...e, was });
|
|
54
|
+
if (PLAN_PROBLEMS.has(e.bucket) && !PLAN_PROBLEMS.has(was)) added.push({ ...e, was: was ?? null });
|
|
55
|
+
}
|
|
56
|
+
return { previous: prev.id, mode: "plan", fixed, new: added };
|
|
57
|
+
}
|
|
58
|
+
const before = findingProblems(prev.capture?.findings);
|
|
59
|
+
const now = findingProblems(findings);
|
|
60
|
+
return {
|
|
61
|
+
previous: prev.id,
|
|
62
|
+
mode: "findings",
|
|
63
|
+
fixed: [...before].filter(([k]) => !now.has(k)).map(([, f]) => f),
|
|
64
|
+
new: [...now].filter(([k]) => !before.has(k)).map(([, f]) => f),
|
|
65
|
+
};
|
|
66
|
+
}
|
package/src/checks.js
CHANGED
|
@@ -110,7 +110,7 @@ function expectedIn(planEvent, journeyId) {
|
|
|
110
110
|
return !planEvent.journeys || planEvent.journeys.includes(journeyId);
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
function isConsentCommand(entry, kind) {
|
|
113
|
+
export function isConsentCommand(entry, kind) {
|
|
114
114
|
return Array.isArray(entry.value) && entry.value[0] === "consent" && entry.value[1] === kind;
|
|
115
115
|
}
|
|
116
116
|
|
|
@@ -444,3 +444,58 @@ export function groupFindings(findings) {
|
|
|
444
444
|
}
|
|
445
445
|
return [...groups.values()].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
|
446
446
|
}
|
|
447
|
+
|
|
448
|
+
const WRONG_VALUE_CHECKS = new Set(["purchase_value_invalid", "purchase_currency_invalid", "purchase_items_empty", "purchase_mismatch"]);
|
|
449
|
+
export const PLAN_BUCKETS = ["implemented", "missing", "missing_params", "wrong_values", "unplanned", "not_checked"];
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Sorts every planned event into one bucket from the runs and the findings checkRun gave them:
|
|
453
|
+
* implemented, missing, missing_params, wrong_values or not_checked, plus one unplanned entry per
|
|
454
|
+
* event that fired but is not in the plan. Coverage = implemented / planned events that were checkable.
|
|
455
|
+
* @param {Plan} plan
|
|
456
|
+
* @param {any[]} runs
|
|
457
|
+
* @param {Finding[]} findings
|
|
458
|
+
*/
|
|
459
|
+
export function planCheck(plan, runs, findings) {
|
|
460
|
+
const events = (plan?.events ?? []).map((e) => ({ ...e, platform: String(e.platform ?? "").toLowerCase() }));
|
|
461
|
+
const entries = [];
|
|
462
|
+
for (const ev of events) {
|
|
463
|
+
const walked = runs.filter((r) => expectedIn(ev, r.journeyId) && (!r.providers || r.providers.includes(ev.platform)));
|
|
464
|
+
const entry = { platform: ev.platform, event: ev.name, bucket: "not_checked", journeys: [...new Set(walked.map((r) => r.journeyId))] };
|
|
465
|
+
if (walked.length) {
|
|
466
|
+
const own = findings.filter((f) => f.event && walked.some((r) => r.journeyId === f.journey && r.viewport === f.viewport) && hitMatchesEvent(ev, { platform: f.platform, name: f.event }));
|
|
467
|
+
const params = own.filter((f) => f.check === "missing_params");
|
|
468
|
+
const values = own.filter((f) => WRONG_VALUE_CHECKS.has(f.check));
|
|
469
|
+
const fired = walked.some((r) => r.hits.some((h) => hitMatchesEvent(ev, h)));
|
|
470
|
+
if (params.length) entry.bucket = "missing_params";
|
|
471
|
+
else if (values.length) entry.bucket = "wrong_values";
|
|
472
|
+
else entry.bucket = fired ? "implemented" : "missing";
|
|
473
|
+
const details = [...new Set([...params, ...values].map((f) => f.detail))];
|
|
474
|
+
if (details.length) entry.details = details;
|
|
475
|
+
}
|
|
476
|
+
entries.push(entry);
|
|
477
|
+
}
|
|
478
|
+
// Unplanned events, from the runs whose unplanned_events finding survived the --event filter.
|
|
479
|
+
const seen = new Set();
|
|
480
|
+
for (const r of runs) {
|
|
481
|
+
if (!findings.some((f) => f.check === "unplanned_events" && f.journey === r.journeyId && f.viewport === r.viewport)) continue;
|
|
482
|
+
for (const h of r.hits) {
|
|
483
|
+
if (events.some((e) => hitMatchesEvent(e, h)) || seen.has(key(h.platform, h.name))) continue;
|
|
484
|
+
seen.add(key(h.platform, h.name));
|
|
485
|
+
entries.push({ platform: h.platform, event: h.name, bucket: "unplanned", journeys: [r.journeyId] });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const score = (list) => {
|
|
489
|
+
const planned = list.filter((e) => e.bucket !== "unplanned");
|
|
490
|
+
const checkable = planned.filter((e) => e.bucket !== "not_checked").length;
|
|
491
|
+
const implemented = planned.filter((e) => e.bucket === "implemented").length;
|
|
492
|
+
return { implemented, checkable, not_checked: planned.length - checkable, percent: checkable ? Math.round((implemented / checkable) * 100) : null };
|
|
493
|
+
};
|
|
494
|
+
const platforms = [...new Set(entries.filter((e) => e.bucket !== "unplanned").map((e) => e.platform))];
|
|
495
|
+
const counts = Object.fromEntries(PLAN_BUCKETS.map((b) => [b, entries.filter((e) => e.bucket === b).length]));
|
|
496
|
+
return {
|
|
497
|
+
coverage: { ...score(entries), platforms: Object.fromEntries(platforms.map((p) => [p, score(entries.filter((e) => e.platform === p))])) },
|
|
498
|
+
counts,
|
|
499
|
+
events: entries,
|
|
500
|
+
};
|
|
501
|
+
}
|