@duckduckgo/autoconsent 16.43.1 → 16.44.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/.agents/lib/regional-testing/harness.mjs +536 -0
- package/.agents/skills/oxylabs-testing/SKILL.md +99 -0
- package/.agents/skills/oxylabs-testing/scripts/oxylabs.mjs +300 -0
- package/.agents/skills/oxylabs-testing/scripts/oxylabs.test.mjs +37 -0
- package/.agents/skills/proxy-testing/SKILL.md +5 -2
- package/.agents/skills/proxy-testing/scripts/regional-proxy.mjs +22 -471
- package/.claude/skills/oxylabs-testing/SKILL.md +6 -0
- package/.claude/skills/proxy-testing/SKILL.md +1 -1
- package/.claude/user-settings.json.TEMPLATE +2 -0
- package/AGENTS.md +10 -2
- package/CHANGELOG.md +47 -0
- package/data/coverage.json +2005 -1512
- package/dist/addon-firefox/background.bundle.js +9 -3
- package/dist/addon-firefox/compact-rules.json +1 -1
- package/dist/addon-firefox/manifest.json +1 -1
- package/dist/addon-firefox/popup.bundle.js +9 -3
- package/dist/addon-firefox/rule-index.json +1 -1
- package/dist/addon-firefox/rules.json +1 -1
- package/dist/addon-mv3/background.bundle.js +9 -3
- package/dist/addon-mv3/compact-rules.json +1 -1
- package/dist/addon-mv3/manifest.json +1 -1
- package/dist/addon-mv3/popup.bundle.js +9 -3
- package/dist/addon-mv3/rule-index.json +1 -1
- package/dist/addon-mv3/rules.json +1 -1
- package/dist/autoconsent.standalone.js +1 -1
- package/docs/rule-syntax.md +4 -4
- package/package.json +2 -2
- package/readme.md +1 -1
- package/rules/autoconsent/bandcamp.json +42 -3
- package/rules/autoconsent/chatgpt.json +2 -2
- package/rules/autoconsent/elsevier.json +69 -0
- package/rules/autoconsent/fides.json +10 -2
- package/rules/autoconsent/lacoccinelle.json +97 -0
- package/rules/autoconsent/woo-commerce-com.json +28 -11
- package/rules/compact-rules.json +1 -1
- package/rules/rule-index.json +1 -1
- package/rules/rules.json +1 -1
- package/tests/bandcamp.spec.ts +7 -1
- package/tests/elsevier.spec.ts +3 -0
- package/tests/fides.spec.ts +7 -1
- package/tests/lacoccinelle.spec.ts +3 -0
- package/tests/woo-commerce-com.spec.ts +4 -1
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-agnostic harness for regional autoconsent testing in Playwright (Chromium only).
|
|
3
|
+
*
|
|
4
|
+
* Injects autoconsent into an isolated world of every frame (via CDP), answers its messages the
|
|
5
|
+
* way the browser extension does, waits for the opt-out/opt-in flow, and screenshots the result.
|
|
6
|
+
* Provider modules (`proxy-testing`, `oxylabs-testing` skills) supply the browser.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {import('playwright').Page} Page
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** @typedef {import('../../../lib/types.js').Config} Config */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Primitives the message handler uses to talk to a specific frame's content script.
|
|
17
|
+
* `frameRef` is the isolated world's execution-context uniqueId the message arrived from;
|
|
18
|
+
* the transport resolves it to the owning CDP session and the matching page-world context.
|
|
19
|
+
* @typedef {Object} MessageTransport
|
|
20
|
+
* @property {(frameRef: string, message: object) => Promise<void>} sendToContentScript
|
|
21
|
+
* @property {(frameRef: string, code: string) => Promise<any>} evalInMainWorld
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {(transport: MessageTransport) => (msg: any, frameRef: string) => Promise<void>} MessageHandlerFactory
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {'regional-proxy'|'oxylabs'} ProviderName
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {Object} TestOptions
|
|
34
|
+
* @property {'optOut'|'optIn'|null} [action='optOut']
|
|
35
|
+
* @property {string} [screenshotsDir]
|
|
36
|
+
* @property {number} [navigationTimeout=45000]
|
|
37
|
+
* @property {number} [completionTimeout=45000]
|
|
38
|
+
* @property {number} [detectionTimeout] - How long to wait for `cmpDetected` before giving up. Defaults to `completionTimeout`.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {Object} TestResult
|
|
43
|
+
* @property {string} url
|
|
44
|
+
* @property {string} region
|
|
45
|
+
* @property {ProviderName} provider - Which browser provider produced this result.
|
|
46
|
+
* @property {string[]} cmpsDetected - All CMPs detected on the page.
|
|
47
|
+
* @property {string|null} cmpActedOn - The CMP that was actually opted out/in.
|
|
48
|
+
* @property {boolean} popupFound
|
|
49
|
+
* @property {boolean|null} optOutResult
|
|
50
|
+
* @property {boolean|null} optInResult
|
|
51
|
+
* @property {boolean|null} selfTestResult
|
|
52
|
+
* @property {boolean} autoconsentDone
|
|
53
|
+
* @property {boolean|null} isCosmetic
|
|
54
|
+
* @property {string[]} errors
|
|
55
|
+
* @property {number} duration
|
|
56
|
+
* @property {string[]} screenshotPaths
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @typedef {Object} AutoconsentContext
|
|
61
|
+
* @property {Object[]} received - All received autoconsent messages.
|
|
62
|
+
* @property {(type: string) => boolean} hasMessage
|
|
63
|
+
* @property {(timeout?: number, detectionTimeout?: number, isPaused?: () => boolean) => Promise<boolean>} waitForCompletion - While `isPaused()` is true, neither timeout runs down.
|
|
64
|
+
* @property {(type: string, timeout?: number) => Promise<boolean>} waitForMessage
|
|
65
|
+
* @property {(url: string, region: string) => TestResult} collectResult
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What a provider module plugs into `runTest`.
|
|
70
|
+
* @typedef {Object} Provider
|
|
71
|
+
* @property {ProviderName} name
|
|
72
|
+
* @property {string} defaultScreenshotsDir
|
|
73
|
+
* @property {string} [screenshotTag] - Added to screenshot file names so providers sharing a directory don't overwrite each other.
|
|
74
|
+
* @property {(page: Page, options: Partial<TestOptions>) => Promise<AutoconsentContext>} inject
|
|
75
|
+
* @property {(page: Page, ctx: any, options: Partial<TestOptions>) => Promise<void>} [afterNavigation] - Runs after the page commits, before waiting for autoconsent.
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Provider code for the isolated world. Its messages go through the same binding as autoconsent's,
|
|
80
|
+
* so they reach Node even if the page navigates right after.
|
|
81
|
+
* @typedef {Object} IsolatedWorldExtension
|
|
82
|
+
* @property {string} [script] - Runs after the content script; reports to Node with `window.autoconsentSendMessage(msg)`.
|
|
83
|
+
* @property {(msg: any) => void} [onMessage] - Called with every message from the isolated world.
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
import fs from 'fs';
|
|
87
|
+
import path from 'path';
|
|
88
|
+
import { fileURLToPath } from 'url';
|
|
89
|
+
|
|
90
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
91
|
+
export const projectRoot = path.resolve(__dirname, '../../..');
|
|
92
|
+
|
|
93
|
+
const contentScript = fs.readFileSync(path.join(projectRoot, 'dist/autoconsent.playwright.js'), 'utf8');
|
|
94
|
+
const rulesJson = JSON.parse(fs.readFileSync(path.join(projectRoot, 'rules/rules.json'), 'utf-8'));
|
|
95
|
+
const fullRules = rulesJson.autoconsent;
|
|
96
|
+
|
|
97
|
+
/** All regions in the verification policy (every region with a regional proxy). */
|
|
98
|
+
export const ALL_REGIONS = ['us', 'gb', 'au', 'ca', 'de', 'fr', 'nl', 'ch', 'no', 'it', 'es', 'pl', 'se', 'dk', 'jp'];
|
|
99
|
+
|
|
100
|
+
/** Core pass: default verification set (CCPA, UK GDPR, EEA GDPR). Add the reported region when not already covered. */
|
|
101
|
+
export const CORE_REGIONS = ['us', 'gb', 'de'];
|
|
102
|
+
|
|
103
|
+
/** Expanded pass: escalation set covering all non-GDPR regimes plus GDPR representatives. */
|
|
104
|
+
export const EXPANDED_REGIONS = ['us', 'gb', 'de', 'fr', 'nl', 'pl', 'au', 'ca', 'jp'];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Inject autoconsent into a page. Call before navigating to the target URL.
|
|
108
|
+
*
|
|
109
|
+
* The content script runs in an isolated world (via CDP) while `eval` snippets execute in
|
|
110
|
+
* the page's main world, mirroring the browser extension. Chromium only.
|
|
111
|
+
* @param {Page} page
|
|
112
|
+
* @param {Partial<TestOptions>} options
|
|
113
|
+
* @param {ProviderName} provider - Recorded in the collected TestResult.
|
|
114
|
+
* @param {IsolatedWorldExtension} [extension] - Provider code that runs next to the content script.
|
|
115
|
+
* @returns {Promise<AutoconsentContext>}
|
|
116
|
+
*/
|
|
117
|
+
export async function injectAutoconsent(page, options, provider, extension = {}) {
|
|
118
|
+
const action = 'action' in options ? options.action : 'optOut';
|
|
119
|
+
/** @type {any[]} */
|
|
120
|
+
const received = [];
|
|
121
|
+
/** @type {Partial<Config>} */
|
|
122
|
+
const config = {
|
|
123
|
+
enabled: true,
|
|
124
|
+
autoAction: action,
|
|
125
|
+
disabledCmps: [],
|
|
126
|
+
enablePrehide: true,
|
|
127
|
+
detectRetries: 20,
|
|
128
|
+
enableCosmeticRules: true,
|
|
129
|
+
enableGeneratedRules: true,
|
|
130
|
+
enableHeuristicDetection: true,
|
|
131
|
+
heuristicMode: 'tier2',
|
|
132
|
+
// The engine runs in the isolated world; eval snippets are forwarded to the main world.
|
|
133
|
+
isMainWorld: false,
|
|
134
|
+
logs: { lifecycle: true, rulesteps: true, detectionsteps: false, evals: false, errors: true, messages: false, waits: false },
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Remembers which frame (and its transport's sender) asked for a self test, so the
|
|
138
|
+
// follow-up `selfTest` message is routed back to the same content-script context even
|
|
139
|
+
// when several frames/CDP sessions are involved.
|
|
140
|
+
/** @type {{ send: MessageTransport['sendToContentScript'], frameRef: any } | null} */
|
|
141
|
+
let selfTestTarget = null;
|
|
142
|
+
|
|
143
|
+
// The transport-agnostic message handler. A transport supplies two primitives:
|
|
144
|
+
// sendToContentScript(frameRef, message) - deliver to autoconsentReceiveMessage in the content-script world
|
|
145
|
+
// evalInMainWorld(frameRef, code) - run an eval snippet in the frame's MAIN world
|
|
146
|
+
// This split mirrors the browser extension: the engine lives in an isolated world,
|
|
147
|
+
// while `eval` snippets execute in the page's main world.
|
|
148
|
+
/** @type {MessageHandlerFactory} */
|
|
149
|
+
const createMessageHandler = ({ sendToContentScript, evalInMainWorld }) => {
|
|
150
|
+
return async function handleMessage(msg, frameRef) {
|
|
151
|
+
received.push(msg);
|
|
152
|
+
extension.onMessage?.(msg);
|
|
153
|
+
switch (msg.type) {
|
|
154
|
+
case 'init':
|
|
155
|
+
await sendToContentScript(frameRef, { type: 'initResp', config, rules: { autoconsent: fullRules } });
|
|
156
|
+
break;
|
|
157
|
+
case 'eval': {
|
|
158
|
+
let result = false;
|
|
159
|
+
try {
|
|
160
|
+
result = await evalInMainWorld(frameRef, msg.code);
|
|
161
|
+
} catch {}
|
|
162
|
+
await sendToContentScript(frameRef, { id: msg.id, type: 'evalResp', result });
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case 'optOutResult':
|
|
166
|
+
case 'optInResult':
|
|
167
|
+
if (msg.scheduleSelfTest) {
|
|
168
|
+
selfTestTarget = { send: sendToContentScript, frameRef };
|
|
169
|
+
}
|
|
170
|
+
break;
|
|
171
|
+
case 'autoconsentDone': {
|
|
172
|
+
const target = selfTestTarget ?? { send: sendToContentScript, frameRef };
|
|
173
|
+
await target.send(target.frameRef, { type: 'selfTest' });
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
case 'autoconsentError':
|
|
177
|
+
console.error('autoconsent error:', msg.details);
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// Isolated-world injection requires CDP, which Playwright exposes for Chromium only.
|
|
184
|
+
const browserName = page.context().browser()?.browserType().name();
|
|
185
|
+
if (browserName && browserName !== 'chromium') {
|
|
186
|
+
throw new Error(`Regional testing supports Chromium only (got "${browserName}").`);
|
|
187
|
+
}
|
|
188
|
+
await injectIntoIsolatedWorld(page, createMessageHandler, extension.script ?? '');
|
|
189
|
+
|
|
190
|
+
function hasMessage(/** @type {string} */ type) {
|
|
191
|
+
return received.some((m) => m.type === type);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function waitForCompletion(timeout = 45000, detectionTimeout = timeout, isPaused = () => false) {
|
|
195
|
+
let start = Date.now();
|
|
196
|
+
while (Date.now() - start < timeout) {
|
|
197
|
+
if (hasMessage('optOutResult') || hasMessage('optInResult')) {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
// Detection-only mode (action: null): no action result will arrive.
|
|
201
|
+
if (!action && hasMessage('popupFound')) {
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
// Bail out early only if no CMP was detected within the detection window. This defaults
|
|
205
|
+
// to the full timeout, so slow regional pages that detect late are not abandoned (which
|
|
206
|
+
// would otherwise yield a false "No CMP detected" and end before opt-out can finish).
|
|
207
|
+
if (Date.now() - start > detectionTimeout && !hasMessage('cmpDetected')) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
const before = Date.now();
|
|
211
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
212
|
+
if (isPaused()) start += Date.now() - before;
|
|
213
|
+
}
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function waitForMessage(/** @type {string} */ type, timeout = 30000) {
|
|
218
|
+
const start = Date.now();
|
|
219
|
+
while (Date.now() - start < timeout) {
|
|
220
|
+
if (hasMessage(type)) return true;
|
|
221
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function collectResult(/** @type {string} */ url, /** @type {string} */ region) {
|
|
227
|
+
const result = emptyResult(url, region, provider);
|
|
228
|
+
for (const msg of received) {
|
|
229
|
+
switch (msg.type) {
|
|
230
|
+
case 'cmpDetected':
|
|
231
|
+
result.cmpsDetected.push(msg.cmp);
|
|
232
|
+
break;
|
|
233
|
+
case 'popupFound':
|
|
234
|
+
result.popupFound = true;
|
|
235
|
+
break;
|
|
236
|
+
case 'optOutResult':
|
|
237
|
+
result.optOutResult = msg.result;
|
|
238
|
+
result.cmpActedOn = msg.cmp;
|
|
239
|
+
break;
|
|
240
|
+
case 'optInResult':
|
|
241
|
+
result.optInResult = msg.result;
|
|
242
|
+
result.cmpActedOn = msg.cmp;
|
|
243
|
+
break;
|
|
244
|
+
case 'selfTestResult':
|
|
245
|
+
result.selfTestResult = msg.result;
|
|
246
|
+
break;
|
|
247
|
+
case 'autoconsentDone':
|
|
248
|
+
result.autoconsentDone = true;
|
|
249
|
+
result.isCosmetic = msg.isCosmetic;
|
|
250
|
+
break;
|
|
251
|
+
case 'autoconsentError':
|
|
252
|
+
result.errors.push(msg.details?.msg || JSON.stringify(msg.details));
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { received, hasMessage, waitForCompletion, waitForMessage, collectResult };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Isolated-world injection via CDP (Chromium only). For each frame we create a dedicated isolated
|
|
264
|
+
* world via `Page.createIsolatedWorld`, then inject the content script there and bridge messages
|
|
265
|
+
* over a per-context CDP binding.
|
|
266
|
+
*
|
|
267
|
+
* @param {Page} page
|
|
268
|
+
* @param {MessageHandlerFactory} createMessageHandler
|
|
269
|
+
* @param {string} extraScript - Runs in each isolated world after the content script.
|
|
270
|
+
*/
|
|
271
|
+
async function injectIntoIsolatedWorld(page, createMessageHandler, extraScript) {
|
|
272
|
+
// Isolated world name: `<prefix><pageWorldUniqueId><separator><frameId>`. Encoding the page-world
|
|
273
|
+
// uniqueId lets us later run eval snippets in that frame's main world.
|
|
274
|
+
const WORLD_PREFIX = 'autoconsent_iw_';
|
|
275
|
+
const WORLD_SEPARATOR = '_frame_';
|
|
276
|
+
const BINDING_PREFIX = 'autoconsentSendMessage_';
|
|
277
|
+
|
|
278
|
+
/** @type {Map<string, string>} isolated-world uniqueId -> page (main) world uniqueId */
|
|
279
|
+
const isolated2pageWorld = new Map();
|
|
280
|
+
/** @type {Map<string, any>} isolated-world uniqueId -> CDP session that owns it */
|
|
281
|
+
const sessionByContext = new Map();
|
|
282
|
+
/** @type {Map<string, string>} binding name -> isolated-world uniqueId */
|
|
283
|
+
const contextByBinding = new Map();
|
|
284
|
+
|
|
285
|
+
const handle = createMessageHandler({
|
|
286
|
+
sendToContentScript: async (isolatedUniqueId, message) => {
|
|
287
|
+
const client = sessionByContext.get(isolatedUniqueId);
|
|
288
|
+
if (!client) return;
|
|
289
|
+
try {
|
|
290
|
+
await client.send('Runtime.evaluate', {
|
|
291
|
+
expression: `autoconsentReceiveMessage(${JSON.stringify(message)})`,
|
|
292
|
+
uniqueContextId: isolatedUniqueId,
|
|
293
|
+
awaitPromise: true,
|
|
294
|
+
// Some pages' CSP would otherwise block evaluating in the isolated world.
|
|
295
|
+
allowUnsafeEvalBlockedByCSP: true,
|
|
296
|
+
});
|
|
297
|
+
} catch {
|
|
298
|
+
// The context may be gone if the frame navigated or detached.
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
evalInMainWorld: async (isolatedUniqueId, code) => {
|
|
302
|
+
const client = sessionByContext.get(isolatedUniqueId);
|
|
303
|
+
const pageWorldUniqueId = isolated2pageWorld.get(isolatedUniqueId);
|
|
304
|
+
if (!client || !pageWorldUniqueId) return false;
|
|
305
|
+
const { result, exceptionDetails } = await client.send('Runtime.evaluate', {
|
|
306
|
+
expression: code,
|
|
307
|
+
uniqueContextId: pageWorldUniqueId,
|
|
308
|
+
returnByValue: true,
|
|
309
|
+
awaitPromise: true,
|
|
310
|
+
// Eval snippets must run even when the page's CSP disallows eval.
|
|
311
|
+
allowUnsafeEvalBlockedByCSP: true,
|
|
312
|
+
});
|
|
313
|
+
return exceptionDetails ? false : (result?.value ?? false);
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
async function attachToSession(/** @type {any} */ client) {
|
|
318
|
+
client.on('Runtime.executionContextCreated', async (/** @type {any} */ event) => {
|
|
319
|
+
const { context } = event;
|
|
320
|
+
const frameId = context.auxData?.frameId;
|
|
321
|
+
|
|
322
|
+
// Our isolated world finished initializing: wire up its binding and content script.
|
|
323
|
+
if (context.auxData?.type === 'isolated' && typeof context.name === 'string' && context.name.startsWith(WORLD_PREFIX)) {
|
|
324
|
+
const separatorIndex = context.name.indexOf(WORLD_SEPARATOR);
|
|
325
|
+
const pageWorldUniqueId = context.name.slice(WORLD_PREFIX.length, separatorIndex);
|
|
326
|
+
const intendedFrameId = context.name.slice(separatorIndex + WORLD_SEPARATOR.length);
|
|
327
|
+
// Chromium may create the named world in other frames too; keep only the one we asked for.
|
|
328
|
+
if (intendedFrameId !== frameId) return;
|
|
329
|
+
|
|
330
|
+
isolated2pageWorld.set(context.uniqueId, pageWorldUniqueId);
|
|
331
|
+
sessionByContext.set(context.uniqueId, client);
|
|
332
|
+
|
|
333
|
+
const bindingName = `${BINDING_PREFIX}${context.uniqueId.replace(/\W/g, '_')}`;
|
|
334
|
+
contextByBinding.set(bindingName, context.uniqueId);
|
|
335
|
+
try {
|
|
336
|
+
await client.send('Runtime.addBinding', { name: bindingName, executionContextName: context.name });
|
|
337
|
+
// CDP bindings take a single string arg, so wrap it in the shape the content script expects.
|
|
338
|
+
await client.send('Runtime.evaluate', {
|
|
339
|
+
expression: `window.autoconsentSendMessage = (m) => { window.${bindingName}(JSON.stringify(m)); return Promise.resolve(); };\n${contentScript}\n${extraScript}`,
|
|
340
|
+
uniqueContextId: context.uniqueId,
|
|
341
|
+
allowUnsafeEvalBlockedByCSP: true,
|
|
342
|
+
});
|
|
343
|
+
} catch {
|
|
344
|
+
// The frame may have navigated or detached before we finished wiring it up.
|
|
345
|
+
}
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// A regular page (main) world: request an isolated world for it, tagged with its id.
|
|
350
|
+
if (!frameId || context.auxData?.type !== 'default' || !context.origin || context.origin === '://') return;
|
|
351
|
+
try {
|
|
352
|
+
await client.send('Page.createIsolatedWorld', {
|
|
353
|
+
frameId,
|
|
354
|
+
worldName: `${WORLD_PREFIX}${context.uniqueId}${WORLD_SEPARATOR}${frameId}`,
|
|
355
|
+
});
|
|
356
|
+
} catch {
|
|
357
|
+
// The frame may have navigated or detached.
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
client.on('Runtime.executionContextDestroyed', (/** @type {any} */ event) => {
|
|
362
|
+
const uniqueId = event.executionContextUniqueId;
|
|
363
|
+
if (!uniqueId) return;
|
|
364
|
+
isolated2pageWorld.delete(uniqueId);
|
|
365
|
+
sessionByContext.delete(uniqueId);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
client.on('Runtime.bindingCalled', (/** @type {any} */ event) => {
|
|
369
|
+
const isolatedUniqueId = contextByBinding.get(event.name);
|
|
370
|
+
if (!isolatedUniqueId) return;
|
|
371
|
+
let msg;
|
|
372
|
+
try {
|
|
373
|
+
msg = JSON.parse(event.payload);
|
|
374
|
+
} catch {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
handle(msg, isolatedUniqueId);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// Page must be enabled before createIsolatedWorld; Runtime.enable replays existing contexts.
|
|
381
|
+
await client.send('Page.enable');
|
|
382
|
+
await client.send('Runtime.enable');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// The page session covers the main frame and all same-process (in-process) iframes.
|
|
386
|
+
await attachToSession(await page.context().newCDPSession(page));
|
|
387
|
+
|
|
388
|
+
// Out-of-process iframes (OOPIFs) are separate CDP targets, so each needs its own session.
|
|
389
|
+
// newCDPSession throws for in-process frames, which are already handled above.
|
|
390
|
+
const attachedFrames = new WeakSet();
|
|
391
|
+
async function attachToOopif(/** @type {import('playwright').Frame} */ frame) {
|
|
392
|
+
if (!frame.parentFrame() || attachedFrames.has(frame)) return;
|
|
393
|
+
// Mark synchronously (before any await) so concurrent frameattached/framenavigated events
|
|
394
|
+
// can't open duplicate sessions for the same frame. Unmark on failure so a later event retries.
|
|
395
|
+
attachedFrames.add(frame);
|
|
396
|
+
let client;
|
|
397
|
+
try {
|
|
398
|
+
client = await page.context().newCDPSession(frame);
|
|
399
|
+
} catch {
|
|
400
|
+
// In-process frame (already covered by the page session) or the frame detached.
|
|
401
|
+
attachedFrames.delete(frame);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
try {
|
|
405
|
+
await attachToSession(client);
|
|
406
|
+
} catch {
|
|
407
|
+
// Wiring up the session failed (e.g. the OOPIF navigated/detached). Detach the
|
|
408
|
+
// half-initialized session before unmarking, otherwise a retry would open a second
|
|
409
|
+
// session for the same frame, leaving duplicate listeners and possible double injection.
|
|
410
|
+
try {
|
|
411
|
+
await client.detach();
|
|
412
|
+
} catch {
|
|
413
|
+
// The session may already be gone.
|
|
414
|
+
}
|
|
415
|
+
attachedFrames.delete(frame);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
page.on('frameattached', attachToOopif);
|
|
419
|
+
page.on('framenavigated', attachToOopif);
|
|
420
|
+
await Promise.all(page.frames().map(attachToOopif));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* A result with nothing detected, optionally carrying an error.
|
|
425
|
+
* @param {string} url
|
|
426
|
+
* @param {string} region
|
|
427
|
+
* @param {ProviderName} provider
|
|
428
|
+
* @param {unknown} [error]
|
|
429
|
+
* @returns {TestResult}
|
|
430
|
+
*/
|
|
431
|
+
export function emptyResult(url, region, provider, error) {
|
|
432
|
+
return {
|
|
433
|
+
url,
|
|
434
|
+
region,
|
|
435
|
+
provider,
|
|
436
|
+
cmpsDetected: [],
|
|
437
|
+
cmpActedOn: null,
|
|
438
|
+
popupFound: false,
|
|
439
|
+
optOutResult: null,
|
|
440
|
+
optInResult: null,
|
|
441
|
+
selfTestResult: null,
|
|
442
|
+
autoconsentDone: false,
|
|
443
|
+
isCosmetic: null,
|
|
444
|
+
errors: error === undefined ? [] : [error instanceof Error ? error.message : String(error)],
|
|
445
|
+
duration: 0,
|
|
446
|
+
screenshotPaths: [],
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Run autoconsent on a URL in an already-created page and collect a TestResult.
|
|
452
|
+
* @param {Page} page
|
|
453
|
+
* @param {string} url
|
|
454
|
+
* @param {string} regionKey
|
|
455
|
+
* @param {Partial<TestOptions>} options
|
|
456
|
+
* @param {Provider} provider
|
|
457
|
+
* @returns {Promise<TestResult>}
|
|
458
|
+
*/
|
|
459
|
+
export async function runTest(page, url, regionKey, options, provider) {
|
|
460
|
+
const navTimeout = options.navigationTimeout ?? 45000;
|
|
461
|
+
const completionTimeout = options.completionTimeout ?? 45000;
|
|
462
|
+
const detectionTimeout = options.detectionTimeout ?? completionTimeout;
|
|
463
|
+
const screenshotsDir = options.screenshotsDir ?? provider.defaultScreenshotsDir;
|
|
464
|
+
const startTime = Date.now();
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
const ctx = await provider.inject(page, options);
|
|
468
|
+
await page.goto(url, { waitUntil: 'commit', timeout: navTimeout });
|
|
469
|
+
await provider.afterNavigation?.(page, ctx, options);
|
|
470
|
+
|
|
471
|
+
const completed = await ctx.waitForCompletion(completionTimeout, detectionTimeout);
|
|
472
|
+
if (completed && !ctx.hasMessage('selfTestResult')) {
|
|
473
|
+
await ctx.waitForMessage('selfTestResult', 10000);
|
|
474
|
+
}
|
|
475
|
+
// Brief settle after the rule (and its verification) has finished, to
|
|
476
|
+
// outlast the page's own close animation / DOM teardown which can
|
|
477
|
+
// still be in flight when the screenshot is taken.
|
|
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 tag = provider.screenshotTag ? `-${provider.screenshotTag}` : '';
|
|
494
|
+
const filepath = path.join(screenshotsDir, `${domain}-${regionKey}${tag}-final.jpg`);
|
|
495
|
+
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
496
|
+
await page.screenshot({ path: filepath, quality: 50, scale: 'css', timeout: 5000, type: 'jpeg' });
|
|
497
|
+
result.screenshotPaths.push(filepath);
|
|
498
|
+
} catch {}
|
|
499
|
+
|
|
500
|
+
return result;
|
|
501
|
+
} catch (e) {
|
|
502
|
+
const result = emptyResult(url, regionKey, provider.name, e);
|
|
503
|
+
result.duration = Date.now() - startTime;
|
|
504
|
+
return result;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Format a TestResult as a human-readable line.
|
|
510
|
+
* @param {TestResult} result
|
|
511
|
+
* @returns {string}
|
|
512
|
+
*/
|
|
513
|
+
export function formatResult(result) {
|
|
514
|
+
const actionResult = result.optOutResult ?? result.optInResult;
|
|
515
|
+
const actionAttempted = result.optOutResult !== null || result.optInResult !== null;
|
|
516
|
+
let status;
|
|
517
|
+
if (actionAttempted && actionResult) status = 'PASS';
|
|
518
|
+
else if (actionAttempted && !actionResult) status = 'ACTION FAILED';
|
|
519
|
+
else if (result.cmpsDetected.length > 0) status = 'PARTIAL';
|
|
520
|
+
else status = 'NO CMP';
|
|
521
|
+
|
|
522
|
+
const parts = [
|
|
523
|
+
`${status} [${result.region}] ${result.url}`,
|
|
524
|
+
` CMP: ${result.cmpActedOn || 'none'}${result.cmpsDetected.length > 1 ? ` (also detected: ${result.cmpsDetected.filter((c) => c !== result.cmpActedOn).join(', ')})` : ''}`,
|
|
525
|
+
` Popup: ${result.popupFound} | OptOut: ${result.optOutResult} | OptIn: ${result.optInResult} | SelfTest: ${result.selfTestResult}`,
|
|
526
|
+
` Done: ${result.autoconsentDone}${result.isCosmetic ? ' (cosmetic)' : ''} | ${result.duration}ms`,
|
|
527
|
+
];
|
|
528
|
+
parts.push(` Provider: ${result.provider}`);
|
|
529
|
+
if (result.errors.length > 0) {
|
|
530
|
+
parts.push(` Errors: ${result.errors.join('; ')}`);
|
|
531
|
+
}
|
|
532
|
+
if (result.screenshotPaths.length > 0) {
|
|
533
|
+
parts.push(` Screenshots: ${result.screenshotPaths.join(', ')}`);
|
|
534
|
+
}
|
|
535
|
+
return parts.join('\n');
|
|
536
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: oxylabs-testing
|
|
3
|
+
description: Test autoconsent rules across geographic regions using Oxylabs Agent Browser remote browsers. Use when a site shows our regional proxies a bot wall (captcha, challenge page, access denied), when a region has no regional proxy (e.g. pt, br), or for US state/city targeting. Default to proxy-testing otherwise.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Oxylabs Regional Testing
|
|
7
|
+
|
|
8
|
+
This skill runs autoconsent in [Oxylabs Agent Browser](https://developers.oxylabs.io/products/agent-browser) sessions in a chosen country.
|
|
9
|
+
|
|
10
|
+
**Default to the `proxy-testing` skill.** Oxylabs costs money per session. Use this skill when:
|
|
11
|
+
|
|
12
|
+
- a site shows our proxies a bot wall (a captcha, a challenge page, an "access denied" page) instead of the real site. Oxylabs gets past most of them. Retest only the affected regions here;
|
|
13
|
+
- the region has no regional proxy (any two-letter country code works here, e.g. `pt`);
|
|
14
|
+
- you need a US state or city (CCPA: `us-california`).
|
|
15
|
+
|
|
16
|
+
The region policy (core and expanded sets) is the one in the `proxy-testing` skill. Results have the same shape, and the same rule applies: autoconsent results can have false positives, so always inspect the screenshots and confirm that opt-out was successful.
|
|
17
|
+
|
|
18
|
+
## Prerequisites
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm run prepublish # builds dist/autoconsent.playwright.js and rules/rules.json
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Environment variables:
|
|
25
|
+
|
|
26
|
+
- `OXYLABS_USER` — the Agent Browser username, including its account suffix (e.g. `user_ab12`)
|
|
27
|
+
- `OXYLABS_PASSWORD`
|
|
28
|
+
- `OXYLABS_HOST` (optional, defaults to `hb.oxylabs.io`. The older `ubc.oxylabs.io` endpoint is deprecated.)
|
|
29
|
+
|
|
30
|
+
The endpoint URL contains the credentials: never log it. Errors returned by this library are already redacted.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
The library is at [scripts/oxylabs.mjs](scripts/oxylabs.mjs). It exports the same functions as `proxy-testing` (`testUrl`, `testRegions`, `testPage`, `injectAutoconsent`, `formatResult`, region sets), so a script can switch providers by changing the import.
|
|
35
|
+
|
|
36
|
+
```javascript
|
|
37
|
+
import { formatResult, testRegions } from './.agents/skills/oxylabs-testing/scripts/oxylabs.mjs';
|
|
38
|
+
|
|
39
|
+
for (const result of await testRegions('https://www.wohnen.de/', ['us', 'gb', 'de', 'pt'])) {
|
|
40
|
+
console.log(formatResult(result));
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Use an existing page (one Oxylabs session; the callback's page is closed afterwards):
|
|
45
|
+
|
|
46
|
+
```javascript
|
|
47
|
+
import { testPage, withOxylabsPage } from './.agents/skills/oxylabs-testing/scripts/oxylabs.mjs';
|
|
48
|
+
|
|
49
|
+
const result = await withOxylabsPage('de', { device: 'mobile' }, async (page) => {
|
|
50
|
+
const result = await testPage(page, 'https://www.wohnen.de/', 'de');
|
|
51
|
+
await page.screenshot({ path: 'test-results/oxylabs/after-de-mobile.png' });
|
|
52
|
+
return result;
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## API
|
|
57
|
+
|
|
58
|
+
- `testUrl(url, regionKey, options?)` — open an Oxylabs session in the region, run autoconsent, screenshot, close; returns a `TestResult`.
|
|
59
|
+
- `testRegions(url, regions?, options?)` — `testUrl` for each region (defaults to `CORE_REGIONS`), a fresh session per region.
|
|
60
|
+
- `testPage(page, url, regionKey, options?)` — run a full test on a page from an Oxylabs session you opened.
|
|
61
|
+
- `withOxylabsPage(regionKey, options, fn)` — open a session, call `fn(page)`, close the session; returns what `fn` returns. Throws if the session cannot be opened.
|
|
62
|
+
- `connectOxylabs(regionKey, options?)` — the raw Playwright `Browser` (`chromium.connectOverCDP`). Close it yourself.
|
|
63
|
+
- `injectAutoconsent(page, options?)` — low level; call before `page.goto()`. Returns the `proxy-testing` context plus `captcha` (`{ detected, solved }`, the Oxylabs solver state) and `captchaPromise` (resolves when solving ends).
|
|
64
|
+
- `isOxylabsConfigured()` — whether `OXYLABS_USER` and `OXYLABS_PASSWORD` are set.
|
|
65
|
+
- `formatResult(result)`, `CORE_REGIONS`, `EXPANDED_REGIONS`, `ALL_REGIONS` — same as `proxy-testing`.
|
|
66
|
+
|
|
67
|
+
Options: everything `testPage` takes in `proxy-testing` (`action`, `screenshotsDir`, `navigationTimeout`, `completionTimeout`, `detectionTimeout`), plus:
|
|
68
|
+
|
|
69
|
+
| Option | Default | Description |
|
|
70
|
+
|--------|---------|-------------|
|
|
71
|
+
| `device` | `'desktop'` | `'desktop' \| 'mobile'` — sets [`?p_device=`](https://developers.oxylabs.io/products/agent-browser/device-type) (viewport, touch, headers, User-Agent). Use it for mobile tests instead of Playwright device descriptors, which would contradict the Oxylabs fingerprint. |
|
|
72
|
+
| `solveCaptcha` | `false` | Sends [`?solve_captcha=true`](https://developers.oxylabs.io/products/agent-browser/captcha-handling) so Oxylabs solves captchas on the page, and waits for the solver (see Gotchas). Accounts without the feature get HTTP 400 for every session, so leave it off unless captcha solving is enabled. |
|
|
73
|
+
|
|
74
|
+
Screenshots default to `test-results/oxylabs`, named `<domain>-<region>-oxylabs-final.jpg`.
|
|
75
|
+
|
|
76
|
+
## Regions
|
|
77
|
+
|
|
78
|
+
Any two-letter country code maps to `?p_cc=` (e.g. `de` → `p_cc=DE`), per the [geolocation docs](https://developers.oxylabs.io/products/agent-browser/geolocation-and-proxy-selection). State- and city-targeted keys (`OXYLABS_LOCAL_REGIONS`):
|
|
79
|
+
|
|
80
|
+
| Key | Location | Query params |
|
|
81
|
+
|-----|----------|--------------|
|
|
82
|
+
| `us-california` | United States (California) | `p_state=california` |
|
|
83
|
+
| `us-newyork` | United States (New York) | `p_cc=US&p_city=new_york` |
|
|
84
|
+
| `us-losangeles` | United States (Los Angeles) | `p_cc=US&p_city=los_angeles` |
|
|
85
|
+
|
|
86
|
+
## Architecture
|
|
87
|
+
|
|
88
|
+
Injection and result collection are shared with `proxy-testing` in [.agents/lib/regional-testing/harness.mjs](../../lib/regional-testing/harness.mjs): autoconsent runs in an isolated world of every frame (including out-of-process iframes) and talks to Node over CDP bindings, and `eval` snippets run in the page's main world. This skill only adds the Oxylabs connection and the captcha handling.
|
|
89
|
+
|
|
90
|
+
With `solveCaptcha: true`, a listener in autoconsent's isolated world forwards the Oxylabs runtime's captcha `window` messages to Node over the same CDP binding, so they arrive even if the page navigates right after.
|
|
91
|
+
|
|
92
|
+
## Gotchas
|
|
93
|
+
|
|
94
|
+
- **CAPTCHA events arrive via `window.postMessage`, not CDP.** With `solveCaptcha: true`, autoconsent's wait pauses whenever a start event arrives, until `oxylabs-captcha-end`, `oxylabs-captcha-solve-end` or `oxylabs-captcha-error`, for at most 60s from the first start (the timeout in Oxylabs' example). Oxylabs documents no deadline for the start event, so captcha-free pages aren't held up waiting for one. Agent Browser sends `oxylabs-captcha-solve-start` with a `captchaType` (e.g. `turnstile`); the documented `oxylabs-captcha-start` is accepted too. A Cloudflare Turnstile took about 35-40s to solve. Hard blocks without a captcha (e.g. DataDome "Access is temporarily restricted", Akamai "Access denied") send no events, so solving doesn't help there.
|
|
95
|
+
- **Main-world CDP bindings don't work on Agent Browser.** `page.exposeBinding`, and `Runtime.addBinding` without `executionContextName`, never reach the page. Bindings scoped to an isolated world (what the harness uses) work, and `window` messages reach isolated-world listeners, so listen there.
|
|
96
|
+
- **Limits:** 100 concurrent sessions and 10 new sessions per second per account.
|
|
97
|
+
- **Call injection before `page.goto`**, so autoconsent is in place before page scripts run.
|
|
98
|
+
- **Oxylabs blocks certain site categories** (e.g. government sites), and some sites block Oxylabs too: check the screenshots. Use alternative URLs for the same CMP, or retest as described in the `proxy-testing` gotchas.
|
|
99
|
+
- **Each region test creates a new browser session** — there's no session reuse across regions.
|