mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
[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:
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user