@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/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) call.status = response.status();
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
- await this.page.waitForLoadState("load", { timeout: 15000 }).catch(() => {});
743
- await this.page.waitForLoadState("networkidle", { timeout: 4000 }).catch(() => {});
744
- await this.page.waitForTimeout(400);
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
- async snapshot() {
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.trackCookies();
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) => [...document.querySelectorAll(sel)].some((n) => {
985
- const r = n.getBoundingClientRect();
986
- return r.width > 0 && r.height > 0 && getComputedStyle(n).visibility !== "hidden";
987
- }), CMP_BANNER_SELECTORS).catch(() => false);
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
- /** Notes which cookies appeared during the step just taken (names and domains only). */
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
- for (const c of await this.cookies()) {
999
- if (!seen.has(`${c.domain}|${c.name}`) && this.cookieLog.length < 500) this.cookieLog.push({ ...c, step: this.currentStep });
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
- async cookies() {
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.map((c) => ({ name: c.name, domain: c.domain }));
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
- /** The address with test campaign parameters added, unless it already carries campaign parameters. */
23
- export function withCampaignParams(url) {
24
- if (Object.keys(campaignParamsIn(url)).length) return url;
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
  }