// 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) }) }) })