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);
+ });
+ });
+});