import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Acceptance test for the service worker, driven from the outside: the module
* is imported against a stubbed `self`/`caches`/`fetch`, and exercised only
* through the 'install'/'activate'/'fetch' listeners it registers.
*
* The behaviour under test is a privacy bug: the SW used to cache every OK
* same-origin page response and replay it offline. Page HTML and
* `__data.json` carry the signed-in user's session (handle, avatar, DID), so
* the replay hands the next user of a shared device the previous user's
* session. The SW must never store or serve those responses — only the
* precached build/static assets.
*/
const ORIGIN = 'http://localhost:5173'
/**
* Imported through a variable specifier on purpose. `src/service-worker.ts` is
* excluded from tsconfig (SvelteKit's default): it declares
* `no-default-lib` + the webworker lib, so pulling it into the app's type
* program strips the DOM lib from every other file. Vite still resolves this
* at runtime; TypeScript leaves it alone.
*/
const SERVICE_WORKER = './service-worker'
// Hoisted so the `$service-worker` factory (which vitest lifts above the
// imports) can close over them, and the assertions can reuse the same lists.
const { BUILD, FILES, VERSION } = vi.hoisted(() => ({
BUILD: ['/_app/immutable/app.js', '/_app/immutable/chunk.js'],
FILES: ['/favicon.png'],
VERSION: 'test-version',
}))
// The real virtual module only exists inside a ServiceWorkerGlobalScope.
vi.mock('$service-worker', () => ({
build: BUILD,
files: FILES,
version: VERSION,
prerendered: [],
base: '',
}))
/**
* The worker's cache names: the security epoch, then the deployment. `e1`
* mirrors `SECURITY_EPOCH` in the worker; bump the two together.
*/
const EPOCH_PREFIX = 'cache-e1-'
const CURRENT_CACHE = `${EPOCH_PREFIX}${VERSION}`
/**
* A response as the Cache returns one the worker fetched itself: same-origin,
* so `basic`. A constructed `new Response` is `default`, which is what a page
* script writing into the cache directly would leave behind.
*/
function fetchedResponse(body: string, init?: ResponseInit): Response {
return Object.defineProperty(new Response(body, init), 'type', {
value: 'basic',
})
}
const SESSION_HTML =
'
mari'
const SESSION_DATA_JSON = '{"session":{"handle":"mari.local.coves.dev"}}'
/**
* Cache keys are pathnames, which is how the SW looks assets up
* (`cache.match('/favicon.png')`). Keying a whole Request the same way lets a
* test seed an entry the way the deleted runtime cache used to, and matches
* what the real Cache does for same-origin requests.
*/
function cacheKey(request: RequestInfo | URL): string {
const url = request instanceof Request ? request.url : String(request)
return new URL(url, ORIGIN).pathname
}
class MockCache {
readonly entries = new Map()
async addAll(requests: readonly string[]): Promise {
for (const request of requests) {
this.entries.set(cacheKey(request), new Response(`asset:${request}`))
}
}
async put(request: RequestInfo | URL, response: Response): Promise {
this.entries.set(cacheKey(request), response)
}
async match(request: RequestInfo | URL): Promise {
return this.entries.get(cacheKey(request))
}
}
class MockCacheStorage {
readonly caches = new Map()
async open(name: string): Promise {
const existing = this.caches.get(name)
if (existing) return existing
const cache = new MockCache()
this.caches.set(name, cache)
return cache
}
async keys(): Promise {
return [...this.caches.keys()]
}
async delete(name: string): Promise {
return this.caches.delete(name)
}
/** Every pathname held across every cache, sorted for stable assertions. */
storedPaths(): string[] {
return [...this.caches.values()]
.flatMap((cache) => [...cache.entries.keys()])
.sort()
}
}
type SwListener = (event: unknown) => void
/** What the SW did with one request, as a string so failures read clearly. */
type FetchOutcome = 'declined' | 'rejected' | `served: ${string}`
describe('service worker', () => {
let addEventListener: ReturnType<
typeof vi.fn<(type: string, listener: SwListener) => void>
>
let cacheStorage: MockCacheStorage
let fetchMock: ReturnType Promise>>
let skipWaiting: ReturnType void>>
let clientsClaim: ReturnType Promise>>
beforeEach(async () => {
vi.resetModules()
vi.spyOn(console, 'info').mockImplementation(() => {})
addEventListener = vi.fn<(type: string, listener: SwListener) => void>()
cacheStorage = new MockCacheStorage()
fetchMock = vi.fn<(input: Request) => Promise>()
skipWaiting = vi.fn<() => void>()
clientsClaim = vi.fn<() => Promise>().mockResolvedValue(undefined)
// Stubbing must precede the import: the module registers its listeners on
// `self` at module-evaluation time.
vi.stubGlobal('self', {
addEventListener,
location: { origin: ORIGIN },
skipWaiting,
clients: { claim: clientsClaim },
})
vi.stubGlobal('caches', cacheStorage)
vi.stubGlobal('fetch', fetchMock)
await import(/* @vite-ignore */ SERVICE_WORKER)
})
// `self`, `caches` and `fetch` are real globals in some environments; leaving
// the stubs attached would leak this suite's fakes into every later file.
afterEach(() => {
vi.unstubAllGlobals()
})
function listenerFor(type: string): SwListener {
const call = addEventListener.mock.calls.find(([name]) => name === type)
if (!call)
throw new Error(`service worker registered no '${type}' listener`)
return call[1]
}
/** Dispatches a lifecycle event and awaits whatever it passed to waitUntil. */
async function dispatchLifecycle(
type: 'install' | 'activate',
extra: Record = {},
): Promise {
const pending: Promise[] = []
listenerFor(type)({
waitUntil: (promise: Promise) => pending.push(promise),
...extra,
})
await Promise.all(pending)
}
/**
* Dispatches a fetch event. The SW may legitimately decline a request by
* returning without calling respondWith — the browser then handles it — so
* that is a first-class outcome here, not a failure.
*/
async function dispatchFetch(
input: string | Request,
init?: RequestInit,
): Promise {
const request = input instanceof Request ? input : new Request(input, init)
let responded: Promise | undefined
const respondWith = vi.fn((promise: Promise) => {
responded = promise
})
listenerFor('fetch')({ request, respondWith })
if (!responded) return 'declined'
try {
return `served: ${await (await responded).text()}`
} catch {
return 'rejected'
}
}
it('never serves a cached page or data response, online or offline', async () => {
await dispatchLifecycle('install')
await dispatchLifecycle('activate')
// The network is primed with session-bearing HTML and data JSON only to
// prove it is never consulted: these are the browser's requests to make,
// and re-issuing them from here would change their credential and redirect
// semantics for no gain.
fetchMock.mockImplementation(async (request) =>
new URL(request.url).pathname.endsWith('.json')
? new Response(SESSION_DATA_JSON, {
headers: { 'content-type': 'application/json' },
})
: new Response(SESSION_HTML, {
headers: { 'content-type': 'text/html' },
}),
)
const online = [
await dispatchFetch(`${ORIGIN}/`),
await dispatchFetch(`${ORIGIN}/feed/__data.json`),
]
expect(online).toEqual(['declined', 'declined'])
expect(fetchMock).not.toHaveBeenCalled()
// Offline: every network attempt fails.
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
const offline = [
await dispatchFetch(`${ORIGIN}/`),
await dispatchFetch(`${ORIGIN}/feed/__data.json`),
]
// Declining or erroring are both acceptable; serving a body is not, because
// the only body available offline is the previous user's session.
expect(offline).toEqual([
expect.stringMatching(/^(declined|rejected)$/),
expect.stringMatching(/^(declined|rejected)$/),
])
// And nothing but the precached assets was ever written to disk.
expect(cacheStorage.storedPaths()).toEqual([...BUILD, ...FILES].sort())
})
it('never writes a page or data response to the cache', async () => {
await dispatchLifecycle('install')
fetchMock.mockImplementation(
async (request) =>
new Response(
new URL(request.url).pathname.endsWith('.json')
? SESSION_DATA_JSON
: SESSION_HTML,
),
)
// Whether the SW declines these or relays the network response is its
// business; writing them to disk is not.
await dispatchFetch(`${ORIGIN}/`)
await dispatchFetch(`${ORIGIN}/feed/__data.json`)
expect(cacheStorage.storedPaths()).toEqual([...BUILD, ...FILES].sort())
})
it('serves a precached asset from the cache without asking the network', async () => {
await dispatchLifecycle('install')
const outcome = await dispatchFetch(`${ORIGIN}${BUILD[0]}`)
expect(outcome).toBe(`served: asset:${BUILD[0]}`)
expect(fetchMock).not.toHaveBeenCalled()
})
it('takes over from the previous worker and purges its cache first', async () => {
// A previous deployment's cache, still holding the page copy the old
// worker used to replay. Until this worker claims the open clients, those
// pages keep talking to it.
const stale = await cacheStorage.open('cache-old')
await stale.put(`${ORIGIN}/`, new Response(SESSION_HTML))
// Recorded at call time so the ordering is checked, not just the end
// state: claiming clients before the purge would hand them the stale cache.
let cachesWhenClaimed: string[] = []
clientsClaim.mockImplementation(async () => {
cachesWhenClaimed = await cacheStorage.keys()
})
await dispatchLifecycle('install')
expect(skipWaiting).toHaveBeenCalled()
await dispatchLifecycle('activate')
expect(clientsClaim).toHaveBeenCalled()
expect(cachesWhenClaimed).toEqual([CURRENT_CACHE])
expect(cacheStorage.storedPaths()).toEqual([...BUILD, ...FILES].sort())
})
it('reuses unchanged build files from the previous deployment', async () => {
// A returning visitor: the last deployment's worker already stored one of
// these content-hashed files, and the new deployment still ships it. The
// other is new in this deployment.
const old = await cacheStorage.open(`${EPOCH_PREFIX}old`)
await old.put(`${ORIGIN}${BUILD[0]}`, fetchedResponse('kept'))
const addAll = vi.spyOn(MockCache.prototype, 'addAll')
await dispatchLifecycle('install')
await dispatchLifecycle('activate')
// Only what the old cache could not supply goes to the network.
expect(addAll).toHaveBeenCalledWith([BUILD[1], ...FILES])
expect(await dispatchFetch(`${ORIGIN}${BUILD[0]}`)).toBe('served: kept')
expect(await dispatchFetch(`${ORIGIN}${BUILD[1]}`)).toBe(
`served: asset:${BUILD[1]}`,
)
expect(cacheStorage.storedPaths()).toEqual([...BUILD, ...FILES].sort())
})
it('reuses nothing cached under a different security epoch', async () => {
// Bumping the epoch is how a deploy that fixes an XSS evicts whatever the
// XSS may have written into the cache, so neither an older epoch's cache
// nor one named before epochs existed may supply anything.
const olderEpoch = await cacheStorage.open(`cache-e0-old`)
await olderEpoch.put(`${ORIGIN}${BUILD[0]}`, fetchedResponse('poisoned'))
const preEpoch = await cacheStorage.open('cache-old')
await preEpoch.put(`${ORIGIN}${BUILD[1]}`, fetchedResponse('poisoned'))
const addAll = vi.spyOn(MockCache.prototype, 'addAll')
await dispatchLifecycle('install')
expect(addAll).toHaveBeenCalledWith([...BUILD, ...FILES])
})
it('reuses only successful responses the worker fetched itself', async () => {
const old = await cacheStorage.open(`${EPOCH_PREFIX}old`)
await old.put(
`${ORIGIN}${BUILD[0]}`,
fetchedResponse('not found', { status: 404 }),
)
// Written by a page script, not fetched: `default`, not `basic`.
await old.put(`${ORIGIN}${BUILD[1]}`, new Response('constructed'))
const addAll = vi.spyOn(MockCache.prototype, 'addAll')
await dispatchLifecycle('install')
expect(addAll).toHaveBeenCalledWith([...BUILD, ...FILES])
})
it('downloads everything when the old caches cannot be listed', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const old = await cacheStorage.open(`${EPOCH_PREFIX}old`)
await old.put(`${ORIGIN}${BUILD[0]}`, fetchedResponse('kept'))
vi.spyOn(cacheStorage, 'keys').mockRejectedValueOnce(
new DOMException('blocked', 'SecurityError'),
)
const addAll = vi.spyOn(MockCache.prototype, 'addAll')
await dispatchLifecycle('install')
expect(addAll).toHaveBeenCalledWith([...BUILD, ...FILES])
expect(warn).toHaveBeenCalled()
})
it('downloads an asset whose old copy cannot be read', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const old = await cacheStorage.open(`${EPOCH_PREFIX}old`)
await old.put(`${ORIGIN}${BUILD[0]}`, fetchedResponse('kept'))
vi.spyOn(old, 'match').mockRejectedValue(new TypeError('read failed'))
const addAll = vi.spyOn(MockCache.prototype, 'addAll')
await dispatchLifecycle('install')
expect(addAll).toHaveBeenCalledWith([...BUILD, ...FILES])
expect(warn).toHaveBeenCalled()
})
it('reports an old copy it could not carry over', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const old = await cacheStorage.open(`${EPOCH_PREFIX}old`)
await old.put(`${ORIGIN}${BUILD[0]}`, fetchedResponse('kept'))
vi.spyOn(MockCache.prototype, 'put').mockRejectedValueOnce(
new DOMException('quota', 'QuotaExceededError'),
)
const addAll = vi.spyOn(MockCache.prototype, 'addAll')
await dispatchLifecycle('install')
expect(addAll).toHaveBeenCalledWith([...BUILD, ...FILES])
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('[sw]'),
BUILD[0],
expect.any(DOMException),
)
})
it('carries nothing but content-hashed build files across deployments', async () => {
// `static/` files keep their names when their bytes change, and the old
// runtime cache may still hold a session-bearing page.
const old = await cacheStorage.open('cache-old')
await old.put(`${ORIGIN}${FILES[0]}`, new Response('stale static'))
await old.put(`${ORIGIN}/`, new Response(SESSION_HTML))
await dispatchLifecycle('install')
await dispatchLifecycle('activate')
expect(await dispatchFetch(`${ORIGIN}${FILES[0]}`)).toBe(
`served: asset:${FILES[0]}`,
)
expect(cacheStorage.storedPaths()).toEqual([...BUILD, ...FILES].sort())
})
it('sends navigations straight to the network where static routing exists', async () => {
const addRoutes = vi.fn(async (_rules: readonly object[]) => {})
await dispatchLifecycle('install', { addRoutes })
expect(addRoutes).toHaveBeenCalledOnce()
expect(addRoutes.mock.calls[0][0]).toContainEqual({
condition: { requestMode: 'navigate' },
source: 'network',
})
// The precache is unaffected.
expect(cacheStorage.storedPaths()).toEqual([...BUILD, ...FILES].sort())
})
it('still installs when the browser rejects the routes', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const addRoutes = vi.fn(async () => {
throw new TypeError('unsupported condition')
})
await dispatchLifecycle('install', { addRoutes })
expect(cacheStorage.storedPaths()).toEqual([...BUILD, ...FILES].sort())
expect(warn).toHaveBeenCalled()
})
it('still precaches when building the static routes throws', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.stubGlobal(
'URLPattern',
class {
constructor() {
throw new TypeError('unsupported pattern')
}
},
)
const addRoutes = vi.fn(async (_rules: readonly object[]) => {})
await dispatchLifecycle('install', { addRoutes })
expect(cacheStorage.storedPaths()).toEqual([...BUILD, ...FILES].sort())
expect(warn).toHaveBeenCalled()
})
it('falls back to the network when the cache layer is unavailable', async () => {
await dispatchLifecycle('install')
// Private browsing and some enterprise policies reject caches.open
// outright. respondWith has already committed us to answering, so an
// unhandled rejection here is a broken page, not a degraded one.
vi.spyOn(cacheStorage, 'open').mockRejectedValue(
new DOMException('blocked', 'SecurityError'),
)
fetchMock.mockResolvedValue(new Response('net'))
expect(await dispatchFetch(`${ORIGIN}${BUILD[0]}`)).toBe('served: net')
})
it('reports a failed precache rather than failing silently', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const failure = new TypeError('Failed to fetch /_app/immutable/app.js')
vi.spyOn(MockCache.prototype, 'addAll').mockRejectedValue(failure)
// The install must still fail — a worker whose precache is half-filled
// must not go on to activate — but it must say why.
await expect(dispatchLifecycle('install')).rejects.toThrow(failure)
expect(errorSpy).toHaveBeenCalled()
})
it('declines a cross-origin request for an asset path', async () => {
await dispatchLifecycle('install')
// Same pathname as a precached asset, different host: re-issuing it from
// here would run it under this script's policy, not the page's.
expect(await dispatchFetch(`https://cdn.example${BUILD[0]}`)).toBe(
'declined',
)
expect(fetchMock).not.toHaveBeenCalled()
})
it('declines a non-GET request', async () => {
await dispatchLifecycle('install')
expect(
await dispatchFetch(`${ORIGIN}${BUILD[0]}`, { method: 'POST' }),
).toBe('declined')
expect(fetchMock).not.toHaveBeenCalled()
})
it('falls back to the network for an asset the precache is missing', async () => {
// No install: the precache was evicted, or never finished.
fetchMock.mockResolvedValue(new Response('net'))
expect(await dispatchFetch(`${ORIGIN}${BUILD[0]}`)).toBe('served: net')
// A miss must not be repaired by writing the response back — that is the
// runtime cache this fix removed.
expect(cacheStorage.storedPaths()).toEqual([])
})
})