diff --git a/docs/MEDIA_PROCESSING.md b/docs/MEDIA_PROCESSING.md index 64d61f52..4c2f638f 100644 --- a/docs/MEDIA_PROCESSING.md +++ b/docs/MEDIA_PROCESSING.md @@ -554,6 +554,24 @@ t=8: Debounce fires, process(img) with src=C t=9: Inference sent for C, prediction applied ✓ ``` +### Resolving the Source Under Firefox's Pending Request + +Every "what is this image's URL" read goes through `resolveImageSource(img)` instead of a bare +`img.currentSrc || img.src`. Firefox keeps `currentSrc` on the **previous** request until the new +image's size is known, so right after `img.src = next` the mutation callback still reads the old +URL. On Google Images that made the placeholder→thumbnail swap (`…&s=10` → `…&s`) look like a no-op +re-stamp: the 10 px placeholder's `safe` verdict stayed on the element and the full thumbnail +rendered bare. For images without `srcset`/`` the reflected `src` is authoritative; +candidate selection keeps using `currentSrc`. As a second net, `process()` registers one `load` +listener per image that re-runs `handleSrcChange` whenever the resolved source no longer matches the +one last processed (also covers srcset re-selection on images that carry no overlay). + +Once such a placeholder has decoded, `isPlaceholderResolution` (both natural sides under 32 px, or +under the host's configured minimum size when that is smaller) finalizes it as `skipped` instead of +inferring it: a 10 px image carries nothing to detect, and on a single-lane mobile queue those +placeholders were the bulk of the backlog ahead of real thumbnails. The swap to the real source +re-enters processing as described above. + ### Robust Image Load Detection For images that aren't yet loaded, we use **both** `decode()` and `load` event - whichever fires @@ -682,11 +700,22 @@ owner is stalled (not loaded, e.g. a lazy copy in a hidden subtree) while the ne pixels. A duplicate send from a superseded owner is harmless — predictions are keyed by src and the second result is idempotent. The entry is deleted when a prediction for that src arrives. -Once a request is actually sent, a 20-second watchdog prevents that entry from living forever if the -background worker loses the task or inference fails without a prediction broadcast. It retries once -using a loaded, visible same-source copy when possible; a second timeout finalizes all pending -copies as `skipped`, matching the pipeline's existing inference-impossible behavior rather than -leaving Reddit images permanently under the initial blur. +Once a request is sent, two watchdogs guard the entry. A 120-second send-time guard covers a task +the background lost before it ever ran. The real 20-second inference watchdog starts only when the +background broadcasts `status: 'started'` for the src (the task left the queue), because queue wait +is unbounded on slow devices: single-lane WASM inference on Firefox mobile with a page of offscreen +Google Images ahead easily exceeded 20 s, and a queue-based timeout revealed the whole below-fold +band unmasked. Either watchdog retries once using a loaded, visible same-source copy when possible. +On the second timeout the images **fail closed**: the initial blur stays and the pending entry is +cleared, so the late prediction still applies when it arrives (`findImagesBySrc` matches blurred +images) and any later `process()` pass re-sends. Only an explicit `status: 'error'` result from the +background (inference genuinely impossible) finalizes the copies as `skipped` after two attempts. + +The background dedupes as well: a request for a `hostname + src` that is already queued or running +joins the in-flight task instead of enqueueing a second one (retries, extra `` copies, and +other tabs on the same host all receive the single broadcast verdict). A joining request with a +higher priority (a visible copy behind an offscreen one) raises the queued task's priority, and one +that joins a task already running receives the `started` broadcast again so its watchdog arms. ### DOM Processing diff --git a/docs/TESTING.md b/docs/TESTING.md index 01f5ced6..8cde9d9b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -218,6 +218,13 @@ Android config expects that action-managed emulator to already be booted when `C workflow sets `ANDROID_SINGLE_SESSION=true` so all features run in one WebDriver session, since repeated geckodriver sessions can leave Fenix profile resources locked on CI. +Once the session is up, the `before` hook broadcasts a local Nimbus rollout to Fenix's +`QANimbusToolingReceiver` that turns the `homepage-as-new-tab` feature off. Fenix Nightly enables it +by default (Bug 1977695), and with it on Fenix opens a fresh homepage tab while Gecko is already +quitting, which trips `MOZ_RELEASE_ASSERT(domWindow)` in `GeckoViewSupport::Open` and crashes the +app during geckodriver's session delete. The override has to be sent after launch because +geckodriver runs `pm clear` on the package while creating the session, wiping any pre-seeded prefs. + **Setup:** ```bash diff --git a/entrypoints/background/services/__tests__/inferenceOrchestrationService.test.ts b/entrypoints/background/services/__tests__/inferenceOrchestrationService.test.ts new file mode 100644 index 00000000..09a78e90 --- /dev/null +++ b/entrypoints/background/services/__tests__/inferenceOrchestrationService.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { InferenceOrchestrationService } from '@/entrypoints/background/services/inferenceOrchestrationService'; +import { QueueService } from '@/entrypoints/background/services/queueService'; +import { DEFAULT_HOST_SETTINGS } from '@/utils/constants/hostsettings'; + +import type { ImageCacheService } from '@/entrypoints/background/services/imageCacheService'; +import type { IImagePrediction, IMediaMetadata, ImageInferenceResult, InferenceTask } from '@/utils/types'; + +const { processInferenceTask, gates } = vi.hoisted(() => { + const gates = new Map void>(); + const processInferenceTask = vi.fn( + (task: { imageSrc: string }) => + new Promise(resolve => { + gates.set(task.imageSrc, () => + resolve({ + src: task.imageSrc, + hostname: 'example.com', + width: 100, + height: 100, + predictions: [], + timestamp: 0, + cacheMetadata: { createdAt: 0, accessedAt: 0 }, + maskTransform: { scaleX: 1, scaleY: 1, offsetX: 0, offsetY: 0 }, + processingTime: { + fetchTime: 0, + decodeTime: 0, + queueTime: 0, + inferenceTime: 0, + e2eTime: 0, + backend: 'wasm', + }, + forcedVisibility: 'auto', + }), + ); + }), + ); + return { processInferenceTask, gates }; +}); + +vi.mock('@inference-runtime', () => ({ getCurrentModelId: () => 'test-model' })); +vi.mock('@/utils/inference', () => ({ + getBatchCap: () => 1, + getInferenceBackend: () => 'wasm', + processInferenceBatch: vi.fn(), + processInferenceTask, +})); +vi.mock('@/entrypoints/background/services/batchCollector', () => ({ + BatchCollector: class { + submit = vi.fn(); + }, +})); + +const cacheService = { + getCachedPredictionsBySrc: vi.fn(() => Promise.resolve([])), + cachePredictions: vi.fn(() => Promise.resolve()), +} as unknown as ImageCacheService; + +function setup() { + const queue = new QueueService(); + const service = new InferenceOrchestrationService(queue, cacheService); + const results: ImageInferenceResult[] = []; + service.setOnImagePredictionsCallback(batch => results.push(...batch)); + const schedule = (imageSrc: string, priority = 0) => + service.scheduleInferenceTask({ + input: { kind: 'src', imageSrc }, + hostname: 'example.com', + hostSettings: DEFAULT_HOST_SETTINGS, + mediaMetadata: { kind: 'image' } as IMediaMetadata, + priority, + }); + return { service, queue, results, schedule }; +} + +const statuses = (results: ImageInferenceResult[], src: string) => + results + .filter(result => (result.status === 'ok' ? result.prediction.src : result.src) === src) + .map(result => result.status); + +const release = (src: string) => { + gates.get(src)?.(); + gates.delete(src); +}; + +describe('InferenceOrchestrationService image dedupe', () => { + it('broadcasts started when the task leaves the queue and ok when it completes', async () => { + const { results, schedule } = setup(); + await schedule('a'); + await vi.waitFor(() => expect(statuses(results, 'a')).toEqual(['started'])); + release('a'); + await vi.waitFor(() => expect(statuses(results, 'a')).toEqual(['started', 'ok'])); + expect(processInferenceTask).toHaveBeenCalledTimes(1); + }); + + it('joins a queued duplicate instead of running it twice and raises its priority', async () => { + const { results, schedule } = setup(); + await schedule('running'); + await vi.waitFor(() => expect(statuses(results, 'running')).toEqual(['started'])); + await schedule('queued', 0); + await schedule('queued', 30); + + release('running'); + await vi.waitFor(() => expect(statuses(results, 'queued')).toEqual(['started'])); + const queuedTask = processInferenceTask.mock.calls.at(-1)?.[0] as InferenceTask; + expect(queuedTask.priority).toBe(30); + release('queued'); + await vi.waitFor(() => expect(statuses(results, 'queued')).toEqual(['started', 'ok'])); + expect(processInferenceTask).toHaveBeenCalledTimes(2); + }); + + it('re-broadcasts started for a duplicate that joins an already running task', async () => { + const { results, schedule } = setup(); + await schedule('a'); + await vi.waitFor(() => expect(statuses(results, 'a')).toEqual(['started'])); + await schedule('a'); + expect(statuses(results, 'a')).toEqual(['started', 'started']); + release('a'); + await vi.waitFor(() => expect(statuses(results, 'a')).toEqual(['started', 'started', 'ok'])); + expect(processInferenceTask).toHaveBeenCalledTimes(1); + }); + + it('runs the same src again once the previous task has finished', async () => { + const { results, schedule } = setup(); + await schedule('a'); + await vi.waitFor(() => expect(statuses(results, 'a')).toEqual(['started'])); + release('a'); + await vi.waitFor(() => expect(statuses(results, 'a')).toEqual(['started', 'ok'])); + await schedule('a'); + await vi.waitFor(() => expect(processInferenceTask).toHaveBeenCalledTimes(2)); + release('a'); + }); +}); diff --git a/entrypoints/background/services/__tests__/queueService.test.ts b/entrypoints/background/services/__tests__/queueService.test.ts index b4e1dd4d..537d5c3b 100644 --- a/entrypoints/background/services/__tests__/queueService.test.ts +++ b/entrypoints/background/services/__tests__/queueService.test.ts @@ -40,3 +40,30 @@ describe('QueueService', () => { expect(started).toEqual(['running', 'latest-frame']); }); }); + +describe('QueueService.raisePriority', () => { + it('moves a queued task ahead of its siblings', async () => { + const queue = new QueueService(); + const gate = deferred(); + const started: string[] = []; + queue.setTaskProcessingHandler(async queued => { + started.push(queued.imageSrc); + if (queued.imageSrc === 'running') await gate.promise; + }); + + const running = queue.enqueue(task('running')); + await vi.waitFor(() => expect(started).toEqual(['running'])); + const first = queue.enqueue(task('first'), undefined, 'first'); + const second = queue.enqueue(task('second'), undefined, 'second'); + queue.raisePriority('second', 30); + + gate.resolve(); + await Promise.all([running, first, second]); + expect(started).toEqual(['running', 'second', 'first']); + }); + + it('ignores an id that is no longer queued', () => { + const queue = new QueueService(); + expect(() => queue.raisePriority('missing', 30)).not.toThrow(); + }); +}); diff --git a/entrypoints/background/services/inferenceOrchestrationService.ts b/entrypoints/background/services/inferenceOrchestrationService.ts index 0b1ec9ec..34b61123 100644 --- a/entrypoints/background/services/inferenceOrchestrationService.ts +++ b/entrypoints/background/services/inferenceOrchestrationService.ts @@ -1,4 +1,4 @@ -import { SpanStatusCode, type Counter, type Histogram, type Span } from '@opentelemetry/api'; +import { SpanStatusCode, type Context, type Counter, type Histogram, type Span } from '@opentelemetry/api'; import { getCurrentModelId } from '@inference-runtime'; @@ -69,6 +69,14 @@ export type ScheduleArgs = { traceparent?: string; }; +type TaskAttributes = ReturnType; + +type ImageTaskKey = string & { readonly __brand: 'ImageTaskKey' }; + +function imageTaskKey(hostname: string, imageSrc: string): ImageTaskKey { + return `${hostname}|${imageSrc}` as ImageTaskKey; +} + const log = getLogger('inferenceOrchestrationService'); const tracer = getTracer('inference'); @@ -123,6 +131,7 @@ export class InferenceOrchestrationService { private onGifFramePredictionsCallback?: OnGifFramePredictionsCallback; /** At most one not-yet-started playback frame is retained per video session. */ private queuedPlaybackFrames = new Map(); + private readonly inFlightImages = new Map(); private queueWaitSpans = new WeakMap(); // Batches concurrent queue tasks into one session.run for dynamic-batch models. @@ -196,6 +205,8 @@ export class InferenceOrchestrationService { } } + if (mediaMetadata.kind === 'image' && this.joinInFlightImage(args, attributes, traceContext)) return; + const queueStartAt = Date.now(); const baseTask = { imageSrc, @@ -258,7 +269,10 @@ export class InferenceOrchestrationService { task, tracer.startSpan(SPAN.queueWait, { attributes: { ...attributes, [ATTR.priority]: args.priority } }, traceContext), ); - this.queueService.enqueue(task, controller?.signal).catch(error => { + const inFlightKey = mediaMetadata.kind === 'image' ? imageTaskKey(hostname, imageSrc) : undefined; + if (inFlightKey) this.inFlightImages.set(inFlightKey, { task, started: false }); + this.queueService.enqueue(task, controller?.signal, inFlightKey).catch(error => { + if (inFlightKey) this.inFlightImages.delete(inFlightKey); if (controller?.signal.aborted) { this.endQueueWait(task, 'aborted'); return; @@ -269,6 +283,26 @@ export class InferenceOrchestrationService { }); } + private joinInFlightImage(args: ScheduleArgs, attributes: TaskAttributes, traceContext?: Context): boolean { + const key = imageTaskKey(args.hostname, args.input.imageSrc); + const inFlight = this.inFlightImages.get(key); + if (!inFlight) return false; + if (args.input.kind === 'bitmap') args.input.bitmap.close(); + if (args.priority > inFlight.task.priority) { + inFlight.task.priority = args.priority; + this.queueService.raisePriority(key, args.priority); + } + if (inFlight.started) this.notifyImageInferenceStarted(inFlight.task); + log.debug('inference.image.joined', { ...attributes, [ATTR.priority]: args.priority }, traceContext); + return true; + } + + private releaseInFlightImage(task: InferenceTask): void { + if (task.mediaMetadata.kind !== 'image') return; + const key = imageTaskKey(task.hostname, task.imageSrc); + if (this.inFlightImages.get(key)?.task === task) this.inFlightImages.delete(key); + } + private endQueueWait(task: InferenceTask, outcome: 'started' | 'aborted' | 'superseded' | 'error'): void { const span = this.queueWaitSpans.get(task); if (!span) return; @@ -281,6 +315,7 @@ export class InferenceOrchestrationService { this.queueService.setTaskProcessingHandler(async (task: InferenceTask) => { this.markPlaybackFrameStarted(task); this.endQueueWait(task, 'started'); + this.notifyImageInferenceStarted(task); const attributes = taskAttributes(task); const runSpan = tracer.startSpan(SPAN.run, { attributes }, task.traceContext); const runStartedAt = Date.now(); @@ -305,6 +340,8 @@ export class InferenceOrchestrationService { }); log.error('inference.run.failed', { ...attributes, error }, task.traceContext); this.sendErrorToContent(task, error); + } finally { + this.releaseInFlightImage(task); } }); } @@ -462,6 +499,23 @@ export class InferenceOrchestrationService { } } + private notifyImageInferenceStarted(task: InferenceTask): void { + if (task.mediaMetadata.kind !== 'image') return; + const inFlight = this.inFlightImages.get(imageTaskKey(task.hostname, task.imageSrc)); + if (inFlight?.task === task) inFlight.started = true; + this.sendImageResultsToContent( + [ + { + status: 'started', + src: task.imageSrc, + hostname: task.hostname, + traceparent: injectTraceparent(task.traceContext), + }, + ], + task.hostname, + ); + } + private sendImageResultsToContent(results: ImageInferenceResult[], hostname: string): void { try { if (this.onImagePredictionsCallback) { diff --git a/entrypoints/background/services/queueService.ts b/entrypoints/background/services/queueService.ts index 30e7042f..eca38a1a 100644 --- a/entrypoints/background/services/queueService.ts +++ b/entrypoints/background/services/queueService.ts @@ -6,6 +6,7 @@ import { type InferenceTask } from '@/utils/types'; export class QueueService { private queue: PQueue; private onTaskProcessing?: (task: InferenceTask) => Promise; + private readonly queuedIds = new Set(); constructor() { this.queue = new PQueue({ @@ -36,15 +37,26 @@ export class QueueService { return () => this.queue.off('idle', callback); } - enqueue(task: InferenceTask, signal?: AbortSignal): Promise { + enqueue(task: InferenceTask, signal?: AbortSignal, id?: string): Promise { + if (id) this.queuedIds.add(id); // p-queue: higher priority number = runs first - return this.queue.add( - async () => { - if (this.onTaskProcessing) { - await this.onTaskProcessing(task); - } - }, - { priority: task.priority, signal }, - ); + return this.queue + .add( + async () => { + if (id) this.queuedIds.delete(id); + if (this.onTaskProcessing) { + await this.onTaskProcessing(task); + } + }, + { priority: task.priority, signal, id }, + ) + .finally(() => { + if (id) this.queuedIds.delete(id); + }); + } + + raisePriority(id: string, priority: number): void { + if (!this.queuedIds.has(id)) return; + this.queue.setPriority(id, priority); } } diff --git a/entrypoints/content/communication/sender.ts b/entrypoints/content/communication/sender.ts index 4d7760a2..f1e2c12a 100644 --- a/entrypoints/content/communication/sender.ts +++ b/entrypoints/content/communication/sender.ts @@ -1,5 +1,6 @@ import { ROOT_CONTEXT, type Context, type Span } from '@opentelemetry/api'; +import { resolveImageSource } from '@/entrypoints/content/core/imageSource'; import { dvrRingBudget } from '@/entrypoints/content/video/dvr/ringBudget'; import { bitmapToCompressedBlob } from '@/entrypoints/content/video/sampling/compression'; import { @@ -127,7 +128,7 @@ async function buildPayload( parent: Context, ): Promise { const requestStartAt = Date.now(); - const src = image.currentSrc || image.src; + const src = resolveImageSource(image); const width = image.naturalWidth || image.width; const height = image.naturalHeight || image.height; const traceparent = injectTraceparent(parent); @@ -244,7 +245,7 @@ async function sendImageForInference( } } - log.error('inference.send.failed', { [ATTR.src]: image.currentSrc || image.src, error: lastError }, parent); + log.error('inference.send.failed', { [ATTR.src]: resolveImageSource(image), error: lastError }, parent); throw lastError; } diff --git a/entrypoints/content/core/ImageProcessor.ts b/entrypoints/content/core/ImageProcessor.ts index ad81783f..90f7191e 100644 --- a/entrypoints/content/core/ImageProcessor.ts +++ b/entrypoints/content/core/ImageProcessor.ts @@ -3,6 +3,7 @@ import { requestImageInference, requestToggleUpdate, } from '@/entrypoints/content/communication/sender'; +import { isPlaceholderResolution, resolveImageSource, type NaturalSize } from '@/entrypoints/content/core/imageSource'; import { PredictionCache } from '@/entrypoints/content/core/predictionCache'; import { decodeGifFrames, @@ -69,6 +70,8 @@ const SVG_PATTERN = /\.svg(?:[?#]|$)|image\/svg\+xml/i; const MAX_CACHE_SIZE = 500; const SRC_STABILIZATION_DELAY = 150; const IMAGE_INFERENCE_TIMEOUT_MS = 20_000; +const IMAGE_QUEUE_WAIT_TIMEOUT_MS = 120_000; +const PLACEHOLDER_MAX_NATURAL_PX = 32; const MAX_IMAGE_INFERENCE_ATTEMPTS = 2; // Animated GIFs: cap tracked decode sessions and fail closed if frame verdicts @@ -135,6 +138,7 @@ export class ImageProcessor { private readonly gifSessions = new Map(); // src → the element whose load listeners drive the request (see queueInference) private readonly pendingInference = new Map(); + private readonly loadDriftWatched = new WeakSet(); private readonly pendingInferenceTimers = new Map>(); private readonly inferenceAttempts = new Map(); /** Last resolved source seen for an element; filters Reddit's no-op attribute churn. */ @@ -179,9 +183,10 @@ export class ImageProcessor { * This is the main entry point for both new images and attribute changes. */ process(img: HTMLImageElement): void { - const src = img.currentSrc || img.src; + const src = resolveImageSource(img); if (!src) return; this.resolvedSrcByImage.set(img, src); + this.watchSourceDriftOnLoad(img); this.visibilityObserver.observe(img); this.trackShadowRoot(img); @@ -241,6 +246,15 @@ export class ImageProcessor { this.queueInference(img, src); } + private watchSourceDriftOnLoad(img: HTMLImageElement): void { + if (this.loadDriftWatched.has(img)) return; + this.loadDriftWatched.add(img); + img.addEventListener('load', () => { + const processedSrc = this.resolvedSrcByImage.get(img); + if (processedSrc && resolveImageSource(img) !== processedSrc) this.handleSrcChange(img); + }); + } + /** * Process multiple images. */ @@ -255,7 +269,7 @@ export class ImageProcessor { * Google Images rapidly changes src (quality upgrades), so we wait for it to settle. */ handleSrcChange(img: HTMLImageElement): void { - const resolvedSrc = img.currentSrc || img.src; + const resolvedSrc = resolveImageSource(img); const previousSrc = this.resolvedSrcByImage.get(img); if (resolvedSrc === previousSrc) { // Lit/React frequently re-stamp an unchanged src/srcset. Treating that @@ -283,7 +297,7 @@ export class ImageProcessor { // Debounce: wait for src to stabilize before processing const timeout = setTimeout(() => { this.srcChangeDebounce.delete(img); - const src = img.currentSrc || img.src; + const src = resolveImageSource(img); if (src) { this.process(img); } @@ -308,8 +322,12 @@ export class ImageProcessor { */ handleInferenceResults(results: ImageInferenceResult[]): void { for (const result of results) { + if (result.status === 'started') { + this.handleInferenceStarted(result.src); + continue; + } if (result.status === 'error') { - this.handleInferenceFailure(result.src, result.reason, result.traceparent); + this.handleInferenceFailure(result.src, 'open', result.reason, result.traceparent); continue; } const pred = result.prediction; @@ -352,7 +370,7 @@ export class ImageProcessor { * Clean up when image removed from DOM. */ handleRemoved(img: HTMLImageElement): void { - const src = img.currentSrc || img.src; + const src = resolveImageSource(img); if (src) { cancelRoundtrip(src); } @@ -479,7 +497,7 @@ export class ImageProcessor { const sendRequest = async () => { // If src changed before load (common with srcset), reprocess with new src - const currentSrc = img.currentSrc || img.src; + const currentSrc = resolveImageSource(img); if (currentSrc !== src) { this.clearPendingInference(src, img); // Re-process with the actual loaded URL instead of just aborting @@ -496,8 +514,15 @@ export class ImageProcessor { return; } + if (isPlaceholderResolution(img, this.placeholderLimit())) { + this.clearPendingInference(src, img, true); + endRoundtrip(src, { status: 'skipped', attributes: { reason: 'placeholder resolution' } }); + this.finalizeAllImagesForSrc(src, 'skipped'); + return; + } + try { - this.armInferenceWatchdog(src, img); + this.armInferenceWatchdog(src, img, IMAGE_QUEUE_WAIT_TIMEOUT_MS); const isVisible = this.visibilityMap.get(img) ?? false; const priority = isVisible ? INFERENCE_PRIORITY.visibleImage : INFERENCE_PRIORITY.offscreenImage; await requestImageInference(this.hostSettings.hostname, img, priority, getRoundtripContext(src)); @@ -559,7 +584,7 @@ export class ImageProcessor { } } - private armInferenceWatchdog(src: string, owner: HTMLImageElement): void { + private armInferenceWatchdog(src: string, owner: HTMLImageElement, timeoutMs: number): void { const existing = this.pendingInferenceTimers.get(src); if (existing) clearTimeout(existing); this.pendingInferenceTimers.set( @@ -567,17 +592,23 @@ export class ImageProcessor { setTimeout(() => { this.pendingInferenceTimers.delete(src); if (this.pendingInference.get(src) !== owner) return; - this.handleInferenceFailure(src); - }, IMAGE_INFERENCE_TIMEOUT_MS), + this.handleInferenceFailure(src, 'closed', 'timeout'); + }, timeoutMs), ); } - /** - * A pending inference failed (errored result from background, or the - * watchdog fired with no reply). Retry with the best candidate element, - * failing open once attempts are exhausted. - */ - private handleInferenceFailure(src: string, reason?: string, traceparent?: string): void { + private handleInferenceStarted(src: string): void { + const owner = this.pendingInference.get(src); + if (!owner) return; + this.armInferenceWatchdog(src, owner, IMAGE_INFERENCE_TIMEOUT_MS); + } + + private handleInferenceFailure( + src: string, + exhaustedMode: 'open' | 'closed', + reason?: string, + traceparent?: string, + ): void { if (!this.pendingInference.has(src)) return; const tracksRoundtrip = roundtripMatches(src, traceparent); this.clearPendingInference(src); @@ -591,9 +622,13 @@ export class ImageProcessor { endRoundtrip(src, { status: 'error', error: new Error(`Image inference failed after ${attempts} attempts${reason ? `: ${reason}` : ''}`), - attributes: { attempts }, + attributes: { attempts, exhaustedMode }, }); } + if (exhaustedMode === 'closed') { + log.warn('inference.exhausted.fail_closed', { [ATTR.src]: src, attempts, reason }); + return; + } this.finalizeAllImagesForSrc(src, 'skipped'); return; } @@ -617,6 +652,14 @@ export class ImageProcessor { if (resetAttempts) this.inferenceAttempts.delete(src); } + private placeholderLimit(): NaturalSize { + const { minSize } = this.hostSettings; + return { + width: Math.min(PLACEHOLDER_MAX_NATURAL_PX, minSize.width), + height: Math.min(PLACEHOLDER_MAX_NATURAL_PX, minSize.height), + }; + } + private isBelowMinSize(img: HTMLImageElement): boolean { const w = img.clientWidth || img.naturalWidth; const h = img.clientHeight || img.naturalHeight; @@ -986,7 +1029,7 @@ export class ImageProcessor { // =========================================================================== private async applyPrediction(img: HTMLImageElement, prediction: IImagePrediction): Promise { - const currentSrc = img.currentSrc || img.src; + const currentSrc = resolveImageSource(img); // Verify src still matches (handles race where src changed) if (currentSrc !== prediction.src) { @@ -1009,7 +1052,7 @@ export class ImageProcessor { await waitForImageReady(img); // Double-check src after any async wait - const srcNow = img.currentSrc || img.src; + const srcNow = resolveImageSource(img); if (srcNow !== prediction.src) { // Responsive images can select a different srcset candidate while // decode() is pending without producing another observable attribute @@ -1067,62 +1110,30 @@ export class ImageProcessor { } private findImagesBySrc(src: string): HTMLImageElement[] { - const results: HTMLImageElement[] = []; - const selector = `img.${BLUR_CLASS}, img[${BLACKLIST_ATTR}]`; - - // Query light DOM - for (const img of document.querySelectorAll(selector)) { - const imgSrc = img.currentSrc || img.src; - if (imgSrc === src) { - results.push(img); - } - } - - // Query only tracked shadow roots (O(shadowRoots) instead of O(allElements)) - for (const shadowRoot of this.knownShadowRoots) { - // Skip disconnected shadow roots - if (!shadowRoot.host.isConnected) { - this.knownShadowRoots.delete(shadowRoot); - continue; - } - for (const img of shadowRoot.querySelectorAll(selector)) { - const imgSrc = img.currentSrc || img.src; - if (imgSrc === src) { - results.push(img); - } - } - } - - return results; + return this.collectMatchingImages(`img.${BLUR_CLASS}, img[${BLACKLIST_ATTR}]`, src); } // Queries all images on each call. Acceptable for user-initiated toggles (infrequent). // Maintaining a src→elements index would require complex cleanup for removed elements. private findAllImagesBySrc(src: string): HTMLImageElement[] { - const results: HTMLImageElement[] = []; - - // Query light DOM - for (const img of document.querySelectorAll('img')) { - const imgSrc = img.currentSrc || img.src; - if (imgSrc === src) { - results.push(img); - } - } + return this.collectMatchingImages('img', src); + } - // Query only tracked shadow roots + private collectMatchingImages(selector: string, src: string): HTMLImageElement[] { + const roots: ParentNode[] = [document]; for (const shadowRoot of this.knownShadowRoots) { if (!shadowRoot.host.isConnected) { this.knownShadowRoots.delete(shadowRoot); continue; } - for (const img of shadowRoot.querySelectorAll('img')) { - const imgSrc = img.currentSrc || img.src; - if (imgSrc === src) { - results.push(img); - } + roots.push(shadowRoot); + } + const results: HTMLImageElement[] = []; + for (const root of roots) { + for (const img of root.querySelectorAll(selector)) { + if (resolveImageSource(img) === src) results.push(img); } } - return results; } diff --git a/entrypoints/content/core/__tests__/imageProcessorWatchdog.test.ts b/entrypoints/content/core/__tests__/imageProcessorWatchdog.test.ts new file mode 100644 index 00000000..16176d54 --- /dev/null +++ b/entrypoints/content/core/__tests__/imageProcessorWatchdog.test.ts @@ -0,0 +1,120 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ImageProcessor } from '@/entrypoints/content/core/ImageProcessor'; +import { BLUR_CLASS, PROCESSED_SKIPPED_ATTR } from '@/entrypoints/content/presentation/constants'; +import { DEFAULT_HOST_SETTINGS } from '@/utils/constants/hostsettings'; + +import type { BadgeCounter } from '@/entrypoints/content/core/BadgeCounter'; + +const { requestImageInference } = vi.hoisted(() => ({ + requestImageInference: vi.fn(() => Promise.resolve()), +})); + +vi.mock('@/entrypoints/content/communication/sender', () => ({ requestImageInference })); +vi.mock('@/entrypoints/content/presentation/quickToggle', () => ({ + initQuickToggle: vi.fn(), + ensureQuickToggleButton: vi.fn(), + removeQuickToggleButton: vi.fn(), + hideQuickToggleButton: vi.fn(), +})); +vi.mock('@/utils/messaging/content', () => ({ waitForMessageChannel: vi.fn(() => Promise.resolve()) })); + +const QUEUE_WAIT_MS = 120_000; +const INFERENCE_MS = 20_000; + +class FakeIntersectionObserver { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); +} + +const loadedImage = (src: string): HTMLImageElement => { + const img = document.createElement('img'); + img.src = src; + Object.defineProperty(img, 'complete', { value: true }); + Object.defineProperty(img, 'naturalWidth', { value: 200 }); + Object.defineProperty(img, 'naturalHeight', { value: 200 }); + document.body.append(img); + return img; +}; + +const hostSettings = { ...DEFAULT_HOST_SETTINGS, hostname: 'example.com' }; +const badgeCounter = { trackDetections: vi.fn() } as unknown as BadgeCounter; + +describe('ImageProcessor inference watchdog', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver); + document.body.innerHTML = ''; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('retries after the queue-wait guard, then keeps the blur instead of revealing', async () => { + const processor = new ImageProcessor(hostSettings, badgeCounter); + const img = loadedImage('https://cdn.test/a.jpg'); + processor.process(img); + await vi.advanceTimersByTimeAsync(0); + expect(requestImageInference).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(INFERENCE_MS); + expect(requestImageInference).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(QUEUE_WAIT_MS - INFERENCE_MS); + expect(requestImageInference).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(QUEUE_WAIT_MS); + expect(requestImageInference).toHaveBeenCalledTimes(2); + expect(img.classList.contains(BLUR_CLASS)).toBe(true); + expect(img.hasAttribute(PROCESSED_SKIPPED_ATTR)).toBe(false); + }); + + it('arms the short watchdog once the background reports the task started', async () => { + const processor = new ImageProcessor(hostSettings, badgeCounter); + const img = loadedImage('https://cdn.test/b.jpg'); + processor.process(img); + await vi.advanceTimersByTimeAsync(0); + expect(requestImageInference).toHaveBeenCalledTimes(1); + + processor.handleInferenceResults([{ status: 'started', src: img.src, hostname: 'example.com' }]); + await vi.advanceTimersByTimeAsync(INFERENCE_MS); + expect(requestImageInference).toHaveBeenCalledTimes(2); + }); + + it('fails open after repeated explicit inference errors', async () => { + const processor = new ImageProcessor(hostSettings, badgeCounter); + const img = loadedImage('https://cdn.test/c.jpg'); + processor.process(img); + await vi.advanceTimersByTimeAsync(0); + + const error = { status: 'error' as const, src: img.src, hostname: 'example.com', reason: 'decode failed' }; + processor.handleInferenceResults([error]); + await vi.advanceTimersByTimeAsync(0); + expect(requestImageInference).toHaveBeenCalledTimes(2); + + processor.handleInferenceResults([error]); + expect(img.classList.contains(BLUR_CLASS)).toBe(false); + expect(img.hasAttribute(PROCESSED_SKIPPED_ATTR)).toBe(true); + }); + + it('skips a decoded placeholder without sending it for inference', async () => { + const processor = new ImageProcessor(hostSettings, badgeCounter); + const img = document.createElement('img'); + img.src = 'https://cdn.test/placeholder.jpg'; + Object.defineProperty(img, 'complete', { value: true }); + Object.defineProperty(img, 'naturalWidth', { value: 10 }); + Object.defineProperty(img, 'naturalHeight', { value: 13 }); + Object.defineProperty(img, 'clientWidth', { value: 300 }); + Object.defineProperty(img, 'clientHeight', { value: 300 }); + document.body.append(img); + + processor.process(img); + await vi.advanceTimersByTimeAsync(0); + expect(requestImageInference).not.toHaveBeenCalled(); + expect(img.hasAttribute(PROCESSED_SKIPPED_ATTR)).toBe(true); + }); +}); diff --git a/entrypoints/content/core/__tests__/imageSource.test.ts b/entrypoints/content/core/__tests__/imageSource.test.ts new file mode 100644 index 00000000..c694ea34 --- /dev/null +++ b/entrypoints/content/core/__tests__/imageSource.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { isPlaceholderResolution, resolveImageSource } from '@/entrypoints/content/core/imageSource'; + +const fakeImage = (fields: { src: string; currentSrc: string; srcset?: boolean; inPicture?: boolean }) => ({ + src: fields.src, + currentSrc: fields.currentSrc, + hasAttribute: (name: string) => name === 'srcset' && Boolean(fields.srcset), + parentElement: fields.inPicture ? ({ tagName: 'PICTURE' } as Element) : ({ tagName: 'DIV' } as Element), +}); + +describe('resolveImageSource', () => { + it('prefers the reflected src when currentSrc still points at the previous request', () => { + const img = fakeImage({ src: 'https://x/full', currentSrc: 'https://x/placeholder' }); + expect(resolveImageSource(img)).toBe('https://x/full'); + }); + + it('falls back to currentSrc when src is empty', () => { + const img = fakeImage({ src: '', currentSrc: 'https://x/from-picture' }); + expect(resolveImageSource(img)).toBe('https://x/from-picture'); + }); + + it('keeps currentSrc for srcset candidates', () => { + const img = fakeImage({ src: 'https://x/fallback', currentSrc: 'https://x/2x', srcset: true }); + expect(resolveImageSource(img)).toBe('https://x/2x'); + }); + + it('keeps currentSrc for picture sources', () => { + const img = fakeImage({ src: 'https://x/fallback', currentSrc: 'https://x/webp', inPicture: true }); + expect(resolveImageSource(img)).toBe('https://x/webp'); + }); +}); + +describe('isPlaceholderResolution', () => { + const limit = { width: 32, height: 32 }; + + it('flags a 10 px Google placeholder', () => { + expect(isPlaceholderResolution({ naturalWidth: 10, naturalHeight: 13 }, limit)).toBe(true); + }); + + it('does not flag a strip that is tiny on one side only', () => { + expect(isPlaceholderResolution({ naturalWidth: 400, naturalHeight: 16 }, limit)).toBe(false); + }); + + it('does not flag an undecoded image', () => { + expect(isPlaceholderResolution({ naturalWidth: 0, naturalHeight: 0 }, limit)).toBe(false); + }); + + it('does not flag a regular thumbnail', () => { + expect(isPlaceholderResolution({ naturalWidth: 160, naturalHeight: 200 }, limit)).toBe(false); + }); + + it('respects a smaller configured limit', () => { + expect(isPlaceholderResolution({ naturalWidth: 10, naturalHeight: 13 }, { width: 8, height: 8 })).toBe(false); + }); +}); diff --git a/entrypoints/content/core/imageSource.ts b/entrypoints/content/core/imageSource.ts new file mode 100644 index 00000000..26e026a5 --- /dev/null +++ b/entrypoints/content/core/imageSource.ts @@ -0,0 +1,23 @@ +type ImageSourceLike = Pick & { + hasAttribute: (name: string) => boolean; + parentElement: Element | null; +}; + +const usesCandidateSelection = (img: ImageSourceLike): boolean => + img.hasAttribute('srcset') || img.parentElement?.tagName === 'PICTURE'; + +export const resolveImageSource = (img: ImageSourceLike): string => { + if (usesCandidateSelection(img)) return img.currentSrc || img.src; + return img.src || img.currentSrc; +}; + +export type NaturalSize = { width: number; height: number }; + +export const isPlaceholderResolution = ( + img: Pick, + maxNatural: NaturalSize, +): boolean => + img.naturalWidth > 0 && + img.naturalHeight > 0 && + img.naturalWidth < maxNatural.width && + img.naturalHeight < maxNatural.height; diff --git a/entrypoints/content/presentation/gifMaskPlayer.ts b/entrypoints/content/presentation/gifMaskPlayer.ts index 1f123a35..93403359 100644 --- a/entrypoints/content/presentation/gifMaskPlayer.ts +++ b/entrypoints/content/presentation/gifMaskPlayer.ts @@ -1,3 +1,4 @@ +import { resolveImageSource } from '@/entrypoints/content/core/imageSource'; import { GIF_MASK_OVERLAY_ATTR } from '@/entrypoints/content/presentation/constants'; import { computeRenderedContentRect, maskGridSrcRect } from '@/entrypoints/content/presentation/imageLayout'; import { @@ -159,7 +160,7 @@ class GifMaskPlayer { viewportHandler: () => this.updateLayout(image), destroyed: false, originalOpacity: image.style.opacity, - trackedSrc: image.currentSrc || image.src, + trackedSrc: resolveImageSource(image), }; gifStates.set(image, state); @@ -220,7 +221,7 @@ class GifMaskPlayer { const state = gifStates.get(image); if (!state || state.destroyed) return; - const currentSrc = image.currentSrc || image.src; + const currentSrc = resolveImageSource(image); if (currentSrc !== state.trackedSrc) { this.clearPlayer(image); notifySrcDrift(image); diff --git a/entrypoints/content/presentation/imageMaskOverlay.ts b/entrypoints/content/presentation/imageMaskOverlay.ts index 61c8971f..072973b0 100644 --- a/entrypoints/content/presentation/imageMaskOverlay.ts +++ b/entrypoints/content/presentation/imageMaskOverlay.ts @@ -1,3 +1,4 @@ +import { resolveImageSource } from '@/entrypoints/content/core/imageSource'; import { IMAGE_MASK_OVERLAY_ATTR } from '@/entrypoints/content/presentation/constants'; import { computeRenderedContentRect, maskGridSrcRect } from '@/entrypoints/content/presentation/imageLayout'; import { hasInitialStyling } from '@/entrypoints/content/presentation/initialStyling'; @@ -275,7 +276,7 @@ class ImageMaskOverlay implements IMediaOverlay { rafId: null, destroyed: false, currentPrediction: undefined, - trackedSrc: image.currentSrc || image.src, + trackedSrc: resolveImageSource(image), masking, }; @@ -336,7 +337,7 @@ class ImageMaskOverlay implements IMediaOverlay { // ResizeObserver for image size changes + src change detection (self-cleaning) state.resizeObserver = new ResizeObserver(entries => { // Self-clean if src changed - const currentSrc = image.currentSrc || image.src; + const currentSrc = resolveImageSource(image); if (state.trackedSrc && currentSrc !== state.trackedSrc) { this.clearMaskOverlay(image); notifySrcDrift(image); @@ -390,7 +391,7 @@ class ImageMaskOverlay implements IMediaOverlay { private updateOverlayForImage(image: HTMLImageElement, state: IMediaOverlayState): void { // Self-clean if src changed - const currentSrc = image.currentSrc || image.src; + const currentSrc = resolveImageSource(image); if (state.trackedSrc && currentSrc !== state.trackedSrc) { this.clearMaskOverlay(image); notifySrcDrift(image); diff --git a/entrypoints/content/presentation/predictionStyling.ts b/entrypoints/content/presentation/predictionStyling.ts index 36a172dd..4bece891 100644 --- a/entrypoints/content/presentation/predictionStyling.ts +++ b/entrypoints/content/presentation/predictionStyling.ts @@ -1,3 +1,4 @@ +import { resolveImageSource } from '@/entrypoints/content/core/imageSource'; import { imageMaskOverlay } from '@/entrypoints/content/presentation/imageMaskOverlay'; import { type IHostSettings, type IImagePrediction } from '@/utils/types'; @@ -11,7 +12,7 @@ export const applyPredictionsStyling = ( const predictionMap = new Map(predictions.map(p => [p.src, p])); for (const image of images) { - const imageSrc = image.currentSrc || image.src; + const imageSrc = resolveImageSource(image); const imagePrediction = predictionMap.get(imageSrc); if (imagePrediction) { diff --git a/package.json b/package.json index 3f87d588..ce82d7ec 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "eslint-plugin-import": "^2.32.0", "eslint-plugin-react": "^7.37.5", "globals": "^17.9.0", + "happy-dom": "^20.14.5", "husky": "^9.1.7", "lint-staged": "^17.3.0", "playwright": "^1.62.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cdbcf15..ccc8c5b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,6 +145,9 @@ importers: globals: specifier: ^17.9.0 version: 17.9.0 + happy-dom: + specifier: ^20.14.5 + version: 20.14.5 husky: specifier: ^9.1.7 version: 9.1.7 @@ -174,7 +177,7 @@ importers: version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(happy-dom@20.14.5)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0)) web-ext: specifier: ^10.6.0 version: 10.6.0(jiti@2.7.0) @@ -1347,6 +1350,9 @@ packages: '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/which@2.0.2': resolution: {integrity: sha512-113D3mDkZDjo+EeUEHCFy0qniNc1ZpecGiAU7WSo7YDoSzolZIQKpYFHrPpjkB2nuyahcKfrmLXeQlh7gqJYdw==} @@ -1956,6 +1962,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -2956,6 +2966,10 @@ packages: guid-typescript@1.0.9: resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==} + happy-dom@20.14.5: + resolution: {integrity: sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==} + engines: {node: '>=20.0.0'} + has-ansi@4.0.1: resolution: {integrity: sha512-Qr4RtTm30xvEdqUXbSBVWDu+PrTokJOwe/FU+VdfJPk+MXAPoeOzKpRyrDTnZIJwAkQ4oBLTU53nu0HrkF/Z2A==} engines: {node: '>=8'} @@ -5165,6 +5179,10 @@ packages: engines: {node: '>=18'} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -6449,6 +6467,8 @@ snapshots: '@types/uuid@9.0.8': {} + '@types/whatwg-mimetype@3.0.2': {} + '@types/which@2.0.2': {} '@types/ws@8.18.1': @@ -7215,6 +7235,10 @@ snapshots: buffer-from@1.1.2: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 26.1.2 + buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -8425,6 +8449,19 @@ snapshots: guid-typescript@1.0.9: {} + happy-dom@20.14.5: + dependencies: + '@types/node': 26.1.2 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-ansi@4.0.1: dependencies: ansi-regex: 4.1.1 @@ -10604,7 +10641,7 @@ snapshots: tsx: 4.23.4 yaml: 2.9.0 - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(happy-dom@20.14.5)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0)) @@ -10629,6 +10666,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 26.1.2 + happy-dom: 20.14.5 transitivePeerDependencies: - msw @@ -10744,6 +10782,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} when-exit@2.1.5: {} diff --git a/tests/e2e/config/wdio.android.conf.ts b/tests/e2e/config/wdio.android.conf.ts index fab6ab1c..eb9a953e 100644 --- a/tests/e2e/config/wdio.android.conf.ts +++ b/tests/e2e/config/wdio.android.conf.ts @@ -50,6 +50,68 @@ const cleanupFirefoxRuntime = (reason: string): void => { runAdbCleanup(['shell', 'am', 'force-stop', FENIX_PACKAGE], 'force-stop Firefox Nightly'); }; +const FENIX_HOMEPAGE_AS_NEW_TAB_OPT_OUT_RECIPE = JSON.stringify({ + data: [ + { + schemaVersion: '1.12.0', + slug: 'haramblock-e2e-disable-homepage-as-new-tab', + appName: 'fenix', + appId: FENIX_PACKAGE, + channel: 'nightly', + userFacingName: 'HaramBlock E2E: disable homepage as new tab', + userFacingDescription: 'Opening a homepage tab while Gecko quits crashes GeckoView.', + isEnrollmentPaused: false, + isRollout: true, + bucketConfig: { + randomizationUnit: 'nimbus_id', + namespace: 'haramblock-e2e', + start: 0, + count: 10000, + total: 10000, + }, + probeSets: [], + outcomes: [], + startDate: null, + endDate: null, + proposedEnrollment: 7, + referenceBranch: 'control', + featureIds: ['homepage-as-new-tab'], + branches: [ + { + slug: 'control', + ratio: 1, + features: [{ featureId: 'homepage-as-new-tab', enabled: true, value: { enabled: false } }], + }, + ], + targeting: 'true', + }, + ], +}); + +const disableFenixHomepageAsNewTab = (): void => { + runAdbCleanup( + [ + 'shell', + 'am', + 'broadcast', + '-a', + `${FENIX_PACKAGE}.NIMBUS_TOOLING`, + '-p', + FENIX_PACKAGE, + '--ez', + 'nimbus-cli', + 'true', + '--ei', + 'version', + '1', + '--es', + 'experiments', + `'${FENIX_HOMEPAGE_AS_NEW_TAB_OPT_OUT_RECIPE}'`, + ], + 'disable Fenix homepage-as-new-tab via Nimbus tooling', + ); +}; + const cleanupFirefoxSessionState = (reason: string): void => { cleanupFirefoxRuntime(reason); runAdbCleanup(['shell', 'pm', 'clear', FENIX_PACKAGE], 'clear Firefox Nightly data'); @@ -364,6 +426,8 @@ export const config: WebdriverIO.Config = { throw new Error('Firefox extension path not set'); } + disableFenixHomepageAsNewTab(); + // Keep the session's initial tab open and create a fresh one: Android // geckodriver can return no remaining handles from closeWindow(), which // makes WebdriverIO terminate the entire session. diff --git a/utils/types/prediction.ts b/utils/types/prediction.ts index 324d22f6..a797ee98 100644 --- a/utils/types/prediction.ts +++ b/utils/types/prediction.ts @@ -71,6 +71,7 @@ export type ForcedVisibility = 'auto' | 'visible' | 'blocked'; */ export type ImageInferenceResult = | { status: 'ok'; prediction: IImagePrediction; traceparent?: string } + | { status: 'started'; src: string; hostname: string; traceparent?: string } | { status: 'error'; src: string; hostname: string; reason?: string; traceparent?: string }; export function shouldBlock(prediction: IImagePrediction): boolean {