@duckduckgo/autoconsent 14.95.1 → 14.97.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.
@@ -0,0 +1,599 @@
1
+ /**
2
+ * Playwright HTTPS regional proxy utilities for multi-region autoconsent testing.
3
+ *
4
+ * Launches a local Chromium browser with an HTTPS proxy selected by region, injects
5
+ * autoconsent into an isolated world (via CDP), and evaluates opt-out/opt-in flows.
6
+ * Chromium only - isolated worlds are reached through CDP, which Playwright exposes for
7
+ * Chromium alone.
8
+ *
9
+ * Requires env vars:
10
+ * - REGIONAL_PROXY_<REGION> (REGION is the uppercased two-letter region code)
11
+ * - REGIONAL_PROXY_USERNAME
12
+ * - REGIONAL_PROXY_PASSWORD
13
+ */
14
+
15
+ /**
16
+ * @typedef {import('playwright').Page} Page
17
+ * @typedef {import('playwright').Browser} Browser
18
+ * @typedef {import('playwright').LaunchOptions} LaunchOptions
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} TestOptions
23
+ * @property {'optOut'|'optIn'|null} [action='optOut']
24
+ * @property {string} [screenshotsDir]
25
+ * @property {number} [navigationTimeout=45000]
26
+ * @property {number} [completionTimeout=45000]
27
+ * @property {number} [detectionTimeout] - How long to wait for `cmpDetected` before giving up. Defaults to `completionTimeout`.
28
+ * @property {boolean} [headless=true]
29
+ * @property {LaunchOptions} [launchOptions]
30
+ */
31
+
32
+ /**
33
+ * @typedef {Object} TestResult
34
+ * @property {string} url
35
+ * @property {string} region
36
+ * @property {string[]} cmpsDetected - All CMPs detected on the page.
37
+ * @property {string|null} cmpActedOn - The CMP that was actually opted out/in.
38
+ * @property {boolean} popupFound
39
+ * @property {boolean|null} optOutResult
40
+ * @property {boolean|null} optInResult
41
+ * @property {boolean|null} selfTestResult
42
+ * @property {boolean} autoconsentDone
43
+ * @property {boolean|null} isCosmetic
44
+ * @property {string[]} errors
45
+ * @property {number} duration
46
+ * @property {string[]} screenshotPaths
47
+ */
48
+
49
+ /**
50
+ * @typedef {Object} AutoconsentContext
51
+ * @property {Object[]} received - All received autoconsent messages.
52
+ * @property {(type: string) => boolean} hasMessage
53
+ * @property {(timeout?: number, detectionTimeout?: number) => Promise<boolean>} waitForCompletion
54
+ * @property {(type: string, timeout?: number) => Promise<boolean>} waitForMessage
55
+ * @property {(url: string, region: string) => TestResult} collectResult
56
+ */
57
+
58
+ /**
59
+ * Primitives the message handler uses to talk to a specific frame's content script.
60
+ * `frameRef` is the isolated world's execution-context uniqueId the message arrived from;
61
+ * the transport resolves it to the owning CDP session and the matching page-world context.
62
+ * @typedef {Object} MessageTransport
63
+ * @property {(frameRef: string, message: object) => Promise<void>} sendToContentScript
64
+ * @property {(frameRef: string, code: string) => Promise<any>} evalInMainWorld
65
+ */
66
+
67
+ /**
68
+ * @typedef {(transport: MessageTransport) => (msg: any, frameRef: string) => Promise<void>} MessageHandlerFactory
69
+ */
70
+
71
+ import fs from 'fs';
72
+ import path from 'path';
73
+ import { fileURLToPath } from 'url';
74
+ import { chromium } from 'playwright';
75
+
76
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
77
+ const projectRoot = path.resolve(__dirname, '../../../..');
78
+
79
+ const contentScript = fs.readFileSync(path.join(projectRoot, 'dist/autoconsent.playwright.js'), 'utf8');
80
+ const rulesJson = JSON.parse(fs.readFileSync(path.join(projectRoot, 'rules/rules.json'), 'utf-8'));
81
+ const fullRules = rulesJson.autoconsent;
82
+
83
+ export const DEFAULT_REGIONS = ['us', 'gb', 'au', 'ca', 'de', 'fr', 'nl', 'ch', 'no', 'it', 'es', 'pl', 'se', 'dk', 'jp'];
84
+
85
+ /**
86
+ * Build the Playwright proxy object for a region.
87
+ * @param {string} regionKey - Two-letter region code (e.g. 'us', 'gb').
88
+ * @param {NodeJS.ProcessEnv} [env]
89
+ * @returns {{ server: string, username: string, password: string }}
90
+ */
91
+ export function buildProxyConfig(regionKey, env = process.env) {
92
+ const envVar = `REGIONAL_PROXY_${regionKey.toUpperCase()}`;
93
+ const endpoint = env[envVar];
94
+ const username = env.REGIONAL_PROXY_USERNAME;
95
+ const password = env.REGIONAL_PROXY_PASSWORD;
96
+
97
+ if (!endpoint || !username || !password) {
98
+ throw new Error(
99
+ `Missing proxy environment variables for region "${regionKey}". ` +
100
+ `Expected ${envVar}, REGIONAL_PROXY_USERNAME, and REGIONAL_PROXY_PASSWORD.`,
101
+ );
102
+ }
103
+ if (endpoint.includes('://') || endpoint.includes('@') || /:\d+$/.test(endpoint)) {
104
+ throw new Error(
105
+ `${envVar} should be a bare hostname without scheme, credentials, or port. ` + 'The library adds https:// and port 443.',
106
+ );
107
+ }
108
+
109
+ return {
110
+ server: `https://${endpoint}:443`,
111
+ username,
112
+ password,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Launch a local Playwright browser through the HTTPS proxy for a region.
118
+ * @param {string} regionKey
119
+ * @param {Partial<TestOptions>} [options]
120
+ * @returns {Promise<Browser>}
121
+ */
122
+ export async function launchRegionalProxyBrowser(regionKey, options = {}) {
123
+ return chromium.launch({
124
+ headless: options.headless ?? true,
125
+ ...(options.launchOptions ?? {}),
126
+ proxy: buildProxyConfig(regionKey),
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Inject autoconsent into a page. Call before navigating to the target URL.
132
+ *
133
+ * The content script runs in an isolated world (via CDP) while `eval` snippets execute in
134
+ * the page's main world, mirroring the browser extension. Chromium only.
135
+ * @param {Page} page
136
+ * @param {Partial<TestOptions>} [options]
137
+ * @returns {Promise<AutoconsentContext>}
138
+ */
139
+ export async function injectAutoconsent(page, options = {}) {
140
+ const action = 'action' in options ? options.action : 'optOut';
141
+ /** @type {any[]} */
142
+ const received = [];
143
+ const config = {
144
+ enabled: true,
145
+ autoAction: action,
146
+ disabledCmps: [],
147
+ enablePrehide: true,
148
+ detectRetries: 20,
149
+ enableCosmeticRules: true,
150
+ enableGeneratedRules: true,
151
+ enableHeuristicDetection: true,
152
+ enableHeuristicAction: true,
153
+ // The engine runs in the isolated world; eval snippets are forwarded to the main world.
154
+ isMainWorld: false,
155
+ logs: { lifecycle: true, rulesteps: true, detectionsteps: false, evals: false, errors: true },
156
+ };
157
+
158
+ // Remembers which frame (and its transport's sender) asked for a self test, so the
159
+ // follow-up `selfTest` message is routed back to the same content-script context even
160
+ // when several frames/CDP sessions are involved.
161
+ /** @type {{ send: MessageTransport['sendToContentScript'], frameRef: any } | null} */
162
+ let selfTestTarget = null;
163
+
164
+ // The transport-agnostic message handler. A transport supplies two primitives:
165
+ // sendToContentScript(frameRef, message) - deliver to autoconsentReceiveMessage in the content-script world
166
+ // evalInMainWorld(frameRef, code) - run an eval snippet in the frame's MAIN world
167
+ // This split mirrors the browser extension: the engine lives in an isolated world,
168
+ // while `eval` snippets execute in the page's main world.
169
+ /** @type {MessageHandlerFactory} */
170
+ const createMessageHandler = ({ sendToContentScript, evalInMainWorld }) => {
171
+ return async function handleMessage(msg, frameRef) {
172
+ received.push(msg);
173
+ switch (msg.type) {
174
+ case 'init':
175
+ await sendToContentScript(frameRef, { type: 'initResp', config, rules: { autoconsent: fullRules } });
176
+ break;
177
+ case 'eval': {
178
+ let result = false;
179
+ try {
180
+ result = await evalInMainWorld(frameRef, msg.code);
181
+ } catch {}
182
+ await sendToContentScript(frameRef, { id: msg.id, type: 'evalResp', result });
183
+ break;
184
+ }
185
+ case 'optOutResult':
186
+ case 'optInResult':
187
+ if (msg.scheduleSelfTest) {
188
+ selfTestTarget = { send: sendToContentScript, frameRef };
189
+ }
190
+ break;
191
+ case 'autoconsentDone': {
192
+ const target = selfTestTarget ?? { send: sendToContentScript, frameRef };
193
+ await target.send(target.frameRef, { type: 'selfTest' });
194
+ break;
195
+ }
196
+ case 'autoconsentError':
197
+ console.error('autoconsent error:', msg.details);
198
+ break;
199
+ }
200
+ };
201
+ };
202
+
203
+ // Isolated-world injection requires CDP, which Playwright exposes for Chromium only.
204
+ const browserName = page.context().browser()?.browserType().name();
205
+ if (browserName && browserName !== 'chromium') {
206
+ throw new Error(`regional-proxy supports Chromium only (got "${browserName}").`);
207
+ }
208
+ await injectIntoIsolatedWorld(page, createMessageHandler);
209
+
210
+ function hasMessage(/** @type {string} */ type) {
211
+ return received.some((m) => m.type === type);
212
+ }
213
+
214
+ async function waitForCompletion(timeout = 45000, detectionTimeout = timeout) {
215
+ const start = Date.now();
216
+ while (Date.now() - start < timeout) {
217
+ if (hasMessage('optOutResult') || hasMessage('optInResult')) {
218
+ return true;
219
+ }
220
+ // Detection-only mode (action: null): no action result will arrive.
221
+ if (!action && hasMessage('popupFound')) {
222
+ return true;
223
+ }
224
+ // Bail out early only if no CMP was detected within the detection window. This defaults
225
+ // to the full timeout, so slow regional pages that detect late are not abandoned (which
226
+ // would otherwise yield a false "No CMP detected" and end before opt-out can finish).
227
+ if (Date.now() - start > detectionTimeout && !hasMessage('cmpDetected')) {
228
+ return false;
229
+ }
230
+ await new Promise((r) => setTimeout(r, 500));
231
+ }
232
+ return false;
233
+ }
234
+
235
+ async function waitForMessage(/** @type {string} */ type, timeout = 30000) {
236
+ const start = Date.now();
237
+ while (Date.now() - start < timeout) {
238
+ if (hasMessage(type)) return true;
239
+ await new Promise((r) => setTimeout(r, 500));
240
+ }
241
+ return false;
242
+ }
243
+
244
+ function collectResult(/** @type {string} */ url, /** @type {string} */ region) {
245
+ /** @type {TestResult} */
246
+ const result = {
247
+ url,
248
+ region,
249
+ cmpsDetected: [],
250
+ cmpActedOn: null,
251
+ popupFound: false,
252
+ optOutResult: null,
253
+ optInResult: null,
254
+ selfTestResult: null,
255
+ autoconsentDone: false,
256
+ isCosmetic: null,
257
+ errors: [],
258
+ duration: 0,
259
+ screenshotPaths: [],
260
+ };
261
+ for (const msg of received) {
262
+ switch (msg.type) {
263
+ case 'cmpDetected':
264
+ result.cmpsDetected.push(msg.cmp);
265
+ break;
266
+ case 'popupFound':
267
+ result.popupFound = true;
268
+ break;
269
+ case 'optOutResult':
270
+ result.optOutResult = msg.result;
271
+ result.cmpActedOn = msg.cmp;
272
+ break;
273
+ case 'optInResult':
274
+ result.optInResult = msg.result;
275
+ result.cmpActedOn = msg.cmp;
276
+ break;
277
+ case 'selfTestResult':
278
+ result.selfTestResult = msg.result;
279
+ break;
280
+ case 'autoconsentDone':
281
+ result.autoconsentDone = true;
282
+ result.isCosmetic = msg.isCosmetic;
283
+ break;
284
+ case 'autoconsentError':
285
+ result.errors.push(msg.details?.msg || JSON.stringify(msg.details));
286
+ break;
287
+ }
288
+ }
289
+ return result;
290
+ }
291
+
292
+ return { received, hasMessage, waitForCompletion, waitForMessage, collectResult };
293
+ }
294
+
295
+ /**
296
+ * Isolated-world injection via CDP (Chromium only). For each frame we create a dedicated isolated
297
+ * world via `Page.createIsolatedWorld`, then inject the content script there and bridge messages
298
+ * over a per-context CDP binding.
299
+ *
300
+ * @param {Page} page
301
+ * @param {MessageHandlerFactory} createMessageHandler
302
+ */
303
+ async function injectIntoIsolatedWorld(page, createMessageHandler) {
304
+ // Isolated world name: `<prefix><pageWorldUniqueId><separator><frameId>`. Encoding the page-world
305
+ // uniqueId lets us later run eval snippets in that frame's main world.
306
+ const WORLD_PREFIX = 'autoconsent_iw_';
307
+ const WORLD_SEPARATOR = '_frame_';
308
+ const BINDING_PREFIX = 'autoconsentSendMessage_';
309
+
310
+ /** @type {Map<string, string>} isolated-world uniqueId -> page (main) world uniqueId */
311
+ const isolated2pageWorld = new Map();
312
+ /** @type {Map<string, any>} isolated-world uniqueId -> CDP session that owns it */
313
+ const sessionByContext = new Map();
314
+ /** @type {Map<string, string>} binding name -> isolated-world uniqueId */
315
+ const contextByBinding = new Map();
316
+
317
+ const handle = createMessageHandler({
318
+ sendToContentScript: async (isolatedUniqueId, message) => {
319
+ const client = sessionByContext.get(isolatedUniqueId);
320
+ if (!client) return;
321
+ try {
322
+ await client.send('Runtime.evaluate', {
323
+ expression: `autoconsentReceiveMessage(${JSON.stringify(message)})`,
324
+ uniqueContextId: isolatedUniqueId,
325
+ awaitPromise: true,
326
+ // Some pages' CSP would otherwise block evaluating in the isolated world.
327
+ allowUnsafeEvalBlockedByCSP: true,
328
+ });
329
+ } catch {
330
+ // The context may be gone if the frame navigated or detached.
331
+ }
332
+ },
333
+ evalInMainWorld: async (isolatedUniqueId, code) => {
334
+ const client = sessionByContext.get(isolatedUniqueId);
335
+ const pageWorldUniqueId = isolated2pageWorld.get(isolatedUniqueId);
336
+ if (!client || !pageWorldUniqueId) return false;
337
+ const { result, exceptionDetails } = await client.send('Runtime.evaluate', {
338
+ expression: code,
339
+ uniqueContextId: pageWorldUniqueId,
340
+ returnByValue: true,
341
+ awaitPromise: true,
342
+ // Eval snippets must run even when the page's CSP disallows eval.
343
+ allowUnsafeEvalBlockedByCSP: true,
344
+ });
345
+ return exceptionDetails ? false : (result?.value ?? false);
346
+ },
347
+ });
348
+
349
+ async function attachToSession(/** @type {any} */ client) {
350
+ client.on('Runtime.executionContextCreated', async (/** @type {any} */ event) => {
351
+ const { context } = event;
352
+ const frameId = context.auxData?.frameId;
353
+
354
+ // Our isolated world finished initializing: wire up its binding and content script.
355
+ if (context.auxData?.type === 'isolated' && typeof context.name === 'string' && context.name.startsWith(WORLD_PREFIX)) {
356
+ const separatorIndex = context.name.indexOf(WORLD_SEPARATOR);
357
+ const pageWorldUniqueId = context.name.slice(WORLD_PREFIX.length, separatorIndex);
358
+ const intendedFrameId = context.name.slice(separatorIndex + WORLD_SEPARATOR.length);
359
+ // Chromium may create the named world in other frames too; keep only the one we asked for.
360
+ if (intendedFrameId !== frameId) return;
361
+
362
+ isolated2pageWorld.set(context.uniqueId, pageWorldUniqueId);
363
+ sessionByContext.set(context.uniqueId, client);
364
+
365
+ const bindingName = `${BINDING_PREFIX}${context.uniqueId.replace(/\W/g, '_')}`;
366
+ contextByBinding.set(bindingName, context.uniqueId);
367
+ try {
368
+ await client.send('Runtime.addBinding', { name: bindingName, executionContextName: context.name });
369
+ // CDP bindings take a single string arg, so wrap it in the shape the content script expects.
370
+ await client.send('Runtime.evaluate', {
371
+ expression: `window.autoconsentSendMessage = (m) => { window.${bindingName}(JSON.stringify(m)); return Promise.resolve(); };\n${contentScript}`,
372
+ uniqueContextId: context.uniqueId,
373
+ allowUnsafeEvalBlockedByCSP: true,
374
+ });
375
+ } catch {
376
+ // The frame may have navigated or detached before we finished wiring it up.
377
+ }
378
+ return;
379
+ }
380
+
381
+ // A regular page (main) world: request an isolated world for it, tagged with its id.
382
+ if (!frameId || context.auxData?.type !== 'default' || !context.origin || context.origin === '://') return;
383
+ try {
384
+ await client.send('Page.createIsolatedWorld', {
385
+ frameId,
386
+ worldName: `${WORLD_PREFIX}${context.uniqueId}${WORLD_SEPARATOR}${frameId}`,
387
+ });
388
+ } catch {
389
+ // The frame may have navigated or detached.
390
+ }
391
+ });
392
+
393
+ client.on('Runtime.executionContextDestroyed', (/** @type {any} */ event) => {
394
+ const uniqueId = event.executionContextUniqueId;
395
+ if (!uniqueId) return;
396
+ isolated2pageWorld.delete(uniqueId);
397
+ sessionByContext.delete(uniqueId);
398
+ });
399
+
400
+ client.on('Runtime.bindingCalled', (/** @type {any} */ event) => {
401
+ const isolatedUniqueId = contextByBinding.get(event.name);
402
+ if (!isolatedUniqueId) return;
403
+ let msg;
404
+ try {
405
+ msg = JSON.parse(event.payload);
406
+ } catch {
407
+ return;
408
+ }
409
+ handle(msg, isolatedUniqueId);
410
+ });
411
+
412
+ // Page must be enabled before createIsolatedWorld; Runtime.enable replays existing contexts.
413
+ await client.send('Page.enable');
414
+ await client.send('Runtime.enable');
415
+ }
416
+
417
+ // The page session covers the main frame and all same-process (in-process) iframes.
418
+ await attachToSession(await page.context().newCDPSession(page));
419
+
420
+ // Out-of-process iframes (OOPIFs) are separate CDP targets, so each needs its own session.
421
+ // newCDPSession throws for in-process frames, which are already handled above.
422
+ const attachedFrames = new WeakSet();
423
+ async function attachToOopif(/** @type {import('playwright').Frame} */ frame) {
424
+ if (!frame.parentFrame() || attachedFrames.has(frame)) return;
425
+ // Mark synchronously (before any await) so concurrent frameattached/framenavigated events
426
+ // can't open duplicate sessions for the same frame. Unmark on failure so a later event retries.
427
+ attachedFrames.add(frame);
428
+ let client;
429
+ try {
430
+ client = await page.context().newCDPSession(frame);
431
+ } catch {
432
+ // In-process frame (already covered by the page session) or the frame detached.
433
+ attachedFrames.delete(frame);
434
+ return;
435
+ }
436
+ try {
437
+ await attachToSession(client);
438
+ } catch {
439
+ // Wiring up the session failed (e.g. the OOPIF navigated/detached). Detach the
440
+ // half-initialized session before unmarking, otherwise a retry would open a second
441
+ // session for the same frame, leaving duplicate listeners and possible double injection.
442
+ try {
443
+ await client.detach();
444
+ } catch {
445
+ // The session may already be gone.
446
+ }
447
+ attachedFrames.delete(frame);
448
+ }
449
+ }
450
+ page.on('frameattached', attachToOopif);
451
+ page.on('framenavigated', attachToOopif);
452
+ await Promise.all(page.frames().map(attachToOopif));
453
+ }
454
+
455
+ /**
456
+ * Test a URL using an already-created Playwright page.
457
+ * @param {Page} page
458
+ * @param {string} url
459
+ * @param {string} regionKey
460
+ * @param {Partial<TestOptions>} [options]
461
+ * @returns {Promise<TestResult>}
462
+ */
463
+ export async function testPage(page, url, regionKey, options = {}) {
464
+ const navTimeout = options.navigationTimeout ?? 45000;
465
+ const completionTimeout = options.completionTimeout ?? 45000;
466
+ const detectionTimeout = options.detectionTimeout ?? completionTimeout;
467
+ const screenshotsDir = options.screenshotsDir ?? path.join(projectRoot, 'test-results/regional-proxy');
468
+ const startTime = Date.now();
469
+
470
+ try {
471
+ const ctx = await injectAutoconsent(page, options);
472
+ await page.goto(url, { waitUntil: 'commit', timeout: navTimeout });
473
+
474
+ const completed = await ctx.waitForCompletion(completionTimeout, detectionTimeout);
475
+ if (completed && !ctx.hasMessage('selfTestResult')) {
476
+ await ctx.waitForMessage('selfTestResult', 10000);
477
+ }
478
+ if (completed) {
479
+ await page.waitForTimeout(1500);
480
+ }
481
+
482
+ const result = ctx.collectResult(url, regionKey);
483
+ result.duration = Date.now() - startTime;
484
+
485
+ if (!completed && !ctx.hasMessage('cmpDetected')) {
486
+ result.errors.push('No CMP detected (site may not show a cookie banner in this region)');
487
+ } else if (!completed) {
488
+ result.errors.push('Timed out waiting for autoconsent to complete');
489
+ }
490
+
491
+ try {
492
+ const domain = new URL(url).hostname;
493
+ const filepath = path.join(screenshotsDir, `${domain}-${regionKey}-final.jpg`);
494
+ fs.mkdirSync(screenshotsDir, { recursive: true });
495
+ await page.screenshot({ path: filepath, quality: 50, scale: 'css', timeout: 5000, type: 'jpeg' });
496
+ result.screenshotPaths.push(filepath);
497
+ } catch {}
498
+
499
+ return result;
500
+ } catch (e) {
501
+ return {
502
+ url,
503
+ region: regionKey,
504
+ cmpsDetected: [],
505
+ cmpActedOn: null,
506
+ popupFound: false,
507
+ optOutResult: null,
508
+ optInResult: null,
509
+ selfTestResult: null,
510
+ autoconsentDone: false,
511
+ isCosmetic: null,
512
+ errors: [e instanceof Error ? e.message : String(e)],
513
+ duration: Date.now() - startTime,
514
+ screenshotPaths: [],
515
+ };
516
+ }
517
+ }
518
+
519
+ /**
520
+ * High-level: test a URL in a specific region.
521
+ * Launches Playwright through the region proxy, injects autoconsent, waits for
522
+ * results, screenshots, and closes the browser.
523
+ * @param {string} url
524
+ * @param {string} regionKey
525
+ * @param {Partial<TestOptions>} [options]
526
+ * @returns {Promise<TestResult>}
527
+ */
528
+ export async function testUrl(url, regionKey, options = {}) {
529
+ let browser = null;
530
+ try {
531
+ browser = await launchRegionalProxyBrowser(regionKey, options);
532
+ const page = await browser.newPage();
533
+ return await testPage(page, url, regionKey, options);
534
+ } catch (e) {
535
+ return {
536
+ url,
537
+ region: regionKey,
538
+ cmpsDetected: [],
539
+ cmpActedOn: null,
540
+ popupFound: false,
541
+ optOutResult: null,
542
+ optInResult: null,
543
+ selfTestResult: null,
544
+ autoconsentDone: false,
545
+ isCosmetic: null,
546
+ errors: [e instanceof Error ? e.message : String(e)],
547
+ duration: 0,
548
+ screenshotPaths: [],
549
+ };
550
+ } finally {
551
+ try {
552
+ await browser?.close();
553
+ } catch {}
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Test one URL across several regions.
559
+ * @param {string} url
560
+ * @param {string[]} [regions]
561
+ * @param {Partial<TestOptions>} [options]
562
+ * @returns {Promise<TestResult[]>}
563
+ */
564
+ export async function testRegions(url, regions = DEFAULT_REGIONS, options = {}) {
565
+ const results = [];
566
+ for (const region of regions) {
567
+ results.push(await testUrl(url, region, options));
568
+ }
569
+ return results;
570
+ }
571
+
572
+ /**
573
+ * Format a TestResult as a human-readable line.
574
+ * @param {TestResult} result
575
+ * @returns {string}
576
+ */
577
+ export function formatResult(result) {
578
+ const actionResult = result.optOutResult ?? result.optInResult;
579
+ const actionAttempted = result.optOutResult !== null || result.optInResult !== null;
580
+ let status;
581
+ if (actionAttempted && actionResult) status = 'PASS';
582
+ else if (actionAttempted && !actionResult) status = 'ACTION FAILED';
583
+ else if (result.cmpsDetected.length > 0) status = 'PARTIAL';
584
+ else status = 'NO CMP';
585
+
586
+ const parts = [
587
+ `${status} [${result.region}] ${result.url}`,
588
+ ` CMP: ${result.cmpActedOn || 'none'}${result.cmpsDetected.length > 1 ? ` (also detected: ${result.cmpsDetected.filter((c) => c !== result.cmpActedOn).join(', ')})` : ''}`,
589
+ ` Popup: ${result.popupFound} | OptOut: ${result.optOutResult} | OptIn: ${result.optInResult} | SelfTest: ${result.selfTestResult}`,
590
+ ` Done: ${result.autoconsentDone}${result.isCosmetic ? ' (cosmetic)' : ''} | ${result.duration}ms`,
591
+ ];
592
+ if (result.errors.length > 0) {
593
+ parts.push(` Errors: ${result.errors.join('; ')}`);
594
+ }
595
+ if (result.screenshotPaths.length > 0) {
596
+ parts.push(` Screenshots: ${result.screenshotPaths.join(', ')}`);
597
+ }
598
+ return parts.join('\n');
599
+ }
@@ -0,0 +1,6 @@
1
+ ---
2
+ name: proxy-testing
3
+ description: Test autoconsent rules across geographic regions with HTTPS regional proxies in Playwright. Use when verifying region-dependent CMP behavior with standard Playwright browsers and proxy authentication.
4
+ ---
5
+
6
+ @.agents/skills/proxy-testing/SKILL.md
package/AGENTS.md CHANGED
@@ -60,7 +60,7 @@ CMPs behave differently by region:
60
60
 
61
61
  Use `if`/`then`/`else` to handle regional variants within a single rule.
62
62
 
63
- **All rule changes MUST be tested across geographic regions** to catch regional popup variations. Test from real geographic locations using available regional-testing tooling (e.g. proxy-based remote browsers).
63
+ **All rule changes MUST be tested across ALL supported geographic regions** to catch regional popup variations. Test from real geographic locations using available regional testing tooling (e.g. `proxy-testing` skill).
64
64
 
65
65
  ### Generic vs Site-Specific Rules
66
66
 
@@ -102,7 +102,7 @@ Single-string selectors cannot pierce — use arrays whenever the target is insi
102
102
  shadow root or same-origin iframe.
103
103
 
104
104
  ### General Guidelines and Gotchas
105
- - **Regional testing is mandatory** for any rule change — CMPs behave differently under GDPR (EU), CCPA (US), and other jurisdictions. Run the rule against different regions using available regional-testing tooling before considering the change done.
105
+ - **Regional testing is mandatory** for any rule change — CMPs behave differently under GDPR (EU), CCPA (US), and other jurisdictions. Run the rule against different regions using available regional testing tooling before considering the change done.
106
106
  - When verifying a rule, **look at the screenshots** on top of the API results — sometimes a rule reports success, but the popup is not actually handled - a screenshot will detect this.
107
107
  - **Paywalls do not need to be handled.** If the website presents the choice to pay or agree to cookies, the correct solution is to disable the feature on that site, so no code changes required in this case.
108
108
  - If the pop-up has an explicit "reject"-like button, you should first consider why HEURISTIC rule didn't handle it. A fix to the heuristic rule is always preferred to a new rule, as long as it doesn't cause potential false-positives on other sites.
@@ -170,4 +170,4 @@ After creating or modifying a rule:
170
170
  3. `npx playwright test tests/<name>.spec.ts` — run the E2E test
171
171
  4. `npm run prepublish` — full build including extension bundle
172
172
  5. Validate that the rule stops matching after the popup is dismissed and the page is reloaded (unless it's a cosmetic rule).
173
- 6. Check the rule works across geographic regions using available regional-testing tooling.
173
+ 6. Check the rule works across all supported geographic regions using available regional testing tooling.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,32 @@
1
+ # v14.97.0 (Fri Jun 19 2026)
2
+
3
+ #### 🚀 Enhancement
4
+
5
+ - Extend bbc.com rule to cover bbc.co.uk [#1400](https://github.com/duckduckgo/autoconsent/pull/1400) ([@cursoragent](https://github.com/cursoragent) [@muodov](https://github.com/muodov))
6
+
7
+ #### Authors: 2
8
+
9
+ - Cursor Agent ([@cursoragent](https://github.com/cursoragent))
10
+ - Maxim Tsoy ([@muodov](https://github.com/muodov))
11
+
12
+ ---
13
+
14
+ # v14.96.0 (Thu Jun 18 2026)
15
+
16
+ #### 🚀 Enhancement
17
+
18
+ - Prompt AI to test in all regions [#1398](https://github.com/duckduckgo/autoconsent/pull/1398) ([@muodov](https://github.com/muodov))
19
+ - Bump tldts-experimental from 7.0.30 to 7.4.3 [#1396](https://github.com/duckduckgo/autoconsent/pull/1396) ([@dependabot[bot]](https://github.com/dependabot[bot]))
20
+ - Add Playwright HTTPS proxy testing skill [#1387](https://github.com/duckduckgo/autoconsent/pull/1387) ([@cursoragent](https://github.com/cursoragent) [@muodov](https://github.com/muodov))
21
+
22
+ #### Authors: 3
23
+
24
+ - [@dependabot[bot]](https://github.com/dependabot[bot])
25
+ - Cursor Agent ([@cursoragent](https://github.com/cursoragent))
26
+ - Maxim Tsoy ([@muodov](https://github.com/muodov))
27
+
28
+ ---
29
+
1
30
  # v14.95.1 (Wed Jun 17 2026)
2
31
 
3
32
  #### 🐛 Bug Fix