Something went wrong. Try again.
Browser extension: detect and subscribe to standard.site publications on ATProto
Something went wrong. Try again.
6.2 kB · 186 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187// 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<typeof vi.fn> location: { href: string } links: Record<string, string> 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<void> { 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) }) })})