@analitikisler/ai-tag-debugger 0.6.3 → 0.7.1
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 +24 -0
- package/README.md +15 -0
- package/README.tr.md +15 -0
- package/package.json +1 -1
- package/src/agent.js +42 -9
- package/src/audit.js +11 -2
- package/src/browser.js +107 -1
- package/src/cli.js +33 -0
- package/src/parsers.js +9 -4
- package/src/replay.js +108 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.1 (2026-10-13)
|
|
4
|
+
|
|
5
|
+
- A page whose script stops responding after a step can no longer stall a run. Reading the page now has a 1 minute limit too, and when it runs out the journey ends as incomplete with a note. Before, only the model call and the browser action itself had a limit.
|
|
6
|
+
- When a saved site's recording can't be cleaned, a warning now says so before the file is deleted.
|
|
7
|
+
|
|
8
|
+
Replays of saved sites are closer to the saved run, after the first save of 18 real shops:
|
|
9
|
+
|
|
10
|
+
- Scripted runs, and so replays, wait 3 seconds after the last step, so tags that send a moment after the last click (GA4 batches events) are captured as in an AI run.
|
|
11
|
+
- A step that failed in the saved run is tried again on replay without ending the journey there.
|
|
12
|
+
- A journey the AI could not finish (no cart, a bot check) stays unfinished on replay, so its missing events are not reported as broken.
|
|
13
|
+
- Cookies the server sets for the page's scripts, such as a country, are kept in the recording; HttpOnly cookies, where sessions and logins live, are still left out. Some sites don't render without them, for example a cookie banner that never appears.
|
|
14
|
+
- Images, fonts and video left out of the recording get an empty answer on replay instead of failing, since a failed image can stop a script.
|
|
15
|
+
- Requests that had no answer yet when the browser closed are left out of the recording. On replay they made the page that asked for them again never finish loading.
|
|
16
|
+
- A site whose recording is missing (the browser closed early) is not saved, and `replay` reports a saved site it can't replay and goes on with the next one instead of stopping.
|
|
17
|
+
|
|
18
|
+
## 0.7.0 (2026-10-12)
|
|
19
|
+
|
|
20
|
+
Fixes from the first audit run across 18 real shops.
|
|
21
|
+
|
|
22
|
+
- **GA4 hits with a `gaf` parameter were missed and sent to the real GA4 property** (since 0.6.2). Some sites send `gaf=2` on every normal hit, so `view_item`, `add_to_cart` and others were reported as missing, and the test visits reached the site's analytics. Only copies to `www.google.com` and `doubleclick.net` are now left out, whatever their parameters, and every GA4 request is answered locally again.
|
|
23
|
+
- More Google tag pings are answered locally instead of reaching the real accounts: Google Ads remarketing (`www.google.com/rmkt/collect`), Merchant Center (`merchant-center-analytics.goog/mc/collect`), the Google tag's `/g/s/collect`, and consent mode pings on any host, including Google tag gateway paths on the site's own domain (`/<id>/gs/ccm/collect`).
|
|
24
|
+
- One browsing step can no longer stall a run: a model call that doesn't answer within 3 minutes, or a browser action that doesn't finish within 2, ends the journey as incomplete with a note.
|
|
25
|
+
- **Saved sites and `replay`.** `run --save-site <dir>` also saves every response the site sent (as a Playwright HAR file per journey and viewport, without tracking requests, cookies sent or set, authorization headers, form bodies, or requests with user data in their address) and the steps taken. `replay <dir>...` audits those pages again offline, with no network and no AI model, and lists findings that are new or gone. It exits with code 1 when any changed; `--update` makes the new findings the expected ones. Steps the AI took by ref are saved as CSS paths, or as visible text inside iframes. Images, fonts and video are left out of the recording; when the browser closed early, there is no recording to clean instead of an error.
|
|
26
|
+
|
|
3
27
|
## 0.6.3 (2026-10-12)
|
|
4
28
|
|
|
5
29
|
- The audited site's own email address in a `mailto:` link or a link field (`link_url`, `link_text`, `gtm.elementUrl`, `gtm.elementText`), such as the contact link in a GTM `gtm.linkClick` push, is no longer reported as unhashed user data or redacted. An own-domain address anywhere else, and every named user data field (`email`, `user_data`, `em`, …), is still redacted and reported.
|
package/README.md
CHANGED
|
@@ -169,6 +169,19 @@ Each browsing step is one model call, so usage grows with **journeys × viewport
|
|
|
169
169
|
- **Replay without AI.** After one AI run, copy the steps into `journeys[].steps` and use `--scripted` (with `--no-ai` for zero model calls) on every deploy.
|
|
170
170
|
- **Use a chat app.** Over MCP, the chat app's own model does the browsing, so no API key is used at all.
|
|
171
171
|
|
|
172
|
+
### Check a new version against saved sites
|
|
173
|
+
|
|
174
|
+
`--save-site <dir>` also saves every response the site sent and the steps taken. `replay` then audits the same pages again offline, with no network and no AI model, so it costs nothing. It lists findings that are new or gone compared with the saved run:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
npx @analitikisler/ai-tag-debugger run --site https://www.yourshop.com --journey purchase --save-site saved/yourshop
|
|
178
|
+
npx @analitikisler/ai-tag-debugger replay saved/*
|
|
179
|
+
✗ https://www.yourshop.com (saved/yourshop): 0 new, 1 gone
|
|
180
|
+
- warning duplicate purchase desktop ga4 page_view
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The first run's findings become the expected ones. When a change is right, for example a false alarm that is fixed, `replay --update` makes the new findings the expected ones. Use it to check a new version of the tool against sites you know, or a site change against its last good run. A saved site is usually a few MB. It leaves out tracking requests, images and video, session cookies and the cookies the browser sent, authorization headers, form data, and requests with an email or other user data in their address. It still holds the site's pages and scripts, so keep sites you don't own out of public repositories.
|
|
184
|
+
|
|
172
185
|
## Configure your site
|
|
173
186
|
|
|
174
187
|
`ai-tag-debugger.config.json`:
|
|
@@ -229,6 +242,8 @@ ai-tag-debugger run [--site <url>] [--config <file>] [--journey <id>] [--goal "<
|
|
|
229
242
|
[--max-steps <n>] [--out <dir>] [--viewport desktop|mobile] [--providers <list>]
|
|
230
243
|
[--provider anthropic|openai|gemini|openai-compatible] [--model <id>] [--base-url <url>]
|
|
231
244
|
[--lang en|tr] [--allow-host <host>] [--keep-bodies] [--scripted] [--no-ai] [--fail-on broken|risk|warning|never] [--headed]
|
|
245
|
+
[--save-site <dir>]
|
|
246
|
+
ai-tag-debugger replay <dir>... [--update] [--out <dir>]
|
|
232
247
|
ai-tag-debugger mcp [--http] [--port <n>] [--host <addr>] [--token <secret>] [--public-url <https url>] [--reports <dir>]
|
|
233
248
|
[--allow-private-network]
|
|
234
249
|
```
|
package/README.tr.md
CHANGED
|
@@ -171,6 +171,19 @@ Her gezinme adımı bir model çağrısıdır. Yani kullanım **senaryo × ekran
|
|
|
171
171
|
- **Yapay zekâ olmadan tekrar oynatın.** Bir yapay zekâ çalıştırmasından sonra adımları `journeys[].steps` içine kopyalayın ve her deploy'da `--scripted` (hiç model çağrısı olmaması için `--no-ai` ile birlikte) kullanın.
|
|
172
172
|
- **Bir yapay zekâ asistanı kullanın.** MCP üzerinden gezinmeyi asistanın kendi modeli yapar, hiç API anahtarı kullanılmaz.
|
|
173
173
|
|
|
174
|
+
### Yeni bir sürümü kaydedilmiş sitelerle karşılaştırın
|
|
175
|
+
|
|
176
|
+
`--save-site <klasör>`, sitenin gönderdiği her yanıtı ve atılan adımları da kaydeder. `replay` aynı sayfaları ağ ve yapay zekâ modeli olmadan, çevrimdışı olarak yeniden denetler; bu yüzden hiçbir maliyeti yoktur. Kaydedilen çalıştırmaya göre yeni çıkan ya da kaybolan bulguları listeler:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
npx @analitikisler/ai-tag-debugger run --site https://www.magazaniz.com --journey purchase --save-site saved/magazaniz
|
|
180
|
+
npx @analitikisler/ai-tag-debugger replay saved/*
|
|
181
|
+
✗ https://www.magazaniz.com (saved/magazaniz): 0 new, 1 gone
|
|
182
|
+
- warning duplicate purchase desktop ga4 page_view
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
İlk çalıştırmanın bulguları beklenen bulgular olur. Bir değişiklik doğruysa (örneğin düzeltilmiş bir yanlış alarm), `replay --update` yeni bulguları beklenen bulgular yapar. Aracın yeni bir sürümünü bildiğiniz sitelerle ya da bir site değişikliğini son sağlıklı çalıştırmayla karşılaştırmak için kullanın. Kaydedilmiş bir site genellikle birkaç MB'tır. İzleme isteklerini, görselleri ve videoları, oturum çerezlerini ve tarayıcının gönderdiği çerezleri, yetkilendirme başlıklarını, form verilerini ve adresinde e-posta ya da başka kullanıcı verisi olan istekleri içermez. Yine de sitenin sayfalarını ve betiklerini içerdiği için size ait olmayan siteleri herkese açık depolara koymayın.
|
|
186
|
+
|
|
174
187
|
## Sitenizi ayarlayın
|
|
175
188
|
|
|
176
189
|
`ai-tag-debugger.config.json`:
|
|
@@ -231,6 +244,8 @@ ai-tag-debugger run [--site <url>] [--config <file>] [--journey <id>] [--goal "<
|
|
|
231
244
|
[--max-steps <n>] [--out <dir>] [--viewport desktop|mobile] [--providers <list>]
|
|
232
245
|
[--provider anthropic|openai|gemini|openai-compatible] [--model <id>] [--base-url <url>]
|
|
233
246
|
[--lang en|tr] [--allow-host <host>] [--keep-bodies] [--scripted] [--no-ai] [--fail-on broken|risk|warning|never] [--headed]
|
|
247
|
+
[--save-site <dir>]
|
|
248
|
+
ai-tag-debugger replay <dir>... [--update] [--out <dir>]
|
|
234
249
|
ai-tag-debugger mcp [--http] [--port <n>] [--host <addr>] [--token <secret>] [--public-url <https url>] [--reports <dir>]
|
|
235
250
|
[--allow-private-network]
|
|
236
251
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@analitikisler/ai-tag-debugger",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Open-source AI agent that QA-tests your website's analytics, ad and consent tracking (GA4, GTM, Google Ads, Meta, TikTok). Bring your own model (Claude, OpenAI, Gemini) or use it from your chat app over MCP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/agent.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { AuditSession } from "./browser.js";
|
|
2
3
|
import { messages } from "./i18n.js";
|
|
3
4
|
import { createProvider, PROVIDERS } from "./providers.js";
|
|
@@ -87,18 +88,37 @@ export function validateAction(input) {
|
|
|
87
88
|
return null;
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
// One hung page or model request must not stall a whole run. A page whose script never
|
|
92
|
+
// stops (an endless loop after a click, for example) can't be read, so reading it has a limit too.
|
|
93
|
+
const TIMEOUTS = { model: 180_000, action: 120_000, page: 60_000 };
|
|
94
|
+
|
|
95
|
+
const LAST_STEP_WAIT_MS = 3000;
|
|
96
|
+
|
|
97
|
+
class StepTimeout extends Error {}
|
|
98
|
+
|
|
99
|
+
/** The promise's value, or a StepTimeout with this message once ms have passed. */
|
|
100
|
+
function withTimeout(promise, ms, message) {
|
|
101
|
+
let timer;
|
|
102
|
+
const timeout = new Promise((_, reject) => (timer = setTimeout(() => reject(new StepTimeout(message)), ms)));
|
|
103
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
104
|
+
}
|
|
105
|
+
|
|
90
106
|
/**
|
|
91
107
|
* Lets the AI model drive the browser through one journey.
|
|
92
108
|
* @param {AuditSession} session
|
|
93
109
|
* @param {{ id: string, goal: string }} journey
|
|
94
|
-
* @param {{ provider?: any, providerName?: string, client?: any, model?: string, maxSteps?: number, lang?: string, log?: (msg: string) => void }} opts
|
|
110
|
+
* @param {{ provider?: any, providerName?: string, client?: any, model?: string, maxSteps?: number, lang?: string, log?: (msg: string) => void, timeouts?: { model?: number, action?: number, page?: number } }} opts
|
|
111
|
+
* timeouts: milliseconds for one model answer, one browser action and one read of the page.
|
|
95
112
|
*/
|
|
96
113
|
export async function runAgentJourney(session, journey, opts = {}) {
|
|
97
114
|
const provider = opts.provider ?? (await createProvider({ name: opts.providerName, model: opts.model, client: opts.client }));
|
|
98
115
|
const maxSteps = opts.maxSteps ?? 30;
|
|
99
116
|
const log = opts.log ?? (() => {});
|
|
117
|
+
const limit = { ...TIMEOUTS, ...opts.timeouts };
|
|
118
|
+
const seconds = (ms) => (ms % 60_000 ? `${Math.round(ms / 1000)} seconds` : ms === 60_000 ? "a minute" : `${ms / 60_000} minutes`);
|
|
119
|
+
const readPage = () => withTimeout(session.snapshot(), limit.page, `The page stopped responding: it could not be read for ${seconds(limit.page)}.`);
|
|
100
120
|
|
|
101
|
-
const first = await
|
|
121
|
+
const first = await readPage();
|
|
102
122
|
const chat = provider.conversation({
|
|
103
123
|
system: systemPrompt(opts.lang ?? "en"),
|
|
104
124
|
tools: TOOLS,
|
|
@@ -106,7 +126,7 @@ export async function runAgentJourney(session, journey, opts = {}) {
|
|
|
106
126
|
});
|
|
107
127
|
|
|
108
128
|
for (let turn = 0; turn < maxSteps; turn++) {
|
|
109
|
-
const { toolCalls, stop } = await chat.next();
|
|
129
|
+
const { toolCalls, stop } = await withTimeout(chat.next(), limit.model, `The model did not answer within ${seconds(limit.model)}.`);
|
|
110
130
|
if (stop === "refusal") return { completed: false, note: "The model declined to continue this journey." };
|
|
111
131
|
if (toolCalls.length === 0) {
|
|
112
132
|
if (stop === "max_tokens") return { completed: false, note: "The model response was cut off." };
|
|
@@ -132,8 +152,8 @@ export async function runAgentJourney(session, journey, opts = {}) {
|
|
|
132
152
|
continue;
|
|
133
153
|
}
|
|
134
154
|
log(` ${call.input.reason}`);
|
|
135
|
-
const error = await session.act(call.input);
|
|
136
|
-
results.push({ id: call.id, content: pageView(await
|
|
155
|
+
const error = await withTimeout(session.act(call.input), limit.action, `The browser action did not finish within ${seconds(limit.action)}.`);
|
|
156
|
+
results.push({ id: call.id, content: pageView(await readPage(), error) });
|
|
137
157
|
}
|
|
138
158
|
if (finished) return finished;
|
|
139
159
|
chat.addToolResults(results);
|
|
@@ -150,23 +170,36 @@ export async function runScriptedJourney(session, journey, opts = {}) {
|
|
|
150
170
|
const error = await session.act(step);
|
|
151
171
|
if (error && !step.optional) return { completed: false, note: `Step "${step.reason ?? step.action}" failed: ${error}` };
|
|
152
172
|
}
|
|
173
|
+
// Tags often send a moment after the last click (GA4 batches events). An AI run gives them that time
|
|
174
|
+
// while the model answers; a scripted run would otherwise close the browser first.
|
|
175
|
+
await session.page.waitForTimeout(LAST_STEP_WAIT_MS);
|
|
176
|
+
if (journey.incomplete) return { completed: false, note: journey.incomplete };
|
|
153
177
|
return { completed: true, note: "All scripted steps completed." };
|
|
154
178
|
}
|
|
155
179
|
|
|
180
|
+
/** The file a saved site keeps one journey's recorded responses in. */
|
|
181
|
+
export const harName = (journeyId, viewport) => `${String(journeyId).replace(/[^\w.-]+/g, "_")}-${viewport}.har`;
|
|
182
|
+
|
|
156
183
|
/**
|
|
157
184
|
* Runs one journey on one viewport and returns everything the checks need.
|
|
185
|
+
* opts.saveSite records the site's responses into that folder; opts.replaySite answers from one.
|
|
158
186
|
*/
|
|
159
187
|
export async function runJourney(config, journey, viewport, opts = {}) {
|
|
160
|
-
const
|
|
188
|
+
const har = (dir) => (dir ? path.join(dir, harName(journey.id, viewport)) : undefined);
|
|
189
|
+
const session = new AuditSession({
|
|
190
|
+
viewport, site: config.site, allowedHosts: config.allowed_hosts, providers: config.providers, keepBodies: config.keep_bodies === true,
|
|
191
|
+
blockHits: config.block_hits !== false, headless: !opts.headed, recordHar: har(opts.saveSite), replayHar: har(opts.replaySite),
|
|
192
|
+
});
|
|
161
193
|
try {
|
|
162
194
|
await session.start();
|
|
163
195
|
const startUrl = new URL(journey.start ?? "/", config.site).href;
|
|
164
196
|
const startError = await session.act({ action: "goto", url: startUrl, reason: `Open ${startUrl}` });
|
|
165
197
|
if (startError) return { ...collect(session, journey, viewport), completed: false, note: `Could not open the start page: ${startError}` };
|
|
166
198
|
|
|
167
|
-
const outcome = opts.scripted
|
|
168
|
-
|
|
169
|
-
|
|
199
|
+
const outcome = await (opts.scripted ? runScriptedJourney(session, journey, opts) : runAgentJourney(session, journey, opts)).catch((err) => {
|
|
200
|
+
if (err instanceof StepTimeout) return { completed: false, note: err.message };
|
|
201
|
+
throw err;
|
|
202
|
+
});
|
|
170
203
|
return { ...collect(session, journey, viewport), ...outcome, cookies: await session.cookies() };
|
|
171
204
|
} finally {
|
|
172
205
|
await session.close();
|
package/src/audit.js
CHANGED
|
@@ -9,6 +9,7 @@ import { LANGUAGES, messages } from "./i18n.js";
|
|
|
9
9
|
import { createProvider } from "./providers.js";
|
|
10
10
|
import { filterFindings, planRun } from "./journeys.js";
|
|
11
11
|
import { resolveProviders } from "./parsers.js";
|
|
12
|
+
import { writeSavedSite } from "./replay.js";
|
|
12
13
|
|
|
13
14
|
export async function readJson(file, baseDir = ".") {
|
|
14
15
|
const full = path.resolve(baseDir, file);
|
|
@@ -48,10 +49,12 @@ export async function writeReports({ outDir, config, plan, runs, findings, expla
|
|
|
48
49
|
/**
|
|
49
50
|
* @param {{ config: any, plan?: any, gtmContainer?: any, journeys?: string[], goals?: string[], events?: string[], viewports?: string[],
|
|
50
51
|
* scripted?: boolean, ai?: boolean, provider?: string, model?: string, baseUrl?: string, maxSteps?: number, lang?: string, headed?: boolean,
|
|
51
|
-
* outDir?: string, branding?: boolean, providers?: string[], log?: (m: string) => void }} opts
|
|
52
|
+
* outDir?: string, branding?: boolean, providers?: string[], saveSite?: string, replaySite?: string, log?: (m: string) => void }} opts
|
|
52
53
|
* journeys: config journey ids or ready-made journeys (purchase, add_to_cart, sign_up, lead, search).
|
|
53
54
|
* goals: extra journeys described in plain language. events: only check these events.
|
|
54
55
|
* providers: tracking providers to check (default: config "providers", else GA4, Google Ads, Meta, TikTok).
|
|
56
|
+
* saveSite: record the site's responses and the steps taken into this folder, for replaySite (see replay.js).
|
|
57
|
+
* replaySite: answer every request from a folder saved with saveSite, without the network.
|
|
55
58
|
*/
|
|
56
59
|
export async function runAudit(opts) {
|
|
57
60
|
const { gtmContainer } = opts;
|
|
@@ -76,17 +79,23 @@ export async function runAudit(opts) {
|
|
|
76
79
|
const provider = useAi ? await createProvider({ name: opts.provider ?? config.provider, model: opts.model ?? config.model, baseUrl: opts.baseUrl ?? config.base_url }) : null;
|
|
77
80
|
if (provider) log(`Using ${provider.name} (${provider.model})`);
|
|
78
81
|
|
|
82
|
+
if (opts.saveSite) await mkdir(opts.saveSite, { recursive: true });
|
|
79
83
|
const runs = [];
|
|
80
84
|
for (const journey of journeys) {
|
|
81
85
|
for (const viewport of viewports) {
|
|
82
86
|
log(`\n▶ ${journey.id} (${viewport})`);
|
|
83
|
-
const run = await runJourney(config, journey, viewport, { scripted: opts.scripted, provider, maxSteps: opts.maxSteps, headed: opts.headed, lang, log });
|
|
87
|
+
const run = await runJourney(config, journey, viewport, { scripted: opts.scripted, provider, maxSteps: opts.maxSteps, headed: opts.headed, lang, log, saveSite: opts.saveSite, replaySite: opts.replaySite });
|
|
84
88
|
log(` ${run.completed ? "✓" : "✗"} ${run.note} · ${run.hits.length} tracking hits captured`);
|
|
85
89
|
runs.push(run);
|
|
86
90
|
}
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
const findings = filterFindings(runs.flatMap((run) => checkRun(plan, run, { lang })), events);
|
|
94
|
+
if (opts.saveSite) {
|
|
95
|
+
await writeSavedSite(opts.saveSite, { config, plan, runs, findings })
|
|
96
|
+
.then(() => log(`\nSaved the site for offline replay in ${opts.saveSite}`))
|
|
97
|
+
.catch((err) => log(`\nCould not save the site in ${opts.saveSite}: ${err.message}`));
|
|
98
|
+
}
|
|
90
99
|
let explained = null;
|
|
91
100
|
if (provider) {
|
|
92
101
|
log("\nAsking the model to explain the findings…");
|
package/src/browser.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
1
2
|
import { chromium, devices } from "playwright";
|
|
2
3
|
import { parseTrackingRequest, isCollectionEndpoint, providerOf, vendorOf, resolveProviders } from "./parsers.js";
|
|
3
4
|
import { redactBody, redactParams, redactPush, redactUrl } from "./redact.js";
|
|
@@ -196,6 +197,76 @@ function trackingBody(request) {
|
|
|
196
197
|
return body ? (body.length > MAX_BODY ? body.slice(0, MAX_BODY) : body) : null;
|
|
197
198
|
}
|
|
198
199
|
|
|
200
|
+
/**
|
|
201
|
+
* How a scripted run can find the element an action used again: the selector it was given,
|
|
202
|
+
* else a CSS path to the element, else its visible text. Refs only mean something in one run.
|
|
203
|
+
*/
|
|
204
|
+
async function replayTarget(a, locator) {
|
|
205
|
+
if (a.selector) return { selector: a.selector };
|
|
206
|
+
const found = await locator
|
|
207
|
+
.evaluate((el) => {
|
|
208
|
+
const text = (el.innerText || el.value || el.getAttribute("aria-label") || "").replace(/\s+/g, " ").trim().slice(0, 80);
|
|
209
|
+
if (window !== window.top) return { text };
|
|
210
|
+
const parts = [];
|
|
211
|
+
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
|
|
212
|
+
if (n.id && document.querySelectorAll(`#${CSS.escape(n.id)}`).length === 1) {
|
|
213
|
+
parts.unshift(`#${CSS.escape(n.id)}`);
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
let i = 1;
|
|
217
|
+
for (let s = n.previousElementSibling; s; s = s.previousElementSibling) if (s.tagName === n.tagName) i++;
|
|
218
|
+
parts.unshift(`${n.tagName.toLowerCase()}:nth-of-type(${i})`);
|
|
219
|
+
}
|
|
220
|
+
return { selector: parts.join(" > "), text };
|
|
221
|
+
}, null, { timeout: 5000 })
|
|
222
|
+
.catch(() => null);
|
|
223
|
+
if (found?.selector) return { selector: found.selector };
|
|
224
|
+
return found?.text ? { text: found.text } : a.text ? { text: a.text } : {};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Images, fonts and video don't change what tracking sends, and are most of a recording's size.
|
|
228
|
+
const MEDIA = /^(image|font|video|audio)\//;
|
|
229
|
+
|
|
230
|
+
const MEDIA_FILE = /\.(png|jpe?g|gif|webp|avif|svg|ico|bmp|woff2?|ttf|otf|eot|mp4|webm|mov|mp3|m4a|ogg|wav)$/i;
|
|
231
|
+
const isDroppedMedia = (request) =>
|
|
232
|
+
["image", "font", "media"].includes(request.resourceType()) || MEDIA_FILE.test(new URL(request.url()).pathname);
|
|
233
|
+
|
|
234
|
+
// Cookies the server sets for the page's scripts (a country or language, for example) are kept, since
|
|
235
|
+
// sites often need them to render; HttpOnly ones, where sessions and logins live, are dropped. Replay
|
|
236
|
+
// applies only one Set-Cookie header, so the kept ones are joined into one, one cookie per line.
|
|
237
|
+
function keptHeaders(headers) {
|
|
238
|
+
const isSetCookie = (h) => /^set-cookie$/i.test(h.name);
|
|
239
|
+
const cookies = headers.filter(isSetCookie).flatMap((h) => h.value.split("\n")).filter((c) => !/;\s*httponly\s*(;|$)/i.test(c));
|
|
240
|
+
return [...headers.filter((h) => !isSetCookie(h)), ...(cookies.length ? [{ name: "Set-Cookie", value: cookies.join("\n") }] : [])];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const SECRET_REQUEST_HEADER = /^(cookie|authorization|proxy-authorization)$/i;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Drops images, fonts, video and tracking requests from a recording (they are answered locally on replay, and their
|
|
247
|
+
* URLs can hold the user data redacted everywhere else), unanswered requests, requests with user data in their address, form bodies,
|
|
248
|
+
* the cookies the browser sent, HttpOnly cookies the server set, and authorization headers.
|
|
249
|
+
*/
|
|
250
|
+
async function cleanRecording(file) {
|
|
251
|
+
const har = JSON.parse(await readFile(file, "utf8"));
|
|
252
|
+
har.log.entries = har.log.entries
|
|
253
|
+
.filter((e) => !isCollectionEndpoint(e.request.url) && !MEDIA.test(e.response?.content?.mimeType ?? ""))
|
|
254
|
+
// A request that had no answer yet when the browser closed would never finish on replay either.
|
|
255
|
+
.filter((e) => e.response?.status > 0)
|
|
256
|
+
// A request whose address holds user data (an email in a form GET, for example) is left out whole.
|
|
257
|
+
.filter((e) => redactUrl(e.request.url).url === e.request.url)
|
|
258
|
+
.map((e) => {
|
|
259
|
+
// Form bodies are dropped; replay then matches POSTs by address and method.
|
|
260
|
+
const { postData: _postData, ...request } = e.request;
|
|
261
|
+
return {
|
|
262
|
+
...e,
|
|
263
|
+
request: { ...request, cookies: [], headers: request.headers.filter((h) => !SECRET_REQUEST_HEADER.test(h.name)) },
|
|
264
|
+
response: { ...e.response, cookies: [], headers: keptHeaders(e.response.headers) },
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
await writeFile(file, JSON.stringify(har));
|
|
268
|
+
}
|
|
269
|
+
|
|
199
270
|
export class AuditSession {
|
|
200
271
|
/**
|
|
201
272
|
* @param {{ viewport: string, site?: string, allowedHosts?: string[], providers?: string[], keepBodies?: boolean, blockPrivate?: boolean | ((ip: string) => boolean), blockHits?: boolean, headless?: boolean }} opts
|
|
@@ -204,6 +275,8 @@ export class AuditSession {
|
|
|
204
275
|
* providers: which tracking providers to record (default GA4, Google Ads, Meta, TikTok). Requests to the
|
|
205
276
|
* others are still answered locally, but only counted, per step, in this.skipped.
|
|
206
277
|
* site: navigation is limited to this site's host and its subdomains, plus allowedHosts.
|
|
278
|
+
* recordHar: save every response the site sends into this .har file, so the journey can be replayed offline later.
|
|
279
|
+
* replayHar: answer requests from a .har file saved with recordHar instead of the network; requests it lacks fail.
|
|
207
280
|
* blockPrivate: refuse loopback, private and link-local addresses (used when the MCP server is reachable over HTTP).
|
|
208
281
|
* All browser traffic then goes through the egress proxy. A function decides which addresses count as private (for tests).
|
|
209
282
|
*/
|
|
@@ -240,7 +313,8 @@ export class AuditSession {
|
|
|
240
313
|
launch.args = ["--force-webrtc-ip-handling-policy=disable_non_proxied_udp"];
|
|
241
314
|
}
|
|
242
315
|
this.browser = await chromium.launch(launch);
|
|
243
|
-
this.
|
|
316
|
+
const record = this.opts.recordHar ? { recordHar: { path: this.opts.recordHar, mode: "minimal" } } : {};
|
|
317
|
+
this.context = await this.browser.newContext({ ...device, locale: "en-US", ...record });
|
|
244
318
|
await this.context.exposeBinding("__atdRecord", (_source, entry) => {
|
|
245
319
|
this.record("dataLayerLog", { ...this.redactEntry(entry), step: this.currentStep, t: Date.now() });
|
|
246
320
|
});
|
|
@@ -315,6 +389,15 @@ export class AuditSession {
|
|
|
315
389
|
// the page navigates away (a page_view on the next load, a beacon on unload) can be
|
|
316
390
|
// cancelled before its route handler runs, but its request event has already fired.
|
|
317
391
|
this.context.on("request", onRequest);
|
|
392
|
+
// Registered first, so the routes below run before it and tracking requests are still answered locally.
|
|
393
|
+
if (this.opts.replayHar) {
|
|
394
|
+
// Requests the recording lacks fail, except the media left out of it: those get an empty answer,
|
|
395
|
+
// since a failed image or icon can stop a script (a consent banner waiting for its logo, for example).
|
|
396
|
+
await this.context.route("**/*", (route) =>
|
|
397
|
+
isDroppedMedia(route.request()) ? route.fulfill({ status: 200, body: "" }) : route.abort(),
|
|
398
|
+
);
|
|
399
|
+
await this.context.routeFromHAR(this.opts.replayHar, { notFound: "fallback" });
|
|
400
|
+
}
|
|
318
401
|
if (this.opts.blockHits) {
|
|
319
402
|
await this.context.route((url) => isCollectionEndpoint(url.href), async (route) => {
|
|
320
403
|
await route.fulfill({ status: 204, body: "" });
|
|
@@ -596,14 +679,23 @@ export class AuditSession {
|
|
|
596
679
|
* @param {{ action: string, ref?: number, selector?: string, text?: string, value?: string, url?: string, reason?: string, accepts_consent?: boolean }} a
|
|
597
680
|
*/
|
|
598
681
|
async act(a) {
|
|
682
|
+
const error = await this.perform(a);
|
|
683
|
+
// A scripted replay of this step may fail the same way, which must not end the journey there.
|
|
684
|
+
if (error) this.steps[this.currentStep].error = error;
|
|
685
|
+
return error;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async perform(a) {
|
|
599
689
|
const describe = a.reason || `${a.action} ${a.selector ?? (a.ref != null ? `#${a.ref}` : a.text != null ? `"${a.text}"` : a.url ?? "")}`.trim();
|
|
600
690
|
this.beginStep(describe, { action: a.action, acceptsConsent: !!a.accepts_consent });
|
|
691
|
+
const step = this.steps[this.currentStep];
|
|
601
692
|
try {
|
|
602
693
|
switch (a.action) {
|
|
603
694
|
case "goto": {
|
|
604
695
|
const target = new URL(a.url, this.page.url() === "about:blank" ? undefined : this.page.url()).href;
|
|
605
696
|
const refused = await this.checkUrl(target);
|
|
606
697
|
if (refused) return refused;
|
|
698
|
+
step.replay = { action: "goto", url: target };
|
|
607
699
|
await this.page.goto(target, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
608
700
|
break;
|
|
609
701
|
}
|
|
@@ -617,12 +709,14 @@ export class AuditSession {
|
|
|
617
709
|
if (found.error) return found.error;
|
|
618
710
|
target = found.locator;
|
|
619
711
|
} else return `${a.action} needs a ref or the element's visible text.`;
|
|
712
|
+
step.replay = { action: a.action, ...(await replayTarget(a, target)), ...(a.value !== undefined ? { value: a.value } : {}) };
|
|
620
713
|
if (a.action === "click") await this.click(target);
|
|
621
714
|
else if (a.action === "fill") await target.fill(a.value ?? "", { timeout: 10000 });
|
|
622
715
|
else await target.selectOption(a.value ?? "", { timeout: 10000 });
|
|
623
716
|
break;
|
|
624
717
|
}
|
|
625
718
|
case "scroll": {
|
|
719
|
+
step.replay = { action: "scroll", ...(a.value !== undefined ? { value: a.value } : {}) };
|
|
626
720
|
const dy = /^up$/i.test(a.value ?? "") ? -900 : 900;
|
|
627
721
|
const size = this.page.viewportSize() ?? { width: 800, height: 600 };
|
|
628
722
|
const before = await this.page.evaluate(() => window.scrollY);
|
|
@@ -634,6 +728,7 @@ export class AuditSession {
|
|
|
634
728
|
break;
|
|
635
729
|
}
|
|
636
730
|
case "wait":
|
|
731
|
+
step.replay = { action: "wait", ...(a.value !== undefined ? { value: a.value } : {}) };
|
|
637
732
|
await this.page.waitForTimeout(Math.min(Number(a.value) || 1000, 10000));
|
|
638
733
|
break;
|
|
639
734
|
default:
|
|
@@ -654,6 +749,17 @@ export class AuditSession {
|
|
|
654
749
|
}
|
|
655
750
|
|
|
656
751
|
async close() {
|
|
752
|
+
if (this.opts.recordHar && this.context) {
|
|
753
|
+
// Writes the recording. If the browser already went away, there is none to clean.
|
|
754
|
+
const written = await this.context.close().then(() => true, () => false);
|
|
755
|
+
// A recording that could not be cleaned may hold cookies or form data, so it is deleted.
|
|
756
|
+
if (written) {
|
|
757
|
+
await cleanRecording(this.opts.recordHar).catch(async (err) => {
|
|
758
|
+
console.warn(`Could not clean the recording ${this.opts.recordHar}, so it was deleted: ${err.message}`);
|
|
759
|
+
await rm(this.opts.recordHar, { force: true });
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
}
|
|
657
763
|
await this.browser?.close();
|
|
658
764
|
await this.egress?.close();
|
|
659
765
|
}
|
package/src/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import { LANGUAGES, messages } from "./i18n.js";
|
|
|
6
6
|
import { PROVIDERS } from "./providers.js";
|
|
7
7
|
import { JOURNEY_PRESETS } from "./journeys.js";
|
|
8
8
|
import { PROVIDERS as TRACKING_PROVIDERS, DEFAULT_PROVIDERS } from "./parsers.js";
|
|
9
|
+
import { replaySavedSite } from "./replay.js";
|
|
9
10
|
|
|
10
11
|
const USAGE = `ai-tag-debugger: an open-source AI agent that QA-tests your analytics and ad tracking.
|
|
11
12
|
|
|
@@ -13,6 +14,8 @@ Usage:
|
|
|
13
14
|
ai-tag-debugger run --site <url> --journey purchase [options] Audit ready-made journeys, no config needed.
|
|
14
15
|
ai-tag-debugger run --site <url> --goal "<what to do>" Audit a journey you describe in plain language.
|
|
15
16
|
ai-tag-debugger run --config <file> [options] Run an audit from a config file.
|
|
17
|
+
ai-tag-debugger replay <dir>... [--update] Audit sites saved with --save-site again, offline and without AI,
|
|
18
|
+
and list findings that changed. Exits with code 1 when any did.
|
|
16
19
|
ai-tag-debugger mcp [--http] [--port <n>] Start the MCP server for Claude, ChatGPT, Gemini and other chat apps.
|
|
17
20
|
|
|
18
21
|
Run options:
|
|
@@ -41,6 +44,12 @@ Run options:
|
|
|
41
44
|
--keep-bodies Store tracking requests as sent. By default, emails, phone numbers and user ids sent
|
|
42
45
|
for ad matching are redacted in the report and capture.json ("keep_bodies" in the config).
|
|
43
46
|
--no-branding Leave the Analitik İşler logo and credit line out of the reports ("branding": false in the config).
|
|
47
|
+
--save-site <dir> Also save the site's responses and the steps taken in this folder, so "replay" can audit
|
|
48
|
+
the same pages again offline. Its findings become the expected ones.
|
|
49
|
+
|
|
50
|
+
Replay options:
|
|
51
|
+
--update Make this replay's findings the expected ones (after checking that the changes are right).
|
|
52
|
+
--out <dir> Where to write the replay's reports (default: <dir>/last-replay).
|
|
44
53
|
|
|
45
54
|
MCP options:
|
|
46
55
|
--http Serve over Streamable HTTP instead of stdio (for apps that connect to a URL).
|
|
@@ -87,6 +96,8 @@ async function main() {
|
|
|
87
96
|
token: { type: "string" },
|
|
88
97
|
reports: { type: "string" },
|
|
89
98
|
"public-url": { type: "string" },
|
|
99
|
+
"save-site": { type: "string" },
|
|
100
|
+
update: { type: "boolean", default: false },
|
|
90
101
|
help: { type: "boolean", short: "h", default: false },
|
|
91
102
|
},
|
|
92
103
|
});
|
|
@@ -97,6 +108,27 @@ async function main() {
|
|
|
97
108
|
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"] });
|
|
98
109
|
return;
|
|
99
110
|
}
|
|
111
|
+
if (command === "replay" && !values.help) {
|
|
112
|
+
const dirs = positionals.slice(1);
|
|
113
|
+
if (!dirs.length) throw new Error("replay needs the folder of at least one site saved with --save-site.");
|
|
114
|
+
let changed = false;
|
|
115
|
+
for (const dir of dirs) {
|
|
116
|
+
const r = await replaySavedSite(dir, { update: values.update, outDir: dirs.length === 1 ? values.out : undefined }).catch((err) => {
|
|
117
|
+
console.log(`✗ ${dir}: could not replay: ${err.message.split("\n")[0]}`);
|
|
118
|
+
changed = true;
|
|
119
|
+
return null;
|
|
120
|
+
});
|
|
121
|
+
if (!r) continue;
|
|
122
|
+
const same = !r.missing.length && !r.extra.length;
|
|
123
|
+
changed ||= !same;
|
|
124
|
+
console.log(`${same ? "✓" : values.update ? "↻" : "✗"} ${r.site} (${dir}): ${same ? "same findings" : `${r.extra.length} new, ${r.missing.length} gone${values.update ? ", saved as expected" : ""}`}`);
|
|
125
|
+
for (const k of r.extra) console.log(` + ${k}`);
|
|
126
|
+
for (const k of r.missing) console.log(` - ${k}`);
|
|
127
|
+
for (const note of r.incomplete) console.log(` ! ${note}`);
|
|
128
|
+
}
|
|
129
|
+
if (changed && !values.update) process.exitCode = 1;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
100
132
|
if (values.help || command !== "run" || (!values.config && !values.site)) {
|
|
101
133
|
console.log(USAGE);
|
|
102
134
|
process.exit(values.help ? 0 : 2);
|
|
@@ -136,6 +168,7 @@ async function main() {
|
|
|
136
168
|
headed: values.headed,
|
|
137
169
|
branding: values["no-branding"] ? false : undefined,
|
|
138
170
|
outDir: values.out,
|
|
171
|
+
saveSite: values["save-site"],
|
|
139
172
|
log: (msg) => console.log(msg),
|
|
140
173
|
});
|
|
141
174
|
|
package/src/parsers.js
CHANGED
|
@@ -35,9 +35,10 @@ export function parseGa4(url, postData) {
|
|
|
35
35
|
// Matched on the GA4 protocol rather than the host, so server-side GTM
|
|
36
36
|
// endpoints on first-party domains are captured too.
|
|
37
37
|
if (!u.pathname.endsWith("/g/collect") || u.searchParams.get("v") !== "2") return null;
|
|
38
|
-
// gtag.js resends a hit to www.google.com/g/collect
|
|
39
|
-
//
|
|
40
|
-
|
|
38
|
+
// gtag.js resends a hit to www.google.com/g/collect when the first send seems lost, and
|
|
39
|
+
// copies some to doubleclick.net. GA4 doesn't count these twice, so they aren't hits.
|
|
40
|
+
// (Primary hits can carry gaf too, e.g. gaf=2, so the host decides, not the parameter.)
|
|
41
|
+
if (GOOGLE_COMPANION_HOST.test(u.hostname)) return null;
|
|
41
42
|
const base = new URLSearchParams(u.search);
|
|
42
43
|
const lines = postData ? postData.split("\n").filter(Boolean) : [""];
|
|
43
44
|
return lines.map((line) => {
|
|
@@ -135,8 +136,12 @@ export function parseTrackingRequest(url, postData) {
|
|
|
135
136
|
// hits: Google Ads remarketing and consent pings, and the other common ad pixels.
|
|
136
137
|
const OTHER_BEACONS = [
|
|
137
138
|
[/(^|\.)(doubleclick\.net|googleadservices\.com|google\.[a-z.]+)$/, /^\/pagead\/(viewthroughconversion|1p-user-list|landing)\//],
|
|
138
|
-
[/(^|\.)google\.[a-z.]+$/, /^\/
|
|
139
|
+
[/(^|\.)google\.[a-z.]+$/, /^\/rmkt\/collect\//],
|
|
139
140
|
[GOOGLE_COMPANION_HOST, /\/g\/collect$/],
|
|
141
|
+
[/(^|\.)merchant-center-analytics\.goog$/, /^\/mc\/collect/],
|
|
142
|
+
// Google tag pings on any host: consent mode (ccm), the Google tag's own /g/s/collect, and
|
|
143
|
+
// Google tag gateway paths served from the site's own domain (/<id>/gs/ccm/collect).
|
|
144
|
+
[/./, /\/(ccm|ccm\/s|g\/s)\/collect$/],
|
|
140
145
|
[/^td\.doubleclick\.net$/, /^\/td\//],
|
|
141
146
|
[/(^|\.)facebook\.com$/, /^\/privacy_sandbox\/pixel\//],
|
|
142
147
|
[/^bat\.bing\.com$/, /^\/action\//],
|
package/src/replay.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Saved sites: the responses a site sent during an audit and the steps taken, so a later
|
|
2
|
+
// version of the tool can audit the same pages offline and show what it now reports
|
|
3
|
+
// differently. Replays need no network and no AI model, so they cost nothing.
|
|
4
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { harName } from "./agent.js";
|
|
7
|
+
import { runAudit } from "./audit.js";
|
|
8
|
+
|
|
9
|
+
export const SAVED_SITE_FILE = "site.json";
|
|
10
|
+
|
|
11
|
+
/** The steps a run took, as fixed steps a scripted run can repeat. */
|
|
12
|
+
export function replayableJourney(run) {
|
|
13
|
+
const steps = run.steps.filter((s) => s.index > 0);
|
|
14
|
+
const start = steps[0]?.replay?.action === "goto" ? steps[0].replay.url : undefined;
|
|
15
|
+
return {
|
|
16
|
+
id: run.journeyId,
|
|
17
|
+
viewport: run.viewport,
|
|
18
|
+
...(start ? { start } : {}),
|
|
19
|
+
// A journey the AI could not finish (no cart, a bot check) is still incomplete when its steps replay.
|
|
20
|
+
...(run.completed ? {} : { incomplete: run.note }),
|
|
21
|
+
steps: (start ? steps.slice(1) : steps)
|
|
22
|
+
.filter((s) => s.replay)
|
|
23
|
+
.map((s) => ({ ...s.replay, reason: s.label, ...(s.acceptsConsent ? { accepts_consent: true } : {}), ...(s.error ? { optional: true } : {}) })),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One line per finding, sorted: what a replay is compared on. */
|
|
28
|
+
export function findingKeys(findings) {
|
|
29
|
+
return findings.map((f) => [f.severity, f.check, f.journey, f.viewport, f.platform ?? "-", f.event ?? "-"].join(" ")).sort();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Lines in one list and not the other, counting repeats. */
|
|
33
|
+
export function compareKeys(expected, actual) {
|
|
34
|
+
const left = [...actual];
|
|
35
|
+
const missing = [];
|
|
36
|
+
for (const k of expected) {
|
|
37
|
+
const i = left.indexOf(k);
|
|
38
|
+
if (i === -1) missing.push(k);
|
|
39
|
+
else left.splice(i, 1);
|
|
40
|
+
}
|
|
41
|
+
return { missing, extra: left };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Writes site.json next to the recorded responses. The findings of this run become the expected ones. */
|
|
45
|
+
export async function writeSavedSite(dir, { config, plan, runs, findings }) {
|
|
46
|
+
await mkdir(dir, { recursive: true });
|
|
47
|
+
for (const run of runs) {
|
|
48
|
+
const har = path.join(dir, harName(run.journeyId, run.viewport));
|
|
49
|
+
if (!(await stat(har).catch(() => null))) {
|
|
50
|
+
throw new Error(`The recording of ${run.journeyId} (${run.viewport}) is missing, probably because the browser closed early. Save the site again.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const { journeys: _journeys, plan: _plan, gtm_container: _gtm, ...site } = config;
|
|
54
|
+
const saved = {
|
|
55
|
+
version: 1,
|
|
56
|
+
saved_at: new Date().toISOString(),
|
|
57
|
+
config: site,
|
|
58
|
+
plan,
|
|
59
|
+
journeys: runs.map(replayableJourney),
|
|
60
|
+
expected: findingKeys(findings),
|
|
61
|
+
};
|
|
62
|
+
await writeFile(path.join(dir, SAVED_SITE_FILE), JSON.stringify(saved, null, 2));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Audits a saved site again, offline and without AI, and compares the findings with the expected ones.
|
|
67
|
+
* update: make this replay's findings the expected ones.
|
|
68
|
+
* @returns {Promise<{ site: string, missing: string[], extra: string[], reportPaths: string[], incomplete: string[] }>}
|
|
69
|
+
*/
|
|
70
|
+
export async function replaySavedSite(dir, { update = false, outDir, log = () => {} } = {}) {
|
|
71
|
+
const file = path.join(dir, SAVED_SITE_FILE);
|
|
72
|
+
let saved;
|
|
73
|
+
try {
|
|
74
|
+
saved = JSON.parse(await readFile(file, "utf8"));
|
|
75
|
+
} catch (err) {
|
|
76
|
+
throw new Error(`Could not read ${file}: ${err.message}`);
|
|
77
|
+
}
|
|
78
|
+
const viewports = [...new Set(saved.journeys.map((j) => j.viewport))];
|
|
79
|
+
const findings = [];
|
|
80
|
+
const reportPaths = [];
|
|
81
|
+
const incomplete = [];
|
|
82
|
+
for (const viewport of viewports) {
|
|
83
|
+
const journeys = saved.journeys.filter((j) => j.viewport === viewport).map(({ viewport: _v, ...j }) => j);
|
|
84
|
+
const out = path.resolve(outDir ?? path.join(dir, "last-replay"), viewports.length > 1 ? viewport : "");
|
|
85
|
+
const result = await runAudit({
|
|
86
|
+
config: { ...saved.config, journeys },
|
|
87
|
+
plan: saved.plan,
|
|
88
|
+
viewports: [viewport],
|
|
89
|
+
scripted: true,
|
|
90
|
+
ai: false,
|
|
91
|
+
replaySite: dir,
|
|
92
|
+
lang: "en",
|
|
93
|
+
outDir: out,
|
|
94
|
+
log,
|
|
95
|
+
});
|
|
96
|
+
findings.push(...result.findings);
|
|
97
|
+
reportPaths.push(result.reportPath);
|
|
98
|
+
// Journeys that were already unfinished when saved are compared through their findings only.
|
|
99
|
+
const wasIncomplete = (r) => journeys.find((j) => j.id === r.journeyId)?.incomplete === r.note;
|
|
100
|
+
incomplete.push(...result.runs.filter((r) => !r.completed && !wasIncomplete(r)).map((r) => `${r.journeyId} (${r.viewport}): ${r.note}`));
|
|
101
|
+
}
|
|
102
|
+
const actual = findingKeys(findings);
|
|
103
|
+
const { missing, extra } = compareKeys(saved.expected ?? [], actual);
|
|
104
|
+
if (update && (missing.length || extra.length)) {
|
|
105
|
+
await writeFile(file, JSON.stringify({ ...saved, expected: actual }, null, 2));
|
|
106
|
+
}
|
|
107
|
+
return { site: saved.config.site, missing, extra, reportPaths, incomplete };
|
|
108
|
+
}
|