Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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": []
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>YouTube Detection Events</title>
</head>
<body>
<div id="movie_player" class="html5-video-player">
<video src="about:blank"></video>
</div>
<div role="dialog" aria-modal="true">
<div class="yt-core-attributed-string" role="text">Ad blockers are not allowed on YouTube</div>
</div>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
1 change: 1 addition & 0 deletions injected/playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
},
Expand Down
60 changes: 47 additions & 13 deletions injected/src/detectors/detections/youtube-ad-detection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean>} [fireDetectionEvents] - Per-type gating for event firing. Only types set to `true` fire events. Absent = no events.
*/

/**
Expand All @@ -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
*/
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 = {
Expand All @@ -46,6 +50,7 @@ class YouTubeAdDetector {
adBlockerDetectionSelectors: config.adBlockerDetectionSelectors,
adBlockerDetectionPatterns: config.adBlockerDetectionPatterns,
loginStateSelectors: config.loginStateSelectors,
fireDetectionEvents: config.fireDetectionEvents,
};

// Initialize state
Expand All @@ -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;
Expand Down Expand Up @@ -126,6 +132,14 @@ class YouTubeAdDetector {
typeState.lastMessage = details.message;
}

if (this.config.fireDetectionEvents?.[type]) {
try {
this.onEvent(`youtube_${type}`);
} catch {
// onEvent callback failure should never break detection
}
}

return true;
}

Expand Down Expand Up @@ -602,7 +616,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');
}
Expand Down Expand Up @@ -638,6 +652,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;
Expand Down Expand Up @@ -721,15 +739,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();
}

Expand All @@ -738,13 +768,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;
}
}
19 changes: 13 additions & 6 deletions injected/src/features/web-interference-detection.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) => {
Expand Down
2 changes: 2 additions & 0 deletions injected/unit-test/features.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
Loading
Loading