@analitikisler/ai-tag-debugger 0.8.1 → 0.9.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 +14 -0
- package/README.md +25 -3
- package/README.tr.md +25 -3
- package/package.json +4 -2
- package/src/agent.js +15 -0
- package/src/audit.js +10 -3
- package/src/checks.js +75 -2
- package/src/cli.js +26 -1
- package/src/i18n.js +10 -0
- package/src/replay.js +47 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0 (2026-10-15)
|
|
4
|
+
|
|
5
|
+
- `aitd run ... --save-steps <file>` writes the steps each journey took into `journeys[].steps` of that config file (creating it if needed, and adding the run's plan when the config has none). A later `aitd run --config <file> --scripted --no-ai` repeats them with no AI model or API key. A journey run on several viewports keeps the steps of its first viewport.
|
|
6
|
+
- New purchase checks: a purchase sent with a value that is 0, negative or not a number (`purchase_value_invalid`), a currency that isn't a 3-letter ISO code such as USD (`purchase_currency_invalid`), or no items (`purchase_items_empty`). GA4 and Meta reporting the same order with a different value or currency is flagged as `purchase_mismatch`; purchases are paired by order ID, or taken as one order when the journey has a single purchase on each.
|
|
7
|
+
|
|
8
|
+
## 0.8.2 (2026-10-15)
|
|
9
|
+
|
|
10
|
+
- A journey that fails with an error, such as the AI provider's API refusing a request or the browser crashing, no longer ends the whole audit. That journey is marked unfinished with the error as its note, what it captured until then is kept, the other journeys still run, and the report is written. This applies to the CLI and to `run_audit` over MCP.
|
|
11
|
+
- A Consent Mode default pushed after the first Google hit is now reported as late (`consent_default_late`) when both happen on the same step, such as the start page. The check used to compare only step numbers, so it missed this. Only Google hits count, since Consent Mode only governs Google tags.
|
|
12
|
+
- An unknown viewport, given with `--viewport` or in the config's `viewports`, now stops the audit at once with a message listing the allowed values (desktop, mobile). Before, it failed only when the browser started for that journey, after the model was set up and earlier journeys had run.
|
|
13
|
+
- `ai-tag-debugger --version` (or `aitd -v`) prints the installed version.
|
|
14
|
+
- New short command `aitd`, an alias for `ai-tag-debugger` installed alongside it.
|
|
15
|
+
- For contributors: `npm run setup` checks the Node version, installs the dependencies and Chromium, and says whether an AI provider key is set. A dev container (`.devcontainer/devcontainer.json`) runs it, so VS Code and Codespaces open the repository ready to run.
|
|
16
|
+
|
|
3
17
|
## 0.8.1 (2026-10-15)
|
|
4
18
|
|
|
5
19
|
- A run with no AI provider key set now stops at once with a message naming the keys to set and the `--scripted --no-ai` option, instead of starting the browser and failing at the first step with the SDK's "Could not resolve authentication method".
|
package/README.md
CHANGED
|
@@ -40,6 +40,10 @@ Manual tag QA catches this, but it's tedious, so it rarely happens after every r
|
|
|
40
40
|
|---|---|
|
|
41
41
|
| Missing events | `purchase` never fired on the checkout journey |
|
|
42
42
|
| Missing parameters | `purchase` sent without `value` and `currency` on mobile |
|
|
43
|
+
| Invalid purchase value | `purchase` sent with `value` `0`, a negative number or text such as `"free"` |
|
|
44
|
+
| Invalid purchase currency | `purchase` sent with currency `usd` or `TL` instead of a 3-letter ISO code such as `USD` or `TRY` |
|
|
45
|
+
| Empty purchase items | `purchase` sent with an empty `items` list |
|
|
46
|
+
| Purchase mismatch | GA4 sends `value` 120 and Meta sends 115 for the same order ID |
|
|
43
47
|
| Duplicates | `add_to_cart` sent twice by one click |
|
|
44
48
|
| Consent | Meta `PageView` fired before the visitor accepted cookies, Consent Mode default missing or set too late, Consent Mode v2 signals (`ad_user_data`, `ad_personalization`) missing, tracking cookies set before consent, tags or cookies still active after the `decline_cookies` journey rejects the banner, a reject button that does nothing |
|
|
45
49
|
| Personal data | An email address sent to an ad platform in plain text |
|
|
@@ -110,6 +114,8 @@ export ANTHROPIC_API_KEY=... # or OPENAI_API_KEY, or GEMINI_API_K
|
|
|
110
114
|
ai-tag-debugger run --site https://staging.yourshop.com --journey add_to_cart --headed
|
|
111
115
|
```
|
|
112
116
|
|
|
117
|
+
The global install also adds `aitd`, a short alias: `aitd run --site … --journey add_to_cart` does the same.
|
|
118
|
+
|
|
113
119
|
Or run it without installing: `npx -y @analitikisler/ai-tag-debugger run --site … --journey add_to_cart`.
|
|
114
120
|
|
|
115
121
|
Run `ai-tag-debugger --help` to see every command and option, such as `--providers`, `--scripted` and `--no-ai`.
|
|
@@ -168,7 +174,7 @@ Each browsing step is one model call, so usage grows with **journeys × viewport
|
|
|
168
174
|
- **Cap the steps.** `--max-steps 15` stops a journey that wanders.
|
|
169
175
|
- **Expect bigger pages to cost more.** Each step sends the page view (up to 250 elements and 3,000 characters of text) to the model, so busy pages use more tokens per step than simple ones.
|
|
170
176
|
- **Use `--events`** to keep the report focused. It doesn't shorten browsing, so it barely changes usage.
|
|
171
|
-
- **Replay without AI.** After one AI run,
|
|
177
|
+
- **Replay without AI.** After one AI run, add `--save-steps <file>` to save its steps, then run that config with `--scripted --no-ai` on every deploy. See [Save the steps and replay them without AI](#save-the-steps-and-replay-them-without-ai).
|
|
172
178
|
- **Use a chat app.** Over MCP, the chat app's own model does the browsing, so no API key is used at all.
|
|
173
179
|
|
|
174
180
|
### Check a new version against saved sites
|
|
@@ -184,6 +190,17 @@ npx @analitikisler/ai-tag-debugger replay saved/*
|
|
|
184
190
|
|
|
185
191
|
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, HttpOnly and login 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.
|
|
186
192
|
|
|
193
|
+
### Save the steps and replay them without AI
|
|
194
|
+
|
|
195
|
+
`--save-steps <file>` writes the steps each journey took into `journeys[].steps` of a config file. It creates the file if it's missing, and it also saves the run's plan when the config has none. The same config then repeats the journey with no AI model and no API key:
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
ai-tag-debugger run --site https://www.yourshop.com --journey purchase --save-steps ai-tag-debugger.config.json
|
|
199
|
+
ai-tag-debugger run --config ai-tag-debugger.config.json --scripted --no-ai
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
When a journey ran on both viewports, only the first viewport's steps are kept. Keep the config in your repository and run the second command on every deploy to check the same journey without any model calls.
|
|
203
|
+
|
|
187
204
|
## Configure your site
|
|
188
205
|
|
|
189
206
|
`ai-tag-debugger.config.json`:
|
|
@@ -246,12 +263,14 @@ ai-tag-debugger run [--site <url>] [--config <file>] [--journey <id>] [--goal "<
|
|
|
246
263
|
[--max-steps <n>] [--out <dir>] [--viewport desktop|mobile] [--providers <list>]
|
|
247
264
|
[--provider anthropic|openai|gemini|openai-compatible] [--model <id>] [--base-url <url>]
|
|
248
265
|
[--lang en|tr] [--allow-host <host>] [--keep-bodies] [--scripted] [--no-ai] [--fail-on broken|risk|warning|never] [--headed]
|
|
249
|
-
[--save-site <dir>]
|
|
266
|
+
[--save-site <dir>] [--save-steps <file>]
|
|
250
267
|
ai-tag-debugger replay <dir>... [--update] [--out <dir>]
|
|
251
268
|
ai-tag-debugger mcp [--http] [--port <n>] [--host <addr>] [--token <secret>] [--public-url <https url>] [--reports <dir>]
|
|
252
269
|
[--allow-private-network]
|
|
253
270
|
```
|
|
254
271
|
|
|
272
|
+
`aitd` is a short alias for `ai-tag-debugger` and takes the same commands and options.
|
|
273
|
+
|
|
255
274
|
The exit code is `1` when findings reach `--fail-on` (default `broken`), so it can gate a deploy. See [`examples/github-actions/ai-tag-debugger.yml`](examples/github-actions/ai-tag-debugger.yml) for running it after every deployment.
|
|
256
275
|
|
|
257
276
|
Output:
|
|
@@ -358,9 +377,12 @@ A hosted server, so you don't need a tunnel, is part of the [Analitik İşler](h
|
|
|
358
377
|
## Development
|
|
359
378
|
|
|
360
379
|
```bash
|
|
361
|
-
npm
|
|
380
|
+
npm run setup # checks Node, installs the dependencies and Chromium, says whether an AI provider key is set
|
|
381
|
+
npm test # parsers, checks, each AI provider and the MCP server, end to end against the demo shop
|
|
362
382
|
```
|
|
363
383
|
|
|
384
|
+
The repository also has a dev container (`.devcontainer/devcontainer.json`). Open it in VS Code with the Dev Containers extension, or in GitHub Codespaces, and it runs `npm run setup` for you, Chromium's system libraries included. To use an AI model there, add your key as a Codespaces secret or export it in the container's terminal.
|
|
385
|
+
|
|
364
386
|
The end-to-end tests use fake model clients, so they run without an API key.
|
|
365
387
|
|
|
366
388
|
## Hosted version
|
package/README.tr.md
CHANGED
|
@@ -40,6 +40,10 @@ Tag'leri elle test etmek bunu yakalar, ama zahmetli olduğu için her sürümden
|
|
|
40
40
|
|---|---|
|
|
41
41
|
| Eksik event'ler | `purchase`, ödeme senaryosunda hiç gönderilmedi |
|
|
42
42
|
| Eksik parametreler | `purchase`, mobilde `value` ve `currency` olmadan gönderildi |
|
|
43
|
+
| Geçersiz satın alma değeri | `purchase`, `value` olarak `0`, negatif bir sayı ya da `"ücretsiz"` gibi bir metinle gönderildi |
|
|
44
|
+
| Geçersiz satın alma para birimi | `purchase`, para birimi `usd` ya da `TL` olarak gönderildi; `USD` ya da `TRY` gibi 3 harfli ISO kodu bekleniyor |
|
|
45
|
+
| Boş satın alma ürünleri | `purchase`, boş bir `items` listesiyle gönderildi |
|
|
46
|
+
| Satın alma uyuşmazlığı | Aynı sipariş numarası için GA4 `value` 120, Meta 115 gönderdi |
|
|
43
47
|
| Tekrar eden event'ler | `add_to_cart` tek tıklamada iki kez gönderildi |
|
|
44
48
|
| Çerez onayı | Meta `PageView`, ziyaretçi çerezleri kabul etmeden gönderildi; Consent Mode varsayılanı yok ya da çok geç ayarlanmış, Consent Mode v2 sinyalleri (`ad_user_data`, `ad_personalization`) eksik, onaydan önce yazılan izleme çerezleri, `decline_cookies` yolculuğu bandı reddettikten sonra hâlâ çalışan etiketler ya da çerezler, işe yaramayan reddet düğmesi |
|
|
45
49
|
| Kişisel veri | Bir e-posta adresi reklam platformuna açık metin olarak gönderildi |
|
|
@@ -110,6 +114,8 @@ export ANTHROPIC_API_KEY=... # or OPENAI_API_KEY, or GEMINI_API_K
|
|
|
110
114
|
ai-tag-debugger run --site https://staging.yourshop.com --journey add_to_cart --headed
|
|
111
115
|
```
|
|
112
116
|
|
|
117
|
+
Global kurulum kısa bir takma ad olan `aitd` komutunu da ekler: `aitd run --site … --journey add_to_cart` aynı işi yapar.
|
|
118
|
+
|
|
113
119
|
Ya da kurmadan çalıştırın: `npx -y @analitikisler/ai-tag-debugger run --site … --journey add_to_cart`.
|
|
114
120
|
|
|
115
121
|
Tüm komutları ve seçenekleri (`--providers`, `--scripted`, `--no-ai` gibi) görmek için `ai-tag-debugger --help` çalıştırın.
|
|
@@ -170,7 +176,7 @@ Her gezinme adımı bir model çağrısıdır. Yani kullanım **senaryo × ekran
|
|
|
170
176
|
- **Adım sayısını sınırlayın.** `--max-steps 15`, yolunu kaybeden bir senaryoyu durdurur.
|
|
171
177
|
- **Büyük sayfaların daha pahalı olduğunu unutmayın.** Her adım modele sayfanın görünümünü (en fazla 250 öğe ve 3.000 karakter metin) gönderir. Bu yüzden kalabalık sayfalar adım başına daha fazla token kullanır.
|
|
172
178
|
- **Raporu odaklı tutmak için `--events` kullanın.** Gezinmeyi kısaltmadığı için kullanımı pek değiştirmez.
|
|
173
|
-
- **Yapay zekâ olmadan tekrar oynatın.** Bir yapay zekâ çalıştırmasından sonra
|
|
179
|
+
- **Yapay zekâ olmadan tekrar oynatın.** Bir yapay zekâ çalıştırmasından sonra `--save-steps <dosya>` ile adımları kaydedin, ardından her deploy'da o ayar dosyasıyla `--scripted --no-ai` kullanın. Ayrıntılar için [Adımları kaydedin ve yapay zekâ olmadan tekrar oynatın](#adımları-kaydedin-ve-yapay-zekâ-olmadan-tekrar-oynatın) bölümüne bakın.
|
|
174
180
|
- **Bir yapay zekâ asistanı kullanın.** MCP üzerinden gezinmeyi asistanın kendi modeli yapar, hiç API anahtarı kullanılmaz.
|
|
175
181
|
|
|
176
182
|
### Yeni bir sürümü kaydedilmiş sitelerle karşılaştırın
|
|
@@ -186,6 +192,17 @@ npx @analitikisler/ai-tag-debugger replay saved/*
|
|
|
186
192
|
|
|
187
193
|
İ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ı, HttpOnly ve oturum açma ç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.
|
|
188
194
|
|
|
195
|
+
### Adımları kaydedin ve yapay zekâ olmadan tekrar oynatın
|
|
196
|
+
|
|
197
|
+
`--save-steps <dosya>`, her senaryonun izlediği adımları bir ayar dosyasındaki `journeys[].steps` alanına yazar. Dosya yoksa oluşturulur; ayar dosyasında plan yoksa çalıştırmanın planı da kaydedilir. Aynı ayar dosyası, hiçbir yapay zekâ modeli ve API anahtarı olmadan senaryoyu tekrar çalıştırır:
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
ai-tag-debugger run --site https://www.magazaniz.com --journey purchase --save-steps ai-tag-debugger.config.json
|
|
201
|
+
ai-tag-debugger run --config ai-tag-debugger.config.json --scripted --no-ai
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Bir senaryo iki ekran boyutunda çalıştıysa yalnızca ilk ekran boyutunun adımları tutulur. Ayar dosyasını depoda saklayın ve ikinci komutu her deploy'da çalıştırarak aynı senaryoyu hiçbir model çağrısı yapmadan kontrol edin.
|
|
205
|
+
|
|
189
206
|
## Sitenizi ayarlayın
|
|
190
207
|
|
|
191
208
|
`ai-tag-debugger.config.json`:
|
|
@@ -246,12 +263,14 @@ ai-tag-debugger run [--site <url>] [--config <file>] [--journey <id>] [--goal "<
|
|
|
246
263
|
[--max-steps <n>] [--out <dir>] [--viewport desktop|mobile] [--providers <list>]
|
|
247
264
|
[--provider anthropic|openai|gemini|openai-compatible] [--model <id>] [--base-url <url>]
|
|
248
265
|
[--lang en|tr] [--allow-host <host>] [--keep-bodies] [--scripted] [--no-ai] [--fail-on broken|risk|warning|never] [--headed]
|
|
249
|
-
[--save-site <dir>]
|
|
266
|
+
[--save-site <dir>] [--save-steps <file>]
|
|
250
267
|
ai-tag-debugger replay <dir>... [--update] [--out <dir>]
|
|
251
268
|
ai-tag-debugger mcp [--http] [--port <n>] [--host <addr>] [--token <secret>] [--public-url <https url>] [--reports <dir>]
|
|
252
269
|
[--allow-private-network]
|
|
253
270
|
```
|
|
254
271
|
|
|
272
|
+
`aitd`, `ai-tag-debugger` için kısa bir takma addır ve aynı komut ve seçenekleri alır.
|
|
273
|
+
|
|
255
274
|
Bulgular `--fail-on` seviyesine (varsayılan `broken`) ulaştığında çıkış kodu `1` olur, böylece bir deploy'u durdurabilir. Her deploy sonrası çalıştırmak için [`examples/github-actions/ai-tag-debugger.yml`](examples/github-actions/ai-tag-debugger.yml) örneğine bakın.
|
|
256
275
|
|
|
257
276
|
Çıktılar:
|
|
@@ -358,9 +377,12 @@ Tünele gerek kalmayan, hazır bir adresle barındırılan sunucu, [Analitik İ
|
|
|
358
377
|
## Geliştirme
|
|
359
378
|
|
|
360
379
|
```bash
|
|
361
|
-
npm
|
|
380
|
+
npm run setup # checks Node, installs the dependencies and Chromium, says whether an AI provider key is set
|
|
381
|
+
npm test # parsers, checks, each AI provider and the MCP server, end to end against the demo shop
|
|
362
382
|
```
|
|
363
383
|
|
|
384
|
+
Depoda bir dev container (`.devcontainer/devcontainer.json`) da var. Depoyu VS Code'da Dev Containers eklentisiyle ya da GitHub Codespaces'te açtığınızda `npm run setup` sizin için çalışır ve Chromium'un sistem kütüphanelerini de kurar. Orada bir yapay zekâ modeli kullanmak için anahtarınızı Codespaces secret olarak ekleyin ya da container'ın terminalinde export edin.
|
|
385
|
+
|
|
364
386
|
Uçtan uca testler sahte model istemcileri kullanır, bu yüzden API anahtarı olmadan çalışır.
|
|
365
387
|
|
|
366
388
|
## Barındırılan sürüm
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@analitikisler/ai-tag-debugger",
|
|
3
3
|
"mcpName": "io.github.analitikisler/ai-tag-debugger",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.9.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": {
|
|
8
|
-
"ai-tag-debugger": "src/cli.js"
|
|
8
|
+
"ai-tag-debugger": "src/cli.js",
|
|
9
|
+
"aitd": "src/cli.js"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
12
|
"src",
|
|
@@ -14,6 +15,7 @@
|
|
|
14
15
|
"CHANGELOG.md"
|
|
15
16
|
],
|
|
16
17
|
"scripts": {
|
|
18
|
+
"setup": "node scripts/setup.js",
|
|
17
19
|
"test": "node --test test/*.test.js",
|
|
18
20
|
"demo:serve": "node examples/serve.js",
|
|
19
21
|
"demo": "node src/cli.js run --config examples/demo.config.json --scripted --no-ai --out reports/demo"
|
package/src/agent.js
CHANGED
|
@@ -203,12 +203,27 @@ export async function runJourney(config, journey, viewport, opts = {}) {
|
|
|
203
203
|
throw err;
|
|
204
204
|
});
|
|
205
205
|
return { ...collect(session, journey, viewport), ...outcome, cookies: await session.cookies() };
|
|
206
|
+
} catch (err) {
|
|
207
|
+
// The model's API, the browser or the site can fail in many ways. That ends this journey, not the run,
|
|
208
|
+
// and what was captured until then is kept.
|
|
209
|
+
return { ...collect(session, journey, viewport), completed: false, note: journeyError(err) };
|
|
206
210
|
} finally {
|
|
207
211
|
if (opts.saveSite) opts.log?.(" Writing the recording…");
|
|
208
212
|
await session.close();
|
|
209
213
|
}
|
|
210
214
|
}
|
|
211
215
|
|
|
216
|
+
/** The note of a journey that ended with an error. */
|
|
217
|
+
export const journeyError = (err) => `The journey stopped with an error: ${err?.message ?? err}`;
|
|
218
|
+
|
|
219
|
+
/** A journey that failed before anything was captured, so the report can still list it. */
|
|
220
|
+
export function failedRun(journey, viewport, err, providers) {
|
|
221
|
+
return {
|
|
222
|
+
journeyId: journey.id, viewport, completed: false, note: journeyError(err), hits: [], steps: [], consentStep: null, rejectStep: null,
|
|
223
|
+
cookieLog: [], dataLayerLog: [], network: [], providers, skipped: {}, bodyBudgetStep: null, startedAt: Date.now(), pageErrors: [],
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
212
227
|
export function collect(session, journey, viewport) {
|
|
213
228
|
return {
|
|
214
229
|
journeyId: journey.id,
|
package/src/audit.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// Shared by the CLI and the MCP server's run_audit tool.
|
|
3
3
|
import { readFile, mkdir, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { runJourney } from "./agent.js";
|
|
5
|
+
import { failedRun, runJourney } from "./agent.js";
|
|
6
|
+
import { VIEWPORTS } from "./browser.js";
|
|
6
7
|
import { checkRun, groupFindings, summarize } from "./checks.js";
|
|
7
8
|
import { explainFindings, renderCsv, renderHtml, renderMarkdown } from "./report.js";
|
|
8
9
|
import { LANGUAGES, messages } from "./i18n.js";
|
|
@@ -70,6 +71,10 @@ export async function runAudit(opts) {
|
|
|
70
71
|
const { journeys, plan } = planRun({ config, plan: opts.plan, picks: opts.journeys, goals: opts.goals, events });
|
|
71
72
|
config = { ...config, providers: resolveProviders(opts.providers ?? config.providers, plan) };
|
|
72
73
|
const viewports = opts.viewports?.length ? opts.viewports : config.viewports ?? ["desktop"];
|
|
74
|
+
const allowed = Object.keys(VIEWPORTS).join(", ");
|
|
75
|
+
if (!Array.isArray(viewports)) throw new Error(`"viewports" must be a list, e.g. ["desktop", "mobile"]. Allowed values: ${allowed}.`);
|
|
76
|
+
const unknown = viewports.filter((v) => !Object.hasOwn(VIEWPORTS, v));
|
|
77
|
+
if (unknown.length) throw new Error(`Unknown viewport ${unknown.map((v) => `"${v}"`).join(", ")}. Use: ${allowed}.`);
|
|
73
78
|
if (!journeys.length) throw new Error("No journeys to run. Pick one with --journey (e.g. purchase) or describe one with --goal.");
|
|
74
79
|
if (opts.scripted) {
|
|
75
80
|
const unscripted = journeys.filter((j) => !j.steps?.length && !j.incomplete).map((j) => j.id);
|
|
@@ -84,7 +89,9 @@ export async function runAudit(opts) {
|
|
|
84
89
|
for (const journey of journeys) {
|
|
85
90
|
for (const viewport of viewports) {
|
|
86
91
|
log(`\n▶ ${journey.id} (${viewport})`);
|
|
87
|
-
|
|
92
|
+
// runJourney keeps its own errors in the run. This catches the rest, such as a browser that won't close,
|
|
93
|
+
// so one journey can't cost the report of the others.
|
|
94
|
+
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 }).catch((err) => failedRun(journey, viewport, err, config.providers));
|
|
88
95
|
log(` ${run.completed ? "✓" : "✗"} ${run.note} · ${run.hits.length} tracking hits captured`);
|
|
89
96
|
runs.push(run);
|
|
90
97
|
}
|
|
@@ -112,5 +119,5 @@ export async function runAudit(opts) {
|
|
|
112
119
|
const outDir = path.resolve(opts.outDir ?? defaultOutDir());
|
|
113
120
|
const reportPath = await writeReports({ outDir, config, plan, runs, findings, explained, lang, branding: opts.branding ?? config.branding !== false });
|
|
114
121
|
const counts = summarize(groupFindings(findings));
|
|
115
|
-
return { runs, findings, explained, counts, reportPath, outDir, lang, ...(saveError ? { saveError } : {}) };
|
|
122
|
+
return { journeys, plan, runs, findings, explained, counts, reportPath, outDir, lang, ...(saveError ? { saveError } : {}) };
|
|
116
123
|
}
|
package/src/checks.js
CHANGED
|
@@ -5,6 +5,11 @@ import { messages } from "./i18n.js";
|
|
|
5
5
|
import { isOwnAddress } from "./redact.js";
|
|
6
6
|
|
|
7
7
|
const NON_GOOGLE_AD_PLATFORMS = new Set(["meta", "tiktok"]);
|
|
8
|
+
const GOOGLE_PLATFORMS = new Set(["ga4", "google_ads"]);
|
|
9
|
+
|
|
10
|
+
/** Whether a happened before b: an earlier step, or the same step and an earlier time when both have one. */
|
|
11
|
+
const sentBefore = (a, b) => a.step < b.step || (a.step === b.step && a.t != null && b.t != null && a.t < b.t);
|
|
12
|
+
|
|
8
13
|
const EMAIL = /[A-Z0-9._%+-]+(@|%40)[A-Z0-9.-]+\.[A-Z]{2,}/gi;
|
|
9
14
|
// Consent Mode v2 added two signals to ad_storage and analytics_storage. Google requires them for EEA/UK traffic.
|
|
10
15
|
const V2_SIGNALS = ["ad_user_data", "ad_personalization"];
|
|
@@ -63,6 +68,17 @@ export function hasParam(hit, name) {
|
|
|
63
68
|
return [name, ...(PARAM_ALIASES[name] ?? [])].some((n) => hit.params?.[n] !== undefined && hit.params[n] !== "");
|
|
64
69
|
}
|
|
65
70
|
|
|
71
|
+
/** A hit's parameter under its own name or the first common alias it carries. */
|
|
72
|
+
function paramOf(hit, name) {
|
|
73
|
+
for (const n of [name, ...(PARAM_ALIASES[name] ?? [])]) {
|
|
74
|
+
if (hit.params?.[n] !== undefined && hit.params[n] !== "") return hit.params[n];
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const isPurchase = (hit) => canonicalEvent(hit.name) === "purchase";
|
|
80
|
+
const EMPTY_LIST = /^\s*(\[\s*\])?\s*$/;
|
|
81
|
+
|
|
66
82
|
/** The page load a hit came from: GA4's page load id (_p) when it has one, else the page address. */
|
|
67
83
|
function pageOf(hit) {
|
|
68
84
|
try {
|
|
@@ -198,6 +214,62 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
198
214
|
});
|
|
199
215
|
}
|
|
200
216
|
|
|
217
|
+
// Purchase values: a value that is 0, negative or not a number, a currency that isn't an ISO
|
|
218
|
+
// code, and an empty items list (reported once per platform and event).
|
|
219
|
+
const purchaseReported = new Set();
|
|
220
|
+
const reportPurchase = (hit, check, severity, detail) => {
|
|
221
|
+
const k = `${check}|${key(hit.platform, hit.name)}`;
|
|
222
|
+
if (purchaseReported.has(k)) return;
|
|
223
|
+
purchaseReported.add(k);
|
|
224
|
+
findings.push({ ...base, severity, check, platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail, evidence: JSON.stringify(hit.params) });
|
|
225
|
+
};
|
|
226
|
+
const purchases = run.hits.filter(isPurchase);
|
|
227
|
+
for (const hit of purchases) {
|
|
228
|
+
const value = paramOf(hit, "value");
|
|
229
|
+
if (value !== undefined) {
|
|
230
|
+
const n = Number(value);
|
|
231
|
+
if (!Number.isFinite(n) || String(value).trim() === "") reportPurchase(hit, "purchase_value_invalid", "broken", d.purchaseValueNaN(hit.platform, hit.name, value));
|
|
232
|
+
else if (n <= 0) reportPurchase(hit, "purchase_value_invalid", "broken", d.purchaseValueZero(hit.platform, hit.name, value));
|
|
233
|
+
}
|
|
234
|
+
const currency = paramOf(hit, "currency");
|
|
235
|
+
if (currency !== undefined && !/^[A-Z]{3}$/.test(currency)) reportPurchase(hit, "purchase_currency_invalid", "broken", d.purchaseCurrency(hit.platform, hit.name, currency));
|
|
236
|
+
// GA4 sends items as pr1, pr2...; Meta as contents or content_ids. Items left out of a GA4
|
|
237
|
+
// purchase count as empty, unless the plan requires them and missing_params already says so.
|
|
238
|
+
const listed = ["items", "contents", "content_ids"].filter((n) => hit.params?.[n] !== undefined);
|
|
239
|
+
const requiresItems = planEventFor(hit)?.required_params?.includes("items");
|
|
240
|
+
const empty = listed.length
|
|
241
|
+
? listed.every((n) => EMPTY_LIST.test(hit.params[n]))
|
|
242
|
+
: hit.platform === "ga4" && !requiresItems && !hasParam(hit, "items");
|
|
243
|
+
if (empty) reportPurchase(hit, "purchase_items_empty", "warning", d.purchaseItemsEmpty(hit.platform, hit.name));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// GA4 and Meta reporting the same order with a different value or currency. Purchases are paired
|
|
247
|
+
// by order ID; without IDs, a run with one purchase on each platform is taken as one order.
|
|
248
|
+
const ga4Buys = purchases.filter((h) => h.platform === "ga4");
|
|
249
|
+
const metaBuys = purchases.filter((h) => h.platform === "meta");
|
|
250
|
+
const orderId = (h) => paramOf(h, "transaction_id");
|
|
251
|
+
const pairs = [];
|
|
252
|
+
for (const g of ga4Buys) {
|
|
253
|
+
const m = orderId(g) !== undefined && metaBuys.find((h) => orderId(h) === orderId(g));
|
|
254
|
+
if (m) pairs.push([g, m]);
|
|
255
|
+
}
|
|
256
|
+
if (!pairs.length && ga4Buys.length === 1 && metaBuys.length === 1) {
|
|
257
|
+
const [g, m] = [ga4Buys[0], metaBuys[0]];
|
|
258
|
+
if (orderId(g) === undefined || orderId(m) === undefined) pairs.push([g, m]);
|
|
259
|
+
}
|
|
260
|
+
const mismatchSeen = new Set();
|
|
261
|
+
for (const [g, m] of pairs) {
|
|
262
|
+
const [gv, mv] = [paramOf(g, "value"), paramOf(m, "value")];
|
|
263
|
+
const [gc, mc] = [paramOf(g, "currency"), paramOf(m, "currency")];
|
|
264
|
+
const diffs = [];
|
|
265
|
+
if (gv !== undefined && mv !== undefined && Number.isFinite(Number(gv)) && Number.isFinite(Number(mv)) && Math.abs(Number(gv) - Number(mv)) > 0.005) diffs.push(["value", gv, mv]);
|
|
266
|
+
if (gc !== undefined && mc !== undefined && gc.toUpperCase() !== mc.toUpperCase()) diffs.push(["currency", gc, mc]);
|
|
267
|
+
const k = `${orderId(g) ?? ""}|${diffs.map((x) => x.join("=")).join(",")}`;
|
|
268
|
+
if (!diffs.length || mismatchSeen.has(k)) continue;
|
|
269
|
+
mismatchSeen.add(k);
|
|
270
|
+
findings.push({ ...base, severity: "warning", check: "purchase_mismatch", platform: "meta", event: m.name, step: stepLabel(m.step), detail: d.purchaseMismatch(orderId(g) ?? orderId(m), diffs), evidence: JSON.stringify({ ga4: g.params, meta: m.params }) });
|
|
271
|
+
}
|
|
272
|
+
|
|
201
273
|
// Server-side tagging. Only what the browser can see: a GA4 hit to a server container, and requests
|
|
202
274
|
// to Stape or Elevar. Meta's and TikTok's server-side copies never pass through the browser.
|
|
203
275
|
const serverHits = run.hits.filter((h) => h.delivery === "server");
|
|
@@ -222,12 +294,13 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
222
294
|
|
|
223
295
|
// Consent.
|
|
224
296
|
const granted = consentGrantedStep(run);
|
|
225
|
-
|
|
297
|
+
// Consent Mode only governs Google tags. Within one step, the push and hit times decide which came first.
|
|
298
|
+
const firstGoogleHit = run.hits.filter((h) => GOOGLE_PLATFORMS.has(h.platform)).reduce((a, h) => (!a || sentBefore(h, a) ? h : a), null);
|
|
226
299
|
if (plan.consent_mode) {
|
|
227
300
|
const defaultEntry = run.dataLayerLog.find((e) => isConsentCommand(e, "default"));
|
|
228
301
|
if (!defaultEntry) {
|
|
229
302
|
findings.push({ ...base, severity: "risk", check: "consent_default_missing", detail: d.consentDefaultMissing() });
|
|
230
|
-
} else if (
|
|
303
|
+
} else if (firstGoogleHit && sentBefore(firstGoogleHit, defaultEntry)) {
|
|
231
304
|
findings.push({ ...base, severity: "risk", check: "consent_default_late", detail: d.consentDefaultLate() });
|
|
232
305
|
}
|
|
233
306
|
}
|
package/src/cli.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { parseArgs } from "node:util";
|
|
4
5
|
import { loadConfig, runAudit } from "./audit.js";
|
|
@@ -6,7 +7,9 @@ import { LANGUAGES, messages } from "./i18n.js";
|
|
|
6
7
|
import { PROVIDERS } from "./providers.js";
|
|
7
8
|
import { JOURNEY_PRESETS } from "./journeys.js";
|
|
8
9
|
import { PROVIDERS as TRACKING_PROVIDERS, DEFAULT_PROVIDERS } from "./parsers.js";
|
|
9
|
-
import { replaySavedSite } from "./replay.js";
|
|
10
|
+
import { replaySavedSite, writeSavedSteps } from "./replay.js";
|
|
11
|
+
|
|
12
|
+
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
10
13
|
|
|
11
14
|
const USAGE = `ai-tag-debugger: an open-source AI agent that QA-tests your analytics and ad tracking.
|
|
12
15
|
|
|
@@ -46,6 +49,9 @@ Run options:
|
|
|
46
49
|
--no-branding Leave the Analitik İşler logo and credit line out of the reports ("branding": false in the config).
|
|
47
50
|
--save-site <dir> Also save the site's responses and the steps taken in this folder, so "replay" can audit
|
|
48
51
|
the same pages again offline. Its findings become the expected ones.
|
|
52
|
+
--save-steps <file> Write the steps each journey took into "journeys[].steps" of this config file (created
|
|
53
|
+
if missing), so "run --config <file> --scripted --no-ai" repeats them with no AI model
|
|
54
|
+
or API key. A journey run on several viewports keeps the steps of the first.
|
|
49
55
|
|
|
50
56
|
Replay options:
|
|
51
57
|
--update Make this replay's findings the expected ones (after checking that the changes are right).
|
|
@@ -61,6 +67,9 @@ MCP options:
|
|
|
61
67
|
--public-url <url> The HTTPS address your tunnel or host gives this server, so audits can link to their reports.
|
|
62
68
|
|
|
63
69
|
-h, --help Show this help.
|
|
70
|
+
-v, --version Print the version.
|
|
71
|
+
|
|
72
|
+
Short alias: aitd (aitd run ... is the same as ai-tag-debugger run ...).
|
|
64
73
|
|
|
65
74
|
API keys: set one of ${Object.values(PROVIDERS).map((p) => p.env).join(", ")}.
|
|
66
75
|
Any OpenAI-compatible API: --provider openai-compatible --base-url <url> --model <id>, with the key in OPENAI_COMPATIBLE_API_KEY.`;
|
|
@@ -97,11 +106,17 @@ async function main() {
|
|
|
97
106
|
reports: { type: "string" },
|
|
98
107
|
"public-url": { type: "string" },
|
|
99
108
|
"save-site": { type: "string" },
|
|
109
|
+
"save-steps": { type: "string" },
|
|
100
110
|
update: { type: "boolean", default: false },
|
|
101
111
|
help: { type: "boolean", short: "h", default: false },
|
|
112
|
+
version: { type: "boolean", short: "v", default: false },
|
|
102
113
|
},
|
|
103
114
|
});
|
|
104
115
|
const command = positionals[0];
|
|
116
|
+
if (values.version) {
|
|
117
|
+
console.log(VERSION);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
105
120
|
|
|
106
121
|
if (command === "mcp" && !values.help) {
|
|
107
122
|
const { startMcp } = await import("./mcp.js");
|
|
@@ -182,6 +197,16 @@ async function main() {
|
|
|
182
197
|
if (levels.slice(0, threshold + 1).some((l) => result.counts[l] > 0)) process.exitCode = 1;
|
|
183
198
|
}
|
|
184
199
|
if (result.saveError) process.exitCode = 1;
|
|
200
|
+
if (values["save-steps"]) {
|
|
201
|
+
try {
|
|
202
|
+
const { saved, skipped } = await writeSavedSteps(values["save-steps"], { site: loaded.config.site, plan: result.plan, journeys: result.journeys, runs: result.runs });
|
|
203
|
+
if (saved.length) console.log(`Saved the steps of ${saved.join(", ")} in ${values["save-steps"]}. Repeat them with: aitd run --config ${values["save-steps"]} --scripted --no-ai`);
|
|
204
|
+
if (skipped.length) console.log(`No steps to save for ${skipped.join(", ")}.`);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
console.log(`Could not save the steps in ${values["save-steps"]}: ${err.message}`);
|
|
207
|
+
process.exitCode = 1;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
185
210
|
}
|
|
186
211
|
|
|
187
212
|
main().catch((err) => {
|
package/src/i18n.js
CHANGED
|
@@ -121,6 +121,11 @@ const en = {
|
|
|
121
121
|
missingEventEarly: (name) => `${q(name)} was not seen, but the journey stopped early, so this may not be a tracking bug.`,
|
|
122
122
|
missingParams: (name, params) => `${q(name)} was sent without ${params.map(q).join(", ")}.`,
|
|
123
123
|
duplicate: (name, n) => `${q(name)} was sent ${n} times by one action, which inflates counts and conversions.`,
|
|
124
|
+
purchaseValueZero: (platform, name, v) => `${platform} ${q(name)} was sent with value ${q(v)}. A purchase worth 0 or less adds no revenue, and ad platforms can't optimize bids on it.`,
|
|
125
|
+
purchaseValueNaN: (platform, name, v) => `${platform} ${q(name)} was sent with value ${q(v)}, which is not a number, so the revenue is lost. Send a plain number such as 49.90, with no currency sign or thousands separator.`,
|
|
126
|
+
purchaseCurrency: (platform, name, c) => `${platform} ${q(name)} was sent with currency ${q(c)}, which is not a 3-letter ISO 4217 code in capitals (such as USD, EUR or TRY). Platforms may drop the revenue or convert it wrongly.`,
|
|
127
|
+
purchaseItemsEmpty: (platform, name) => `${platform} ${q(name)} was sent with no items, so product and item reports get nothing for this order.`,
|
|
128
|
+
purchaseMismatch: (id, diffs) => `GA4 and Meta reported ${id ? `order ${q(id)}` : "the purchase"} differently: ${diffs.map(([f, g, m]) => `${f} ${q(g)} in GA4 and ${q(m)} in Meta`).join("; ")}. One of the tags reads the wrong data.`,
|
|
124
129
|
serverSide: (platform, server, browser) => `${platform}: ${server.map(q).join(", ")} ${server.length === 1 ? "was" : "were"} sent to a server container on the site's own domain (server-side tagging)${browser.length ? `; ${browser.map(q).join(", ")} went directly from the browser` : ""}. Server-side copies of Meta and TikTok events never pass through the browser, so they can't be seen here.`,
|
|
125
130
|
dedupIdMissing: (platform, name) => `${platform} ${q(name)} was sent from the browser without an event ID, while a server-side setup was seen on this site. If the server also sends this event, ${platform} can't match the two and counts it twice.`,
|
|
126
131
|
consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
|
|
@@ -263,6 +268,11 @@ const tr = {
|
|
|
263
268
|
missingEventEarly: (name) => `${q(name)} görülmedi, ancak yolculuk erken durduğu için bu bir izleme hatası olmayabilir.`,
|
|
264
269
|
missingParams: (name, params) => `${q(name)} olayı ${params.map(q).join(", ")} ${params.length > 1 ? "parametreleri" : "parametresi"} olmadan gönderildi.`,
|
|
265
270
|
duplicate: (name, n) => `${q(name)} tek bir işlemde ${n} kez gönderildi; bu da sayıları ve dönüşümleri şişirir.`,
|
|
271
|
+
purchaseValueZero: (platform, name, v) => `${platform} ${q(name)} ${q(v)} değeriyle gönderildi. 0 veya daha düşük değerli bir satın alma gelire eklenmez ve reklam platformları teklifleri buna göre optimize edemez.`,
|
|
272
|
+
purchaseValueNaN: (platform, name, v) => `${platform} ${q(name)} sayı olmayan ${q(v)} değeriyle gönderildi, bu yüzden gelir kayboluyor. Para birimi işareti veya binlik ayırıcı olmadan 49.90 gibi düz bir sayı gönderin.`,
|
|
273
|
+
purchaseCurrency: (platform, name, c) => `${platform} ${q(name)} ${q(c)} para birimiyle gönderildi; bu, büyük harfli 3 harfli bir ISO 4217 kodu değil (USD, EUR veya TRY gibi). Platformlar geliri atabilir veya yanlış çevirebilir.`,
|
|
274
|
+
purchaseItemsEmpty: (platform, name) => `${platform} ${q(name)} ürün listesi boş gönderildi, bu yüzden ürün raporları bu siparişten veri alamaz.`,
|
|
275
|
+
purchaseMismatch: (id, diffs) => `GA4 ve Meta ${id ? `${q(id)} siparişini` : "satın almayı"} farklı bildirdi: ${diffs.map(([f, g, m]) => `${f === "value" ? "değer" : "para birimi"} GA4'te ${q(g)}, Meta'da ${q(m)}`).join("; ")}. Etiketlerden biri yanlış veriyi okuyor.`,
|
|
266
276
|
serverSide: (platform, server, browser) => `${platform}: ${server.map(q).join(", ")} sitenin kendi alan adındaki bir sunucu konteynerine gönderildi (sunucu tarafı etiketleme)${browser.length ? `; ${browser.map(q).join(", ")} doğrudan tarayıcıdan gitti` : ""}. Meta ve TikTok olaylarının sunucu tarafı kopyaları tarayıcıdan geçmez, bu yüzden burada görünmez.`,
|
|
267
277
|
dedupIdMissing: (platform, name) => `${platform} ${q(name)} tarayıcıdan olay kimliği olmadan gönderildi; sitede sunucu tarafı bir kurulum görüldü. Sunucu da bu olayı gönderiyorsa ${platform} ikisini eşleştiremez ve iki kez sayar.`,
|
|
268
278
|
consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
|
package/src/replay.js
CHANGED
|
@@ -24,6 +24,53 @@ export function replayableJourney(run) {
|
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Writes the steps each journey took into journeys[].steps of a config file, so a later
|
|
29
|
+
* --scripted --no-ai run repeats them without an AI model. Creates the file when it doesn't exist.
|
|
30
|
+
* A journey run on several viewports keeps the steps of its first one. Journeys that took no
|
|
31
|
+
* repeatable steps are left as they were. A config with no plan gets the run's, so ready-made
|
|
32
|
+
* journeys keep their expected events.
|
|
33
|
+
* @returns {Promise<{ saved: string[], skipped: string[] }>}
|
|
34
|
+
*/
|
|
35
|
+
export async function writeSavedSteps(file, { site, plan, journeys, runs }) {
|
|
36
|
+
let config = {};
|
|
37
|
+
const text = await readFile(file, "utf8").catch((err) => {
|
|
38
|
+
if (err.code === "ENOENT") return null;
|
|
39
|
+
throw new Error(`Could not read ${file}: ${err.message}`);
|
|
40
|
+
});
|
|
41
|
+
if (text !== null) {
|
|
42
|
+
try {
|
|
43
|
+
config = JSON.parse(text);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
throw new Error(`Could not read ${file}: ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
config.site ??= site;
|
|
49
|
+
if (config.plan === undefined && plan?.events?.length) config.plan = plan;
|
|
50
|
+
const list = [...(config.journeys ?? [])];
|
|
51
|
+
const saved = [];
|
|
52
|
+
const skipped = [];
|
|
53
|
+
for (const journey of journeys) {
|
|
54
|
+
const run = runs.find((r) => r.journeyId === journey.id);
|
|
55
|
+
const { viewport: _v, ...steps } = run ? replayableJourney(run) : { steps: [] };
|
|
56
|
+
if (!steps.steps.length && !steps.start) {
|
|
57
|
+
skipped.push(journey.id);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const i = list.findIndex((j) => j.id === journey.id);
|
|
61
|
+
const { start: _s, steps: _st, incomplete: _in, ...kept } = i === -1 ? { id: journey.id, ...(journey.goal ? { goal: journey.goal } : {}) } : list[i];
|
|
62
|
+
const entry = { ...kept, ...steps, id: journey.id };
|
|
63
|
+
if (i === -1) list.push(entry);
|
|
64
|
+
else list[i] = entry;
|
|
65
|
+
saved.push(journey.id);
|
|
66
|
+
}
|
|
67
|
+
if (saved.length) {
|
|
68
|
+
await mkdir(path.dirname(path.resolve(file)), { recursive: true });
|
|
69
|
+
await writeFile(file, `${JSON.stringify({ ...config, journeys: list }, null, 2)}\n`);
|
|
70
|
+
}
|
|
71
|
+
return { saved, skipped };
|
|
72
|
+
}
|
|
73
|
+
|
|
27
74
|
/** One line per finding, sorted: what a replay is compared on. */
|
|
28
75
|
export function findingKeys(findings) {
|
|
29
76
|
return findings.map((f) => [f.severity, f.check, f.journey, f.viewport, f.platform ?? "-", f.event ?? "-"].join(" ")).sort();
|