Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
39 changes: 27 additions & 12 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 Down Expand Up @@ -126,6 +131,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 @@ -721,8 +734,16 @@ 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');
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: [] };
Expand All @@ -738,13 +759,7 @@ 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();
}

return { detected: false, type: 'youtubeAds', results: [] };
detectorInstance = new YouTubeAdDetector(config, logger, fireEvent);
detectorInstance.start();
return detectorInstance.getResults();
}
14 changes: 9 additions & 5 deletions injected/src/features/web-interference-detection.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,15 @@ 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 {
await this.callFeatureMethod('webEvents', 'fireEvent', { type });
} 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
154 changes: 154 additions & 0 deletions injected/unit-test/youtube-ad-detection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { YouTubeAdDetector } 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', () => {
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('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);
});
});
});
Loading