@duckduckgo/autoconsent 16.59.0 → 16.61.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.
Files changed (216) hide show
  1. package/.agents/lib/regional-testing/harness.mjs +4 -172
  2. package/.agents/skills/oxylabs-testing/SKILL.md +4 -4
  3. package/.agents/skills/oxylabs-testing/scripts/oxylabs.mjs +17 -164
  4. package/.agents/skills/oxylabs-testing/scripts/oxylabs.test.mjs +6 -3
  5. package/.agents/skills/verify-pr/SKILL.md +1 -1
  6. package/AGENTS.md +1 -1
  7. package/CHANGELOG.md +107 -0
  8. package/Jenkinsfile +36 -1
  9. package/dist/addon-firefox/background.bundle.js +21 -0
  10. package/dist/addon-firefox/compact-rules.json +1 -1
  11. package/dist/addon-firefox/content.bundle.js +253 -93
  12. package/dist/addon-firefox/manifest.json +1 -1
  13. package/dist/addon-firefox/rule-index.json +1 -1
  14. package/dist/addon-firefox/rules.json +1 -1
  15. package/dist/addon-mv3/background.bundle.js +21 -0
  16. package/dist/addon-mv3/compact-rules.json +1 -1
  17. package/dist/addon-mv3/content.bundle.js +253 -93
  18. package/dist/addon-mv3/manifest.json +1 -1
  19. package/dist/addon-mv3/rule-index.json +1 -1
  20. package/dist/addon-mv3/rules.json +1 -1
  21. package/dist/autoconsent.cjs.js +253 -93
  22. package/dist/autoconsent.esm.js +253 -93
  23. package/dist/autoconsent.playwright.js +253 -93
  24. package/dist/autoconsent.standalone.js +254 -94
  25. package/dist/heuristics.cjs.js +29 -3
  26. package/dist/heuristics.mjs +28 -3
  27. package/dist/types/cmps/onetrust.d.ts +1 -0
  28. package/dist/types/eval-snippets.d.ts +3 -0
  29. package/dist/types/heuristic-classify.d.ts +1 -0
  30. package/dist/types/heuristics.d.ts +10 -1
  31. package/lib/cmps/onetrust.ts +15 -0
  32. package/lib/eval-snippets.ts +21 -0
  33. package/lib/heuristic-classify.ts +6 -0
  34. package/lib/heuristic-patterns.ts +26 -1
  35. package/lib/heuristics.ts +256 -104
  36. package/package.json +1 -1
  37. package/playwright/isolated-world.mjs +217 -0
  38. package/playwright/oxylabs.mjs +196 -0
  39. package/playwright/runner.ts +183 -39
  40. package/playwright.config.ts +4 -1
  41. package/rules/autoconsent/aeolservice.json +39 -0
  42. package/rules/autoconsent/airbnb.json +32 -0
  43. package/rules/autoconsent/amazon-music.json +13 -0
  44. package/rules/autoconsent/annunci69.json +19 -0
  45. package/rules/autoconsent/appconsent.json +6 -1
  46. package/rules/autoconsent/arbeidsplassen-nav-no.json +33 -0
  47. package/rules/autoconsent/att.json +36 -0
  48. package/rules/autoconsent/auctionet.json +39 -0
  49. package/rules/autoconsent/autodoc.json +18 -0
  50. package/rules/autoconsent/bbc.com-orb-cosmetic.json +34 -0
  51. package/rules/autoconsent/bbc.com.json +8 -5
  52. package/rules/autoconsent/bokadirekt.se.json +50 -0
  53. package/rules/autoconsent/bouvet-cookiepanel.json +29 -0
  54. package/rules/autoconsent/com-consent-layer.json +10 -0
  55. package/rules/autoconsent/consent-studio.json +25 -13
  56. package/rules/autoconsent/cookielegit.json +21 -0
  57. package/rules/autoconsent/cookieprompter.json +21 -0
  58. package/rules/autoconsent/cookiereports.json +10 -0
  59. package/rules/autoconsent/cookietractor.json +37 -0
  60. package/rules/autoconsent/de-volksbank.json +14 -0
  61. package/rules/autoconsent/dpgmedia-nl.json +20 -8
  62. package/rules/autoconsent/eon.pl.json +18 -0
  63. package/rules/autoconsent/eroticiracconti-it.json +13 -0
  64. package/rules/autoconsent/ersatzteile-24.json +49 -0
  65. package/rules/autoconsent/fastweb-cookie-center.json +13 -0
  66. package/rules/autoconsent/futurabolzano-it.json +35 -0
  67. package/rules/autoconsent/gobasic.json +10 -0
  68. package/rules/autoconsent/gravito.json +23 -16
  69. package/rules/autoconsent/hacon-next-gen.json +41 -0
  70. package/rules/autoconsent/ing-de.json +31 -0
  71. package/rules/autoconsent/interia.json +1 -4
  72. package/rules/autoconsent/intesasanpaolo.json +37 -0
  73. package/rules/autoconsent/jobindex.json +40 -0
  74. package/rules/autoconsent/kaspersky-cookies-notification.json +9 -0
  75. package/rules/autoconsent/lantmateriet.json +13 -0
  76. package/rules/autoconsent/livsmedelsverket.json +18 -0
  77. package/rules/autoconsent/mainichi.json +39 -0
  78. package/rules/autoconsent/mango.json +56 -0
  79. package/rules/autoconsent/nederlandseloterij.json +35 -0
  80. package/rules/autoconsent/newsbreak.json +16 -0
  81. package/rules/autoconsent/nhsapp.json +10 -0
  82. package/rules/autoconsent/nordea.json +21 -0
  83. package/rules/autoconsent/nordnet.json +33 -0
  84. package/rules/autoconsent/ok.json +4 -12
  85. package/rules/autoconsent/onlineradiobox.json +35 -0
  86. package/rules/autoconsent/pe-cookies.json +30 -0
  87. package/rules/autoconsent/pensionsmyndigheten.json +13 -0
  88. package/rules/autoconsent/pro-euclp-cookies-box.json +31 -0
  89. package/rules/autoconsent/pubfactory.json +45 -0
  90. package/rules/autoconsent/puesc-gov-pl.json +37 -0
  91. package/rules/autoconsent/rambler.json +30 -0
  92. package/rules/autoconsent/rejestr-io.json +36 -0
  93. package/rules/autoconsent/sas.json +6 -6
  94. package/rules/autoconsent/scb.se.json +13 -0
  95. package/rules/autoconsent/schumacher.com.json +53 -0
  96. package/rules/autoconsent/sitevision.json +18 -1
  97. package/rules/autoconsent/skatteverket-se.json +33 -0
  98. package/rules/autoconsent/smile-insurances.json +13 -0
  99. package/rules/autoconsent/studentapan.json +16 -0
  100. package/rules/autoconsent/svt.se.json +7 -7
  101. package/rules/autoconsent/temu.json +12 -16
  102. package/rules/autoconsent/thecode.json +21 -0
  103. package/rules/autoconsent/tv2reg-cookie-notice.json +39 -0
  104. package/rules/autoconsent/uk-parliament.json +19 -0
  105. package/rules/autoconsent/wirtualna-polska.json +13 -10
  106. package/rules/autoconsent/xenforo-cookie-consent.json +62 -0
  107. package/rules/autoconsent/zorginstituut-cookiewarning.json +33 -0
  108. package/rules/compact-rules.json +1 -1
  109. package/rules/rule-index.json +1 -1
  110. package/rules/rules.json +1 -1
  111. package/tests/aeolservice.spec.ts +5 -0
  112. package/tests/airbnb.spec.ts +3 -0
  113. package/tests/amazon-music.spec.ts +9 -0
  114. package/tests/annunci69.spec.ts +5 -0
  115. package/tests/appconsent.spec.ts +6 -0
  116. package/tests/arbeidsplassen-nav-no.spec.ts +3 -0
  117. package/tests/att.spec.ts +3 -0
  118. package/tests/auctionet.spec.ts +3 -0
  119. package/tests/autodoc.spec.ts +8 -0
  120. package/tests/bbc.com-orb-cosmetic.spec.ts +8 -0
  121. package/tests/bbc.com.spec.ts +11 -9
  122. package/tests/bokadirekt.se.spec.ts +3 -0
  123. package/tests/bouvet-cookiepanel.spec.ts +9 -0
  124. package/tests/com-consent-layer.spec.ts +6 -0
  125. package/tests/consent-studio.spec.ts +5 -0
  126. package/tests/cookiebot.spec.ts +1 -0
  127. package/tests/cookielegit.spec.ts +3 -0
  128. package/tests/cookieprompter.spec.ts +9 -0
  129. package/tests/cookiereports.spec.ts +13 -0
  130. package/tests/cookietractor.spec.ts +12 -0
  131. package/tests/de-volksbank.spec.ts +5 -0
  132. package/tests/dpgmedia-nl.spec.ts +15 -1
  133. package/tests/eon.pl.spec.ts +3 -0
  134. package/tests/eroticiracconti-it.spec.ts +3 -0
  135. package/tests/ersatzteile-24.spec.ts +6 -0
  136. package/tests/fastweb-cookie-center.spec.ts +3 -0
  137. package/tests/futurabolzano-it.spec.ts +3 -0
  138. package/tests/gobasic.spec.ts +3 -0
  139. package/tests/gravito.spec.ts +7 -1
  140. package/tests/hacon-next-gen.spec.ts +6 -0
  141. package/tests/ing-de.spec.ts +3 -0
  142. package/tests/interia.spec.ts +1 -1
  143. package/tests/intesasanpaolo.spec.ts +3 -0
  144. package/tests/jobindex.spec.ts +3 -0
  145. package/tests/kaspersky-cookies-notification.spec.ts +8 -0
  146. package/tests/lantmateriet.spec.ts +3 -0
  147. package/tests/livsmedelsverket.spec.ts +5 -0
  148. package/tests/mainichi.spec.ts +10 -0
  149. package/tests/mango.spec.ts +3 -0
  150. package/tests/nederlandseloterij.spec.ts +9 -0
  151. package/tests/newsbreak.spec.ts +3 -0
  152. package/tests/nhsapp.spec.ts +3 -0
  153. package/tests/nordea.spec.ts +6 -0
  154. package/tests/nordnet.spec.ts +8 -0
  155. package/tests/onlineradiobox.spec.ts +5 -0
  156. package/tests/pe-cookies.spec.ts +6 -0
  157. package/tests/pensionsmyndigheten.spec.ts +3 -0
  158. package/tests/pro-euclp-cookies-box.spec.ts +8 -0
  159. package/tests/pubfactory.spec.ts +10 -0
  160. package/tests/puesc-gov-pl.spec.ts +3 -0
  161. package/tests/rambler.spec.ts +3 -0
  162. package/tests/rejestr-io.spec.ts +3 -0
  163. package/tests/sas.spec.ts +1 -1
  164. package/tests/scb.se.spec.ts +4 -0
  165. package/tests/schumacher.com.spec.ts +6 -0
  166. package/tests/sitevision-cookie-consent.spec.ts +1 -0
  167. package/tests/sitevision.spec.ts +7 -0
  168. package/tests/skatteverket-se.spec.ts +3 -0
  169. package/tests/smile-insurances.spec.ts +6 -0
  170. package/tests/studentapan.spec.ts +3 -0
  171. package/tests/svt.se.spec.ts +1 -3
  172. package/tests/temu.spec.ts +3 -1
  173. package/tests/thecode.spec.ts +3 -0
  174. package/tests/tv2reg-cookie-notice.spec.ts +16 -0
  175. package/tests/uk-parliament.spec.ts +8 -0
  176. package/tests/wirtualna-polska.spec.ts +6 -1
  177. package/tests/xenforo-cookie-consent.spec.ts +6 -0
  178. package/tests/zorginstituut-cookiewarning.spec.ts +10 -0
  179. package/tests-wtr/heuristics/get-potential-popups.test.ts +330 -9
  180. package/tests-wtr/heuristics/heuristics-utils.test.ts +98 -0
  181. package/rules/autoconsent/twitter.json +0 -34
  182. package/rules/generated/auto_AU_flysas.com_r1s.json +0 -40
  183. package/rules/generated/auto_CH_airbnb.de_83z.json +0 -40
  184. package/rules/generated/auto_CH_autodoc.de_krd.json +0 -40
  185. package/rules/generated/auto_CH_svtplay.se_d39.json +0 -40
  186. package/rules/generated/auto_GB_parliamentlive.tv_r3v.json +0 -28
  187. package/rules/generated/auto_NL_asnbank.nl_e28.json +0 -40
  188. package/rules/generated/auto_NL_eurojackpot.nederlandseloterij.nl_7hr.json +0 -40
  189. package/rules/generated/auto_NL_lotto.nederlandseloterij.nl_7c8.json +0 -40
  190. package/rules/generated/auto_NL_nederlandseloterij.nl_b60.json +0 -40
  191. package/rules/generated/auto_NL_staatsloterij.nederlandseloterij.nl_197.json +0 -40
  192. package/rules/generated/auto_NO_arronet.se_vo5.json +0 -40
  193. package/rules/generated/auto_NO_liseberg.se_qy4.json +0 -40
  194. package/rules/generated/auto_NO_nordnet.se_2en.json +0 -40
  195. package/rules/generated/auto_NO_regeringen.se_uhv.json +0 -40
  196. package/rules/generated/auto_NO_svtplay.se_ujn.json +0 -40
  197. package/rules/generated/auto_NO_uu.se_eea.json +0 -40
  198. package/rules/generated/auto_US_newsbreak.com_0.json +0 -28
  199. package/tests/generated/auto_AU_flysas.com_r1s.spec.ts +0 -2
  200. package/tests/generated/auto_CH_airbnb.de_83z.spec.ts +0 -2
  201. package/tests/generated/auto_CH_autodoc.de_krd.spec.ts +0 -2
  202. package/tests/generated/auto_CH_svtplay.se_d39.spec.ts +0 -2
  203. package/tests/generated/auto_GB_parliamentlive.tv_r3v.spec.ts +0 -6
  204. package/tests/generated/auto_NL_asnbank.nl_e28.spec.ts +0 -6
  205. package/tests/generated/auto_NL_eurojackpot.nederlandseloterij.nl_7hr.spec.ts +0 -6
  206. package/tests/generated/auto_NL_lotto.nederlandseloterij.nl_7c8.spec.ts +0 -6
  207. package/tests/generated/auto_NL_nederlandseloterij.nl_b60.spec.ts +0 -6
  208. package/tests/generated/auto_NL_staatsloterij.nederlandseloterij.nl_197.spec.ts +0 -6
  209. package/tests/generated/auto_NO_arronet.se_vo5.spec.ts +0 -2
  210. package/tests/generated/auto_NO_liseberg.se_qy4.spec.ts +0 -2
  211. package/tests/generated/auto_NO_nordnet.se_2en.spec.ts +0 -2
  212. package/tests/generated/auto_NO_regeringen.se_uhv.spec.ts +0 -6
  213. package/tests/generated/auto_NO_svtplay.se_ujn.spec.ts +0 -2
  214. package/tests/generated/auto_NO_uu.se_eea.spec.ts +0 -2
  215. package/tests/generated/auto_US_newsbreak.com_0.spec.ts +0 -2
  216. package/tests/twitter.spec.ts +0 -10
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Oxylabs Agent Browser connection: region targeting, the CDP endpoint, and credential redaction.
3
+ * Docs: https://developers.oxylabs.io/products/agent-browser
4
+ * Shared by the Playwright E2E runner and the `oxylabs-testing` skill.
5
+ *
6
+ * Requires env vars: OXYLABS_USER, OXYLABS_PASSWORD.
7
+ * Optional: OXYLABS_HOST (defaults to hb.oxylabs.io).
8
+ */
9
+
10
+ /**
11
+ * @typedef {import('@playwright/test').Browser} Browser
12
+ */
13
+
14
+ /**
15
+ * @typedef {'desktop'|'mobile'} OxylabsDevice
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} OxylabsBrowserOptions
20
+ * @property {OxylabsDevice} [device] - Oxylabs ?p_device= value. Oxylabs defaults to 'desktop'.
21
+ * @property {boolean} [solveCaptcha=true] - Send ?solve_captcha=true: let Oxylabs solve captchas on the page.
22
+ */
23
+
24
+ import { chromium } from '@playwright/test';
25
+
26
+ /**
27
+ * US state- and city-targeted region keys, in addition to any two-letter country code. Query params
28
+ * follow https://developers.oxylabs.io/products/agent-browser/geolocation-and-proxy-selection
29
+ * (`p_state` is US-only; `p_city` needs `p_cc` or `p_state`).
30
+ * @type {Record<string, Record<string, string>>}
31
+ */
32
+ export const OXYLABS_LOCAL_REGIONS = {
33
+ 'us-california': { p_state: 'california' },
34
+ 'us-newyork': { p_cc: 'US', p_city: 'new_york' },
35
+ 'us-losangeles': { p_cc: 'US', p_city: 'los_angeles' },
36
+ };
37
+
38
+ /**
39
+ * @param {string} regionKey - A two-letter country code (e.g. 'de', 'pt') or a key of OXYLABS_LOCAL_REGIONS.
40
+ * @returns {Record<string, string>}
41
+ */
42
+ export function oxylabsRegionParams(regionKey) {
43
+ if (Object.prototype.hasOwnProperty.call(OXYLABS_LOCAL_REGIONS, regionKey)) {
44
+ return OXYLABS_LOCAL_REGIONS[regionKey];
45
+ }
46
+ if (/^[a-z]{2}$/i.test(regionKey)) {
47
+ return { p_cc: regionKey.toUpperCase() };
48
+ }
49
+ throw new Error(
50
+ `Unknown Oxylabs region "${regionKey}". Use a two-letter country code or one of: ${Object.keys(OXYLABS_LOCAL_REGIONS).join(', ')}`,
51
+ );
52
+ }
53
+
54
+ /**
55
+ * @param {NodeJS.ProcessEnv} [env]
56
+ */
57
+ export function isOxylabsConfigured(env = process.env) {
58
+ return Boolean(env.OXYLABS_USER && env.OXYLABS_PASSWORD);
59
+ }
60
+
61
+ /**
62
+ * Build the Oxylabs CDP endpoint for a region. The result contains credentials: never log it.
63
+ * @param {string} regionKey
64
+ * @param {OxylabsBrowserOptions} [opts]
65
+ * @param {NodeJS.ProcessEnv} [env]
66
+ */
67
+ export function buildOxylabsEndpoint(regionKey, opts = {}, env = process.env) {
68
+ const user = env.OXYLABS_USER;
69
+ const password = env.OXYLABS_PASSWORD;
70
+ const host = env.OXYLABS_HOST || 'hb.oxylabs.io';
71
+ if (!user || !password) {
72
+ throw new Error('Missing Oxylabs credentials. Set OXYLABS_USER and OXYLABS_PASSWORD.');
73
+ }
74
+ const params = new URLSearchParams(oxylabsRegionParams(regionKey));
75
+ if (opts.device) {
76
+ params.set('p_device', opts.device);
77
+ }
78
+ // Accounts without captcha solving get HTTP 400 for this param, so only send it when asked.
79
+ if (opts.solveCaptcha ?? true) {
80
+ params.set('solve_captcha', 'true');
81
+ }
82
+ // user/password are interpolated raw — Oxylabs expects literal characters in
83
+ // userinfo (e.g. '+' must NOT be percent-encoded), matching their Python/JS
84
+ // SDK examples. If your credentials contain ':' or '@', escape upstream.
85
+ return `wss://${user}:${password}@${host}/?${params.toString()}`;
86
+ }
87
+
88
+ /**
89
+ * Playwright puts the endpoint URL in connection error messages; strip the credentials.
90
+ * @param {string} message
91
+ * @param {NodeJS.ProcessEnv} [env]
92
+ */
93
+ export function redactCredentials(message, env = process.env) {
94
+ let out = message;
95
+ for (const secret of [env.OXYLABS_PASSWORD, env.OXYLABS_USER]) {
96
+ if (secret) out = out.split(secret).join('***');
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /**
102
+ * Connect to an Oxylabs remote browser for a specific region.
103
+ * @param {string} regionKey
104
+ * @param {OxylabsBrowserOptions} [opts]
105
+ * @returns {Promise<Browser>}
106
+ */
107
+ export async function connectOxylabs(regionKey, opts = {}) {
108
+ try {
109
+ return await chromium.connectOverCDP(buildOxylabsEndpoint(regionKey, opts), { timeout: 30000 });
110
+ } catch (e) {
111
+ // The first line has the cause; the call log after it repeats the endpoint URL.
112
+ const message = redactCredentials((e instanceof Error ? e.message : String(e)).split('\n')[0]);
113
+ const hint =
114
+ (opts.solveCaptcha ?? true) && message.includes('400') ? ' (is captcha solving enabled for this Oxylabs account?)' : '';
115
+ // eslint-disable-next-line preserve-caught-error -- the original error carries the credentials
116
+ throw new Error(message + hint);
117
+ }
118
+ }
119
+
120
+ // Captcha events arrive as window messages from the Oxylabs runtime. A listener in autoconsent's
121
+ // isolated world forwards them over its CDP binding: Agent Browser drops main-world bindings, so
122
+ // page.exposeBinding can't deliver them. See https://developers.oxylabs.io/products/agent-browser/captcha-handling
123
+ const CAPTCHA_MESSAGE = 'oxylabsCaptcha';
124
+ // Agent Browser sends `oxylabs-captcha-solve-start`; the docs list `-start`, `-end`, `-solve-end` and `-error`.
125
+ const CAPTCHA_START_EVENTS = ['oxylabs-captcha-start', 'oxylabs-captcha-solve-start'];
126
+ const CAPTCHA_END_EVENTS = ['oxylabs-captcha-end', 'oxylabs-captcha-solve-end'];
127
+ const CAPTCHA_ERROR_EVENTS = ['oxylabs-captcha-error', 'oxylabs-captcha-solve-error'];
128
+ // Longest a test pauses for solving, counted from the first start event: the wait in Oxylabs' example.
129
+ const CAPTCHA_SOLVE_TIMEOUT_MS = 60000;
130
+ const captchaListenerScript = `
131
+ window.addEventListener("message", (e) => {
132
+ if (e?.data?.source === "oxylabs-runtime" && typeof e.data.type === "string") {
133
+ window.autoconsentSendMessage({ type: "${CAPTCHA_MESSAGE}", event: e.data.type });
134
+ }
135
+ });
136
+ `;
137
+
138
+ /**
139
+ * Tracks the Oxylabs captcha solver for one page. Run `script` in each isolated world next to the
140
+ * content script and pass every message from it to `handleMessage`.
141
+ * @typedef {Object} CaptchaTracker
142
+ * @property {{ detected: boolean, solved: boolean|null }} state - The solver state.
143
+ * @property {Promise<void>} done - Resolves when solving ends (success or error).
144
+ * @property {() => boolean} isSolving - Whether waits should pause for the solver.
145
+ * @property {string} script
146
+ * @property {(msg: any) => boolean} handleMessage - Returns true for a captcha event.
147
+ */
148
+
149
+ /**
150
+ * @returns {CaptchaTracker}
151
+ */
152
+ export function createCaptchaTracker() {
153
+ /** @type {{ detected: boolean, solved: boolean|null }} */
154
+ const state = { detected: false, solved: null };
155
+ /** @type {() => void} */
156
+ let resolveDone = () => {};
157
+ /** @type {Promise<void>} */
158
+ const done = new Promise((resolve) => {
159
+ resolveDone = resolve;
160
+ });
161
+
162
+ let solving = false;
163
+ /** @type {number|null} */
164
+ let firstStartAt = null;
165
+
166
+ function handleCaptchaEvent(/** @type {string} */ type) {
167
+ if (CAPTCHA_START_EVENTS.includes(type)) {
168
+ state.detected = true;
169
+ solving = true;
170
+ firstStartAt ??= Date.now();
171
+ } else if (CAPTCHA_END_EVENTS.includes(type)) {
172
+ state.detected = true;
173
+ state.solved = true;
174
+ solving = false;
175
+ resolveDone();
176
+ } else if (CAPTCHA_ERROR_EVENTS.includes(type)) {
177
+ state.detected = true;
178
+ state.solved = false;
179
+ solving = false;
180
+ resolveDone();
181
+ }
182
+ }
183
+
184
+ return {
185
+ state,
186
+ done,
187
+ // Oxylabs gives no deadline for a start event, so waits pause whenever one arrives.
188
+ isSolving: () => solving && firstStartAt !== null && Date.now() - firstStartAt < CAPTCHA_SOLVE_TIMEOUT_MS,
189
+ script: captchaListenerScript,
190
+ handleMessage: (msg) => {
191
+ if (msg?.type !== CAPTCHA_MESSAGE) return false;
192
+ handleCaptchaEvent(msg.event);
193
+ return true;
194
+ },
195
+ };
196
+ }
@@ -1,8 +1,7 @@
1
1
  import crypto from 'crypto';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
- import { test, expect, Page, Frame, TestInfo } from '@playwright/test';
5
- import { waitFor } from '../lib/utils';
4
+ import { test as base, expect, Page, Frame, TestInfo, Response } from '@playwright/test';
6
5
  import { ContentScriptMessage } from '../lib/messages';
7
6
  import { AutoAction, RuleBundle } from '../lib/types';
8
7
  import { filterCompactRules } from '../lib/encoding';
@@ -10,6 +9,8 @@ import compactRules from '../rules/compact-rules.json';
10
9
  // Full rules include optIn steps that compact rules intentionally omit.
11
10
  // We need both formats: compact for optOut tests, full for optIn tests.
12
11
  import { autoconsent as fullRules } from '../rules/rules.json';
12
+ import { injectIntoIsolatedWorld } from './isolated-world.mjs';
13
+ import { connectOxylabs, createCaptchaTracker } from './oxylabs.mjs';
13
14
 
14
15
  const LOG_MESSAGES: ContentScriptMessage['type'][] = process.env.CI
15
16
  ? []
@@ -17,8 +18,53 @@ const LOG_MESSAGES: ContentScriptMessage['type'][] = process.env.CI
17
18
  const LOG_PAGE_LOGS = false;
18
19
 
19
20
  const testRegion = (process.env.REGION || 'NA').trim();
21
+
22
+ // OXYLABS=1 runs each test in a fresh Oxylabs Agent Browser session in REGION instead of a local browser.
23
+ const useOxylabs = process.env.OXYLABS === '1';
24
+ // Local runs list the tests that failed on a bot wall here; Oxylabs runs only define the listed tests.
25
+ const oxylabsCandidatesFile = process.env.OXYLABS_CANDIDATES;
26
+ const oxylabsCandidates = useOxylabs && oxylabsCandidatesFile ? readOxylabsCandidates(oxylabsCandidatesFile) : null;
27
+ const recordOxylabsCandidates = !useOxylabs && !!oxylabsCandidatesFile;
28
+ // Pages load slower through remote Oxylabs sessions, so detection gets a longer window there.
29
+ const detectionSlowdown = useOxylabs ? 2 : 1;
30
+ // Status codes that bot protection answers with; other errors (404, 5xx) point to a stale spec URL instead.
31
+ const BOT_WALL_STATUSES = [401, 403, 429, 503];
32
+ // Navigation errors of a dead or misconfigured site rather than a blocked one.
33
+ const DEAD_SITE_ERRORS = /ERR_NAME_NOT_RESOLVED|ERR_SOCKS_CONNECTION_FAILED|ERR_CONNECTION_REFUSED|ERR_ADDRESS_UNREACHABLE|ERR_CERT_/;
34
+ // Page titles of common bot-protection challenge and block pages.
35
+ const BOT_WALL_TITLE =
36
+ /just a moment|attention required|client challenge|access denied|access to this page has been denied|pardon our interruption|request unsuccessful|are you a robot|verify you are human|security check|captcha/i;
37
+
38
+ const test = useOxylabs
39
+ ? base.extend({
40
+ page: async ({ browserName }, use) => {
41
+ if (browserName !== 'chromium') {
42
+ throw new Error('Oxylabs runs need a Chromium project');
43
+ }
44
+ const browser = await connectOxylabs(testRegion.toLowerCase());
45
+ try {
46
+ await use(await browser.newPage());
47
+ } finally {
48
+ await browser.close().catch(() => {});
49
+ }
50
+ },
51
+ })
52
+ : base;
20
53
  test.describe.configure({ mode: 'parallel' });
21
54
 
55
+ // The CMP name keeps tests for the same site in different specs apart.
56
+ function oxylabsCandidateKey(cmp: string, testName: string) {
57
+ return `${cmp} › ${testName}`;
58
+ }
59
+
60
+ function readOxylabsCandidates(file: string) {
61
+ try {
62
+ return new Set(fs.readFileSync(file, 'utf8').split('\n').filter(Boolean));
63
+ } catch {
64
+ return new Set<string>();
65
+ }
66
+ }
67
+
22
68
  type TestOptions = {
23
69
  testOptOut: boolean;
24
70
  testSelfTest: boolean;
@@ -30,6 +76,13 @@ type TestOptions = {
30
76
  expectedRuns: number;
31
77
  gpc: boolean;
32
78
  };
79
+ // A frame's content script, reached through either the isolated-world transport or a main-world Frame.
80
+ type ContentScriptTarget = {
81
+ send: (message: object) => Promise<unknown>;
82
+ evalInMainWorld: (code: string) => Promise<unknown>;
83
+ isMainFrame: boolean;
84
+ };
85
+
33
86
  const defaultOptions: TestOptions = {
34
87
  testOptOut: true,
35
88
  testOptIn: true,
@@ -43,7 +96,7 @@ const defaultOptions: TestOptions = {
43
96
  };
44
97
 
45
98
  const contentScript = fs.readFileSync(path.join(__dirname, '../dist/autoconsent.playwright.js'), 'utf8');
46
- const screenshotsDir = path.join(__dirname, '../test-results/screenshots');
99
+ const screenshotsDir = path.join(__dirname, '../test-results/screenshots', useOxylabs ? 'oxylabs' : '');
47
100
  const deduplicatedRuleLookup = (
48
101
  JSON.parse(fs.readFileSync(path.join(__dirname, '../rules/rules.json'), 'utf-8')) as RuleBundle
49
102
  ).autoconsent.reduce((acc, rule) => {
@@ -57,6 +110,7 @@ class TestRun {
57
110
  page: Page;
58
111
  testInfo: TestInfo;
59
112
  url: string;
113
+ cmp: string;
60
114
  expectedCmp: string;
61
115
  options: TestOptions;
62
116
  autoAction: AutoAction | null;
@@ -65,12 +119,16 @@ class TestRun {
65
119
  formFactor: string;
66
120
  received: ContentScriptMessage[] = [];
67
121
  screenshotCounter = 0;
68
- selfTestFrame: Frame | null = null;
122
+ selfTestTarget: ContentScriptTarget | null = null;
123
+ response: Response | null = null;
124
+ // Oxylabs solves captchas on the page; waits pause while it does.
125
+ captcha = useOxylabs ? createCaptchaTracker() : null;
69
126
 
70
127
  constructor(page: Page, testInfo: TestInfo, url: string, expectedCmp: string, options: TestOptions, autoAction: AutoAction | null) {
71
128
  this.page = page;
72
129
  this.testInfo = testInfo;
73
130
  this.url = url;
131
+ this.cmp = expectedCmp;
74
132
  // if a rule was deduplicated, update the expectation to the new deduplicate rule name
75
133
  if (deduplicatedRuleLookup.has(expectedCmp)) {
76
134
  this.expectedCmp = deduplicatedRuleLookup.get(expectedCmp)!;
@@ -94,15 +152,43 @@ class TestRun {
94
152
  console.log(` page log:`, msg.text());
95
153
  });
96
154
 
97
- await this.page.exposeBinding('autoconsentSendMessage', this.messageCallback.bind(this));
155
+ // Chromium runs the content script in an isolated world like the extension; other browsers have no CDP for that.
156
+ const useIsolatedWorld = this.page.context().browser()?.browserType().name() === 'chromium';
157
+ if (useIsolatedWorld) {
158
+ await injectIntoIsolatedWorld(
159
+ this.page,
160
+ (transport) => async (msg, frameRef) => {
161
+ if (this.captcha?.handleMessage(msg)) {
162
+ return;
163
+ }
164
+ await this.messageCallback(msg, {
165
+ send: (message) => transport.sendToContentScript(frameRef, message),
166
+ evalInMainWorld: (code) => transport.evalInMainWorld(frameRef, code),
167
+ isMainFrame: transport.isMainFrame(frameRef),
168
+ });
169
+ },
170
+ contentScript,
171
+ this.captcha?.script,
172
+ );
173
+ } else {
174
+ await this.page.exposeBinding('autoconsentSendMessage', ({ frame }, msg: ContentScriptMessage) =>
175
+ this.messageCallback(msg, {
176
+ send: (message) => frame.evaluate(`autoconsentReceiveMessage(${JSON.stringify(message)})`),
177
+ evalInMainWorld: (code) => frame.evaluate(code),
178
+ isMainFrame: frame.parentFrame() === null,
179
+ }),
180
+ );
181
+ }
98
182
  if (this.options.gpc) {
99
183
  await this.enableGpc();
100
184
  }
101
- await this.page.goto(this.url, { waitUntil: 'commit' });
185
+ try {
186
+ this.response = await this.page.goto(this.url, { waitUntil: 'commit' });
102
187
 
103
- await this.injectContentScripts();
188
+ if (!useIsolatedWorld) {
189
+ await this.injectContentScripts();
190
+ }
104
191
 
105
- try {
106
192
  await this.runAssertions();
107
193
  } catch (e) {
108
194
  if (e instanceof Error) {
@@ -115,6 +201,7 @@ class TestRun {
115
201
  formFactor: this.formFactor,
116
202
  testName: this.testInfo.title,
117
203
  retry: this.testInfo.retry,
204
+ provider: useOxylabs ? 'oxylabs' : 'local',
118
205
  };
119
206
  // log the full url in the error message, this will be parsed by the review tool
120
207
  console.error(`Autoconsent test failed on ${this.url} failure stats: ${JSON.stringify(failureStats)}`);
@@ -128,6 +215,34 @@ class TestRun {
128
215
  }
129
216
  }
130
217
 
218
+ // On the last attempt, list a failed test that hit a bot wall so the Oxylabs pass can rerun it.
219
+ // Called from afterEach, which also runs when the test times out before reaching its own catch.
220
+ async recordOxylabsCandidate() {
221
+ const { status, retry, project, error } = this.testInfo;
222
+ if (status === 'passed' || status === 'skipped' || status === 'interrupted' || retry < project.retries) {
223
+ return;
224
+ }
225
+ if (await this.looksBlocked(error?.message ?? '')) {
226
+ fs.appendFileSync(oxylabsCandidatesFile!, `${oxylabsCandidateKey(this.cmp, this.testInfo.title)}\n`);
227
+ }
228
+ }
229
+
230
+ // The site served a bot wall instead of its content: the page never loaded (other than a dead host),
231
+ // or nothing was detected on a bot-wall status code or a challenge page.
232
+ async looksBlocked(errorMessage: string) {
233
+ if (!this.response) {
234
+ return !DEAD_SITE_ERRORS.test(errorMessage);
235
+ }
236
+ if (this.isMessageReceived({ type: 'cmpDetected' })) {
237
+ return false;
238
+ }
239
+ if (BOT_WALL_STATUSES.includes(this.response.status())) {
240
+ return true;
241
+ }
242
+ const title = await this.page.title().catch(() => '');
243
+ return BOT_WALL_TITLE.test(title);
244
+ }
245
+
131
246
  // emulate a browser that sends the Global Privacy Control signal
132
247
  async enableGpc() {
133
248
  await this.page.setExtraHTTPHeaders({ 'Sec-GPC': '1' });
@@ -159,29 +274,29 @@ class TestRun {
159
274
  }
160
275
  }
161
276
 
162
- async messageCallback({ frame }: { frame: Frame }, msg: ContentScriptMessage) {
277
+ async messageCallback(msg: ContentScriptMessage, target: ContentScriptTarget) {
163
278
  LOG_MESSAGES.includes(msg.type) && console.log(msg);
164
279
  this.received.push(msg);
165
280
  switch (msg.type) {
166
281
  case 'init': {
167
- const url = frame.url();
168
- const mainFrame = frame.parentFrame() === null;
169
282
  // Use full rules for optIn (compact rules omit optIn steps), compact rules for optOut.
170
283
  const rules =
171
284
  this.autoAction === 'optIn'
172
285
  ? { autoconsent: fullRules }
173
- : { compact: filterCompactRules(compactRules, { url, mainFrame }) };
174
- await frame.evaluate(
175
- `autoconsentReceiveMessage({ type: "initResp", config: ${JSON.stringify({
286
+ : { compact: filterCompactRules(compactRules, { url: msg.url, mainFrame: target.isMainFrame }) };
287
+ await target.send({
288
+ type: 'initResp',
289
+ config: {
176
290
  enabled: true,
177
291
  autoAction: this.autoAction,
178
292
  disabledCmps: [],
179
293
  enablePrehide: false,
180
- detectRetries: 20,
294
+ detectRetries: 20 * detectionSlowdown,
181
295
  enableCosmeticRules: true,
182
296
  visualTest: true,
183
- })}, rules: ${JSON.stringify(rules)} })`,
184
- );
297
+ },
298
+ rules,
299
+ });
185
300
  break;
186
301
  }
187
302
  case 'cmpDetected': {
@@ -196,20 +311,25 @@ class TestRun {
196
311
  case 'optOutResult': {
197
312
  await this.takeScreenshot(`${this.screenshotCounter++}-result`);
198
313
  if (msg.scheduleSelfTest) {
199
- this.selfTestFrame = frame;
314
+ this.selfTestTarget = target;
200
315
  }
201
316
  break;
202
317
  }
203
318
  case 'autoconsentDone': {
204
319
  await this.takeScreenshot(`${this.screenshotCounter++}-done`);
205
- if (this.selfTestFrame && this.options.testSelfTest) {
206
- await this.selfTestFrame.evaluate(`autoconsentReceiveMessage({ type: "selfTest" })`);
320
+ if (this.selfTestTarget && this.options.testSelfTest) {
321
+ await this.selfTestTarget.send({ type: 'selfTest' });
207
322
  }
208
323
  break;
209
324
  }
210
325
  case 'eval': {
211
- const result = await frame.evaluate(msg.code);
212
- await frame.evaluate(`autoconsentReceiveMessage({ id: "${msg.id}", type: "evalResp", result: ${JSON.stringify(result)} })`);
326
+ let result: unknown = false;
327
+ try {
328
+ result = await target.evalInMainWorld(msg.code);
329
+ } catch {
330
+ // the frame may have navigated or detached
331
+ }
332
+ await target.send({ id: msg.id, type: 'evalResp', result });
213
333
  break;
214
334
  }
215
335
  case 'visualDelay': {
@@ -240,8 +360,19 @@ class TestRun {
240
360
  return this.findReceivedMessages(msg).length > 0;
241
361
  }
242
362
 
243
- waitForMessage(msg: Partial<ContentScriptMessage>, maxTimes = 50, interval = 500) {
244
- return waitFor(() => this.isMessageReceived(msg), maxTimes, interval);
363
+ async waitForMessage(msg: Partial<ContentScriptMessage>, maxTimes = 50, interval = 500) {
364
+ let attempts = 0;
365
+ while (!this.isMessageReceived(msg)) {
366
+ if (attempts >= maxTimes) {
367
+ return false;
368
+ }
369
+ await new Promise((resolve) => setTimeout(resolve, interval));
370
+ // time spent waiting for the captcha solver doesn't count
371
+ if (!this.captcha?.isSolving()) {
372
+ attempts++;
373
+ }
374
+ }
375
+ return true;
245
376
  }
246
377
 
247
378
  async assertMessageReceived(
@@ -282,7 +413,7 @@ class TestRun {
282
413
  }
283
414
 
284
415
  async runAssertions() {
285
- await this.assertMessageReceived(`no CMP detected`, { type: 'cmpDetected' });
416
+ await this.assertMessageReceived(`no CMP detected`, { type: 'cmpDetected' }, true, 50 * detectionSlowdown);
286
417
 
287
418
  const expectedCmpDetected: Partial<ContentScriptMessage> = { type: 'cmpDetected', cmp: this.expectedCmp };
288
419
  await this.assertMessageReceived(`detected a wrong CMP`, expectedCmpDetected);
@@ -320,7 +451,7 @@ class TestRun {
320
451
  await this.assertMessageReceived(`optInResult not received`, { type: 'optInResult' }, true, 1, 300);
321
452
  await this.assertMessageReceived(`optInResult received, but failed`, { type: 'optInResult', result: true }, true, 1, 300);
322
453
  }
323
- if (this.options.testSelfTest && this.selfTestFrame) {
454
+ if (this.options.testSelfTest && this.selfTestTarget) {
324
455
  await this.assertMessageReceived(`selfTestResult not received`, { type: 'selfTestResult' }, true, 1, 300);
325
456
  await this.assertMessageReceived(
326
457
  `selfTestResult received, but failed`,
@@ -340,8 +471,19 @@ class TestRun {
340
471
  }
341
472
  }
342
473
 
474
+ // Runs by test id, for the afterEach hook that records Oxylabs candidates.
475
+ const testRuns = new Map<string, TestRun>();
476
+
343
477
  export default function generateCMPTests(cmp: string, sites: string[], overrideOptions: Partial<TestOptions> = {}) {
344
478
  test.describe(cmp, () => {
479
+ if (recordOxylabsCandidates) {
480
+ // eslint-disable-next-line no-empty-pattern
481
+ test.afterEach(async ({}, testInfo) => {
482
+ const testRun = testRuns.get(testInfo.testId);
483
+ testRuns.delete(testInfo.testId);
484
+ await testRun?.recordOxylabsCandidate();
485
+ });
486
+ }
345
487
  sites.forEach((url) => {
346
488
  const finalOptions = { ...defaultOptions, ...overrideOptions };
347
489
  if (finalOptions.onlyRegions && finalOptions.onlyRegions.length > 0 && !finalOptions.onlyRegions.includes(testRegion)) {
@@ -355,26 +497,28 @@ export default function generateCMPTests(cmp: string, sites: string[], overrideO
355
497
  const urlHash = crypto.createHash('md5').update(url).digest('hex').slice(0, 4);
356
498
  const formFactor = finalOptions.mobile ? 'mobile' : 'desktop';
357
499
 
358
- if (!finalOptions.testOptIn && !finalOptions.testOptOut) {
359
- const testName = `${domain} ${urlHash} .${testRegion} noaction ${formFactor}`;
500
+ const defineTest = (label: string, autoAction: AutoAction | null) => {
501
+ const testName = `${domain} ${urlHash} .${testRegion} ${label} ${formFactor}`;
502
+ if (oxylabsCandidates && !oxylabsCandidates.has(oxylabsCandidateKey(cmp, testName))) {
503
+ return;
504
+ }
360
505
  test(testName, async ({ page }, testInfo) => {
361
- const testRun = new TestRun(page, testInfo, url, cmp, finalOptions, null);
506
+ const testRun = new TestRun(page, testInfo, url, cmp, finalOptions, autoAction);
507
+ if (recordOxylabsCandidates) {
508
+ testRuns.set(testInfo.testId, testRun);
509
+ }
362
510
  await testRun.run();
363
511
  });
512
+ };
513
+
514
+ if (!finalOptions.testOptIn && !finalOptions.testOptOut) {
515
+ defineTest('noaction', null);
364
516
  }
365
517
  if (finalOptions.testOptIn) {
366
- const testName = `${domain} ${urlHash} .${testRegion} optIn ${formFactor}`;
367
- test(testName, async ({ page }, testInfo) => {
368
- const testRun = new TestRun(page, testInfo, url, cmp, finalOptions, 'optIn');
369
- await testRun.run();
370
- });
518
+ defineTest('optIn', 'optIn');
371
519
  }
372
520
  if (finalOptions.testOptOut) {
373
- const testName = `${domain} ${urlHash} .${testRegion} optOut ${formFactor}`;
374
- test(testName, async ({ page }, testInfo) => {
375
- const testRun = new TestRun(page, testInfo, url, cmp, finalOptions, 'optOut');
376
- await testRun.run();
377
- });
521
+ defineTest('optOut', 'optOut');
378
522
  }
379
523
  });
380
524
  });
@@ -1,10 +1,13 @@
1
1
  import { PlaywrightTestConfig, devices } from '@playwright/test';
2
2
 
3
- const proxy = process.env.PROXY_SERVER ? { server: process.env.PROXY_SERVER } : undefined;
3
+ // Oxylabs sessions bring their own regional proxy and reject contexts that set another one.
4
+ const proxy = process.env.PROXY_SERVER && process.env.OXYLABS !== '1' ? { server: process.env.PROXY_SERVER } : undefined;
4
5
 
5
6
  const config: PlaywrightTestConfig = {
6
7
  forbidOnly: !!process.env.CI,
7
8
  retries: process.env.CI ? 2 : 0,
9
+ // Remote Oxylabs sessions load pages more slowly and may wait up to 60s for the captcha solver.
10
+ timeout: process.env.OXYLABS === '1' ? 240000 : undefined,
8
11
  testDir: 'tests',
9
12
  use: {
10
13
  trace: 'off',
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "aeolservice",
3
+ "vendorUrl": "https://cloud.aeolservice.es/",
4
+ "runContext": {
5
+ "urlPattern": "^https?://cloud\\.aeolservice\\.es/"
6
+ },
7
+ "prehideSelectors": [],
8
+ "detectCmp": [
9
+ {
10
+ "exists": "#cookies_consent_div"
11
+ }
12
+ ],
13
+ "detectPopup": [
14
+ {
15
+ "visible": "#popup_content.cookies_consent_div .config_cookies"
16
+ }
17
+ ],
18
+ "optIn": [
19
+ {
20
+ "waitForThenClick": "#popup_content.cookies_consent_div .explanation .aceptar_cokies"
21
+ }
22
+ ],
23
+ "optOut": [
24
+ {
25
+ "waitForThenClick": "#popup_content.cookies_consent_div .config_cookies"
26
+ },
27
+ {
28
+ "waitForVisible": "#popup_content #configurar_cookies"
29
+ },
30
+ {
31
+ "click": "#popup_content .cookie_button:not(.cookies_tecnicas) input[type=checkbox]:checked",
32
+ "all": true,
33
+ "optional": true
34
+ },
35
+ {
36
+ "waitForThenClick": "#popup_content .not_explanation.aceptar_cokies"
37
+ }
38
+ ]
39
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "airbnb",
3
+ "vendorUrl": "https://www.airbnb.com/",
4
+ "runContext": { "urlPattern": "^https?://(www\\.)?airbnb\\.[a-z.]+/" },
5
+ "prehideSelectors": ["[data-testid=\"main-cookies-banner-container\"]"],
6
+ "detectCmp": [
7
+ { "exists": "[data-testid=\"main-cookies-banner-container\"] section > div > div:nth-child(2) > div:nth-child(2) > button" }
8
+ ],
9
+ "detectPopup": [
10
+ { "visible": "[data-testid=\"main-cookies-banner-container\"] section > div > div:nth-child(2) > div:nth-child(2) > button" }
11
+ ],
12
+ "optIn": [
13
+ {
14
+ "waitForThenClick": "[data-testid=\"main-cookies-banner-container\"] section > div > div:nth-child(2) > div:nth-child(1) > button"
15
+ }
16
+ ],
17
+ "optOut": [
18
+ {
19
+ "waitForThenClick": "[data-testid=\"main-cookies-banner-container\"] section > div > div:nth-child(2) > div:nth-child(2) > button",
20
+ "retry": 5,
21
+ "retryInterval": 500,
22
+ "comment": "Only necessary; retry in case handlers are not attached yet"
23
+ }
24
+ ],
25
+ "test": [
26
+ {
27
+ "waitForVisible": "[data-testid=\"main-cookies-banner-container\"] section > div > div:nth-child(2) > div:nth-child(2) > button",
28
+ "timeout": 1000,
29
+ "check": "none"
30
+ }
31
+ ]
32
+ }