ext: capture/index.ts — blocking onHeadersReceived hook, fail-open
For a response shouldCapture() likes: gather cookies, offer to the daemon
under a hard 750 ms budget, and return {cancel:true} ONLY on an explicit
{action:"take"}. Every other outcome — shouldCapture says no, daemon down /
slow / erroring, cookies fail, anything throws — resolves to {} and Firefox
downloads normally.
Fail-open tests written first (tests/capture/hook.test.ts): offer() rejects,
offer() never settles (resolves within budget), timeout-shaped rejection,
getRules() throws, malformed details. Plus the happy paths and a check that
the capture.offer payload is well-formed from details + stash + headers.
background/index.ts: wire the stash + hook onto browser.webRequest, mirror
capture rules via capture.getRules on connect and on a capture.* settings
change; DEFAULT_CAPTURE_RULES (enabled:false) until the first mirror lands.
84 tests green; web-ext lint clean.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y9RU58hD1BuwP82DySUHk
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
// The blocking onHeadersReceived hook.
|
||||
//
|
||||
// For a response that shouldCapture() likes: gather cookies, offer it to the daemon with
|
||||
// a hard 750 ms budget, and cancel the browser's own download ONLY on an explicit
|
||||
// {action:"take"}. Everything else — shouldCapture says no, the daemon is down / slow /
|
||||
// erroring, cookies fail, anything throws — resolves to {} and Firefox downloads
|
||||
// normally. Fail-open is not a fallback path here; it is the default every branch returns
|
||||
// to. tests/capture/hook.test.ts pins that.
|
||||
|
||||
import type {
|
||||
CaptureOfferParams,
|
||||
CaptureOfferResult,
|
||||
CaptureRules,
|
||||
Cookie,
|
||||
} from '../../shared/protocol/index.js';
|
||||
|
||||
import { normalizeHeaders } from './headers.js';
|
||||
import type { HeaderStash } from './headers.js';
|
||||
import { filenameExtension, shouldCapture, type CaptureCandidate, type ResourceType } from './rules.js';
|
||||
|
||||
const BUDGET_MS = 750; // docs/05 §2 and METHODS['capture.offer'].deadlineMs
|
||||
const PROCEED: BlockingResponse = {};
|
||||
const CANCEL: BlockingResponse = { cancel: true };
|
||||
|
||||
export interface BlockingResponse {
|
||||
cancel?: boolean;
|
||||
}
|
||||
|
||||
export interface OnHeadersReceivedDetails {
|
||||
requestId: string;
|
||||
url: string;
|
||||
method: string;
|
||||
type: ResourceType;
|
||||
statusCode: number;
|
||||
tabId: number;
|
||||
responseHeaders?: { name: string; value?: string }[];
|
||||
documentUrl?: string | null;
|
||||
originUrl?: string | null;
|
||||
}
|
||||
|
||||
interface WebRequestEvent<L extends (...a: never[]) => void> {
|
||||
addListener(listener: L, filter: { urls: string[] }, extraInfoSpec?: string[]): void;
|
||||
removeListener(listener: L): void;
|
||||
}
|
||||
|
||||
export interface HeadersReceivedWebRequest {
|
||||
onHeadersReceived: WebRequestEvent<
|
||||
(d: OnHeadersReceivedDetails) => BlockingResponse | Promise<BlockingResponse>
|
||||
>;
|
||||
}
|
||||
|
||||
/** A browser cookie, as `browser.cookies.getAll` returns it (superset of the wire type). */
|
||||
export interface CookieRecord {
|
||||
name: string;
|
||||
value: string;
|
||||
domain?: string;
|
||||
path?: string;
|
||||
secure?: boolean;
|
||||
httpOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface CaptureHookDeps {
|
||||
/** Usually `(p, o) => transport.call('capture.offer', p, o)`. */
|
||||
offer: (params: CaptureOfferParams, opts?: { timeoutMs?: number }) => Promise<CaptureOfferResult>;
|
||||
stash: Pick<HeaderStash, 'take' | 'peek'>;
|
||||
getCookies: (url: string) => Promise<CookieRecord[]>;
|
||||
/** A synchronous snapshot of the daemon's capture policy (mirrored via capture.getRules). */
|
||||
getRules: () => CaptureRules;
|
||||
/** Whether the user held the bypass modifier for the click behind this request. */
|
||||
bypassHeld?: (details: OnHeadersReceivedDetails) => boolean;
|
||||
/** moz-extension://<uuid>, for CaptureOfferParams.origin. */
|
||||
origin?: string;
|
||||
budgetMs?: number;
|
||||
}
|
||||
|
||||
const ALL_URLS = '<all_urls>';
|
||||
|
||||
export class CaptureHook {
|
||||
private readonly budgetMs: number;
|
||||
private bound?: (d: OnHeadersReceivedDetails) => Promise<BlockingResponse>;
|
||||
private attachedTo?: HeadersReceivedWebRequest;
|
||||
|
||||
constructor(private readonly deps: CaptureHookDeps) {
|
||||
this.budgetMs = deps.budgetMs ?? BUDGET_MS;
|
||||
}
|
||||
|
||||
/** The listener. Always resolves within the budget to {} or {cancel:true}; never rejects. */
|
||||
handle = (details: OnHeadersReceivedDetails): Promise<BlockingResponse> => {
|
||||
return this.withinBudget(this.decide(details));
|
||||
};
|
||||
|
||||
attach(webRequest: HeadersReceivedWebRequest): void {
|
||||
if (this.attachedTo) throw new Error('CaptureHook is already attached');
|
||||
this.attachedTo = webRequest;
|
||||
this.bound = this.handle;
|
||||
webRequest.onHeadersReceived.addListener(this.bound, { urls: [ALL_URLS] }, [
|
||||
'blocking',
|
||||
'responseHeaders',
|
||||
]);
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
if (this.attachedTo && this.bound) {
|
||||
this.attachedTo.onHeadersReceived.removeListener(this.bound);
|
||||
}
|
||||
this.attachedTo = undefined;
|
||||
this.bound = undefined;
|
||||
}
|
||||
|
||||
private withinBudget(work: Promise<BlockingResponse>): Promise<BlockingResponse> {
|
||||
return new Promise<BlockingResponse>((resolve) => {
|
||||
let settled = false;
|
||||
const done = (value: BlockingResponse): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
};
|
||||
const timer = setTimeout(() => done(PROCEED), this.budgetMs);
|
||||
(timer as { unref?: () => void }).unref?.();
|
||||
work.then(done, () => done(PROCEED));
|
||||
});
|
||||
}
|
||||
|
||||
private async decide(details: OnHeadersReceivedDetails): Promise<BlockingResponse> {
|
||||
try {
|
||||
const rules = this.deps.getRules();
|
||||
const responseHeaders = normalizeHeaders(details.responseHeaders);
|
||||
const stashed = this.stashPeek(details.requestId);
|
||||
|
||||
const candidate: CaptureCandidate = {
|
||||
url: details.url,
|
||||
method: details.method,
|
||||
statusCode: details.statusCode,
|
||||
type: details.type,
|
||||
responseHeaders,
|
||||
requestHeaders: stashed?.headers ?? {},
|
||||
documentUrl: details.documentUrl ?? details.originUrl ?? null,
|
||||
bypassHeld: this.deps.bypassHeld?.(details) ?? false,
|
||||
};
|
||||
|
||||
if (!shouldCapture(candidate, rules).capture) return PROCEED;
|
||||
|
||||
// We are acting on this request now — take the stash entry.
|
||||
const taken = this.deps.stash.take(details.requestId);
|
||||
const requestHeaders = taken?.headers ?? candidate.requestHeaders;
|
||||
|
||||
let cookies: Cookie[] = [];
|
||||
try {
|
||||
cookies = toWireCookies(await this.deps.getCookies(details.url));
|
||||
} catch {
|
||||
cookies = []; // best-effort; still worth offering
|
||||
}
|
||||
|
||||
const params: CaptureOfferParams = {
|
||||
url: details.url,
|
||||
method: candidate.method.toUpperCase() === 'POST' ? 'POST' : 'GET',
|
||||
tabUrl: candidate.documentUrl ?? '',
|
||||
headers: requestHeaders,
|
||||
cookies,
|
||||
contentType: responseHeaders['content-type'] ?? null,
|
||||
contentLength: intOrNull(responseHeaders['content-length']),
|
||||
contentDisposition: responseHeaders['content-disposition'] ?? null,
|
||||
filename: guessFilename(details.url, responseHeaders['content-disposition']),
|
||||
referrer: requestHeaders['referer'] ?? null,
|
||||
origin: this.deps.origin ?? null,
|
||||
requestId: details.requestId,
|
||||
};
|
||||
|
||||
const result = await this.deps.offer(params, { timeoutMs: this.budgetMs });
|
||||
return result.action === 'take' ? CANCEL : PROCEED;
|
||||
} catch {
|
||||
return PROCEED; // fail open on anything at all
|
||||
}
|
||||
}
|
||||
|
||||
private stashPeek(requestId: string) {
|
||||
try {
|
||||
return this.deps.stash.peek(requestId);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ----------------------------------------------------------------------
|
||||
|
||||
function toWireCookies(records: CookieRecord[]): Cookie[] {
|
||||
return records.map((c) => {
|
||||
const out: Cookie = { name: c.name, value: c.value };
|
||||
if (c.domain !== undefined) out.domain = c.domain;
|
||||
if (c.path !== undefined) out.path = c.path;
|
||||
if (c.secure !== undefined) out.secure = c.secure;
|
||||
if (c.httpOnly !== undefined) out.httpOnly = c.httpOnly;
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
function intOrNull(value: string | undefined): number | null {
|
||||
if (value === undefined) return null;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** RFC 6266 filename, falling back to the URL's last path segment. */
|
||||
export function guessFilename(url: string, disposition: string | undefined): string | null {
|
||||
if (disposition) {
|
||||
const star = /filename\*\s*=\s*(?:[^']*'[^']*')?([^;]+)/i.exec(disposition);
|
||||
if (star?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(star[1].trim().replace(/^"|"$/g, '')) || null;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
const plain = /filename\s*=\s*"?([^";]+)"?/i.exec(disposition);
|
||||
if (plain?.[1]) return plain[1].trim() || null;
|
||||
}
|
||||
try {
|
||||
const path = new URL(url).pathname;
|
||||
const base = decodeURIComponent(path.slice(path.lastIndexOf('/') + 1));
|
||||
if (base) return base;
|
||||
} catch {
|
||||
/* not a parseable URL */
|
||||
}
|
||||
const ext = filenameExtension(url);
|
||||
return ext ? `download.${ext}` : null;
|
||||
}
|
||||
@@ -63,6 +63,19 @@ export interface CaptureDecision {
|
||||
reason: CaptureReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* What shouldCapture() runs against before the first capture.getRules has been mirrored
|
||||
* from the daemon. `enabled: false` — we intercept nothing until we know the real policy.
|
||||
*/
|
||||
export const DEFAULT_CAPTURE_RULES: CaptureRules = {
|
||||
enabled: false,
|
||||
monitoredExtensions: [],
|
||||
monitoredMimeTypes: [],
|
||||
minSizeBytes: 1024 * 1024,
|
||||
excludedHosts: [],
|
||||
rulesVersion: 0,
|
||||
};
|
||||
|
||||
const RENDERABLE_TYPES = new Set([
|
||||
'text/html',
|
||||
'application/xhtml+xml',
|
||||
|
||||
@@ -1,20 +1,58 @@
|
||||
// Background event-page entry.
|
||||
//
|
||||
// Brings the transport up and holds the single shared reference to it. Capture, the
|
||||
// popup relay, and context menus attach here as they land (docs/05 §6 build order).
|
||||
// Brings the transport up, mirrors the daemon's capture rules, and wires the two capture
|
||||
// paths: the blocking onHeadersReceived hook and (next) the downloads.onCreated safety
|
||||
// net. The popup relay and context menus attach here in later steps of the build order.
|
||||
|
||||
import { CaptureHook } from './capture/index.js';
|
||||
import { HeaderStash } from './capture/headers.js';
|
||||
import type { WebRequestLike } from './capture/headers.js';
|
||||
import type { HeadersReceivedWebRequest } from './capture/index.js';
|
||||
import { DEFAULT_CAPTURE_RULES } from './capture/rules.js';
|
||||
import { createTransport, type TransportStatus, type VeloxTransport } from './transport/index.js';
|
||||
import type { CaptureRules } from '../shared/protocol/index.js';
|
||||
|
||||
let transport: VeloxTransport | undefined;
|
||||
let rules: CaptureRules = DEFAULT_CAPTURE_RULES;
|
||||
|
||||
const stash = new HeaderStash();
|
||||
const hook = new CaptureHook({
|
||||
offer: (params, opts) => mustTransport().call('capture.offer', params, opts),
|
||||
stash,
|
||||
getCookies: (url) => browser.cookies.getAll({ url }),
|
||||
getRules: () => rules,
|
||||
origin: new URL(browser.runtime.getURL('/')).origin,
|
||||
});
|
||||
|
||||
function mustTransport(): VeloxTransport {
|
||||
if (!transport) throw new Error('transport not ready');
|
||||
return transport;
|
||||
}
|
||||
|
||||
async function refreshRules(): Promise<void> {
|
||||
try {
|
||||
rules = await mustTransport().call('capture.getRules', {});
|
||||
} catch {
|
||||
// Keep the last known rules; capture stays fail-open regardless (docs/05 §2).
|
||||
}
|
||||
}
|
||||
|
||||
function onTransportState(status: TransportStatus): void {
|
||||
const detail = status.fatal ?? (status.needsPairing ? 'needs pairing' : '');
|
||||
console.debug(`[velox] transport ${status.state}${detail ? ` — ${detail}` : ''}`);
|
||||
if (status.state === 'connected') void refreshRules();
|
||||
}
|
||||
|
||||
async function start(): Promise<void> {
|
||||
stash.attach(browser.webRequest as unknown as WebRequestLike);
|
||||
hook.attach(browser.webRequest as unknown as HeadersReceivedWebRequest);
|
||||
|
||||
transport = await createTransport();
|
||||
transport.onStateChange(onTransportState);
|
||||
transport.on('event.settings.changed', (payload) => {
|
||||
const keys = (payload as { keys?: string[] }).keys ?? [];
|
||||
if (keys.some((k) => k.startsWith('capture.'))) void refreshRules();
|
||||
});
|
||||
onTransportState(transport.status);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
// The blocking onHeadersReceived hook.
|
||||
//
|
||||
// Fail-open is the hard requirement (docs/05 §2, AGENT-EXT M1 DoD): daemon down, slow, or
|
||||
// erroring must never cost the user a download. Those cases are written first below.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CaptureOfferParams, CaptureOfferResult, CaptureRules } from '../../src/shared/protocol/index.js';
|
||||
import {
|
||||
CaptureHook,
|
||||
type CaptureHookDeps,
|
||||
type HeadersReceivedWebRequest,
|
||||
type OnHeadersReceivedDetails,
|
||||
} from '../../src/background/capture/index.js';
|
||||
|
||||
const RULES: CaptureRules = {
|
||||
enabled: true,
|
||||
monitoredExtensions: ['zip', 'iso'],
|
||||
monitoredMimeTypes: ['application/octet-stream'],
|
||||
minSizeBytes: 1024 * 1024,
|
||||
excludedHosts: [],
|
||||
rulesVersion: 1,
|
||||
};
|
||||
|
||||
function detailsFor(over: Partial<OnHeadersReceivedDetails> = {}): OnHeadersReceivedDetails {
|
||||
return {
|
||||
requestId: '42',
|
||||
url: 'https://cdn.example.com/files/ubuntu.iso',
|
||||
method: 'GET',
|
||||
type: 'other',
|
||||
statusCode: 200,
|
||||
tabId: 3,
|
||||
responseHeaders: [
|
||||
{ name: 'Content-Type', value: 'application/octet-stream' },
|
||||
{ name: 'Content-Length', value: String(900 * 1024 * 1024) },
|
||||
],
|
||||
documentUrl: 'https://example.com/releases',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
hook: CaptureHook;
|
||||
offer: ReturnType<typeof vi.fn>;
|
||||
stash: { take: ReturnType<typeof vi.fn>; peek: ReturnType<typeof vi.fn> };
|
||||
getCookies: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function makeHook(over: Partial<CaptureHookDeps> = {}): Harness {
|
||||
const { offer: offerOver, getCookies: cookiesOver, stash: stashOver, ...rest } = over;
|
||||
const stashEntry = { requestId: '42', url: '', method: 'GET', tabId: 3, stashedAt: 0, headers: { referer: 'https://example.com/releases' } };
|
||||
const stash = (stashOver as unknown as Harness['stash']) ?? {
|
||||
take: vi.fn(() => stashEntry),
|
||||
peek: vi.fn(() => stashEntry),
|
||||
};
|
||||
const offer =
|
||||
(offerOver as unknown as ReturnType<typeof vi.fn>) ??
|
||||
vi.fn(async (): Promise<CaptureOfferResult> => ({ action: 'ignore', reason: 'type_not_monitored' }));
|
||||
const getCookies =
|
||||
(cookiesOver as unknown as ReturnType<typeof vi.fn>) ??
|
||||
vi.fn(async () => [{ name: 'sid', value: 'abc', domain: '.example.com', secure: true }]);
|
||||
const deps: CaptureHookDeps = {
|
||||
offer: offer as unknown as CaptureHookDeps['offer'],
|
||||
stash: stash as unknown as CaptureHookDeps['stash'],
|
||||
getCookies: getCookies as unknown as CaptureHookDeps['getCookies'],
|
||||
getRules: () => RULES,
|
||||
origin: 'moz-extension://11111111-2222-3333-4444-555555555555',
|
||||
budgetMs: 40,
|
||||
...rest,
|
||||
};
|
||||
return { hook: new CaptureHook(deps), offer, stash, getCookies };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('CaptureHook — fail-open (written before the feature)', () => {
|
||||
it('daemon down: offer() rejects → Firefox downloads normally', async () => {
|
||||
const { hook } = makeHook({ offer: vi.fn().mockRejectedValue(new Error('transport is not connected')) });
|
||||
await expect(hook.handle(detailsFor())).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('daemon slow: offer() never settles → resolves to {} within the budget', async () => {
|
||||
const { hook } = makeHook({ offer: vi.fn(() => new Promise<never>(() => {})), budgetMs: 30 });
|
||||
const started = Date.now();
|
||||
await expect(hook.handle(detailsFor())).resolves.toEqual({});
|
||||
expect(Date.now() - started).toBeGreaterThanOrEqual(20);
|
||||
expect(Date.now() - started).toBeLessThan(300);
|
||||
});
|
||||
|
||||
it('daemon erroring: offer() rejects with a timeout-like error → {}', async () => {
|
||||
const err = Object.assign(new Error('capture.offer timed out after 750 ms'), { name: 'RpcTimeoutError' });
|
||||
const { hook } = makeHook({ offer: vi.fn().mockRejectedValue(err) });
|
||||
await expect(hook.handle(detailsFor())).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('getRules() throws → {} and no offer attempted', async () => {
|
||||
const offer = vi.fn();
|
||||
const { hook } = makeHook({
|
||||
offer: offer as unknown as CaptureHookDeps['offer'],
|
||||
getRules: () => {
|
||||
throw new Error('rules not loaded');
|
||||
},
|
||||
});
|
||||
await expect(hook.handle(detailsFor())).resolves.toEqual({});
|
||||
expect(offer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the listener never rejects, whatever the details', async () => {
|
||||
const { hook } = makeHook();
|
||||
// @ts-expect-error deliberately malformed
|
||||
await expect(hook.handle({})).resolves.toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CaptureHook — the happy paths', () => {
|
||||
it('shouldCapture says no → {} and the offer is not made, stash not consumed', async () => {
|
||||
const { hook, offer, stash } = makeHook({ getRules: () => ({ ...RULES, enabled: false }) });
|
||||
await expect(hook.handle(detailsFor())).resolves.toEqual({});
|
||||
expect(offer).not.toHaveBeenCalled();
|
||||
expect(stash.take).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offer → {action:"take"} cancels the browser download', async () => {
|
||||
const { hook, offer, stash } = makeHook({
|
||||
offer: vi.fn(async () => ({ action: 'take', taskId: 't1' })) as unknown as CaptureHookDeps['offer'],
|
||||
});
|
||||
await expect(hook.handle(detailsFor())).resolves.toEqual({ cancel: true });
|
||||
expect(offer).toHaveBeenCalledOnce();
|
||||
expect(stash.take).toHaveBeenCalledWith('42');
|
||||
});
|
||||
|
||||
it('offer → {action:"ignore"} lets Firefox proceed', async () => {
|
||||
const { hook } = makeHook();
|
||||
await expect(hook.handle(detailsFor())).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('builds a well-formed capture.offer from details + stash + response headers', async () => {
|
||||
const seen: CaptureOfferParams[] = [];
|
||||
const { hook } = makeHook({
|
||||
offer: (vi.fn(async (p: CaptureOfferParams) => {
|
||||
seen.push(p);
|
||||
return { action: 'take', taskId: 't' };
|
||||
}) as unknown) as CaptureHookDeps['offer'],
|
||||
});
|
||||
await hook.handle(detailsFor());
|
||||
expect(seen[0]).toMatchObject({
|
||||
url: 'https://cdn.example.com/files/ubuntu.iso',
|
||||
method: 'GET',
|
||||
tabUrl: 'https://example.com/releases',
|
||||
contentType: 'application/octet-stream',
|
||||
contentLength: 900 * 1024 * 1024,
|
||||
referrer: 'https://example.com/releases',
|
||||
origin: 'moz-extension://11111111-2222-3333-4444-555555555555',
|
||||
requestId: '42',
|
||||
filename: 'ubuntu.iso',
|
||||
});
|
||||
expect(seen[0]!.cookies).toEqual([{ name: 'sid', value: 'abc', domain: '.example.com', secure: true }]);
|
||||
});
|
||||
|
||||
it('still offers when cookie collection fails, with an empty cookie list', async () => {
|
||||
const seen: CaptureOfferParams[] = [];
|
||||
const { hook } = makeHook({
|
||||
offer: (async (p: CaptureOfferParams) => {
|
||||
seen.push(p);
|
||||
return { action: 'take', taskId: 't' };
|
||||
}) as CaptureHookDeps['offer'],
|
||||
getCookies: (async () => {
|
||||
throw new Error('no cookies perm');
|
||||
}) as CaptureHookDeps['getCookies'],
|
||||
});
|
||||
await expect(hook.handle(detailsFor())).resolves.toEqual({ cancel: true });
|
||||
expect(seen[0]!.cookies).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CaptureHook.attach', () => {
|
||||
class FakeEvent {
|
||||
listeners: Array<(d: OnHeadersReceivedDetails) => unknown> = [];
|
||||
addListener = (l: (d: OnHeadersReceivedDetails) => unknown): void => void this.listeners.push(l);
|
||||
removeListener = (l: (d: OnHeadersReceivedDetails) => unknown): void => {
|
||||
this.listeners = this.listeners.filter((x) => x !== l);
|
||||
};
|
||||
}
|
||||
|
||||
it('registers a blocking responseHeaders listener and unregisters on detach', () => {
|
||||
const evt = new FakeEvent();
|
||||
const wr = { onHeadersReceived: evt } as unknown as HeadersReceivedWebRequest;
|
||||
const { hook } = makeHook();
|
||||
|
||||
hook.attach(wr);
|
||||
expect(evt.listeners).toHaveLength(1);
|
||||
expect(() => hook.attach(wr)).toThrow(/already attached/);
|
||||
|
||||
hook.detach();
|
||||
expect(evt.listeners).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user