diff --git a/injected/integration-test/test-pages/web-interference-detection/config/youtube-detection-events.json b/injected/integration-test/test-pages/web-interference-detection/config/youtube-detection-events.json new file mode 100644 index 00000000000..91637ed81a6 --- /dev/null +++ b/injected/integration-test/test-pages/web-interference-detection/config/youtube-detection-events.json @@ -0,0 +1,43 @@ +{ + "readme": "Config for testing YouTube detection event firing via webInterferenceDetection + webEvents", + "version": 1, + "unprotectedTemporary": [], + "features": { + "webInterferenceDetection": { + "state": "enabled", + "hash": "test", + "exceptions": [], + "settings": { + "interferenceTypes": { + "youtubeAds": { + "state": "enabled", + "sweepIntervalMs": 500, + "slowLoadThresholdMs": 5000, + "playerSelectors": ["#movie_player"], + "adClasses": ["ad-showing"], + "adTextPatterns": [], + "staticAdSelectors": { "background": "", "thumbnail": "", "image": "" }, + "playabilityErrorSelectors": [], + "playabilityErrorPatterns": [], + "adBlockerDetectionSelectors": ["[role=\"dialog\"]"], + "adBlockerDetectionPatterns": ["ad\\s*blockers?\\s*(are)?\\s*not allowed"], + "loginStateSelectors": { + "signInButton": "", + "avatarButton": "", + "premiumLogo": "" + }, + "fireDetectionEvents": { + "adBlocker": true, + "playabilityError": true + } + } + } + } + }, + "webEvents": { + "state": "enabled", + "hash": "test", + "exceptions": [] + } + } +} diff --git a/injected/integration-test/test-pages/web-interference-detection/pages/youtube-detection-events.html b/injected/integration-test/test-pages/web-interference-detection/pages/youtube-detection-events.html new file mode 100644 index 00000000000..566f06ee6f1 --- /dev/null +++ b/injected/integration-test/test-pages/web-interference-detection/pages/youtube-detection-events.html @@ -0,0 +1,15 @@ + + + + + YouTube Detection Events + + +
+ +
+
+
Ad blockers are not allowed on YouTube
+
+ + diff --git a/injected/integration-test/web-interference-detection-events.spec.js b/injected/integration-test/web-interference-detection-events.spec.js new file mode 100644 index 00000000000..15ee09c7f69 --- /dev/null +++ b/injected/integration-test/web-interference-detection-events.spec.js @@ -0,0 +1,66 @@ +import { test, expect } from '@playwright/test'; +import { ResultsCollector } from './page-objects/results-collector.js'; + +const CONFIG_ENABLED = './integration-test/test-pages/web-interference-detection/config/youtube-detection-events.json'; +const TEST_PAGE = '/web-interference-detection/pages/youtube-detection-events.html'; + +/** + * @param {ResultsCollector} collector + * @param {string} method + */ +async function getMessagesOfType(collector, method) { + const calls = await collector.outgoingMessages(); + return calls.filter((c) => /** @type {import('../../messaging/index.js').NotificationMessage} */ (c.payload).method === method); +} + +test.describe('YouTube detection events via webInterferenceDetection', () => { + test('sends webEvent with youtube_adBlocker when ad-blocker modal is detected', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + await collector.load(TEST_PAGE, CONFIG_ENABLED); + + await page.waitForTimeout(2000); + + const webEventMessages = await getMessagesOfType(collector, 'webEvent'); + expect(webEventMessages.length).toBeGreaterThanOrEqual(1); + const params = + /** @type {import('../../messaging/index.js').NotificationMessage} */ + (webEventMessages[0].payload).params; + expect(params).toEqual({ + type: 'youtube_adBlocker', + data: {}, + }); + }); + + test('webEvent message has correct structure', async ({ page }, testInfo) => { + const collector = ResultsCollector.create(page, testInfo.project.use); + await collector.load(TEST_PAGE, CONFIG_ENABLED); + + await page.waitForTimeout(2000); + + const webEventMessages = await getMessagesOfType(collector, 'webEvent'); + expect(webEventMessages.length).toBeGreaterThanOrEqual(1); + + for (const msg of webEventMessages) { + expect(msg.payload.context).toBe('contentScopeScripts'); + expect(msg.payload.featureName).toBe('webEvents'); + expect(msg.payload).not.toHaveProperty('nativeData'); + expect(/** @type {import('../../messaging/index.js').NotificationMessage} */ (msg.payload).params).not.toHaveProperty( + 'nativeData', + ); + } + }); + + test('does not produce page errors during detection and event firing', async ({ page }, testInfo) => { + const errors = []; + page.on('pageerror', (error) => errors.push(error)); + + const collector = ResultsCollector.create(page, testInfo.project.use); + collector.withMockResponse({ webEvent: null }); + await collector.load(TEST_PAGE, CONFIG_ENABLED); + + await page.waitForTimeout(2000); + + const relevantErrors = errors.filter((e) => !e.message.includes('net::')); + expect(relevantErrors).toEqual([]); + }); +}); diff --git a/injected/playwright.config.js b/injected/playwright.config.js index a9e9f0d993b..67fa113477d 100644 --- a/injected/playwright.config.js +++ b/injected/playwright.config.js @@ -20,6 +20,7 @@ export default defineConfig({ 'integration-test/duck-ai-chat-history.spec.js', 'integration-test/web-detection.spec.js', 'integration-test/web-events.spec.js', + 'integration-test/web-interference-detection-events.spec.js', ], use: { injectName: 'windows', platform: 'windows' }, }, diff --git a/injected/src/detectors/detections/youtube-ad-detection.js b/injected/src/detectors/detections/youtube-ad-detection.js index 0b698386b65..7d91d12c4b6 100644 --- a/injected/src/detectors/detections/youtube-ad-detection.js +++ b/injected/src/detectors/detections/youtube-ad-detection.js @@ -14,6 +14,7 @@ import { isVisible, toRegExpArray } from '../utils/detection-utils.js'; * @property {string[]} adBlockerDetectionSelectors * @property {string[]} adBlockerDetectionPatterns * @property {{signInButton: string, avatarButton: string, premiumLogo: string}} loginStateSelectors + * @property {Record} [fireDetectionEvents] - Per-type gating for event firing. Only types set to `true` fire events. Absent = no events. */ /** @@ -24,14 +25,17 @@ import { isVisible, toRegExpArray } from '../utils/detection-utils.js'; /** @type {{info: Function, warn: Function, error: Function}} */ const noopLogger = { info: () => {}, warn: () => {}, error: () => {} }; -class YouTubeAdDetector { +export class YouTubeAdDetector { /** * @param {YouTubeDetectorConfig} config - Configuration from privacy-config (required) * @param {{info: Function, warn: Function, error: Function}} [logger] - Optional logger from ContentFeature + * @param {(type: string) => void} [onEvent] - Callback fired when a new detection occurs (may be async) */ - constructor(config, logger) { + constructor(config, logger, onEvent) { // Logger for debug output (only logs when debug mode is enabled) this.log = logger || noopLogger; + /** @type {(type: string) => void} */ + this.onEvent = onEvent || (() => {}); // All config comes from privacy-config this.config = { @@ -46,6 +50,7 @@ class YouTubeAdDetector { adBlockerDetectionSelectors: config.adBlockerDetectionSelectors, adBlockerDetectionPatterns: config.adBlockerDetectionPatterns, loginStateSelectors: config.loginStateSelectors, + fireDetectionEvents: config.fireDetectionEvents, }; // Initialize state @@ -54,6 +59,7 @@ class YouTubeAdDetector { // Intervals and tracking this.pollInterval = null; this.rerootInterval = null; + this.startRetryTimeout = null; this.trackedVideoElement = null; this.lastLoggedVideoId = null; this.currentVideoId = null; @@ -126,6 +132,18 @@ class YouTubeAdDetector { typeState.lastMessage = details.message; } + if (this.config.fireDetectionEvents?.[type]) { + try { + const result = /** @type {any} */ (this.onEvent(`youtube_${type}`)); + if (result && typeof result.catch === 'function') { + // eslint-disable-next-line promise/prefer-await-to-then + result.catch(() => {}); + } + } catch { + // onEvent callback failure should never break detection + } + } + return true; } @@ -602,7 +620,7 @@ class YouTubeAdDetector { if (!root) { if (attempt < 25) { this.log.info(`Player root not found, retrying in 500ms (attempt ${attempt}/25)`); - setTimeout(() => this.start(attempt + 1), 500); + this.startRetryTimeout = setTimeout(() => this.start(attempt + 1), 500); } else { this.log.info('Player root not found after 25 attempts, giving up'); } @@ -638,6 +656,10 @@ class YouTubeAdDetector { * Stop the detector */ stop() { + if (this.startRetryTimeout) { + clearTimeout(this.startRetryTimeout); + this.startRetryTimeout = null; + } if (this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; @@ -721,15 +743,27 @@ let detectorInstance = null; /** * @param {YouTubeDetectorConfig} [config] - Configuration from privacy-config * @param {{info: Function, warn: Function, error: Function}} [logger] - Optional logger from ContentFeature + * @param {(type: string) => void} [fireEvent] - Callback fired when a new detection occurs */ -export function runYoutubeAdDetection(config, logger) { +export function runYoutubeAdDetection(config, logger, fireEvent) { + const hostname = window.location.hostname; + const isYouTube = hostname === 'youtube.com' || hostname.endsWith('.youtube.com'); + const isTestDomain = + hostname === 'privacy-test-pages.site' || hostname.endsWith('.privacy-test-pages.site') || hostname === 'localhost'; + if (!isYouTube && !isTestDomain) { + return { detected: false, type: 'youtubeAds', results: [] }; + } + // Only run if explicitly enabled or internal if (config?.state !== 'enabled' && config?.state !== 'internal') { return { detected: false, type: 'youtubeAds', results: [] }; } - // If detector already exists, return its results (even if config is undefined) + // If detector already exists, update callback if provided and return results if (detectorInstance) { + if (fireEvent) { + detectorInstance.onEvent = fireEvent; + } return detectorInstance.getResults(); } @@ -738,13 +772,17 @@ export function runYoutubeAdDetection(config, logger) { return { detected: false, type: 'youtubeAds', results: [] }; } - // Auto-initialize on first call if on YouTube - const hostname = window.location.hostname; - if (hostname === 'youtube.com' || hostname.endsWith('.youtube.com')) { - detectorInstance = new YouTubeAdDetector(config, logger); - detectorInstance.start(); - return detectorInstance.getResults(); - } + detectorInstance = new YouTubeAdDetector(config, logger, fireEvent); + detectorInstance.start(); + return detectorInstance.getResults(); +} - return { detected: false, type: 'youtubeAds', results: [] }; +/** + * @visibleForTesting + */ +export function resetYoutubeAdDetection() { + if (detectorInstance) { + detectorInstance.stop(); + detectorInstance = null; + } } diff --git a/injected/src/features/web-interference-detection.js b/injected/src/features/web-interference-detection.js index 6f1ecd045bb..98addb3d4b2 100644 --- a/injected/src/features/web-interference-detection.js +++ b/injected/src/features/web-interference-detection.js @@ -1,4 +1,4 @@ -import ContentFeature from '../content-feature.js'; +import ContentFeature, { CallFeatureMethodError } from '../content-feature.js'; import { runBotDetection } from '../detectors/detections/bot-detection.js'; import { runFraudDetection } from '../detectors/detections/fraud-detection.js'; import { runAdwallDetection } from '../detectors/detections/adwall-detection.js'; @@ -20,11 +20,18 @@ export default class WebInterferenceDetection extends ContentFeature { // Get settings with conditionalChanges already applied by framework const settings = this.getFeatureSetting('interferenceTypes'); - // Initialize YouTube detector early on YouTube pages to capture video load times - const hostname = window.location.hostname; - if (hostname === 'youtube.com' || hostname.endsWith('.youtube.com')) { - runYoutubeAdDetection(settings?.youtubeAds, this.log); - } + const fireEvent = async (type) => { + try { + const result = await this.callFeatureMethod('webEvents', 'fireEvent', { type }); + if (result instanceof CallFeatureMethodError && this.isDebug) { + this.log.warn('webEvents.fireEvent failed:', result.message); + } + } catch { + // webEvents may not be loaded on this platform — silently ignore + } + }; + + runYoutubeAdDetection(settings?.youtubeAds, this.log, fireEvent); // Register messaging handler for PIR/native requests this.messaging.subscribe('detectInterference', (params) => { diff --git a/injected/unit-test/features.js b/injected/unit-test/features.js index 1afbafe11cf..e1f93d69d15 100644 --- a/injected/unit-test/features.js +++ b/injected/unit-test/features.js @@ -87,6 +87,8 @@ describe('test-pages/*/config/*.json schema validation', () => { path.resolve(__dirname, '../integration-test/test-pages/message-bridge/config/message-bridge-disabled.json'), // Legacy conditionalChanges format (domain at root instead of condition.domain) path.resolve(__dirname, '../integration-test/test-pages/ua-ch-brands/config/domain-brand-override-legacy.json'), + // Uses fireDetectionEvents which is not yet in the published schema + path.resolve(__dirname, '../integration-test/test-pages/web-interference-detection/config/youtube-detection-events.json'), ]; for (const configPath of configFiles) { if (legacyAllowlist.includes(configPath)) { diff --git a/injected/unit-test/youtube-ad-detection.js b/injected/unit-test/youtube-ad-detection.js new file mode 100644 index 00000000000..78c3c64571b --- /dev/null +++ b/injected/unit-test/youtube-ad-detection.js @@ -0,0 +1,264 @@ +import { YouTubeAdDetector, runYoutubeAdDetection, resetYoutubeAdDetection } from '../src/detectors/detections/youtube-ad-detection.js'; + +const minimalConfig = { + playerSelectors: ['#movie_player'], + adClasses: ['ad-showing'], + adTextPatterns: [], + sweepIntervalMs: 2000, + slowLoadThresholdMs: 5000, + staticAdSelectors: { background: '', thumbnail: '', image: '' }, + playabilityErrorSelectors: [], + playabilityErrorPatterns: [], + adBlockerDetectionSelectors: [], + adBlockerDetectionPatterns: [], + loginStateSelectors: { signInButton: '', avatarButton: '', premiumLogo: '' }, +}; + +const configWithAllEvents = { + ...minimalConfig, + fireDetectionEvents: { + adBlocker: true, + playabilityError: true, + videoAd: true, + staticAd: true, + }, +}; + +describe('YouTubeAdDetector', () => { + describe('onEvent callback', () => { + it('calls onEvent with youtube_ prefix when a new detection occurs', () => { + const events = []; + const detector = new YouTubeAdDetector(configWithAllEvents, undefined, (type) => events.push(type)); + + detector.reportDetection('adBlocker'); + + expect(events).toEqual(['youtube_adBlocker']); + }); + + it('fires for each detection type', () => { + const events = []; + const detector = new YouTubeAdDetector(configWithAllEvents, undefined, (type) => events.push(type)); + + detector.reportDetection('videoAd'); + detector.reportDetection('playabilityError', { message: 'error' }); + detector.reportDetection('adBlocker'); + detector.reportDetection('staticAd'); + + expect(events).toEqual(['youtube_videoAd', 'youtube_playabilityError', 'youtube_adBlocker', 'youtube_staticAd']); + }); + + it('does not fire for duplicate detections', () => { + const events = []; + const detector = new YouTubeAdDetector(configWithAllEvents, undefined, (type) => events.push(type)); + + detector.reportDetection('adBlocker'); + detector.reportDetection('adBlocker'); + detector.reportDetection('adBlocker'); + + expect(events).toEqual(['youtube_adBlocker']); + }); + + it('fires again after detection is cleared and re-detected', () => { + const events = []; + const detector = new YouTubeAdDetector(configWithAllEvents, undefined, (type) => events.push(type)); + + detector.reportDetection('adBlocker'); + detector.clearDetection('adBlocker'); + detector.reportDetection('adBlocker'); + + expect(events).toEqual(['youtube_adBlocker', 'youtube_adBlocker']); + }); + + it('fires for playabilityError with a different message', () => { + const events = []; + const detector = new YouTubeAdDetector(configWithAllEvents, undefined, (type) => events.push(type)); + + detector.reportDetection('playabilityError', { message: 'error A' }); + detector.reportDetection('playabilityError', { message: 'error B' }); + detector.reportDetection('playabilityError', { message: 'error B' }); + + expect(events).toEqual(['youtube_playabilityError', 'youtube_playabilityError']); + }); + + it('does not break detection when callback throws synchronously', () => { + const detector = new YouTubeAdDetector(configWithAllEvents, undefined, () => { + throw new Error('callback failure'); + }); + + const result = detector.reportDetection('adBlocker'); + + expect(result).toBe(true); + expect(detector.state.detections.adBlocker.count).toBe(1); + expect(detector.state.detections.adBlocker.showing).toBe(true); + }); + + it('does not produce unhandled rejections when async callback rejects', async () => { + const detector = new YouTubeAdDetector(configWithAllEvents, undefined, () => { + return Promise.reject(new Error('async callback failure')); + }); + + const result = detector.reportDetection('adBlocker'); + + expect(result).toBe(true); + expect(detector.state.detections.adBlocker.count).toBe(1); + expect(detector.state.detections.adBlocker.showing).toBe(true); + + // Flush microtask queue — if the rejection is unhandled, the + // runtime's unhandledrejection listener (installed by Jasmine or + // Node) would fail the spec. + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + it('does not break detection when callback is async', () => { + let callbackInvoked = false; + const detector = new YouTubeAdDetector(configWithAllEvents, undefined, () => { + callbackInvoked = true; + }); + + const result = detector.reportDetection('adBlocker'); + + expect(result).toBe(true); + expect(callbackInvoked).toBe(true); + expect(detector.state.detections.adBlocker.count).toBe(1); + expect(detector.state.detections.adBlocker.showing).toBe(true); + }); + + it('defaults to no-op when onEvent is not provided', () => { + const detector = new YouTubeAdDetector(minimalConfig); + + const result = detector.reportDetection('videoAd'); + + expect(result).toBe(true); + expect(detector.state.detections.videoAd.count).toBe(1); + }); + }); + + describe('fireDetectionEvents gating', () => { + it('does not fire events when fireDetectionEvents is absent', () => { + const events = []; + const detector = new YouTubeAdDetector(minimalConfig, undefined, (type) => events.push(type)); + + detector.reportDetection('adBlocker'); + detector.reportDetection('playabilityError', { message: 'error' }); + + expect(events).toEqual([]); + }); + + it('does not fire events for types set to false', () => { + const events = []; + const config = { + ...minimalConfig, + fireDetectionEvents: { adBlocker: false, playabilityError: false }, + }; + const detector = new YouTubeAdDetector(config, undefined, (type) => events.push(type)); + + detector.reportDetection('adBlocker'); + detector.reportDetection('playabilityError', { message: 'error' }); + + expect(events).toEqual([]); + }); + + it('fires only for types set to true', () => { + const events = []; + const config = { + ...minimalConfig, + fireDetectionEvents: { adBlocker: true, playabilityError: false }, + }; + const detector = new YouTubeAdDetector(config, undefined, (type) => events.push(type)); + + detector.reportDetection('adBlocker'); + detector.reportDetection('playabilityError', { message: 'error' }); + + expect(events).toEqual(['youtube_adBlocker']); + }); + + it('still tracks detection state even when events are gated off', () => { + const detector = new YouTubeAdDetector(minimalConfig); + + const result = detector.reportDetection('adBlocker'); + + expect(result).toBe(true); + expect(detector.state.detections.adBlocker.count).toBe(1); + expect(detector.state.detections.adBlocker.showing).toBe(true); + }); + }); + + describe('runYoutubeAdDetection hostname gating', () => { + const enabledConfig = { ...minimalConfig, state: 'enabled' }; + const emptyResult = { detected: false, type: 'youtubeAds', results: [] }; + let savedWindow; + let savedDocument; + + beforeEach(() => { + savedWindow = globalThis.window; + savedDocument = globalThis.document; + }); + + afterEach(() => { + resetYoutubeAdDetection(); + globalThis.window = savedWindow; + globalThis.document = savedDocument; + }); + + function setHostname(name) { + const mockDoc = { querySelector: () => null, querySelectorAll: () => [], body: null, hidden: false, readyState: 'complete' }; + globalThis.window = /** @type {any} */ ({ + location: { hostname: name, search: '' }, + document: mockDoc, + navigator: { userActivation: { isActive: false } }, + addEventListener: () => {}, + performance: { now: () => 0 }, + setTimeout: () => {}, + setInterval: () => {}, + URLSearchParams, + }); + globalThis.document = /** @type {any} */ (mockDoc); + } + + it('rejects other domains', () => { + setHostname('example.com'); + expect(runYoutubeAdDetection(enabledConfig)).toEqual(emptyResult); + }); + + it('rejects domains containing youtube as a substring', () => { + setHostname('notyoutube.com'); + expect(runYoutubeAdDetection(enabledConfig)).toEqual(emptyResult); + }); + + it('allows localhost', () => { + setHostname('localhost'); + const result = runYoutubeAdDetection(enabledConfig); + expect(result).not.toEqual(emptyResult); + }); + + it('allows youtube.com', () => { + setHostname('youtube.com'); + const result = runYoutubeAdDetection(enabledConfig); + expect(result).not.toEqual(emptyResult); + }); + + it('allows www.youtube.com', () => { + setHostname('www.youtube.com'); + const result = runYoutubeAdDetection(enabledConfig); + expect(result).not.toEqual(emptyResult); + }); + + it('allows m.youtube.com', () => { + setHostname('m.youtube.com'); + const result = runYoutubeAdDetection(enabledConfig); + expect(result).not.toEqual(emptyResult); + }); + + it('allows privacy-test-pages.site', () => { + setHostname('privacy-test-pages.site'); + const result = runYoutubeAdDetection(enabledConfig); + expect(result).not.toEqual(emptyResult); + }); + + it('allows subdomains of privacy-test-pages.site', () => { + setHostname('test.privacy-test-pages.site'); + const result = runYoutubeAdDetection(enabledConfig); + expect(result).not.toEqual(emptyResult); + }); + }); +});