diff --git a/src/content.test.ts b/src/content.test.ts new file mode 100644 index 0000000..c945749 --- /dev/null +++ b/src/content.test.ts @@ -0,0 +1,186 @@ +// The content script is a side-effecting global script: importing it is what +// runs it. Each case builds the browser globals it reads, then imports a fresh +// copy. + +import { afterEach, describe, expect, it, vi } from 'vitest' + +interface Fake { + sendMessage: ReturnType + location: { href: string } + links: Record + navigation?: EventTarget + observers: { target: unknown; options: unknown; fire: () => void }[] + orphan: () => void +} + +function fakeWorld(opts: { navigationApi: boolean }): Fake { + const sendMessage = vi.fn(async () => undefined) + const runtime: { id?: string } = { id: 'ext-id' } + const world: Fake = { + sendMessage, + location: { href: 'https://example.com/one' }, + links: {}, + navigation: opts.navigationApi ? new EventTarget() : undefined, + observers: [], + orphan: () => { + delete runtime.id + }, + } + + vi.stubGlobal('chrome', { + runtime: { + get id() { + return runtime.id + }, + sendMessage, + onMessage: { addListener: vi.fn() }, + }, + }) + vi.stubGlobal('location', world.location) + vi.stubGlobal('document', { + querySelector(selector: string) { + const rel = /link\[rel="(.+)"\]/.exec(selector)?.[1] ?? '' + const href = world.links[rel] + return href === undefined ? null : { getAttribute: () => href } + }, + }) + vi.stubGlobal('navigation', world.navigation) + vi.stubGlobal( + 'MutationObserver', + class { + constructor(private cb: () => void) {} + observe(target: unknown, options: unknown) { + world.observers.push({ target, options, fire: () => this.cb() }) + } + disconnect() { + world.observers.length = 0 + } + }, + ) + return world +} + +async function load(): Promise { + vi.resetModules() + await import('./content') +} + +afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +describe('content script', () => { + it('reports the page hints on load', async () => { + const world = fakeWorld({ navigationApi: true }) + world.links['site.standard.publication'] = 'at://did:plc:pub/site.standard.publication/self' + await load() + expect(world.sendMessage).toHaveBeenCalledWith({ + type: 'page-hints', + pubHint: 'at://did:plc:pub/site.standard.publication/self', + docHint: undefined, + }) + }) + + it('does not watch the document for mutations when the Navigation API exists', async () => { + const world = fakeWorld({ navigationApi: true }) + await load() + expect(world.observers).toHaveLength(0) + }) + + it('reports again after an SPA navigation', async () => { + const world = fakeWorld({ navigationApi: true }) + await load() + world.sendMessage.mockClear() + + world.location.href = 'https://example.com/two' + world.links['site.standard.document'] = 'at://did:plc:doc/site.standard.document/two' + world.navigation?.dispatchEvent(new Event('navigatesuccess')) + + expect(world.sendMessage).toHaveBeenCalledWith({ + type: 'page-hints', + pubHint: undefined, + docHint: 'at://did:plc:doc/site.standard.document/two', + }) + }) + + it('re-reads once after the navigation settles, for routers that swap the head late', async () => { + vi.useFakeTimers() + const world = fakeWorld({ navigationApi: true }) + await load() + world.sendMessage.mockClear() + + world.location.href = 'https://example.com/two' + world.navigation?.dispatchEvent(new Event('navigatesuccess')) + expect(world.sendMessage).toHaveBeenCalledTimes(1) + + // The router swaps the link tag a beat after the URL changed. + world.links['site.standard.publication'] = 'at://did:plc:late/site.standard.publication/self' + vi.advanceTimersByTime(1000) + expect(world.sendMessage).toHaveBeenCalledTimes(2) + expect(world.sendMessage).toHaveBeenLastCalledWith({ + type: 'page-hints', + pubHint: 'at://did:plc:late/site.standard.publication/self', + docHint: undefined, + }) + }) + + // Chrome fires navigatesuccess for the initial page load as well, just after + // the load event, so every page would otherwise report itself twice. + it('sends nothing when a navigation changes neither the url nor the hints', async () => { + vi.useFakeTimers() + const world = fakeWorld({ navigationApi: true }) + await load() + world.sendMessage.mockClear() + + world.navigation?.dispatchEvent(new Event('navigatesuccess')) + vi.advanceTimersByTime(1000) + expect(world.sendMessage).not.toHaveBeenCalled() + }) + + it('drops its navigation listener once the extension context is gone', async () => { + const world = fakeWorld({ navigationApi: true }) + await load() + world.sendMessage.mockClear() + + world.orphan() + world.location.href = 'https://example.com/two' + world.navigation?.dispatchEvent(new Event('navigatesuccess')) + expect(world.sendMessage).not.toHaveBeenCalled() + + // The listener removed itself, so a later navigation cannot revive it even + // once a reloaded extension makes runtime.id truthy again. + vi.stubGlobal('chrome', { + runtime: { id: 'ext-id', sendMessage: world.sendMessage, onMessage: { addListener: vi.fn() } }, + }) + world.location.href = 'https://example.com/three' + world.navigation?.dispatchEvent(new Event('navigatesuccess')) + expect(world.sendMessage).not.toHaveBeenCalled() + }) + + describe('without the Navigation API', () => { + it('falls back to the document mutation watch', async () => { + const world = fakeWorld({ navigationApi: false }) + await load() + world.sendMessage.mockClear() + expect(world.observers).toHaveLength(1) + expect(world.observers[0]?.options).toEqual({ subtree: true, childList: true }) + + world.observers[0]?.fire() + expect(world.sendMessage).not.toHaveBeenCalled() + + world.location.href = 'https://example.com/two' + world.observers[0]?.fire() + expect(world.sendMessage).toHaveBeenCalledTimes(1) + }) + + it('disconnects the observer once the extension context is gone', async () => { + const world = fakeWorld({ navigationApi: false }) + await load() + const observer = world.observers[0] + world.orphan() + observer?.fire() + expect(world.observers).toHaveLength(0) + }) + }) +}) diff --git a/src/content.ts b/src/content.ts index 73fe231..eb49601 100644 --- a/src/content.ts +++ b/src/content.ts @@ -1,6 +1,15 @@ // Reads standard.site link-tag hints from the page head and reports them to // the background worker. Kept dependency-free; built as an IIFE. +// No exports — this only marks the file as a module, so src/content.test.ts +// can import it and so the declaration below stays out of the global scope. +export {} + +// The Navigation API is not in TypeScript's DOM lib yet (5.9 ships only +// NavigationHistoryEntry), so declare the sliver we use. Access is guarded by +// `typeof`, which is safe for an identifier the browser does not define. +declare const navigation: EventTarget | undefined + function hint(rel: string): string | undefined { const el = document.querySelector(`link[rel="${rel}"]`) const href = el?.getAttribute('href')?.trim() @@ -14,17 +23,28 @@ function orphaned(): boolean { return !chrome.runtime?.id } +function readHints(): { pubHint?: string; docHint?: string } { + return { + pubHint: hint('site.standard.publication'), + docHint: hint('site.standard.document'), + } +} + +// Detection is keyed on the page URL and the two hints; re-sending an +// unchanged triple would only make the worker redo the same lookups. Chrome +// fires a navigatesuccess for the initial page load too — measured, it lands +// just after the load event — so without this every page would report twice. +let lastReported: string | undefined + function report() { if (orphaned()) return - chrome.runtime - .sendMessage({ - type: 'page-hints', - pubHint: hint('site.standard.publication'), - docHint: hint('site.standard.document'), - }) - .catch(() => { - // background worker not ready; it will probe on demand instead - }) + const hints = readHints() + const key = `${location.href}\n${hints.pubHint ?? ''}\n${hints.docHint ?? ''}` + if (key === lastReported) return + lastReported = key + chrome.runtime.sendMessage({ type: 'page-hints', ...hints }).catch(() => { + // background worker not ready; it will probe on demand instead + }) } report() @@ -32,23 +52,54 @@ report() // The background worker asks for hints again on manual refresh. chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg?.type === 'read-hints') { - sendResponse({ - pubHint: hint('site.standard.publication'), - docHint: hint('site.standard.document'), - }) + sendResponse(readHints()) } }) // SPA navigations swap head tags without a page load. -let lastUrl = location.href -const observer = new MutationObserver(() => { - if (orphaned()) { - observer.disconnect() - return - } - if (location.href !== lastUrl) { - lastUrl = location.href +// +// The Navigation API reports each navigation once — pushState, replaceState, +// fragment changes and back/forward included — so this script costs the pages +// it runs on one listener instead of a callback per DOM mutation. Measured on +// bsky.app (headless Chrome 149, ~20s of scrolling and one in-app link click), +// the observer this replaces ran 66 times over 448 mutation records to notice +// one URL change; the listener below runs twice. +// +// `navigatesuccess` fires once the navigation has committed, so location.href +// is already the new one. Routers often swap the head tags a tick later, so +// read again after the page settles; report() drops that second read unless +// something actually changed. +const SETTLE_MS = 400 +let settleTimer: ReturnType | undefined + +if (typeof navigation !== 'undefined') { + const onNavigated = () => { + if (orphaned()) { + navigation.removeEventListener('navigatesuccess', onNavigated) + clearTimeout(settleTimer) + return + } + console.debug('[substandard] spa navigation', location.href) report() + clearTimeout(settleTimer) + settleTimer = setTimeout(report, SETTLE_MS) } -}) -observer.observe(document, { subtree: true, childList: true }) + navigation.addEventListener('navigatesuccess', onNavigated) +} else { + // Browsers without the Navigation API (it is Chrome-only as of writing). + // A content script runs in an isolated world, where patching pushState is + // invisible to the page, so there is nothing cheaper to fall back to than + // the mutation watch this replaces. + let lastUrl = location.href + const observer = new MutationObserver(() => { + if (orphaned()) { + observer.disconnect() + return + } + if (location.href !== lastUrl) { + lastUrl = location.href + report() + } + }) + observer.observe(document, { subtree: true, childList: true }) +}