@analitikisler/ai-tag-debugger 0.20.0 → 0.21.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 +39 -0
- package/README.md +22 -11
- package/README.tr.md +22 -11
- package/package.json +1 -1
- package/src/agent.js +5 -1
- package/src/browser.js +211 -19
- package/src/campaign.js +7 -4
- package/src/checks.js +118 -32
- package/src/cli.js +2 -1
- package/src/cmp.js +166 -0
- package/src/consent.js +200 -26
- package/src/help.js +1 -0
- package/src/hit-params.js +70 -0
- package/src/html/consent.js +34 -9
- package/src/html/journeys.js +36 -71
- package/src/html/summary.js +1 -1
- package/src/i18n.js +169 -4
- package/src/mcp.js +335 -28
- package/src/metrics.js +13 -7
- package/src/parsers.js +65 -8
- package/src/report.js +9 -3
- package/src/verify.js +3 -0
package/src/browser.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { createRequire } from "node:module";
|
|
3
4
|
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
4
5
|
import path from "node:path";
|
|
@@ -7,6 +8,8 @@ import { parseTrackingRequest, isCollectionEndpoint, providerOf, vendorOf, resol
|
|
|
7
8
|
import { redactBody, redactParams, redactPush, redactUrl } from "./redact.js";
|
|
8
9
|
import { hostScope, isPrivateAddress, navigationError, privateHostChecker } from "./guard.js";
|
|
9
10
|
import { startEgressProxy } from "./egress.js";
|
|
11
|
+
import { campaignParamsIn } from "./campaign.js";
|
|
12
|
+
import { detectCmpInPage } from "./cmp.js";
|
|
10
13
|
|
|
11
14
|
// Consent banners of the common consent platforms (CMPs), by their container. A cover that matches
|
|
12
15
|
// one of these always blocks the journey, and a reject click is checked against them afterwards.
|
|
@@ -26,7 +29,9 @@ export function installBrowser() {
|
|
|
26
29
|
export const CMP_BANNER_SELECTORS = `#onetrust-banner-sdk, #onetrust-consent-sdk, #CybotCookiebotDialog, #usercentrics-root, #didomi-host, #cookiescript_injected, .cky-consent-container, .cc-window, #cmpbox, .qc-cmp2-container, \
|
|
27
30
|
#truste-consent-track, #consent_blackbar, .osano-cm-window, #iubenda-cs-banner, .iubenda-cs-container, #cmplz-cookiebanner-container, .cmplz-cookiebanner, #cookiefirst-root, .cookiefirst-root, #axeptio_overlay, \
|
|
28
31
|
#sp_message_container, [id^="sp_message_container_"], #cookie-law-info-bar, #BorlabsCookieBox, .klaro, #termly-code-snippet-support, #cookie-information-template-wrapper, #coiOverlay, \
|
|
29
|
-
#cmpwrapper, #cmpbox2, .fc-consent-root, #ppms_cm_popup_overlay, [aria-label*="cookie" i][role=dialog]
|
|
32
|
+
#cmpwrapper, #cmpbox2, .fc-consent-root, #ppms_cm_popup_overlay, [aria-label*="cookie" i][role=dialog], \
|
|
33
|
+
[data-name^="Banner"][efl-consent-not-saved]`;
|
|
34
|
+
// Efilli draws its banner inside the open shadow root of <efilli-layout-dynamic>, so the checks below also look inside open shadow roots.
|
|
30
35
|
|
|
31
36
|
// Caps on what one audit records, so a looping page can't grow memory without limit.
|
|
32
37
|
const LOG_LIMITS = { hits: 5000, dataLayerLog: 5000, network: 10000 };
|
|
@@ -113,7 +118,7 @@ const INIT_SCRIPT = `(() => {
|
|
|
113
118
|
// and form fields, it finds custom controls that sites build from divs and spans
|
|
114
119
|
// (role attributes, tabindex, or a pointer cursor). Elements on screen come first,
|
|
115
120
|
// so a long menu or footer can't push the button the agent needs out of the list.
|
|
116
|
-
function snapshotInPage({ first, limit, textLimit, frameBox }) {
|
|
121
|
+
function snapshotInPage({ first, limit, textLimit, frameBox, offscreenLimit = null }) {
|
|
117
122
|
const visible = (el) => {
|
|
118
123
|
const r = el.getBoundingClientRect();
|
|
119
124
|
if (r.width <= 0 || r.height <= 0) return false;
|
|
@@ -187,8 +192,11 @@ function snapshotInPage({ first, limit, textLimit, frameBox }) {
|
|
|
187
192
|
.map((el, order) => ({ el, order, tier: tierOf(el) }))
|
|
188
193
|
.sort((a, b) => a.tier - b.tier || a.order - b.order);
|
|
189
194
|
const firstCovered = ranked.findIndex((x) => x.tier === 2);
|
|
195
|
+
let offscreen = 0;
|
|
190
196
|
const items = ranked
|
|
191
197
|
.filter((item, i) => item.tier < 2 || i - firstCovered < 30) // only a few covered or clipped ones
|
|
198
|
+
// A compact view keeps a few elements above or below the screen; scrolling brings the rest on screen.
|
|
199
|
+
.filter((item) => item.tier !== 1 || offscreenLimit === null || offscreen++ < offscreenLimit)
|
|
192
200
|
.slice(0, limit)
|
|
193
201
|
.map((item) => ({ ...item, onScreen: item.tier === 0 }));
|
|
194
202
|
const lines = [];
|
|
@@ -377,6 +385,72 @@ export function isTransientNavigationError(err, failedLoad) {
|
|
|
377
385
|
return code === "timeout" || TRANSIENT_NET_ERRORS.test(code);
|
|
378
386
|
}
|
|
379
387
|
|
|
388
|
+
/**
|
|
389
|
+
* What a step's analytics and ad cookies say, without keeping their values: the GA4 client id from _ga
|
|
390
|
+
* (the cid GA4 hits carry anyway), whether _gcl_aw and _fbc hold the click ids the landing page was opened
|
|
391
|
+
* with ("landing") or other ones ("other"), and whether _gcl_au and _fbp exist. Null when none of them do.
|
|
392
|
+
*/
|
|
393
|
+
export function identityOf(cookies, landingUrl) {
|
|
394
|
+
const value = (name) => cookies.find((c) => c.name === name)?.value ?? "";
|
|
395
|
+
const sent = landingUrl ? campaignParamsIn(landingUrl) : {};
|
|
396
|
+
const out = {};
|
|
397
|
+
const ga = /^GA\d\.\d+\.(\d+\.\d+)$/.exec(value("_ga"));
|
|
398
|
+
if (ga) out.ga_client_id = ga[1];
|
|
399
|
+
// _gcl_aw is GCL.<time>.<gclid>; _fbc is fb.<n>.<time>.<fbclid>.
|
|
400
|
+
const aw = /^GCL\.\d+\.(.+)$/.exec(value("_gcl_aw"));
|
|
401
|
+
if (aw) out.gcl_aw = aw[1] === sent.gclid ? "landing" : "other";
|
|
402
|
+
const fbc = /^fb\.\d+\.\d+\.(.+)$/.exec(value("_fbc"));
|
|
403
|
+
if (fbc) out.fbc = fbc[1] === sent.fbclid ? "landing" : "other";
|
|
404
|
+
if (value("_gcl_au")) out.gcl_au = true;
|
|
405
|
+
if (value("_fbp")) out.fbp = true;
|
|
406
|
+
return Object.keys(out).length ? out : null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Runs in the page: the Google tag's own consent state (google_tag_data.ics), or null before a Google tag loaded.
|
|
411
|
+
* Per consent type the default and update it holds, and whether it applied a default and an update at all, whether
|
|
412
|
+
* the default came after a tag had already read consent (wasSetLate), and whether wait_for_update ran out. It is how
|
|
413
|
+
* the tag sees consent, wherever it was set: gtag() in the page, or a consent platform's GTM template, which never
|
|
414
|
+
* shows in the dataLayer. Not documented by Google; read defensively.
|
|
415
|
+
*/
|
|
416
|
+
export function googleConsentStateInPage() {
|
|
417
|
+
const ics = window.google_tag_data?.ics;
|
|
418
|
+
if (!ics || typeof ics !== "object") return null;
|
|
419
|
+
const word = (v) => (v === true ? "granted" : v === false ? "denied" : undefined);
|
|
420
|
+
const entries = {};
|
|
421
|
+
for (const [type, e] of Object.entries(ics.entries ?? {})) {
|
|
422
|
+
if (!e || typeof e !== "object") continue;
|
|
423
|
+
const d = word(e.default);
|
|
424
|
+
const u = word(e.update);
|
|
425
|
+
entries[type] = { ...(d ? { default: d } : {}), ...(u ? { update: u } : {}) };
|
|
426
|
+
}
|
|
427
|
+
return { entries, usedDefault: Boolean(ics.usedDefault), usedUpdate: Boolean(ics.usedUpdate), wasSetLate: Boolean(ics.wasSetLate), waitPeriodTimedOut: Boolean(ics.waitPeriodTimedOut) };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Runs in the page: whether it is something other than the site, so what a step captured there says nothing about
|
|
432
|
+
* the site's tracking. A bot challenge in its place (Cloudflare, DataDome, HUMAN, Akamai), or a page that crashed in
|
|
433
|
+
* the browser (the error screen a Next.js app shows when its scripts fail). Null for a page that looks like itself.
|
|
434
|
+
* @returns {null | { kind: "challenge"|"crash", vendor?: string }}
|
|
435
|
+
*/
|
|
436
|
+
export function pageProblemInPage() {
|
|
437
|
+
const title = (document.title || "").trim();
|
|
438
|
+
const text = (document.body?.innerText || "").replace(/\s+/g, " ").trim().slice(0, 3000);
|
|
439
|
+
const has = (selector) => {
|
|
440
|
+
try {
|
|
441
|
+
return Boolean(document.querySelector(selector));
|
|
442
|
+
} catch {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
if (/^just a moment\.\.\.$|^attention required! \| cloudflare$/i.test(title) || has("#challenge-form, #challenge-running, #cf-challenge-running")) return { kind: "challenge", vendor: "Cloudflare" };
|
|
447
|
+
if (has('iframe[src*="captcha-delivery.com"]')) return { kind: "challenge", vendor: "DataDome" };
|
|
448
|
+
if (has("#px-captcha")) return { kind: "challenge", vendor: "HUMAN" };
|
|
449
|
+
if (/^access denied$/i.test(title) && /reference\s*#\s*[\d.a-f]+/i.test(text)) return { kind: "challenge", vendor: "Akamai" };
|
|
450
|
+
if (/^application error: a client-side exception has occurred/i.test(text)) return { kind: "crash", vendor: "Next.js" };
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
|
|
380
454
|
export class AuditSession {
|
|
381
455
|
/**
|
|
382
456
|
* @param {{ viewport: string, site?: string, allowedHosts?: string[], providers?: string[], keepBodies?: boolean, blockPrivate?: boolean | ((ip: string) => boolean), blockHits?: boolean, headless?: boolean }} opts
|
|
@@ -406,6 +480,10 @@ export class AuditSession {
|
|
|
406
480
|
this.consentStep = null;
|
|
407
481
|
this.rejectStep = null;
|
|
408
482
|
this.cookieLog = []; // each cookie name and domain with the step it was first seen at (never values)
|
|
483
|
+
this.cookieChanges = []; // { step, name, domain, change: added|changed|removed }, never values
|
|
484
|
+
this.cookieHashes = new Map(); // domain|name -> a hash of the value at the last step, kept in memory only
|
|
485
|
+
this.cmp = null; // the consent platform and its setup, from detectCmpInPage, with the step it was first seen at
|
|
486
|
+
this.refusedScripts = new Map(); // step -> how many of the page's scripts were refused for coming back as HTML
|
|
409
487
|
this.errors = [];
|
|
410
488
|
this.dataLayerLog = [];
|
|
411
489
|
this.network = [];
|
|
@@ -419,6 +497,7 @@ export class AuditSession {
|
|
|
419
497
|
this.errorPageShown = false; // whether Chromium's error page committed in the main frame since navigate() began
|
|
420
498
|
this.bodyBytes = 0;
|
|
421
499
|
this.bodyBudgetStep = null; // the step at which POST bodies stopped being stored
|
|
500
|
+
this.lastTrackingAt = 0; // when a tag, consent or tracking request or a dataLayer push last happened, for settle()
|
|
422
501
|
this.startedAt = Date.now();
|
|
423
502
|
}
|
|
424
503
|
|
|
@@ -448,11 +527,16 @@ export class AuditSession {
|
|
|
448
527
|
const record = this.opts.recordHar ? { recordHar: { path: this.opts.recordHar, mode: "minimal" } } : {};
|
|
449
528
|
this.context = await this.browser.newContext({ ...device, locale: "en-US", ...record });
|
|
450
529
|
await this.context.exposeBinding("__atdRecord", (_source, entry) => {
|
|
530
|
+
this.lastTrackingAt = Date.now();
|
|
451
531
|
this.record("dataLayerLog", { ...this.redactEntry(entry), step: this.currentStep, t: Date.now() });
|
|
452
532
|
});
|
|
453
533
|
await this.context.addInitScript(INIT_SCRIPT);
|
|
454
534
|
this.page = await this.context.newPage();
|
|
455
535
|
this.page.on("pageerror", (err) => this.errors.push({ step: this.currentStep, message: err.message }));
|
|
536
|
+
// A script that comes back as an HTML page (a bot check, or files a new deployment no longer has) is refused.
|
|
537
|
+
this.page.on("console", (msg) => {
|
|
538
|
+
if (msg.type() === "error" && /^Refused to execute script from .*MIME type \('text\/html'\)/.test(msg.text())) this.refusedScripts.set(this.currentStep, (this.refusedScripts.get(this.currentStep) ?? 0) + 1);
|
|
539
|
+
});
|
|
456
540
|
this.page.on("framenavigated", (frame) => {
|
|
457
541
|
if (frame !== this.page.mainFrame()) return;
|
|
458
542
|
if (frame.url().startsWith("chrome-error:")) this.errorPageShown = true;
|
|
@@ -476,6 +560,8 @@ export class AuditSession {
|
|
|
476
560
|
for (const hit of hits) {
|
|
477
561
|
if (!this.checks(hit.platform)) continue;
|
|
478
562
|
const entry = { ...this.redactHit(hit), step: this.stepForHit(hit), pageUrl: this.page.url(), t: Date.now() };
|
|
563
|
+
const late = this.lateFor(hit, entry);
|
|
564
|
+
if (late) Object.assign(entry, late);
|
|
479
565
|
if (this.record("hits", entry)) recorded.push(entry);
|
|
480
566
|
}
|
|
481
567
|
if (recorded.length) hitsOf.set(request, recorded);
|
|
@@ -493,6 +579,7 @@ export class AuditSession {
|
|
|
493
579
|
this.context.on("request", (request) => {
|
|
494
580
|
const provider = providerOf(request.url());
|
|
495
581
|
if (!provider) return;
|
|
582
|
+
this.lastTrackingAt = Date.now();
|
|
496
583
|
if (!provider.always && !this.checks(provider.key)) {
|
|
497
584
|
const counts = (this.skipped[this.currentStep] ??= {});
|
|
498
585
|
counts[provider.label] = (counts[provider.label] ?? 0) + 1;
|
|
@@ -516,7 +603,14 @@ export class AuditSession {
|
|
|
516
603
|
});
|
|
517
604
|
this.context.on("response", (response) => {
|
|
518
605
|
const call = calls.get(response.request());
|
|
519
|
-
if (call)
|
|
606
|
+
if (!call) return;
|
|
607
|
+
call.status = response.status();
|
|
608
|
+
// A consent platform's script or setup file date tells which build of its setup the site serves. Setup files
|
|
609
|
+
// (OneTrust's, Cookiebot's) are fetched by script rather than loaded as one.
|
|
610
|
+
if (call.provider === "consent" && ["script", "fetch", "xhr"].includes(call.type)) {
|
|
611
|
+
const lastModified = response.headers()["last-modified"];
|
|
612
|
+
if (lastModified) call.lastModified = lastModified;
|
|
613
|
+
}
|
|
520
614
|
});
|
|
521
615
|
this.context.on("requestfailed", (request) => {
|
|
522
616
|
const call = calls.get(request);
|
|
@@ -603,6 +697,25 @@ export class AuditSession {
|
|
|
603
697
|
return loaded !== undefined && loaded < this.currentStep ? loaded : this.currentStep;
|
|
604
698
|
}
|
|
605
699
|
|
|
700
|
+
/**
|
|
701
|
+
* A page_view that arrives during a wait or a scroll for the page an earlier step loaded, when that step
|
|
702
|
+
* got none: gtag can hold it for a consent update or a slow tag until after the step was reported. It goes
|
|
703
|
+
* to the step that loaded the page, marked late, so the report doesn't read as "this click sent nothing".
|
|
704
|
+
* Returns { step, late: { during, ms } } or null.
|
|
705
|
+
*/
|
|
706
|
+
lateFor(hit, entry) {
|
|
707
|
+
if (hit.name !== "page_view" || !hit.params?.page_location || entry.step !== this.currentStep) return null;
|
|
708
|
+
const now = this.steps[this.currentStep];
|
|
709
|
+
if (!["wait", "scroll"].includes(now?.action)) return null;
|
|
710
|
+
const page = pageKey(hit.params.page_location);
|
|
711
|
+
const loaded = this.loadedIn.get(page);
|
|
712
|
+
if (loaded === undefined || loaded >= this.currentStep || page !== pageKey(this.page.url())) return null;
|
|
713
|
+
// A second page_view for the same page (a virtual one, a resend) is the step's own.
|
|
714
|
+
if (this.hits.some((h) => h.name === "page_view" && h.step >= loaded && h.params?.page_location && pageKey(h.params.page_location) === page)) return null;
|
|
715
|
+
const since = this.steps[loaded].endedAt ?? this.steps[loaded].t;
|
|
716
|
+
return { step: loaded, late: { during: this.currentStep, ms: Math.max(0, entry.t - since) } };
|
|
717
|
+
}
|
|
718
|
+
|
|
606
719
|
/** The hit with user data for ad matching redacted, and userData: { fields, plain } when there was any. */
|
|
607
720
|
/** Whether requests of this provider are recorded; with detection on, a known provider seen for the first time joins the list. */
|
|
608
721
|
checks(key) {
|
|
@@ -739,25 +852,41 @@ export class AuditSession {
|
|
|
739
852
|
}
|
|
740
853
|
|
|
741
854
|
async settle() {
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
855
|
+
for (let round = 0; round < 2; round++) {
|
|
856
|
+
await this.page.waitForLoadState("load", { timeout: 15000 }).catch(() => {});
|
|
857
|
+
await this.page.waitForLoadState("networkidle", { timeout: 4000 }).catch(() => {});
|
|
858
|
+
await this.page.waitForTimeout(400);
|
|
859
|
+
// The network can go quiet before the tags are done: gtag holds a page_view until the consent
|
|
860
|
+
// platform answers (wait_for_update), and a consent platform loads its own scripts first. So the
|
|
861
|
+
// step ends once no tag, consent or tracking request and no dataLayer push came for quietMs.
|
|
862
|
+
const quiet = this.opts.quietMs ?? 1200;
|
|
863
|
+
const until = Date.now() + (this.opts.quietCapMs ?? 3000);
|
|
864
|
+
while (Date.now() < until) {
|
|
865
|
+
const wait = this.lastTrackingAt + quiet - Date.now();
|
|
866
|
+
if (wait <= 0) break;
|
|
867
|
+
await this.page.waitForTimeout(Math.min(wait, until - Date.now()));
|
|
868
|
+
}
|
|
869
|
+
// A click whose page starts loading only now (a link delayed for its tags) is waited for once more.
|
|
870
|
+
if (!this.leaving) break;
|
|
871
|
+
await this.page.waitForEvent("framenavigated", { predicate: (f) => f === this.page.mainFrame(), timeout: 5000 }).catch(() => {});
|
|
872
|
+
}
|
|
745
873
|
}
|
|
746
874
|
|
|
747
|
-
|
|
875
|
+
/** @param {{ offscreenLimit?: number|null }} [opts] offscreenLimit: how many elements above or below the screen to list; null for all. */
|
|
876
|
+
async snapshot({ offscreenLimit = null } = {}) {
|
|
748
877
|
const blocked = await this.enforce();
|
|
749
878
|
if (blocked && this.page.url() === "about:blank") return { url: "about:blank", title: "", elements: "", text: `(${blocked})` };
|
|
750
879
|
try {
|
|
751
880
|
// The main page first, then iframes (booking and payment widgets often live in one).
|
|
752
881
|
const frames = await this.visibleFrames();
|
|
753
|
-
const main = await this.page.evaluate(snapshotInPage, { first: 1, limit: frames.length ? MAX_ELEMENTS - 60 : MAX_ELEMENTS, textLimit: 3000 });
|
|
882
|
+
const main = await this.page.evaluate(snapshotInPage, { first: 1, limit: frames.length ? MAX_ELEMENTS - 60 : MAX_ELEMENTS, textLimit: 3000, offscreenLimit });
|
|
754
883
|
this.refFrames = new Map();
|
|
755
884
|
const lines = [...main.lines];
|
|
756
885
|
let next = main.next;
|
|
757
886
|
let more = main.more;
|
|
758
887
|
for (const { frame, frameBox } of frames) {
|
|
759
888
|
if (next > MAX_ELEMENTS) break;
|
|
760
|
-
const sub = await frame.evaluate(snapshotInPage, { first: next, limit: MAX_ELEMENTS - next + 1, textLimit: 0, frameBox }).catch(() => null);
|
|
889
|
+
const sub = await frame.evaluate(snapshotInPage, { first: next, limit: MAX_ELEMENTS - next + 1, textLimit: 0, frameBox, offscreenLimit }).catch(() => null);
|
|
761
890
|
if (!sub?.lines.length) continue;
|
|
762
891
|
lines.push(`(inside an embedded frame from ${new URL(sub.url).host})`, ...sub.lines);
|
|
763
892
|
for (let r = next; r < sub.next; r++) this.refFrames.set(r, frame);
|
|
@@ -901,13 +1030,36 @@ export class AuditSession {
|
|
|
901
1030
|
*/
|
|
902
1031
|
async act(a) {
|
|
903
1032
|
const error = await this.perform(a);
|
|
904
|
-
await this.
|
|
1033
|
+
await this.observe();
|
|
905
1034
|
// A scripted replay of this step may fail the same way, which must not end the journey there.
|
|
906
1035
|
if (error) this.steps[this.currentStep].error = error;
|
|
907
1036
|
this.steps[this.currentStep].pagePath = pagePath(this.page.url());
|
|
1037
|
+
this.steps[this.currentStep].endedAt = Date.now(); // when the step was reported, so a late hit can say how late
|
|
908
1038
|
return error;
|
|
909
1039
|
}
|
|
910
1040
|
|
|
1041
|
+
/**
|
|
1042
|
+
* What the step just taken left behind, beyond its requests: the cookies it changed, the consent platform the
|
|
1043
|
+
* first time one is seen, and the Google tag's consent state when it changed. act() calls it after every action;
|
|
1044
|
+
* the consent scenarios call it after the steps they take themselves.
|
|
1045
|
+
*/
|
|
1046
|
+
async observe() {
|
|
1047
|
+
await this.trackCookies();
|
|
1048
|
+
if (!this.cmp) {
|
|
1049
|
+
const cmp = await this.evaluateWithin(detectCmpInPage);
|
|
1050
|
+
if (cmp) this.cmp = { ...cmp, step: this.currentStep };
|
|
1051
|
+
}
|
|
1052
|
+
const problem = await this.evaluateWithin(pageProblemInPage);
|
|
1053
|
+
if (problem) {
|
|
1054
|
+
const refused = this.refusedScripts.get(this.currentStep) ?? 0;
|
|
1055
|
+
this.steps[this.currentStep].pageProblem = { ...problem, ...(refused ? { refusedScripts: refused } : {}) };
|
|
1056
|
+
}
|
|
1057
|
+
// Kept on the step only when it changed, so the steps that hold one are where the Google tag's state moved.
|
|
1058
|
+
const tagConsent = await this.evaluateWithin(googleConsentStateInPage);
|
|
1059
|
+
const before = this.steps.slice(0, this.currentStep).map((st) => st.tagConsent).filter(Boolean).at(-1);
|
|
1060
|
+
if (tagConsent && JSON.stringify(tagConsent) !== JSON.stringify(before)) this.steps[this.currentStep].tagConsent = tagConsent;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
911
1063
|
async perform(a) {
|
|
912
1064
|
const describe = a.reason || `${a.action} ${a.selector ?? (a.ref != null ? `#${a.ref}` : a.text != null ? `"${a.text}"` : a.url ?? "")}`.trim();
|
|
913
1065
|
this.beginStep(describe, { action: a.action, acceptsConsent: !!a.accepts_consent, rejectsConsent: !!a.rejects_consent });
|
|
@@ -981,10 +1133,14 @@ export class AuditSession {
|
|
|
981
1133
|
if (a.rejects_consent && this.rejectStep === null) {
|
|
982
1134
|
this.rejectStep = this.currentStep;
|
|
983
1135
|
// A rejected banner should close; one that is still showing means the button did nothing.
|
|
984
|
-
step.bannerOpenAfter = await this.page.evaluate((sel) =>
|
|
985
|
-
const
|
|
986
|
-
|
|
987
|
-
|
|
1136
|
+
step.bannerOpenAfter = await this.page.evaluate((sel) => {
|
|
1137
|
+
const roots = [document];
|
|
1138
|
+
for (let i = 0; i < roots.length; i++) for (const n of roots[i].querySelectorAll("*")) if (n.shadowRoot) roots.push(n.shadowRoot);
|
|
1139
|
+
return roots.flatMap((r) => [...r.querySelectorAll(sel)]).some((n) => {
|
|
1140
|
+
const r = n.getBoundingClientRect();
|
|
1141
|
+
return r.width > 0 && r.height > 0 && getComputedStyle(n).visibility !== "hidden";
|
|
1142
|
+
});
|
|
1143
|
+
}, CMP_BANNER_SELECTORS).catch(() => false);
|
|
988
1144
|
}
|
|
989
1145
|
return null;
|
|
990
1146
|
} catch (err) {
|
|
@@ -992,20 +1148,56 @@ export class AuditSession {
|
|
|
992
1148
|
}
|
|
993
1149
|
}
|
|
994
1150
|
|
|
995
|
-
/**
|
|
1151
|
+
/**
|
|
1152
|
+
* Notes the cookies the step just taken added, changed or removed, and what the analytics and ad cookies
|
|
1153
|
+
* say (identityOf). Values never leave this function: a short hash of each, held until the next step,
|
|
1154
|
+
* is enough to tell that one changed.
|
|
1155
|
+
*/
|
|
996
1156
|
async trackCookies() {
|
|
1157
|
+
const all = await this.rawCookies();
|
|
997
1158
|
const seen = new Set(this.cookieLog.map((c) => `${c.domain}|${c.name}`));
|
|
998
|
-
|
|
999
|
-
|
|
1159
|
+
const now = new Map();
|
|
1160
|
+
for (const c of all) {
|
|
1161
|
+
const k = `${c.domain}|${c.name}`;
|
|
1162
|
+
const hash = createHash("sha256").update(c.value).digest("base64").slice(0, 16);
|
|
1163
|
+
now.set(k, hash);
|
|
1164
|
+
if (!seen.has(k) && this.cookieLog.length < 500) this.cookieLog.push({ name: c.name, domain: c.domain, step: this.currentStep });
|
|
1165
|
+
const before = this.cookieHashes.get(k);
|
|
1166
|
+
const change = before === undefined ? "added" : before !== hash ? "changed" : null;
|
|
1167
|
+
if (change && this.cookieChanges.length < 2000) this.cookieChanges.push({ step: this.currentStep, name: c.name, domain: c.domain, change });
|
|
1168
|
+
}
|
|
1169
|
+
for (const k of this.cookieHashes.keys()) {
|
|
1170
|
+
if (now.has(k) || this.cookieChanges.length >= 2000) continue;
|
|
1171
|
+
const [domain, ...name] = k.split("|");
|
|
1172
|
+
this.cookieChanges.push({ step: this.currentStep, name: name.join("|"), domain, change: "removed" });
|
|
1000
1173
|
}
|
|
1174
|
+
this.cookieHashes = now;
|
|
1175
|
+
const identity = identityOf(all, this.landing?.requested);
|
|
1176
|
+
if (identity) this.steps[this.currentStep].identity = identity;
|
|
1001
1177
|
}
|
|
1002
1178
|
|
|
1003
|
-
|
|
1179
|
+
/** page.evaluate(fn), or null when it fails or the page does not answer within ms: a page stuck in a script never answers, and the next page read reports that. */
|
|
1180
|
+
async evaluateWithin(fn, ms = 2000) {
|
|
1181
|
+
let timer;
|
|
1182
|
+
const result = await Promise.race([
|
|
1183
|
+
this.page.evaluate(fn).catch(() => null),
|
|
1184
|
+
new Promise((resolve) => (timer = setTimeout(() => resolve(null), ms))),
|
|
1185
|
+
]);
|
|
1186
|
+
clearTimeout(timer);
|
|
1187
|
+
return result;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/** The browser's cookies with their values, for trackCookies only; [] when the browser takes too long. */
|
|
1191
|
+
async rawCookies() {
|
|
1004
1192
|
let timer;
|
|
1005
1193
|
const tooLong = new Promise((resolve) => (timer = setTimeout(() => resolve([]), 10_000)));
|
|
1006
1194
|
const cookies = await Promise.race([this.context.cookies().catch(() => []), tooLong]);
|
|
1007
1195
|
clearTimeout(timer);
|
|
1008
|
-
return cookies
|
|
1196
|
+
return cookies;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
async cookies() {
|
|
1200
|
+
return (await this.rawCookies()).map((c) => ({ name: c.name, domain: c.domain }));
|
|
1009
1201
|
}
|
|
1010
1202
|
|
|
1011
1203
|
async close() {
|
package/src/campaign.js
CHANGED
|
@@ -19,10 +19,13 @@ export function campaignParamsIn(url) {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
/**
|
|
23
|
+
* The address with test campaign parameters added, unless it already carries campaign parameters.
|
|
24
|
+
* Given parameters of the caller's own (a real campaign's UTM values, a gclid), those are set instead, always.
|
|
25
|
+
*/
|
|
26
|
+
export function withCampaignParams(url, own = null) {
|
|
27
|
+
if (!own && Object.keys(campaignParamsIn(url)).length) return url;
|
|
25
28
|
const u = new URL(url);
|
|
26
|
-
for (const [k, v] of Object.entries(TEST_PARAMS)) u.searchParams.set(k, v);
|
|
29
|
+
for (const [k, v] of Object.entries(own ?? TEST_PARAMS)) u.searchParams.set(k, v);
|
|
27
30
|
return u.href;
|
|
28
31
|
}
|