Skip to content

Commit 4b0f889

Browse files
jdorweilercursoragentGuiltyDolphin
authored
Fire information detection events in youtube detector (#2439)
* yt events * update test * remove extra hostname check * fix test * fix: apply prettier formatting to youtube-ad-detection unit tests Made-with: Cursor * fix: handle promise rejection in fireEvent callback Made-with: Cursor * fix: use async/await instead of .catch() in fireEvent callback Made-with: Cursor * add hostname gating unit tests for runYoutubeAdDetection Tests lock in expected behavior: allows youtube.com and subdomains, privacy-test-pages.site and subdomains, rejects other domains. Also adds resetYoutubeAdDetection() for test cleanup and stores the start retry timeout so stop() can clear it properly. Made-with: Cursor * add integration tests and debug logging for YouTube detection events - Integration test: verifies YouTube detection triggers webEvent E2E (ad-blocker modal DOM → detector sweep → webEvent notification) - Integration test: verifies webEvent message structure (context, featureName, no nativeData) - Integration test: verifies no page errors during detection and event firing - Unit test: callback invocation when async - Debug logging: log CallFeatureMethodError in debug mode when webEvents is unavailable - Allow localhost in hostname check for integration test compatibility - Add youtube-detection-events config to schema validation allowlist (fireDetectionEvents not yet in published schema) Made-with: Cursor * fix: update callback on existing detector singleton If the detector singleton already exists and a new fireEvent callback is provided, update the callback. This handles the case where the detector is created without a callback and later called with one. Made-with: Cursor * Handle async rejection in onEvent callback to prevent unhandled promise rejections The synchronous try/catch in reportDetection does not catch rejected promises from async onEvent callbacks. Although the current fireEvent caller has its own internal try/catch, the detector's contract should be safe for any async callback. - Check if onEvent returns a thenable and attach .catch() to absorb rejections - Widen JSDoc to note the callback may be async - Add unit test with a rejecting async callback to verify no unhandled rejection Co-authored-by: Ben Moon <GuiltyDolphin@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Ben Moon <GuiltyDolphin@users.noreply.github.com>
1 parent 98abb0e commit 4b0f889

File tree

8 files changed

+455
-19
lines changed

8 files changed

+455
-19
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"readme": "Config for testing YouTube detection event firing via webInterferenceDetection + webEvents",
3+
"version": 1,
4+
"unprotectedTemporary": [],
5+
"features": {
6+
"webInterferenceDetection": {
7+
"state": "enabled",
8+
"hash": "test",
9+
"exceptions": [],
10+
"settings": {
11+
"interferenceTypes": {
12+
"youtubeAds": {
13+
"state": "enabled",
14+
"sweepIntervalMs": 500,
15+
"slowLoadThresholdMs": 5000,
16+
"playerSelectors": ["#movie_player"],
17+
"adClasses": ["ad-showing"],
18+
"adTextPatterns": [],
19+
"staticAdSelectors": { "background": "", "thumbnail": "", "image": "" },
20+
"playabilityErrorSelectors": [],
21+
"playabilityErrorPatterns": [],
22+
"adBlockerDetectionSelectors": ["[role=\"dialog\"]"],
23+
"adBlockerDetectionPatterns": ["ad\\s*blockers?\\s*(are)?\\s*not allowed"],
24+
"loginStateSelectors": {
25+
"signInButton": "",
26+
"avatarButton": "",
27+
"premiumLogo": ""
28+
},
29+
"fireDetectionEvents": {
30+
"adBlocker": true,
31+
"playabilityError": true
32+
}
33+
}
34+
}
35+
}
36+
},
37+
"webEvents": {
38+
"state": "enabled",
39+
"hash": "test",
40+
"exceptions": []
41+
}
42+
}
43+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8">
5+
<title>YouTube Detection Events</title>
6+
</head>
7+
<body>
8+
<div id="movie_player" class="html5-video-player">
9+
<video src="about:blank"></video>
10+
</div>
11+
<div role="dialog" aria-modal="true">
12+
<div class="yt-core-attributed-string" role="text">Ad blockers are not allowed on YouTube</div>
13+
</div>
14+
</body>
15+
</html>
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { test, expect } from '@playwright/test';
2+
import { ResultsCollector } from './page-objects/results-collector.js';
3+
4+
const CONFIG_ENABLED = './integration-test/test-pages/web-interference-detection/config/youtube-detection-events.json';
5+
const TEST_PAGE = '/web-interference-detection/pages/youtube-detection-events.html';
6+
7+
/**
8+
* @param {ResultsCollector} collector
9+
* @param {string} method
10+
*/
11+
async function getMessagesOfType(collector, method) {
12+
const calls = await collector.outgoingMessages();
13+
return calls.filter((c) => /** @type {import('../../messaging/index.js').NotificationMessage} */ (c.payload).method === method);
14+
}
15+
16+
test.describe('YouTube detection events via webInterferenceDetection', () => {
17+
test('sends webEvent with youtube_adBlocker when ad-blocker modal is detected', async ({ page }, testInfo) => {
18+
const collector = ResultsCollector.create(page, testInfo.project.use);
19+
await collector.load(TEST_PAGE, CONFIG_ENABLED);
20+
21+
await page.waitForTimeout(2000);
22+
23+
const webEventMessages = await getMessagesOfType(collector, 'webEvent');
24+
expect(webEventMessages.length).toBeGreaterThanOrEqual(1);
25+
const params =
26+
/** @type {import('../../messaging/index.js').NotificationMessage} */
27+
(webEventMessages[0].payload).params;
28+
expect(params).toEqual({
29+
type: 'youtube_adBlocker',
30+
data: {},
31+
});
32+
});
33+
34+
test('webEvent message has correct structure', async ({ page }, testInfo) => {
35+
const collector = ResultsCollector.create(page, testInfo.project.use);
36+
await collector.load(TEST_PAGE, CONFIG_ENABLED);
37+
38+
await page.waitForTimeout(2000);
39+
40+
const webEventMessages = await getMessagesOfType(collector, 'webEvent');
41+
expect(webEventMessages.length).toBeGreaterThanOrEqual(1);
42+
43+
for (const msg of webEventMessages) {
44+
expect(msg.payload.context).toBe('contentScopeScripts');
45+
expect(msg.payload.featureName).toBe('webEvents');
46+
expect(msg.payload).not.toHaveProperty('nativeData');
47+
expect(/** @type {import('../../messaging/index.js').NotificationMessage} */ (msg.payload).params).not.toHaveProperty(
48+
'nativeData',
49+
);
50+
}
51+
});
52+
53+
test('does not produce page errors during detection and event firing', async ({ page }, testInfo) => {
54+
const errors = [];
55+
page.on('pageerror', (error) => errors.push(error));
56+
57+
const collector = ResultsCollector.create(page, testInfo.project.use);
58+
collector.withMockResponse({ webEvent: null });
59+
await collector.load(TEST_PAGE, CONFIG_ENABLED);
60+
61+
await page.waitForTimeout(2000);
62+
63+
const relevantErrors = errors.filter((e) => !e.message.includes('net::'));
64+
expect(relevantErrors).toEqual([]);
65+
});
66+
});

injected/playwright.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export default defineConfig({
2020
'integration-test/duck-ai-chat-history.spec.js',
2121
'integration-test/web-detection.spec.js',
2222
'integration-test/web-events.spec.js',
23+
'integration-test/web-interference-detection-events.spec.js',
2324
],
2425
use: { injectName: 'windows', platform: 'windows' },
2526
},

injected/src/detectors/detections/youtube-ad-detection.js

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { isVisible, toRegExpArray } from '../utils/detection-utils.js';
1414
* @property {string[]} adBlockerDetectionSelectors
1515
* @property {string[]} adBlockerDetectionPatterns
1616
* @property {{signInButton: string, avatarButton: string, premiumLogo: string}} loginStateSelectors
17+
* @property {Record<string, boolean>} [fireDetectionEvents] - Per-type gating for event firing. Only types set to `true` fire events. Absent = no events.
1718
*/
1819

1920
/**
@@ -24,14 +25,17 @@ import { isVisible, toRegExpArray } from '../utils/detection-utils.js';
2425
/** @type {{info: Function, warn: Function, error: Function}} */
2526
const noopLogger = { info: () => {}, warn: () => {}, error: () => {} };
2627

27-
class YouTubeAdDetector {
28+
export class YouTubeAdDetector {
2829
/**
2930
* @param {YouTubeDetectorConfig} config - Configuration from privacy-config (required)
3031
* @param {{info: Function, warn: Function, error: Function}} [logger] - Optional logger from ContentFeature
32+
* @param {(type: string) => void} [onEvent] - Callback fired when a new detection occurs (may be async)
3133
*/
32-
constructor(config, logger) {
34+
constructor(config, logger, onEvent) {
3335
// Logger for debug output (only logs when debug mode is enabled)
3436
this.log = logger || noopLogger;
37+
/** @type {(type: string) => void} */
38+
this.onEvent = onEvent || (() => {});
3539

3640
// All config comes from privacy-config
3741
this.config = {
@@ -46,6 +50,7 @@ class YouTubeAdDetector {
4650
adBlockerDetectionSelectors: config.adBlockerDetectionSelectors,
4751
adBlockerDetectionPatterns: config.adBlockerDetectionPatterns,
4852
loginStateSelectors: config.loginStateSelectors,
53+
fireDetectionEvents: config.fireDetectionEvents,
4954
};
5055

5156
// Initialize state
@@ -54,6 +59,7 @@ class YouTubeAdDetector {
5459
// Intervals and tracking
5560
this.pollInterval = null;
5661
this.rerootInterval = null;
62+
this.startRetryTimeout = null;
5763
this.trackedVideoElement = null;
5864
this.lastLoggedVideoId = null;
5965
this.currentVideoId = null;
@@ -126,6 +132,18 @@ class YouTubeAdDetector {
126132
typeState.lastMessage = details.message;
127133
}
128134

135+
if (this.config.fireDetectionEvents?.[type]) {
136+
try {
137+
const result = /** @type {any} */ (this.onEvent(`youtube_${type}`));
138+
if (result && typeof result.catch === 'function') {
139+
// eslint-disable-next-line promise/prefer-await-to-then
140+
result.catch(() => {});
141+
}
142+
} catch {
143+
// onEvent callback failure should never break detection
144+
}
145+
}
146+
129147
return true;
130148
}
131149

@@ -602,7 +620,7 @@ class YouTubeAdDetector {
602620
if (!root) {
603621
if (attempt < 25) {
604622
this.log.info(`Player root not found, retrying in 500ms (attempt ${attempt}/25)`);
605-
setTimeout(() => this.start(attempt + 1), 500);
623+
this.startRetryTimeout = setTimeout(() => this.start(attempt + 1), 500);
606624
} else {
607625
this.log.info('Player root not found after 25 attempts, giving up');
608626
}
@@ -638,6 +656,10 @@ class YouTubeAdDetector {
638656
* Stop the detector
639657
*/
640658
stop() {
659+
if (this.startRetryTimeout) {
660+
clearTimeout(this.startRetryTimeout);
661+
this.startRetryTimeout = null;
662+
}
641663
if (this.pollInterval) {
642664
clearInterval(this.pollInterval);
643665
this.pollInterval = null;
@@ -721,15 +743,27 @@ let detectorInstance = null;
721743
/**
722744
* @param {YouTubeDetectorConfig} [config] - Configuration from privacy-config
723745
* @param {{info: Function, warn: Function, error: Function}} [logger] - Optional logger from ContentFeature
746+
* @param {(type: string) => void} [fireEvent] - Callback fired when a new detection occurs
724747
*/
725-
export function runYoutubeAdDetection(config, logger) {
748+
export function runYoutubeAdDetection(config, logger, fireEvent) {
749+
const hostname = window.location.hostname;
750+
const isYouTube = hostname === 'youtube.com' || hostname.endsWith('.youtube.com');
751+
const isTestDomain =
752+
hostname === 'privacy-test-pages.site' || hostname.endsWith('.privacy-test-pages.site') || hostname === 'localhost';
753+
if (!isYouTube && !isTestDomain) {
754+
return { detected: false, type: 'youtubeAds', results: [] };
755+
}
756+
726757
// Only run if explicitly enabled or internal
727758
if (config?.state !== 'enabled' && config?.state !== 'internal') {
728759
return { detected: false, type: 'youtubeAds', results: [] };
729760
}
730761

731-
// If detector already exists, return its results (even if config is undefined)
762+
// If detector already exists, update callback if provided and return results
732763
if (detectorInstance) {
764+
if (fireEvent) {
765+
detectorInstance.onEvent = fireEvent;
766+
}
733767
return detectorInstance.getResults();
734768
}
735769

@@ -738,13 +772,17 @@ export function runYoutubeAdDetection(config, logger) {
738772
return { detected: false, type: 'youtubeAds', results: [] };
739773
}
740774

741-
// Auto-initialize on first call if on YouTube
742-
const hostname = window.location.hostname;
743-
if (hostname === 'youtube.com' || hostname.endsWith('.youtube.com')) {
744-
detectorInstance = new YouTubeAdDetector(config, logger);
745-
detectorInstance.start();
746-
return detectorInstance.getResults();
747-
}
775+
detectorInstance = new YouTubeAdDetector(config, logger, fireEvent);
776+
detectorInstance.start();
777+
return detectorInstance.getResults();
778+
}
748779

749-
return { detected: false, type: 'youtubeAds', results: [] };
780+
/**
781+
* @visibleForTesting
782+
*/
783+
export function resetYoutubeAdDetection() {
784+
if (detectorInstance) {
785+
detectorInstance.stop();
786+
detectorInstance = null;
787+
}
750788
}

injected/src/features/web-interference-detection.js

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import ContentFeature from '../content-feature.js';
1+
import ContentFeature, { CallFeatureMethodError } from '../content-feature.js';
22
import { runBotDetection } from '../detectors/detections/bot-detection.js';
33
import { runFraudDetection } from '../detectors/detections/fraud-detection.js';
44
import { runAdwallDetection } from '../detectors/detections/adwall-detection.js';
@@ -20,11 +20,18 @@ export default class WebInterferenceDetection extends ContentFeature {
2020
// Get settings with conditionalChanges already applied by framework
2121
const settings = this.getFeatureSetting('interferenceTypes');
2222

23-
// Initialize YouTube detector early on YouTube pages to capture video load times
24-
const hostname = window.location.hostname;
25-
if (hostname === 'youtube.com' || hostname.endsWith('.youtube.com')) {
26-
runYoutubeAdDetection(settings?.youtubeAds, this.log);
27-
}
23+
const fireEvent = async (type) => {
24+
try {
25+
const result = await this.callFeatureMethod('webEvents', 'fireEvent', { type });
26+
if (result instanceof CallFeatureMethodError && this.isDebug) {
27+
this.log.warn('webEvents.fireEvent failed:', result.message);
28+
}
29+
} catch {
30+
// webEvents may not be loaded on this platform — silently ignore
31+
}
32+
};
33+
34+
runYoutubeAdDetection(settings?.youtubeAds, this.log, fireEvent);
2835

2936
// Register messaging handler for PIR/native requests
3037
this.messaging.subscribe('detectInterference', (params) => {

injected/unit-test/features.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ describe('test-pages/*/config/*.json schema validation', () => {
8787
path.resolve(__dirname, '../integration-test/test-pages/message-bridge/config/message-bridge-disabled.json'),
8888
// Legacy conditionalChanges format (domain at root instead of condition.domain)
8989
path.resolve(__dirname, '../integration-test/test-pages/ua-ch-brands/config/domain-brand-override-legacy.json'),
90+
// Uses fireDetectionEvents which is not yet in the published schema
91+
path.resolve(__dirname, '../integration-test/test-pages/web-interference-detection/config/youtube-detection-events.json'),
9092
];
9193
for (const configPath of configFiles) {
9294
if (legacyAllowlist.includes(configPath)) {

0 commit comments

Comments
 (0)