[turbopack] Add E2E tests for service workers (#94924)

This PR adds E2E test for the new service worker syntax introduced in
https://github.com/vercel/next.js/pull/94923. The tests do the
following:

`service-worker-multiple` tests that you can not have multiple service
workers with the same scope. this is because their file names would
clash; additionally, this is generally a bad practise.

`service-worker-scopes` tests that you register multiple service workers
with different scopes and they will be different files

`service-worker` tests that service workers work when registered using
the new syntax.
This commit is contained in:
Sam Poder
2026-07-06 08:57:17 -07:00
committed by GitHub
parent c2b5142640
commit 6065bf6fcf
17 changed files with 351 additions and 0 deletions
@@ -0,0 +1,8 @@
export default function AppLayout({ children }) {
return (
<html>
<head></head>
<body>{children}</body>
</html>
)
}
@@ -0,0 +1,12 @@
'use client'
import { useEffect } from 'react'
export default function Page() {
useEffect(() => {
// Registering two different service-worker files is unsupported: a single app
// serves one worker at /sw.js, so this must fail the build.
navigator.serviceWorker.register(new URL('../lib/pwa-a', import.meta.url))
navigator.serviceWorker.register(new URL('../lib/pwa-b', import.meta.url))
}, [])
return <p id="page">multiple service workers</p>
}
@@ -0,0 +1,3 @@
declare const self: ServiceWorkerGlobalScope
self.addEventListener('install', () => self.skipWaiting())
export {}
@@ -0,0 +1,3 @@
declare const self: ServiceWorkerGlobalScope
self.addEventListener('activate', () => {})
export {}
@@ -0,0 +1,6 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {}
module.exports = nextConfig
@@ -0,0 +1,31 @@
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
describe('app dir - service worker (multiple registrations error)', () => {
const { next, isTurbopack, isNextDev } = nextTestSetup({
files: __dirname,
skipStart: true,
})
if (!isTurbopack) {
// Compiling `navigator.serviceWorker.register(new URL(...))` is a
// Turbopack-only feature.
it('skips on webpack', () => {})
return
}
it('errors when different service worker files are registered', async () => {
// In production `next.start()` runs the build, which fails. In dev the server
// boots and the failure surfaces when the registering page is compiled.
await next.start().catch(() => {})
if (isNextDev) {
await next.fetch('/').catch(() => {})
}
await retry(async () => {
expect(next.cliOutput).toContain(
'Multiple service workers with different source files'
)
})
})
})
@@ -0,0 +1,8 @@
export default function AppLayout({ children }) {
return (
<html>
<head></head>
<body>{children}</body>
</html>
)
}
@@ -0,0 +1,43 @@
'use client'
import { useEffect, useState } from 'react'
export default function Page() {
const [rootScope, setRootScope] = useState('default')
const [offlineScope, setOfflineScope] = useState('default')
const [offlineScript, setOfflineScript] = useState('default')
useEffect(() => {
async function run() {
// Two different workers at two different scopes — both are allowed and are
// served at distinct, scope-derived file names.
const root = await navigator.serviceWorker.register(
new URL('../lib/pwa-root', import.meta.url)
)
setRootScope(new URL(root.scope).pathname)
const offline = await navigator.serviceWorker.register(
new URL('../lib/pwa-offline', import.meta.url),
{ scope: '/offline/mode' }
)
setOfflineScope(new URL(offline.scope).pathname)
const offlineWorker =
offline.installing || offline.waiting || offline.active
if (offlineWorker) {
setOfflineScript(new URL(offlineWorker.scriptURL).pathname)
}
}
run().catch((err) => {
setRootScope('error: ' + err.message)
setOfflineScope('error: ' + err.message)
})
}, [])
return (
<div>
<p id="root-scope">{rootScope}</p>
<p id="offline-scope">{offlineScope}</p>
<p id="offline-script">{offlineScript}</p>
</div>
)
}
@@ -0,0 +1,3 @@
declare const self: ServiceWorkerGlobalScope
self.addEventListener('install', () => self.skipWaiting())
export {}
@@ -0,0 +1,3 @@
declare const self: ServiceWorkerGlobalScope
self.addEventListener('install', () => self.skipWaiting())
export {}
@@ -0,0 +1,6 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {}
module.exports = nextConfig
@@ -0,0 +1,46 @@
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
describe('app dir - service worker (one worker per scope)', () => {
const { next, isTurbopack } = nextTestSetup({
files: __dirname,
})
if (!isTurbopack) {
it('skips on webpack', () => {})
return
}
it('serves one worker per scope at scope-derived file names', async () => {
const browser = await next.browser('/')
await retry(async () => {
expect(await browser.elementByCss('#root-scope').text()).toBe('/')
})
await retry(async () => {
expect(await browser.elementByCss('#offline-scope').text()).toBe(
'/offline/mode'
)
})
// The root scope "/" is served unhashed at /sw.js. Both workers are served
// as mutable, always-revalidated assets (never immutable).
const root = await next.fetch('/sw.js')
expect(root.status).toBe(200)
expect(root.headers.get('cache-control')).toContain('max-age=0')
expect(root.headers.get('cache-control')).not.toContain('immutable')
// scope "/offline/mode" -> a distinct, scope-derived file name. The slug is
// lossy, so a hash of the scope is appended to keep file names unique.
let offlineScript = ''
await retry(async () => {
offlineScript = await browser.elementByCss('#offline-script').text()
expect(offlineScript).toMatch(/^\/sw-offline-mode-[0-9a-f]+\.js$/)
})
const offline = await next.fetch(offlineScript)
expect(offline.status).toBe(200)
expect(offline.headers.get('cache-control')).toContain('max-age=0')
expect(offline.headers.get('cache-control')).not.toContain('immutable')
})
})
@@ -0,0 +1,8 @@
export default function AppLayout({ children }) {
return (
<html>
<head></head>
<body>{children}</body>
</html>
)
}
@@ -0,0 +1,59 @@
'use client'
import { useEffect, useState } from 'react'
export default function Page() {
const [scope, setScope] = useState('default')
const [controller, setController] = useState('default')
const [script, setScript] = useState('default')
const [fetchResult, setFetchResult] = useState('default')
useEffect(() => {
if (!('serviceWorker' in navigator)) {
setController('unsupported')
return
}
async function register() {
const registration = await navigator.serviceWorker.register(
new URL('../lib/pwa', import.meta.url)
)
setScope(registration.scope)
await navigator.serviceWorker.ready
const updateController = () => {
const active = navigator.serviceWorker.controller
setController(active ? 'controlled' : 'none')
if (active) {
setScript(new URL(active.scriptURL).pathname)
}
}
updateController()
navigator.serviceWorker.addEventListener(
'controllerchange',
updateController
)
}
register().catch((err) => {
setController('error: ' + err.message)
})
}, [])
return (
<div>
<p id="sw-scope">{scope}</p>
<p id="sw-controller">{controller}</p>
<p id="sw-script">{script}</p>
<button
onClick={async () => {
const res = await fetch('/sw-intercepted')
setFetchResult(await res.text())
}}
>
Fetch through service worker
</button>
<p id="fetch-result">{fetchResult}</p>
</div>
)
}
@@ -0,0 +1,26 @@
/// <reference lib="webworker" />
// A minimal service worker used to verify that
// `navigator.serviceWorker.register(new URL(...))` is compiled and served
// correctly. It claims clients immediately and intercepts a sentinel request.
declare const self: ServiceWorkerGlobalScope
self.addEventListener('install', () => {
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim())
})
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
if (url.pathname === '/sw-intercepted') {
event.respondWith(
new Response('intercepted-by-sw', {
headers: { 'content-type': 'text/plain' },
})
)
}
})
export {}
@@ -0,0 +1,6 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {}
module.exports = nextConfig
@@ -0,0 +1,80 @@
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
describe('app dir - service worker register', () => {
const { next, isTurbopack } = nextTestSetup({
files: __dirname,
})
if (!isTurbopack) {
// Compiling `navigator.serviceWorker.register(new URL(...))` is a
// Turbopack-only feature.
it('skips on webpack', () => {})
return
}
it('registers the service worker and controls the page at scope "/"', async () => {
const browser = await next.browser('/')
await retry(async () => {
expect(await browser.elementByCss('#sw-controller').text()).toBe(
'controlled'
)
})
// Served from the origin root, so the registration scope is the whole origin
// with no Service-Worker-Allowed header needed.
const scope = await browser.elementByCss('#sw-scope').text()
expect(new URL(scope).pathname).toBe('/')
// A single service worker per app is served at the fixed, root-scoped /sw.js.
const script = await browser.elementByCss('#sw-script').text()
expect(script).toBe('/sw.js')
})
it('serves the worker chunk at the root as a revalidated, mutable asset', async () => {
const browser = await next.browser('/')
await retry(async () => {
expect(await browser.elementByCss('#sw-script').text()).toBe('/sw.js')
})
const res = await next.fetch('/sw.js')
expect(res.status).toBe(200)
// Stable URL across builds: it must be served as a mutable asset (never
// immutable) that is revalidated on every use, so a new worker ships
// immediately rather than being pinned by a long-lived cache entry.
const cacheControl = res.headers.get('cache-control')
expect(cacheControl).not.toContain('immutable')
expect(cacheControl).toContain('max-age=0')
// Revalidated on every use, so it must ship an ETag to turn those
// revalidations into cheap 304s instead of re-downloading the worker body.
const etag = res.headers.get('etag')
expect(etag).toBeTruthy()
// A conditional re-fetch with the ETag returns 304 Not Modified (no body),
// confirming we don't over-fetch an unchanged worker.
const conditional = await next.fetch('/sw.js', {
headers: { 'If-None-Match': etag },
})
expect(conditional.status).toBe(304)
})
it('intercepts fetches within scope', async () => {
const browser = await next.browser('/')
await retry(async () => {
expect(await browser.elementByCss('#sw-controller').text()).toBe(
'controlled'
)
})
await browser.elementByCss('button').click()
await retry(async () => {
expect(await browser.elementByCss('#fetch-result').text()).toBe(
'intercepted-by-sw'
)
})
})
})