From 4a5ec2ff48051bda79a0c0a753b810ec39a35e50 Mon Sep 17 00:00:00 2001 From: Pascal Baljet Date: Thu, 30 Jul 2026 10:13:39 +0200 Subject: [PATCH 1/2] Scope tab header to hosts running the recorder --- src/background.ts | 29 +++- src/background/hosts.ts | 66 +++++++++ src/background/tabRules.ts | 28 +++- src/constants.ts | 1 + src/types.ts | 1 + tests/e2e/fixtures.ts | 2 +- tests/unit/contract.test.ts | 255 --------------------------------- tests/unit/entry.navigate.json | 43 ------ tests/unit/guards.test.ts | 22 +++ tests/unit/tabRules.test.ts | 142 ++++++++++++++++++ 10 files changed, 279 insertions(+), 310 deletions(-) create mode 100644 src/background/hosts.ts delete mode 100644 tests/unit/contract.test.ts delete mode 100644 tests/unit/entry.navigate.json create mode 100644 tests/unit/tabRules.test.ts diff --git a/src/background.ts b/src/background.ts index 91436e0..99b6d0b 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,5 +1,6 @@ import { broadcastDevStatus, broadcastEntryPageState, broadcastRequestActive } from './background/broadcasts' import { handleCacheHit } from './background/cacheHit' +import { forgetProvenHosts, rememberProvenHost } from './background/hosts' import { ingestEntry } from './background/ingest' import { appendAndBroadcast } from './background/record' import { @@ -16,7 +17,7 @@ import { setTabMaxEntries, } from './background/runtimeStore' import { resolveClientVisitBatchId, synthesizeCacheHitEntry, synthesizeClientVisitEntry } from './background/synthesize' -import { ensureTabRule, migrateTabRule, primeExistingTabs, removeTabRule } from './background/tabRules' +import { ensureTabRule, migrateTabRule, removeTabRule, syncAllTabRules } from './background/tabRules' import { browser } from './browser' import { DEVTOOLS_ID_HEADER, REEMIT_PAGE_STATE_MESSAGE } from './constants' import { isBackgroundMessage } from './guards' @@ -48,8 +49,8 @@ workerScope.addEventListener('unhandledrejection', (event) => { console.error('[inertia-devtools] service worker unhandled rejection:', event) }) -browser.runtime.onInstalled.addListener(() => void primeExistingTabs()) -browser.runtime.onStartup.addListener(() => void primeExistingTabs()) +browser.runtime.onInstalled.addListener(() => void syncAllTabRules()) +browser.runtime.onStartup.addListener(() => void syncAllTabRules()) browser.tabs.onCreated.addListener((tab) => { if (typeof tab.id === 'number') { void ensureTabRule(tab.id) @@ -76,7 +77,10 @@ browser.webRequest.onHeadersReceived.addListener( return } - void ingestEntry(tabId, new URL(url).origin, idHeader.value) + const origin = new URL(url).origin + + void noteProvenHost(origin) + void ingestEntry(tabId, origin, idHeader.value) }, { urls: [''] }, ['responseHeaders'], @@ -88,6 +92,10 @@ self.__inertiaDevtools = { getPageStates: (tabId) => getPageStatesForTab(tabId), clearAll: () => clearAll(), ingest: (tabId, origin, id) => ingestEntry(tabId, origin, id), + forgetHosts: async () => { + await forgetProvenHosts() + await syncAllTabRules() + }, } function replyOk(sendResponse: (response: { ok: true }) => void): boolean { @@ -96,6 +104,18 @@ function replyOk(sendResponse: (response: { ok: true }) => void): boolean { return false } +/** + * Unlock the tab header for a host that just proved it runs the recorder. + * + * Its first response is therefore unstamped; every request made after the rule lands carries the + * tab UUID. + */ +async function noteProvenHost(origin: string): Promise { + if (await rememberProvenHost(origin)) { + await syncAllTabRules() + } +} + /** * Pair an incoming page-state snapshot with its owning timeline entry before notifying panels. */ @@ -158,6 +178,7 @@ function recordClientVisit(tabId: number, visit: ClientVisitSnapshot): void { function handleContentMessage(tabId: number, message: ContentToBackgroundMessage): void { switch (message.type) { case 'content:initial-id': + void noteProvenHost(message.origin) void ingestEntry(tabId, message.origin, message.id) return diff --git a/src/background/hosts.ts b/src/background/hosts.ts new file mode 100644 index 0000000..67215c5 --- /dev/null +++ b/src/background/hosts.ts @@ -0,0 +1,66 @@ +import { browser } from '../browser' +import { DEVTOOLS_HOSTS_STORAGE_KEY } from '../constants' + +// Hosts that served an `x-inertia-devtools-id` header or rendered the id tag, and so proved they +// run the recorder. The tab header is a stable identifier, so it is only ever stamped on these. +// +// Persisted because the proof outlives the session rules it scopes, and cached because a rule is +// rewritten for every tab Chrome opens. +let cachedHosts: Set | null = null + +async function loadHosts(): Promise> { + if (cachedHosts) { + return cachedHosts + } + + const stored = await browser.storage.local.get(DEVTOOLS_HOSTS_STORAGE_KEY) + const hosts: unknown[] = Array.isArray(stored[DEVTOOLS_HOSTS_STORAGE_KEY]) ? stored[DEVTOOLS_HOSTS_STORAGE_KEY] : [] + + cachedHosts = new Set(hosts.filter((host): host is string => typeof host === 'string' && host.length > 0)) + + return cachedHosts +} + +export async function getProvenHosts(): Promise { + return [...(await loadHosts())] +} + +// Origins reach this module from page-controlled messages, so only http(s) ones earn a rule. +function hostnameOf(origin: string): string | null { + try { + const url = new URL(origin) + + return url.protocol === 'http:' || url.protocol === 'https:' ? url.hostname : null + } catch { + return null + } +} + +/** + * Record a host that proved it runs the recorder, resolving true when the set actually grew + * and the tab rules therefore have to be rewritten to cover it. + */ +export async function rememberProvenHost(origin: string): Promise { + const hostname = hostnameOf(origin) + + if (!hostname) { + return false + } + + const hosts = await loadHosts() + + if (hosts.has(hostname)) { + return false + } + + hosts.add(hostname) + await browser.storage.local.set({ [DEVTOOLS_HOSTS_STORAGE_KEY]: [...hosts] }) + + return true +} + +/** Forget every proven host, so no tab rule survives. Used by the E2E hook in `background.ts`. */ +export async function forgetProvenHosts(): Promise { + cachedHosts = new Set() + await browser.storage.local.remove(DEVTOOLS_HOSTS_STORAGE_KEY) +} diff --git a/src/background/tabRules.ts b/src/background/tabRules.ts index 470f969..f6dae0f 100644 --- a/src/background/tabRules.ts +++ b/src/background/tabRules.ts @@ -1,5 +1,6 @@ import { browser } from '../browser' import { DEVTOOLS_TAB_HEADER, TAB_STORAGE_KEY_PREFIX } from '../constants' +import { getProvenHosts } from './hosts' import { clearTab, migrateTab } from './runtimeStore' function tabStorageKey(tabId: number): string { @@ -15,9 +16,22 @@ export async function readTabUuid(tabId: number): Promise { /** * Install the per-tab DNR rule that stamps outgoing requests with the stable tab UUID. + * + * The rule only covers hosts that have proven they run the recorder — `requestDomains` is the + * closest DNR gets to a host allowlist, so it widens to their subdomains too. */ export async function writeDnrRule(tabId: number, uuid: string): Promise { + const provenHosts = await getProvenHosts() + try { + // Until some host proves it runs the recorder there is no rule at all: a UUID stamped on every + // request would be a stable identifier handed to every site the user visits. + if (provenHosts.length === 0) { + await browser.declarativeNetRequest.updateSessionRules({ removeRuleIds: [tabId] }) + + return + } + await browser.declarativeNetRequest.updateSessionRules({ removeRuleIds: [tabId], addRules: [ @@ -26,6 +40,7 @@ export async function writeDnrRule(tabId: number, uuid: string): Promise { priority: 1, condition: { tabIds: [tabId], + requestDomains: provenHosts, resourceTypes: [ chrome.declarativeNetRequest.ResourceType.XMLHTTPREQUEST, chrome.declarativeNetRequest.ResourceType.MAIN_FRAME, @@ -52,15 +67,13 @@ export async function writeDnrRule(tabId: number, uuid: string): Promise { /** * Ensure a tab has a stable UUID and matching header-injection rule before traffic starts. + * + * The rule is rewritten rather than assumed live: session rules die on browser restart, and the + * proven-host set they are scoped to grows as hosts reveal a recorder. */ export async function ensureTabRule(tabId: number): Promise { - const existing = await readTabUuid(tabId) - - if (existing) { - return existing - } + const uuid = (await readTabUuid(tabId)) ?? crypto.randomUUID() - const uuid = crypto.randomUUID() await browser.storage.local.set({ [tabStorageKey(tabId)]: uuid }) await writeDnrRule(tabId, uuid) @@ -103,7 +116,8 @@ export async function migrateTabRule(addedTabId: number, removedTabId: number): migrateTab(removedTabId, addedTabId) } -export async function primeExistingTabs(): Promise { +/** Reinstall every tab rule: on worker start, and when a newly proven host widens their scope. */ +export async function syncAllTabRules(): Promise { const tabs = await browser.tabs.query({}) for (const tab of tabs) { diff --git a/src/constants.ts b/src/constants.ts index 68b2755..e2268be 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -11,4 +11,5 @@ export const DEVTOOLS_ID_HEADER = 'x-inertia-devtools-id' export const DEVTOOLS_TAB_HEADER = 'x-inertia-devtools-tab' export const INITIAL_ID_TAG_SELECTOR = 'script[data-inertia-devtools-id][type="application/json"]' export const TAB_STORAGE_KEY_PREFIX = 'tab-' +export const DEVTOOLS_HOSTS_STORAGE_KEY = 'devtools-hosts' export const SESSION_TAB_ID_KEY = 'devtoolsTabId' diff --git a/src/types.ts b/src/types.ts index f80f6db..4d7cfed 100644 --- a/src/types.ts +++ b/src/types.ts @@ -170,4 +170,5 @@ export type DevToolsTestHooks = { getPageStates: (tabId: number) => Record clearAll: () => void ingest: (tabId: number, origin: string, id: string) => Promise + forgetHosts: () => Promise } diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts index 3b3d1f1..0100b8c 100644 --- a/tests/e2e/fixtures.ts +++ b/tests/e2e/fixtures.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { test as base, chromium, type BrowserContext, type Page, type Worker } from '@playwright/test' -import type { Entry, PageStateSnapshot } from '../src/types' +import type { Entry, PageStateSnapshot } from '../../src/types' const here = dirname(fileURLToPath(import.meta.url)) diff --git a/tests/unit/contract.test.ts b/tests/unit/contract.test.ts deleted file mode 100644 index 4f1c82d..0000000 --- a/tests/unit/contract.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs' -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { isEntry } from '../../src/guards' -import navigateFixture from './entry.navigate.json' - -// `entry.navigate.json` is a verbatim capture of the shape produced by the PHP package's -// `Inertia\DevTools\Data\IncomingEntry::toArray()`. It is the documented reference payload -// for the wire contract. -// -// The wire contract has three representations that must stay in lockstep: the spec below -// (the source of truth), the TypeScript `Entry` type (src/types.ts), and the reference PHP -// adapter's `IncomingEntry`. This file parses all three and asserts they agree, so a field -// or enum value added to one without the others fails CI. -// -// There is deliberately no runtime contract validator in production code: a forward-compatible -// server that adds fields must never have its entries dropped. The parity checks here run -// only in the test build; the shipped ingest guard (`isEntry`) stays loose on purpose. - -const here = dirname(fileURLToPath(import.meta.url)) -const pkgRoot = resolve(here, '../..') - -const typesSource = readFileSync(resolve(pkgRoot, 'src/types.ts'), 'utf8') - -// The reference PHP adapter is a sibling checkout on dev machines but absent in the JS-only -// CI checkout. When present we assert its shape too; when absent the JS-side parity still runs. -const phpRoot = [process.env.INERTIA_LARAVEL_DEVTOOLS_PATH, resolve(pkgRoot, '../inertia-laravel-devtools')].find( - (candidate) => candidate && existsSync(resolve(candidate, 'src/DevTools/Data/IncomingEntry.php')), -) - -const hasPhp = typeof phpRoot === 'string' - -// --------------------------------------------------------------------------- -// The spec: the single source of truth every representation is checked against. -// --------------------------------------------------------------------------- - -const META_REQUIRED = [ - 'id', - 'method', - 'url', - 'status', - 'requestType', - 'component', - 'timestamp', - 'utime', - 'tabUuid', - 'batchId', -] - -const META_OPTIONAL = ['serverTimingMs', 'redirectLocation', 'visitId'] - -const TOP_LEVEL_KEYS = ['__meta', 'http', 'props', 'propValues', 'route', 'renderSource', 'componentPath'] - -// Request types an adapter emits on the wire. The two synthetic client-only types -// (client-visit, cache-hit) are produced by the extension, never by an adapter, so they -// live in the TS union but must not appear in the PHP enum or the section 5 table. -const ADAPTER_REQUEST_TYPES = ['navigate', 'partial', 'deferred', 'poll', 'prefetch', 'initial', 'http', 'precognition'] - -const CLIENT_ONLY_REQUEST_TYPES = ['client-visit', 'cache-hit'] - -const PROP_TYPES = ['always', 'defer', 'optional', 'merge', 'scroll', 'once'] - -// --------------------------------------------------------------------------- -// Parsers: extract each representation's view of the contract from its source. -// --------------------------------------------------------------------------- - -function sliceBetween(source: string, start: string, end: string): string { - const from = source.indexOf(start) - const to = source.indexOf(end, from + start.length) - - if (from === -1 || to === -1) { - throw new Error(`Could not slice source between "${start}" and "${end}"`) - } - - return source.slice(from + start.length, to) -} - -function quotedTokens(block: string): string[] { - return [...block.matchAll(/'([^']+)'/g)].map((match) => match[1]) -} - -function tsUnionValues(typeName: string): string[] { - const marker = `export type ${typeName} =` - const start = typesSource.indexOf(marker) - - if (start === -1) { - throw new Error(`Type ${typeName} not found in types.ts`) - } - - // A union runs until the next top-level `export type` declaration. - const rest = typesSource.slice(start + marker.length) - const nextExport = rest.indexOf('\nexport ') - const block = nextExport === -1 ? rest : rest.slice(0, nextExport) - - return quotedTokens(block) -} - -function tsObjectFieldNames(typeName: string): string[] { - const block = sliceBetween(typesSource, `export type ${typeName} = {`, '\n}') - - return [...block.matchAll(/^\s*([A-Za-z_][A-Za-z0-9_]*)\??:/gm)].map((match) => match[1]) -} - -function phpEnumValues(file: string): string[] { - const contents = readFileSync(resolve(phpRoot!, 'src/DevTools/Data', file), 'utf8') - - return [...contents.matchAll(/case\s+\w+\s*=\s*'([^']+)'/g)].map((match) => match[1]) -} - -function phpArrayKeys(block: string): string[] { - return [...block.matchAll(/'([A-Za-z_][A-Za-z0-9_]*)'\s*=>/g)].map((match) => match[1]) -} - -// --------------------------------------------------------------------------- -// 1. Positive parity: all four representations agree with the spec. -// --------------------------------------------------------------------------- - -describe('entry wire contract parity', () => { - it('the reference server payload passes the ingest guard', () => { - expect(isEntry(navigateFixture)).toBe(true) - }) - - it('the reference fixture has exactly the spec top-level keys and __meta fields', () => { - const fixture = navigateFixture as Record - - expect(Object.keys(fixture).sort()).toEqual([...TOP_LEVEL_KEYS].sort()) - - const meta = fixture.__meta as Record - - for (const field of META_REQUIRED) { - expect(meta, `__meta.${field} missing from fixture`).toHaveProperty(field) - } - - const allowed = new Set([...META_REQUIRED, ...META_OPTIONAL, 'consumedAt', 'clientVisitMode']) - - for (const key of Object.keys(meta)) { - expect(allowed.has(key), `__meta.${key} in fixture is not in the spec`).toBe(true) - } - }) - - it('the TypeScript EntryMeta type declares every spec field', () => { - const fields = tsObjectFieldNames('EntryMeta') - - for (const field of [...META_REQUIRED, ...META_OPTIONAL]) { - expect(fields, `EntryMeta.${field} missing from types.ts`).toContain(field) - } - }) - - it('the TypeScript Entry type declares every spec top-level key', () => { - const fields = tsObjectFieldNames('Entry') - - for (const key of TOP_LEVEL_KEYS) { - expect(fields, `Entry.${key} missing from types.ts`).toContain(key) - } - }) - - it('the TypeScript RequestType union is the adapter types plus the client-only synthetics', () => { - expect(tsUnionValues('RequestType').sort()).toEqual([...ADAPTER_REQUEST_TYPES, ...CLIENT_ONLY_REQUEST_TYPES].sort()) - }) - - it('the TypeScript PropType union matches the spec', () => { - expect(tsUnionValues('PropType').sort()).toEqual([...PROP_TYPES].sort()) - }) - - it.skipIf(!hasPhp)('the PHP IncomingEntry::toArray() emits exactly the spec keys', () => { - const php = readFileSync(resolve(phpRoot!, 'src/DevTools/Data/IncomingEntry.php'), 'utf8') - - const toArray = sliceBetween(php, 'public function toArray(): array', '\n }') - const metaBlock = sliceBetween(toArray, "'__meta' => [", '],') - - const metaKeys = phpArrayKeys(metaBlock) - - for (const field of [...META_REQUIRED, ...META_OPTIONAL]) { - expect(metaKeys, `PHP __meta.${field} missing`).toContain(field) - } - - const topLevelBlock = toArray.slice(toArray.indexOf('],', toArray.indexOf("'__meta'")) + 2) - const topLevelKeys = phpArrayKeys(topLevelBlock) - - for (const key of TOP_LEVEL_KEYS.filter((key) => key !== '__meta')) { - expect(topLevelKeys, `PHP top-level ${key} missing`).toContain(key) - } - }) - - it.skipIf(!hasPhp)('the PHP RequestType enum matches the adapter request types', () => { - expect(phpEnumValues('RequestType.php').sort()).toEqual([...ADAPTER_REQUEST_TYPES].sort()) - }) - - it.skipIf(!hasPhp)('the PHP PropType enum matches the spec prop types', () => { - expect(phpEnumValues('PropType.php').sort()).toEqual([...PROP_TYPES].sort()) - }) -}) - -// --------------------------------------------------------------------------- -// 2. Negative + drift: the loose ingest guard must accept forward-compatible -// payloads and reject only structurally broken ones. -// --------------------------------------------------------------------------- - -describe('entry ingest guard drift tolerance', () => { - function clone(): Record { - return JSON.parse(JSON.stringify(navigateFixture)) - } - - it('rejects a payload missing a required structural block', () => { - for (const block of ['__meta', 'props', 'route']) { - const broken = clone() - delete broken[block] - - expect(isEntry(broken), `guard should reject entry missing "${block}"`).toBe(false) - } - }) - - it('rejects non-object payloads', () => { - expect(isEntry(null)).toBe(false) - expect(isEntry('entry')).toBe(false) - expect(isEntry(42)).toBe(false) - expect(isEntry([])).toBe(false) - }) - - it('accepts a new-PHP / old-extension payload carrying an unknown extra field', () => { - const forward = clone() - forward.__meta = { ...(forward.__meta as object), somethingNewInV2: 'value' } - forward.brandNewTopLevelSection = { anything: true } - - expect(isEntry(forward), 'guard must not drop entries from a newer forward-compatible server').toBe(true) - }) - - it('accepts an old-PHP / new-extension payload missing a newer optional field', () => { - const backward = clone() - const meta = backward.__meta as Record - - for (const optional of META_OPTIONAL) { - delete meta[optional] - } - - delete backward.propValues - - expect(isEntry(backward), 'guard must accept entries from an older server lacking optional fields').toBe(true) - }) - - it('accepts an unknown requestType enum value (panel maps unknowns to a fallback)', () => { - const drifted = clone() - ;(drifted.__meta as Record).requestType = 'some-future-type' - - expect(isEntry(drifted)).toBe(true) - }) - - it('accepts an unknown PropType enum value on a prop', () => { - const drifted = clone() - ;(drifted.props as Record).name = { inertiaType: 'future-prop-type', shared: false } - - expect(isEntry(drifted)).toBe(true) - }) -}) diff --git a/tests/unit/entry.navigate.json b/tests/unit/entry.navigate.json deleted file mode 100644 index 0d54341..0000000 --- a/tests/unit/entry.navigate.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "__meta": { - "id": "01JADEVTOOLS0000000000000", - "tabUuid": "tab-abc", - "batchId": null, - "timestamp": "2026-07-09T10:00:00.000Z", - "utime": 1783591200.123, - "method": "GET", - "url": "http://localhost/users", - "component": "Users/Index", - "requestType": "navigate", - "status": 200, - "redirectLocation": null, - "serverTimingMs": 12.5, - "visitId": "visit-1" - }, - "http": { - "requestHeaders": { "x-inertia": "true" }, - "responseHeaders": { "content-type": "application/json" }, - "requestBody": { "status": "empty" }, - "responseBody": { - "status": "present", - "value": { - "component": "Users/Index", - "props": { "name": "Alice" }, - "flash": { "message": "User created" } - } - } - }, - "props": { - "name": { "shared": false }, - "errors": { "inertiaType": "always", "shared": true, "shareSource": { "file": "Middleware.php", "line": 72 } } - }, - "propValues": { "name": "Alice", "errors": {} }, - "route": { - "name": "users.index", - "uri": "/users", - "action": "App\\Http\\Controllers\\UsersController@index", - "actionSource": { "file": "UsersController.php", "line": 15 } - }, - "renderSource": { "file": "UsersController.php", "line": 17 }, - "componentPath": "resources/js/Pages/Users/Index.vue" -} diff --git a/tests/unit/guards.test.ts b/tests/unit/guards.test.ts index a99b9f9..ae23851 100644 --- a/tests/unit/guards.test.ts +++ b/tests/unit/guards.test.ts @@ -16,6 +16,28 @@ describe('isEntry', () => { expect(isEntry(entry)).toBe(true) }) + + // The wire contract moves independently of the extension: an entry from a newer adapter must + // never be dropped for carrying fields or enum values this build has never seen, and one from + // an older adapter must not be dropped for lacking the newest optional fields. + it('accepts entries that drifted from a newer or an older adapter', () => { + const forward = makeEntry() as Record + forward.__meta = { ...(forward.__meta as object), requestType: 'some-future-type', newInV2: 'value' } + forward.brandNewSection = { anything: true } + + expect(isEntry(forward)).toBe(true) + + const backward = makeEntry() as Record + const meta = backward.__meta as Record + + for (const optional of ['serverTimingMs', 'redirectLocation', 'visitId']) { + delete meta[optional] + } + + delete backward.propValues + + expect(isEntry(backward)).toBe(true) + }) }) describe('isBackgroundMessage', () => { diff --git a/tests/unit/tabRules.test.ts b/tests/unit/tabRules.test.ts new file mode 100644 index 0000000..a71ac9a --- /dev/null +++ b/tests/unit/tabRules.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type SessionRule = { id: number; condition: Record; action: Record } + +type SessionRuleUpdate = { removeRuleIds?: number[]; addRules?: SessionRule[] } + +let ruleUpdates: SessionRuleUpdate[] = [] +let storage: Record = {} + +function stubChrome(openTabIds: number[] = []): void { + ruleUpdates = [] + storage = {} + + vi.stubGlobal('chrome', { + storage: { + local: { + get: async (key: string) => (key in storage ? { [key]: storage[key] } : {}), + set: async (items: Record) => { + Object.assign(storage, items) + }, + remove: async (key: string) => { + delete storage[key] + }, + }, + }, + declarativeNetRequest: { + updateSessionRules: async (update: SessionRuleUpdate) => { + ruleUpdates.push(update) + }, + ResourceType: { XMLHTTPREQUEST: 'xmlhttprequest', MAIN_FRAME: 'main_frame', SUB_FRAME: 'sub_frame' }, + RuleActionType: { MODIFY_HEADERS: 'modifyHeaders' }, + HeaderOperation: { SET: 'set' }, + }, + tabs: { query: async () => openTabIds.map((id) => ({ id })) }, + }) +} + +// hosts.ts caches the proven-host set in module scope, and browser.ts reads the global at import +// time, so both have to be imported after the stub is in place. +async function load() { + vi.resetModules() + + return { + ...(await import('../../src/background/hosts')), + ...(await import('../../src/background/tabRules')), + } +} + +function lastAddedRule(): SessionRule | undefined { + return ruleUpdates.at(-1)?.addRules?.[0] +} + +describe('writeDnrRule', () => { + beforeEach(() => stubChrome()) + + it('installs no rule while no host has proven it runs the recorder', async () => { + const { ensureTabRule } = await load() + + await ensureTabRule(7) + + expect(ruleUpdates).toEqual([{ removeRuleIds: [7] }]) + expect(typeof storage['tab-7']).toBe('string') + }) + + it('scopes the rule to the hosts that served a devtools id', async () => { + const { ensureTabRule, rememberProvenHost } = await load() + + await rememberProvenHost('http://localhost:13337') + await ensureTabRule(7) + + expect(lastAddedRule()?.condition).toMatchObject({ tabIds: [7], requestDomains: ['localhost'] }) + expect(lastAddedRule()?.action.requestHeaders).toEqual([ + { header: 'x-inertia-devtools-tab', operation: 'set', value: storage['tab-7'] }, + ]) + }) + + it('reads the hosts persisted by an earlier worker generation', async () => { + storage['devtools-hosts'] = ['app.test', 42, ''] + + const { ensureTabRule } = await load() + + await ensureTabRule(3) + + expect(lastAddedRule()?.condition.requestDomains).toEqual(['app.test']) + }) + + it('drops the rule again once the proven hosts are forgotten', async () => { + const { ensureTabRule, forgetProvenHosts, rememberProvenHost } = await load() + + await rememberProvenHost('https://app.test') + await ensureTabRule(3) + + await forgetProvenHosts() + await ensureTabRule(3) + + expect(ruleUpdates.at(-1)).toEqual({ removeRuleIds: [3] }) + }) +}) + +describe('rememberProvenHost', () => { + beforeEach(() => stubChrome()) + + it('reports whether the set grew, so rules are only rewritten when they have to be', async () => { + const { getProvenHosts, rememberProvenHost } = await load() + + expect(await rememberProvenHost('https://app.test')).toBe(true) + expect(await rememberProvenHost('https://app.test/some/path')).toBe(false) + expect(await rememberProvenHost('chrome://extensions')).toBe(false) + expect(await getProvenHosts()).toEqual(['app.test']) + }) +}) + +describe('ensureTabRule', () => { + beforeEach(() => stubChrome()) + + it('rewrites the rule of a known tab without minting a new uuid', async () => { + const { ensureTabRule, rememberProvenHost } = await load() + + const uuid = await ensureTabRule(9) + await rememberProvenHost('https://app.test') + + expect(await ensureTabRule(9)).toBe(uuid) + expect(lastAddedRule()?.action.requestHeaders).toEqual([ + { header: 'x-inertia-devtools-tab', operation: 'set', value: uuid }, + ]) + }) +}) + +describe('syncAllTabRules', () => { + it('covers every open tab when a newly proven host widens the scope', async () => { + stubChrome([1, 2]) + + const { rememberProvenHost, syncAllTabRules } = await load() + + await rememberProvenHost('https://app.test') + await syncAllTabRules() + + const domains = ruleUpdates.flatMap((update) => update.addRules ?? []).map((rule) => rule.condition.requestDomains) + + expect(domains).toEqual([['app.test'], ['app.test']]) + }) +}) From f9eeeb6651f76621793af8a71ad582272ae21d18 Mon Sep 17 00:00:00 2001 From: Pascal Baljet Date: Thu, 30 Jul 2026 10:19:24 +0200 Subject: [PATCH 2/2] Fix code style --- src/background/hosts.ts | 17 +++++++++++------ tests/unit/tabRules.test.ts | 6 ++++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/background/hosts.ts b/src/background/hosts.ts index 67215c5..975c4d7 100644 --- a/src/background/hosts.ts +++ b/src/background/hosts.ts @@ -1,11 +1,13 @@ import { browser } from '../browser' import { DEVTOOLS_HOSTS_STORAGE_KEY } from '../constants' -// Hosts that served an `x-inertia-devtools-id` header or rendered the id tag, and so proved they -// run the recorder. The tab header is a stable identifier, so it is only ever stamped on these. -// -// Persisted because the proof outlives the session rules it scopes, and cached because a rule is -// rewritten for every tab Chrome opens. +/** + * Hosts that served an `x-inertia-devtools-id` header or rendered the id tag, and so proved they + * run the recorder. The tab header is a stable identifier, so it is only ever stamped on these. + * + * Persisted because the proof outlives the session rules it scopes, and cached because a rule is + * rewritten for every tab Chrome opens. + */ let cachedHosts: Set | null = null async function loadHosts(): Promise> { @@ -25,7 +27,10 @@ export async function getProvenHosts(): Promise { return [...(await loadHosts())] } -// Origins reach this module from page-controlled messages, so only http(s) ones earn a rule. +/** + * Reduce a page-controlled origin to the hostname a DNR rule may name, or null when it is neither + * http nor https. + */ function hostnameOf(origin: string): string | null { try { const url = new URL(origin) diff --git a/tests/unit/tabRules.test.ts b/tests/unit/tabRules.test.ts index a71ac9a..a5eff0e 100644 --- a/tests/unit/tabRules.test.ts +++ b/tests/unit/tabRules.test.ts @@ -35,8 +35,10 @@ function stubChrome(openTabIds: number[] = []): void { }) } -// hosts.ts caches the proven-host set in module scope, and browser.ts reads the global at import -// time, so both have to be imported after the stub is in place. +/** + * hosts.ts caches the proven-host set in module scope, and browser.ts reads the global at import + * time, so both have to be imported after the stub is in place. + */ async function load() { vi.resetModules()