diff --git a/packages/kit/src/exports/node/index.spec.js b/packages/kit/src/exports/node/index.spec.js index 6d8c751b588f..8327660e7895 100644 --- a/packages/kit/src/exports/node/index.spec.js +++ b/packages/kit/src/exports/node/index.spec.js @@ -1,4 +1,5 @@ import { EventEmitter, once } from 'node:events'; +import { validateHeaderValue } from 'node:http'; import { PassThrough } from 'node:stream'; import { expect, test, vi } from 'vitest'; import { getRequest, setResponse } from './index.js'; @@ -92,11 +93,17 @@ function create_response(req) { res.destroyed = false; res.headers = new Map(); res.chunks = []; - res.setHeader = (/** @type {string} */ name, /** @type {unknown} */ value) => + res.setHeader = (/** @type {string} */ name, /** @type {string} */ value) => { + validateHeaderValue(name, value); res.headers.set(name.toLowerCase(), value); + }; res.hasHeader = (/** @type {string} */ name) => res.headers.has(name.toLowerCase()); res.getHeaderNames = () => [...res.headers.keys()]; - res.writeHead = () => res; + res.removeHeader = (/** @type {string} */ name) => res.headers.delete(name.toLowerCase()); + res.writeHead = (/** @type {number} */ status) => { + res.statusCode = status; + return res; + }; res.write = (/** @type {unknown} */ chunk) => { res.chunks.push(chunk); return true; @@ -255,6 +262,18 @@ test('does not abort the request signal when the response finishes normally', as expect(request.signal.aborted).toBe(false); }); +test('responds with a 500 when Node rejects a header, instead of leaving the request open', async () => { + const res = /** @type {any} */ (create_response()); + const finished = once(res, 'finish'); + + setResponse(res, new Response('{}', { headers: { 'x-test': '\u001f' } })); + + await finished; + expect(res.statusCode).toBe(500); + expect(res.headers.size).toBe(0); + expect(Buffer.concat(res.chunks.map(Buffer.from)).toString()).toMatch('ERR_INVALID_CHAR'); +}); + test('sends fixed response bodies with a content-length', async () => { const res = /** @type {any} */ (create_response()); const finished = once(res, 'finish'); diff --git a/packages/kit/test/apps/basics/package.json b/packages/kit/test/apps/basics/package.json index ee230f92f808..726625db498c 100644 --- a/packages/kit/test/apps/basics/package.json +++ b/packages/kit/test/apps/basics/package.json @@ -8,8 +8,8 @@ "preview": "vite preview", "check": "svelte-kit sync && node node_modules/@typescript/native/bin/tsc && svelte-check --tsgo --incremental", "test": "pnpm test:dev && pnpm test:build", - "test:dev": "node test/setup.js && DEV=true playwright test", - "test:build": "node test/setup.js && PUBLIC_PRERENDERING=false playwright test", + "test:dev": "node test/setup.js && DEV=true playwright test && KIT_TEST_DEV=true vitest run -c vitest.server.config.js", + "test:build": "node test/setup.js && PUBLIC_PRERENDERING=false playwright test && vitest run -c vitest.server.config.js", "test:cross-platform:dev": "pnpm test:client-import && node test/setup.js && DEV=true playwright test test/cross-platform/", "test:cross-platform:build": "node test/setup.js && playwright test test/cross-platform/", "test:server-side-route-resolution:dev": "node test/setup.js && DEV=true ROUTER_RESOLUTION=server playwright test", @@ -25,6 +25,7 @@ "@sveltejs/kit": "workspace:^", "@sveltejs/vite-plugin-svelte": "catalog:", "@vitest/browser-playwright": "catalog:", + "jsdom": "catalog:", "svelte": "catalog:", "svelte-check": "catalog:", "test-redirect-importer": "workspace:*", diff --git a/packages/kit/test/apps/basics/src/routes/endpoint-output/fetch-asset/absolute/+server.js b/packages/kit/test/apps/basics/src/routes/endpoint-output/fetch-asset/absolute/+server.js deleted file mode 100644 index 9f1b32595f58..000000000000 --- a/packages/kit/test/apps/basics/src/routes/endpoint-output/fetch-asset/absolute/+server.js +++ /dev/null @@ -1,14 +0,0 @@ -import file_path from '../test.txt?url'; - -/** @type {import('@sveltejs/kit').RequestHandler} */ -export async function GET({ url }) { - const absolute = new URL(file_path, url.origin); - - const response = await fetch(absolute); - - return new Response(response.body, { - headers: { - 'Content-Type': response.headers.get('Content-Type') ?? '' - } - }); -} diff --git a/packages/kit/test/apps/basics/src/routes/endpoint-output/head-write-error/+server.js b/packages/kit/test/apps/basics/src/routes/endpoint-output/head-write-error/+server.js deleted file mode 100644 index 72664eee7dcc..000000000000 --- a/packages/kit/test/apps/basics/src/routes/endpoint-output/head-write-error/+server.js +++ /dev/null @@ -1,10 +0,0 @@ -import { json } from '@sveltejs/kit'; - -/** @type {import('@sveltejs/kit').RequestHandler} */ -export function GET({ setHeaders }) { - setHeaders({ - 'x-test': '\u001f' - }); - - return json({}); -} diff --git a/packages/kit/test/apps/basics/test/server.test.js b/packages/kit/test/apps/basics/test/server.test.js index 8232717f4d8f..7973f6ad39a2 100644 --- a/packages/kit/test/apps/basics/test/server.test.js +++ b/packages/kit/test/apps/basics/test/server.test.js @@ -1,10 +1,6 @@ import process from 'node:process'; import { expect } from '@playwright/test'; import { test } from '../../../utils.js'; -import { createHash, randomBytes } from 'node:crypto'; -import fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import path from 'node:path'; /** @typedef {import('@playwright/test').Response} Response */ @@ -12,232 +8,7 @@ test.skip(({ javaScriptEnabled }) => javaScriptEnabled); test.describe.configure({ mode: 'parallel' }); -test.describe('Caching', () => { - test('caches pages', async ({ request }) => { - const response = await request.get('/caching'); - expect(response.headers()['cache-control']).toBe('public, max-age=30'); - }); -}); - -test.describe('Content-Type', () => { - test('sets Content-Type on page', async ({ request }) => { - const response = await request.get('/content-type-header'); - expect(response.headers()['content-type']).toBe('text/html'); - }); -}); - -test.describe('Content-Length', () => { - test('sets Content-Length on page', async ({ request }) => { - const response = await request.get('/content-length-header'); - - // TODO this would ideally be a unit test of `Server`, - // as would most of the tests in this file - if (!response.headers()['content-encoding']) { - expect(+response.headers()['content-length']).toBeGreaterThan(1000); - } - }); -}); - -test.describe('Cookies', () => { - test('does not forward cookies from external domains', async ({ request, start_server }) => { - const { port } = await start_server(async (req, res) => { - if (req.url === '/') { - res.writeHead(200, { - 'set-cookie': 'external=true', - 'access-control-allow-origin': '*' - }); - - res.end('ok'); - } else { - res.writeHead(404); - res.end('not found'); - } - }); - - const response = await request.get(`/load/fetch-external-no-cookies?port=${port}`); - expect(response.headers()['set-cookie']).not.toContain('external=true'); - }); -}); - -test.describe('CSRF', () => { - if (process.env.DEV) { - return; - } - - test('Blocks requests with incorrect origin', async ({ baseURL }) => { - const content_types = [ - 'application/x-www-form-urlencoded', - 'multipart/form-data', - 'text/plain', - 'text/plaiN' - ]; - const methods = ['POST', 'PUT', 'PATCH', 'DELETE']; - for (const method of methods) { - for (const content_type of content_types) { - const res = await fetch(`${baseURL}/csrf`, { - method, - headers: { - 'content-type': content_type - } - }); - const message = `request method: ${method}, content-type: ${content_type}`; - expect(res.status, message).toBe(403); - expect(await res.text(), message).toBe( - `Cross-site ${method} form submissions are forbidden` - ); - } - } - }); - - test('Allows requests from same origin', async ({ baseURL }) => { - const url = new URL(baseURL); - const res = await fetch(`${baseURL}/csrf`, { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - origin: url.origin - } - }); - expect(res.status).toBe(200); - expect(await res.text()).toBe('ok'); - }); - - test('Allows requests from allowed origins', async ({ baseURL }) => { - // Test with trusted.example.com which is in trustedOrigins - const res1 = await fetch(`${baseURL}/csrf`, { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - origin: 'https://trusted.example.com' - } - }); - expect(res1.status).toBe(200); - expect(await res1.text()).toBe('ok'); - - // Test with payment-gateway.test which is also in trustedOrigins - const res2 = await fetch(`${baseURL}/csrf`, { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - origin: 'https://payment-gateway.test' - } - }); - expect(res2.status).toBe(200); - expect(await res2.text()).toBe('ok'); - }); - - test('Blocks requests from non-allowed origins', async ({ baseURL }) => { - // Test with origin not in trustedOrigins list - const res1 = await fetch(`${baseURL}/csrf`, { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - origin: 'https://malicious-site.com' - } - }); - expect(res1.status).toBe(403); - expect(await res1.text()).toBe('Cross-site POST form submissions are forbidden'); - - // Test with similar but not exact origin - const res2 = await fetch(`${baseURL}/csrf`, { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - origin: 'https://trusted.example.com.evil.com' - } - }); - expect(res2.status).toBe(403); - expect(await res2.text()).toBe('Cross-site POST form submissions are forbidden'); - - // Test subdomain attack (should be blocked) - const res3 = await fetch(`${baseURL}/csrf`, { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - origin: 'https://evil.trusted.example.com' - } - }); - expect(res3.status).toBe(403); - expect(await res3.text()).toBe('Cross-site POST form submissions are forbidden'); - }); - - test('Allows GET requests regardless of origin', async ({ baseURL }) => { - const res = await fetch(`${baseURL}/csrf`, { - method: 'GET', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - origin: 'https://any-origin.com' - } - }); - expect(res.status).toBe(200); - }); - - test('Allows non-form content types regardless of origin', async ({ baseURL }) => { - const res = await fetch(`${baseURL}/csrf`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - origin: 'https://any-origin.com' - } - }); - expect(res.status).toBe(200); - }); - - test('Allows all protected HTTP methods from allowed origins', async ({ baseURL }) => { - const methods = ['POST', 'PUT', 'PATCH', 'DELETE']; - for (const method of methods) { - const res = await fetch(`${baseURL}/csrf`, { - method, - headers: { - 'content-type': 'application/x-www-form-urlencoded', - origin: 'https://trusted.example.com' - } - }); - expect(res.status, `Method ${method} should be allowed from trusted origin`).toBe(200); - expect(await res.text(), `Method ${method} should return ok`).toBe('ok'); - } - }); - - test('Handles undefined origin correctly', async ({ baseURL }) => { - // Some requests may have null origin (e.g., from certain mobile apps) - const res = await fetch(`${baseURL}/csrf`, { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded' - } - }); - expect(res.status).toBe(403); - expect(await res.text()).toBe('Cross-site POST form submissions are forbidden'); - }); -}); - test.describe('Endpoints', () => { - test('HEAD with matching headers but without body', async ({ request }) => { - const url = '/endpoint-output/body'; - - const responses = { - head: await request.head(url), - get: await request.get(url) - }; - - const headers = { - head: responses.head.headers(), - get: responses.get.headers() - }; - - expect(responses.head.status()).toBe(200); - expect(responses.get.status()).toBe(200); - expect(await responses.head.text()).toBe(''); - expect(await responses.get.text()).toBe('{}'); - - ['date', 'transfer-encoding'].forEach((name) => { - delete headers.head[name]; - delete headers.get[name]; - }); - - expect(headers.head).toEqual(headers.get); - }); - test('Prerendered +server.js called from a non-prerendered +server.js works', async ({ baseURL }) => { @@ -270,296 +41,13 @@ test.describe('Endpoints', () => { } }); - test('invalid request method returns allow header', async ({ request }) => { - const response = await request.post('/endpoint-output/body', { - headers: { origin: 'https://trusted.example.com' } - }); - - expect(response.status()).toBe(405); - - const allow_header = response.headers()['allow']; - expect(allow_header).toMatch(/\bGET\b/); - expect(allow_header).toMatch(/\bHEAD\b/); - }); - - test('405 allow header has no duplicate methods listed', async ({ request }) => { - const response = await request.post('/endpoint-output/head-handler', { - headers: { origin: 'https://trusted.example.com' } - }); - - expect(response.status()).toBe(405); - - const allow_header = response.headers()['allow']; - const methods = allow_header.split(',').map((m) => m.trim()); - const unique_methods = [...new Set(methods)]; - - expect(methods).toEqual(unique_methods); - }); - - test('serves page for GET request when endpoint has no GET handler', async ({ request }) => { - const response = await request.fetch('/endpoint-output/post-only-with-page', { - headers: { - accept: '*/*' - } - }); - - expect(response.status()).toBe(200); - expect(response.headers()['content-type']).toContain('text/html'); - expect(await response.text()).toContain('POST-only endpoint page'); - }); - - test('serves page for HEAD request when endpoint has no HEAD or GET handler', async ({ - request - }) => { - const response = await request.fetch('/endpoint-output/post-only-with-page', { - method: 'HEAD', - headers: { - accept: '*/*' - } - }); - - expect(response.status()).toBe(200); - expect(response.headers()['content-type']).toContain('text/html'); - expect(response.headers()['x-sveltekit-page']).toBe('true'); - expect(await response.text()).toBe(''); - }); - - test('POST to post-only endpoint with sibling page still hits endpoint', async ({ request }) => { - const response = await request.post('/endpoint-output/post-only-with-page', { - headers: { origin: 'https://trusted.example.com' } - }); - - expect(response.status()).toBe(200); - expect(await response.text()).toBe('ok'); - }); - - test('uses fallback handler instead of page when endpoint has no GET but has fallback', async ({ - request - }) => { - const response = await request.fetch('/endpoint-output/fallback-with-page', { - headers: { - accept: '*/*' - } - }); - - expect(response.status()).toBe(200); - expect(await response.text()).toBe('catch-all'); - }); - - test('content negotiation: API requests hit endpoint, browser requests hit page', async ({ - request - }) => { - // application/json → endpoint - const api_response = await request.fetch('/routing/content-negotiation', { - headers: { - accept: 'application/json' - } - }); - - expect(api_response.status()).toBe(200); - expect(await api_response.text()).toBe('GET'); - - // text/html → page - const html_response = await request.fetch('/routing/content-negotiation', { - headers: { - accept: 'text/html' - } - }); - - expect(html_response.status()).toBe(200); - expect(html_response.headers()['content-type']).toContain('text/html'); - expect(await html_response.text()).toContain('Hi'); - }); - // TODO all the remaining tests in this section are really only testing // setResponse, since we're not otherwise changing anything on the response. // might be worth making these unit tests instead - test('multiple set-cookie on endpoints using GET', async ({ request }) => { - const response = await request.get('/set-cookie'); - - const cookies = response - .headersArray() - .filter((obj) => obj.name === 'set-cookie') - .map((obj) => obj.value); - - expect(cookies).toEqual([ - 'answer=42; HttpOnly', - 'problem=comma, separated, values; HttpOnly', - 'name=SvelteKit; path=/; HttpOnly' - ]); - }); - - // TODO see above - test('body can be a binary ReadableStream', async ({ request }) => { - const interruptedResponse = request.get('/endpoint-output/stream-throw-error'); - await expect(interruptedResponse).rejects.toThrow('socket hang up'); - - const response = await request.get('/endpoint-output/stream'); - const body = await response.body(); - const digest = createHash('sha256').update(body).digest('base64url'); - expect(response.headers()['digest']).toEqual(`sha-256=${digest}`); - }); - - // TODO see above - test('stream can be canceled with TypeError', async ({ request }) => { - const responseBefore = await request.get('/endpoint-output/stream-typeerror?what'); - expect(await responseBefore.text()).toEqual('null'); - - const interruptedResponse = request.get('/endpoint-output/stream-typeerror'); - await expect(interruptedResponse).rejects.toThrow('socket hang up'); - - const responseAfter = await request.get('/endpoint-output/stream-typeerror?what'); - expect(await responseAfter.text()).toEqual('TypeError'); - }); - // TODO see above - test('request body can be read slow', async ({ request }) => { - const data = randomBytes(1024 * 256); - const digest = createHash('sha256').update(data).digest('base64url'); - const response = await request.put('/endpoint-input/sha256', { data }); - expect(await response.text()).toEqual(digest); - }); - - // TODO see above - test('invalid headers return a 500', async ({ request }) => { - const response = await request.get('/endpoint-output/head-write-error'); - expect(response.status()).toBe(500); - expect(await response.text()).toMatch( - 'TypeError [ERR_INVALID_CHAR]: Invalid character in header content ["x-test"]' - ); - }); - - test('OPTIONS handler', async ({ request }) => { - const url = '/endpoint-output'; - - const response = await request.fetch(url, { - method: 'OPTIONS' - }); - - expect(response.status()).toBe(200); - expect(await response.text()).toBe('ok'); - }); - - test('HEAD handler', async ({ request }) => { - const url = '/endpoint-output/head-handler'; - - const page_response = await request.fetch(url, { - method: 'HEAD', - headers: { - accept: 'text/html' - } - }); - - expect(page_response.status()).toBe(200); - expect(await page_response.text()).toBe(''); - expect(page_response.headers()['x-sveltekit-page']).toBe('true'); - - const endpoint_response = await request.fetch(url, { - method: 'HEAD', - headers: { - accept: 'application/json' - } - }); - - expect(endpoint_response.status()).toBe(200); - expect(await endpoint_response.text()).toBe(''); - expect(endpoint_response.headers()['x-sveltekit-head-endpoint']).toBe('true'); - }); - - test('catch-all handler', async ({ request }) => { - const url = '/endpoint-output/fallback'; - - let response = await request.fetch(url, { - method: 'GET' - }); - - expect(response.status()).toBe(200); - expect(await response.text()).toBe('ok'); - - response = await request.fetch(url, { - method: 'MOVE' // also works with arcane methods - }); - - expect(response.status()).toBe(200); - expect(await response.text()).toBe('catch-all'); - - response = await request.fetch(url, { - method: 'OPTIONS' - }); - - expect(response.status()).toBe(200); - expect(await response.text()).toBe('catch-all'); - }); - - test('QUERY handler', async ({ request }) => { - const url = '/endpoint-output/query'; - - let response = await request.fetch(url, { - method: 'QUERY', - data: 'name=world' - }); - - expect(response.status()).toBe(200); - expect(await response.text()).toBe('query: name=world'); - - response = await request.fetch(url, { - method: 'MOVE' - }); - - expect(response.status()).toBe(405); - expect(response.headers()['allow']).toBe('GET, QUERY, HEAD'); - }); - - test('can get assets using absolute path', async ({ request }) => { - const response = await request.get('/endpoint-output/fetch-asset/absolute'); - expect(response.status()).toBe(200); - expect(response.headers()['content-type']).toBe('text/plain'); - expect(await response.text()).toBe('Cos sie konczy, cos zaczyna'); - }); - - test('can get assets using relative path', async ({ request }) => { - const response = await request.get('/endpoint-output/fetch-asset/relative'); - expect(response.status()).toBe(200); - expect(response.headers()['content-type']).toBe('text/plain'); - expect(await response.text()).toBe('Cos sie konczy, cos zaczyna'); - }); }); test.describe('Errors', () => { - test('uses the handleError status for the fallback page served to error-page sub-requests', async ({ - request - }) => { - const response = await request.get('/errors/handle-error-status-fallback', { - headers: { 'x-sveltekit-error': 'true' } - }); - expect(response.status()).toBe(503); - }); - - test('invalid route response is handled', async ({ request }) => { - const response = await request.get('/errors/invalid-route-response'); - - expect(/** @type {import('@playwright/test').APIResponse} */ (response).status()).toBe(500); - expect(await response.text()).toMatch( - 'Invalid response from route /errors/invalid-route-response: handler should return a Response object' - ); - }); - - test('unhandled http method', async ({ request }) => { - const response = await request.put('/errors/invalid-route-response', { - headers: { origin: 'https://trusted.example.com' } - }); - - expect(response.status()).toBe(405); - expect(await response.text()).toMatch('PUT method not allowed'); - }); - - test('error evaluating module', async ({ request }) => { - const response = await request.get('/errors/init-error-endpoint'); - - expect(response.status()).toBe(500); - expect(await response.text()).toMatch('thisvariableisnotdefined is not defined'); - }); - test('returns 400 when accessing a malformed URI', async ({ page }) => { const response = await page.goto('/%c0%ae%c0%ae/etc/passwd'); if (process.env.DEV) { @@ -570,348 +58,9 @@ test.describe('Errors', () => { expect(/** @type {Response} */ (response).status()).toBe(400); } }); - - test('returns a lightweight 404 for subresource requests', async ({ request, read_errors }) => { - // distinct from the path used by the test below, which _does_ invoke the hook - const response = await request.get('/errors/does-not-exist-lightweight-subresource', { - headers: { 'sec-fetch-dest': 'image' } - }); - - expect(response.status()).toBe(404); - expect(await response.text()).toBe('Not Found'); - expect(response.headers()['vary']).toContain('Sec-Fetch-Dest'); - - // lightweight 404s bypass the handleError hook - expect(read_errors('/errors/does-not-exist-lightweight-subresource')).toBe(undefined); - }); - - test('renders the error page for document and fetch requests', async ({ request }) => { - for (const destination of ['document', null, 'empty']) { - const response = await request.get( - '/errors/does-not-exist-subresource', - destination ? { headers: { 'sec-fetch-dest': destination } } : {} - ); - - expect(response.status()).toBe(404); - expect(await response.text()).toContain('This is your custom error page saying:'); - } - }); - - test('stack traces are not fixed twice', async ({ page }) => { - await page.goto('/errors/stack-trace'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Cannot read properties of undefined (reading \'toUpperCase\') (500 Internal Error)"' - ); - - // check the stack wasn't mutated - await page.goto('/errors/stack-trace'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Cannot read properties of undefined (reading \'toUpperCase\') (500 Internal Error)"' - ); - }); - - test('error(...) in endpoint', async ({ request, read_errors }) => { - // HTML - { - const res = await request.get('/errors/endpoint-throw-error', { - headers: { - accept: 'text/html' - } - }); - - expect(read_errors('/errors/endpoint-throw-error')).toEqual({ - kind: 'app', - error: { status: 401, message: 'You shall not pass' } - }); - - expect(res.status()).toBe(401); - expect(await res.text()).toContain( - 'This is the static error page with the following message: You shall not pass' - ); - } - - // JSON (default) - { - const res = await request.get('/errors/endpoint-throw-error'); - - expect(read_errors('/errors/endpoint-throw-error')).toEqual({ - kind: 'app', - error: { status: 401, message: 'You shall not pass' } - }); - - expect(res.status()).toBe(401); - expect(await res.json()).toEqual({ - message: 'You shall not pass', - status: 401 - }); - } - }); - - test('redirect(...) in endpoint', async ({ page, read_errors }) => { - const res = await page.goto('/errors/endpoint-throw-redirect'); - expect(res?.status()).toBe(200); // redirects are opaque to the browser - - const error = read_errors('/errors/endpoint-throw-redirect'); - expect(error).toBe(undefined); - - expect(await page.textContent('h1')).toBe('the answer is 42'); - }); - - test('POST to missing page endpoint', async ({ request }) => { - const res = await request.post('/errors/missing-actions', { - headers: { - accept: 'text/html', - origin: 'https://trusted.example.com' - } - }); - expect(res?.status()).toBe(405); - - const res_json = await request.post('/errors/missing-actions', { - headers: { - accept: 'application/json', - origin: 'https://trusted.example.com' - } - }); - expect(res_json?.status()).toBe(405); - expect(await res_json.json()).toEqual({ - type: 'error', - location: '/errors/missing-actions', - error: { - message: 'Method Not Allowed (405 Method Not Allowed)', - status: 405 - } - }); - }); - - test('error thrown in handle results in a rendered error page or JSON response', async ({ - request - }) => { - // HTML - { - const res = await request.get('/errors/error-in-handle', { - headers: { - accept: 'text/html' - } - }); - - expect(res.status()).toBe(500); - expect(await res.text()).toContain( - 'This is the static error page with the following message: Error in handle' - ); - } - - // JSON (default) - { - const res = await request.get('/errors/error-in-handle'); - - const error = await res.json(); - - expect(error.stack).toBe(undefined); - expect(res.status()).toBe(500); - expect(error).toEqual({ - message: 'Error in handle (500 Internal Error)', - status: 500 - }); - } - }); - - test('expected error thrown in handle results in a rendered error page or JSON response', async ({ - request - }) => { - // HTML - { - const res = await request.get('/errors/expected-error-in-handle', { - headers: { - accept: 'text/html' - } - }); - - expect(res.status()).toBe(500); - expect(await res.text()).toContain( - 'This is the static error page with the following message: Expected error in handle' - ); - } - - // JSON (default) - { - const res = await request.get('/errors/expected-error-in-handle'); - - const error = await res.json(); - - expect(error.stack).toBe(undefined); - expect(res.status()).toBe(500); - expect(error).toEqual({ - message: 'Expected error in handle', - status: 500 - }); - } - }); - - test('error thrown from load on the server respects page options when rendering the error page', async ({ - request - }) => { - const res = await request.get('/errors/load-error-page-options/csr'); - expect(res.status()).toBe(500); - const content = await res.text(); - expect(content).toContain('Crashing now'); - // the hydration script should not be present if the csr page option is respected - expect(content).not.toContain('kit.start(app'); - }); - - test('returns root layout data for a missing route error page data request', async ({ - request - }) => { - const data_response = await request.get( - '/this-route-does-not-exist/__data.json?x-sveltekit-invalidated=1' - ); - expect(data_response.status()).toBe(200); - expect(data_response.headers()['content-type']).toContain('application/json'); - - const data = await data_response.json(); - expect(data.type).toBe('data'); - expect(data.nodes[0].type).toBe('data'); - expect(data.nodes[0].data).toContain('rootlayout'); - - const page_response = await request.get('/this-route-does-not-exist/__data.json'); - expect(page_response.status()).toBe(404); - expect(page_response.headers()['content-type']).toContain('text/html'); - - // a single-node request that invalidates nothing is not an error-page data request - const crafted_response = await request.get( - '/this-route-does-not-exist/__data.json?x-sveltekit-invalidated=0' - ); - expect(crafted_response.status()).toBe(404); - }); - - test('preserves metadata for a streamed missing route error page', async ({ request }) => { - const response = await request.get('/non-existent-streamed-route', { - headers: { cookie: 'defer=true' } - }); - - expect(response.status()).toBe(404); - expect(response.headers()['etag']).toBeUndefined(); - - const body = await response.text(); - expect(body).toMatch(/
(.+?)<\/pre>/s.exec(html))[1]
- );
- expect(headers.accept).toBe('*/*');
- expect(headers['accept-language']).toBeUndefined();
- });
-
- test('includes origin header on non-GET internal request', async ({ page, baseURL }) => {
- await page.goto('/load/fetch-origin-internal');
- expect(await page.textContent('h1')).toBe(`origin: ${new URL(baseURL).origin}`);
- });
-
- test('includes origin header on external request', async ({ page, baseURL, start_server }) => {
- const { port } = await start_server((req, res) => {
- if (req.url === '/') {
- res.writeHead(200, {
- 'content-type': 'application/json',
- 'access-control-allow-origin': '*'
- });
-
- res.end(JSON.stringify({ origin: req.headers.origin }));
- } else {
- res.writeHead(404);
- res.end('not found');
- }
- });
-
- await page.goto(`/load/fetch-origin-external?port=${port}`);
- expect(await page.textContent('h1')).toBe(`origin: ${new URL(baseURL).origin}`);
- });
-
- test('does not run when using invalid request methods', async ({ request }) => {
- const load_url = '/load';
-
- let response = await request.fetch(load_url, {
- method: 'OPTIONS'
- });
-
- expect(response.status()).toBe(204);
- expect(await response.text()).toBe('');
- expect(response.headers()['allow']).toBe('GET, HEAD, OPTIONS');
-
- const actions_url = '/actions/enhance';
- response = await request.fetch(actions_url, {
- method: 'OPTIONS'
- });
-
- expect(response.status()).toBe(204);
- expect(await response.text()).toBe('');
- expect(response.headers()['allow']).toBe('GET, HEAD, OPTIONS, POST');
- });
-
- test('allows logging URL search params', async ({ page }) => {
- await page.goto('/load/server-log-search-param');
-
- expect(await page.textContent('p')).toBe('hello world');
- });
});
test.describe('Routing', () => {
- test('event.params are available in handle', async ({ request }) => {
- const response = await request.get('/routing/params-in-handle/banana');
- expect(await response.json()).toStrictEqual({
- key: '/routing/params-in-handle/[x]',
- params: { x: 'banana' }
- });
- });
-
- test('/favicon.ico is a valid route', async ({ request }) => {
- const response = await request.get('/favicon.ico');
- expect(response.status()).toBe(200);
-
- const data = await response.json();
- expect(data).toEqual({ surprise: 'lol' });
- });
-
- test('falls back to page actions if sibling endpoint has no POST handler', async ({
- baseURL,
- request
- }) => {
- const response = await request.post('/endpoint-output/actions-with-endpoint', {
- form: {},
- headers: {
- accept: 'application/json',
- origin: new URL(baseURL).origin
- }
- });
-
- expect(response.status()).toBe(200);
- expect((await response.json()).type).toBe('success');
- });
-
test('Vite trailing slash redirect for prerendered pages retains URL query string', async ({
request
}) => {
@@ -928,61 +77,6 @@ test.describe('Routing', () => {
});
});
-test.describe('Shadowed pages', () => {
- test('Action can return undefined', async ({ baseURL, request }) => {
- const response = await request.post('/shadowed/simple/post', {
- form: {},
- headers: {
- accept: 'application/json',
- origin: new URL(baseURL).origin
- }
- });
-
- expect(response.status()).toBe(200);
- expect(await response.json()).toEqual({
- type: 'success',
- status: 204,
- location: '/shadowed/simple/post'
- });
- });
-
- test('action response includes the stripped landing location', async ({ baseURL, request }) => {
- const response = await request.post(
- '/actions/cross-page/destination?redirectTo=%2Fdashboard&/failure',
- {
- form: { username: 'paolo' },
- headers: {
- accept: 'application/json',
- origin: new URL(baseURL).origin
- }
- }
- );
-
- expect(response.status()).toBe(400);
- expect(await response.json()).toEqual({
- type: 'failure',
- status: 400,
- location: '/actions/cross-page/destination?redirectTo=%2Fdashboard',
- data: '[{"problem":1,"username":2},"invalid","paolo"]'
- });
- });
-
- test('Action fail() returns matching HTTP status code', async ({ baseURL, request }) => {
- const response = await request.post('/actions/form-errors', {
- form: {},
- headers: {
- accept: 'application/json',
- origin: new URL(baseURL).origin
- }
- });
-
- expect(response.status()).toBe(400);
- const body = await response.json();
- expect(body.type).toBe('failure');
- expect(body.status).toBe(400);
- });
-});
-
test.describe('Static files', () => {
test('static files', async ({ request }) => {
let response = await request.get('/static.json');
@@ -1038,27 +132,6 @@ test.describe('Static files', () => {
});
});
-test.describe('setHeaders', () => {
- test('allows multiple set-cookie headers with different values', async ({ page }) => {
- const response = await page.goto('/headers/set-cookie/sub');
- const cookies = response ? (await response.allHeaders())['set-cookie'] : '';
-
- expect(cookies).toMatch('cookie1=value1');
- expect(cookies).toMatch('cookie2=value2');
- });
-});
-
-test.describe('cookies', () => {
- test('cookie.stringifySetCookie created correct cookie header string', async ({ page }) => {
- const response = await page.goto('/cookies/serialize');
- const cookies = response ? await response.headerValue('set-cookie') : '';
-
- expect(cookies).toMatch('before=before');
- expect(cookies).toMatch('after=after');
- expect(cookies).toMatch('endpoint=endpoint');
- });
-});
-
test.describe('Miscellaneous', () => {
test('does not serve version.json with an immutable cache header', async ({ request }) => {
// this isn't actually a great test, because caching behaviour is down to adapters.
@@ -1068,46 +141,13 @@ test.describe('Miscellaneous', () => {
expect(headers['cache-control'] || '').not.toContain('immutable');
});
- test('handles responses with immutable headers', async ({ request }) => {
- const response = await request.get('/immutable-headers');
- expect(response.status()).toBe(200);
- expect(await response.text()).toBe('foo');
- });
-
test('serves prerendered non-latin pages', async ({ request }) => {
const response = await request.get('/prerendering/中文');
expect(response.status()).toBe(200);
});
-
- test('does not send x-sveltekit-version header on document responses', async ({ page }) => {
- const response = await page.goto('/');
- expect(response?.headers()['x-sveltekit-version']).toBeUndefined();
- });
-
- test('sends x-sveltekit-version header on data responses', async ({ request }) => {
- const response = await request.get('/__data.json');
- expect(response.headers()['x-sveltekit-version']).toBeTruthy();
- });
});
test.describe('reroute', () => {
- test('Apply reroute when directly accessing a page', async ({ page }) => {
- await page.goto('/reroute/basic/a');
- expect(await page.textContent('h1')).toContain(
- 'Successfully rewritten, URL should still show a: /reroute/basic/a'
- );
- });
-
- test('Apply async reroute when directly accessing a page', async ({ page }) => {
- page
- .context()
- .addCookies([{ name: 'reroute-cookie', value: 'yes', path: '/', domain: 'localhost' }]);
- await page.goto('/reroute/async/a');
- expect(await page.textContent('h1')).toContain(
- 'Successfully rewritten, URL should still show a: /reroute/async/a'
- );
- });
-
test('Apply async prerendered reroute when directly accessing a page', async ({ page }) => {
await page.goto('/reroute/async/c');
expect(await page.textContent('h1')).toContain(
@@ -1119,564 +159,4 @@ test.describe('reroute', () => {
await page.goto('/reroute/prerendered/to-destination');
expect(await page.textContent('h1')).toContain('reroute that points to prerendered page works');
});
-
- test('Returns a 500 response if reroute throws an error on the server', async ({ page }) => {
- const response = await page.goto('/reroute/error-handling/server-error');
- expect(response?.status()).toBe(500);
- });
-});
-
-test.describe('init', () => {
- test('init server hook is called once before the load function', async ({ page }) => {
- await page.goto('/init-hooks');
- await expect(page.locator('p')).toHaveText('1');
- await page.reload();
- await expect(page.locator('p')).toHaveText('1');
- });
-});
-
-test.describe('getRequestEvent', () => {
- test('getRequestEvent works in server endpoints', async ({ request }) => {
- const response = await request.get('/get-request-event/endpoint');
- expect(await response.text()).toBe('hello from hooks.server.js');
- });
-});
-
-test.describe('$app/forms', () => {
- test('deserialize works on the server', async ({ request }) => {
- const response = await request.get('/serialization-form-non-enhanced/server-deserialize');
- expect(await response.json()).toEqual({ data: 'It works!' });
- });
-});
-
-const root = path.resolve(fileURLToPath(import.meta.url), '..', '..');
-
-test.describe('$app/env', () => {
- test('treeshakes dev check', async () => {
- test.skip(!!process.env.DEV, 'skip when in dev mode');
-
- const code = fs.readFileSync(
- path.join(root, '.svelte-kit/output/server/entries/pages/treeshaking/dev/_page.svelte.js'),
- 'utf-8'
- );
- expect(code).not.toContain('not prod');
- });
-
- test('treeshakes browser check', async () => {
- test.skip(!!process.env.DEV, 'skip when in dev mode');
-
- const code = fs.readFileSync(
- path.join(
- root,
- '.svelte-kit/output/server/entries/pages/treeshaking/browser/_page.svelte.js'
- ),
- 'utf-8'
- );
- expect(code).not.toMatch('client');
- });
-});
-
-test.describe('web workers', () => {
- test('worker files emitted by the server build are copied to the client output', () => {
- test.skip(!!process.env.DEV, 'checks the build output');
-
- const workers = path.join(root, '.svelte-kit/output/client/_app/immutable/workers');
- expect(fs.readdirSync(workers).some((file) => file.startsWith('worker-'))).toBe(true);
- });
-});
-
-test.describe('tracing', () => {
- // Helper function to find the resolve.root span deep in the handle.child chain
- /**
- * @param {import('../../../types.js').SpanTree} span
- * @returns {import('../../../types.js').SpanTree | null}
- */
- function find_resolve_root_span(span) {
- if (span.name === 'sveltekit.resolve') {
- return span;
- }
- for (const child of span.children || []) {
- const found = find_resolve_root_span(child);
- if (found) return found;
- }
- return null;
- }
-
- function rand() {
- // node 18 doesn't have crypto.randomUUID() and we run tests in node 18
- return Math.random().toString(36).substring(2, 15);
- }
-
- test('correct spans are created for a regular navigation', async ({ page, read_traces }) => {
- const test_id = rand();
- await page.goto(`/tracing/one/two/three/four/five?test_id=${test_id}`);
- const traces = read_traces(test_id);
- expect(traces.length).toBeGreaterThan(0);
-
- const trace = traces[0];
- const trace_id = trace.trace_id;
-
- // Verify root span structure
- expect(trace).toEqual({
- name: 'sveltekit.handle.root',
- status: { code: 0 },
- start_time: [expect.any(Number), expect.any(Number)],
- end_time: [expect.any(Number), expect.any(Number)],
- attributes: {
- 'http.route': '/tracing/one/two/three/[...four]',
- 'http.method': 'GET',
- 'http.url': expect.stringContaining(`/tracing/one/two/three/four/five?test_id=${test_id}`),
- 'sveltekit.is_data_request': false,
- 'sveltekit.is_sub_request': false,
- test_id
- },
- links: [],
- trace_id,
- span_id: expect.any(String),
- children: expect.arrayContaining([
- expect.objectContaining({
- name: 'sveltekit.handle.sequenced.set_tracing_test_id',
- attributes: {}
- })
- ])
- });
-
- // Find and verify the resolve.root span
- const resolve_root_span = find_resolve_root_span(trace);
- expect(resolve_root_span).not.toBeNull();
- expect(resolve_root_span).toEqual({
- name: 'sveltekit.resolve',
- status: { code: 0 },
- start_time: [expect.any(Number), expect.any(Number)],
- end_time: [expect.any(Number), expect.any(Number)],
- attributes: {
- 'http.route': '/tracing/one/two/three/[...four]',
- 'http.response.status_code': 200,
- 'http.response.body.size': expect.stringMatching(/^\d+$/)
- },
- links: [],
- trace_id,
- span_id: expect.any(String),
- parent_span_id: expect.any(String),
- children: [
- {
- name: 'sveltekit.load',
- status: { code: 0 },
- start_time: [expect.any(Number), expect.any(Number)],
- end_time: [expect.any(Number), expect.any(Number)],
- attributes: {
- 'sveltekit.load.node_id': 'src/routes/+layout.server.js',
- 'sveltekit.load.node_type': '+layout.server',
- 'sveltekit.load.environment': 'server',
- 'http.route': '/tracing/one/two/three/[...four]'
- },
- links: [],
- trace_id,
- span_id: expect.any(String),
- parent_span_id: expect.any(String),
- children: []
- },
- {
- name: 'sveltekit.load',
- status: { code: 0 },
- start_time: [expect.any(Number), expect.any(Number)],
- end_time: [expect.any(Number), expect.any(Number)],
- attributes: {
- 'sveltekit.load.node_id': 'src/routes/+layout.js',
- 'sveltekit.load.node_type': '+layout',
- 'sveltekit.load.environment': 'server',
- 'http.route': '/tracing/one/two/three/[...four]'
- },
- links: [],
- trace_id,
- span_id: expect.any(String),
- parent_span_id: expect.any(String),
- children: []
- }
- ]
- });
- });
-
- test('correct spans are created for HttpError', async ({ page, read_traces }) => {
- const test_id = rand();
- const response = await page.goto(`/tracing/http-error?test_id=${test_id}`);
- expect(response?.status()).toBe(500);
-
- const traces = read_traces(test_id);
- const trace_id = traces[0].trace_id;
- const trace = traces[0];
-
- // Verify root span structure
- expect(trace).toEqual({
- name: 'sveltekit.handle.root',
- status: { code: 0 },
- start_time: [expect.any(Number), expect.any(Number)],
- end_time: [expect.any(Number), expect.any(Number)],
- attributes: {
- 'http.route': '/tracing/http-error',
- 'http.method': 'GET',
- 'http.url': expect.stringContaining(`/tracing/http-error?test_id=${test_id}`),
- 'sveltekit.is_data_request': false,
- 'sveltekit.is_sub_request': false,
- test_id
- },
- links: [],
- trace_id,
- span_id: expect.any(String),
- children: expect.arrayContaining([
- expect.objectContaining({
- name: 'sveltekit.handle.sequenced.set_tracing_test_id',
- attributes: {}
- })
- ])
- });
-
- // Find and verify the resolve.root span
- const resolve_root_span = find_resolve_root_span(trace);
- expect(resolve_root_span).not.toBeNull();
- expect(resolve_root_span).toEqual({
- name: 'sveltekit.resolve',
- status: { code: 0 },
- start_time: [expect.any(Number), expect.any(Number)],
- end_time: [expect.any(Number), expect.any(Number)],
- attributes: {
- 'http.route': '/tracing/http-error',
- 'http.response.status_code': 500,
- 'http.response.body.size': expect.stringMatching(/^\d+$/)
- },
- links: [],
- trace_id,
- span_id: expect.any(String),
- parent_span_id: expect.any(String),
- children: expect.arrayContaining([
- expect.objectContaining({
- name: 'sveltekit.load',
- status: { code: 2, message: 'Internal server error from tracing test' },
- attributes: expect.objectContaining({
- 'sveltekit.load.node_id': 'src/routes/tracing/http-error/+page.server.js',
- 'sveltekit.load.result.type': 'known_error',
- 'sveltekit.load.result.status': 500,
- 'sveltekit.load.result.message': 'Internal server error from tracing test'
- })
- })
- ])
- });
- });
-
- test('correct spans are created for Redirect', async ({ page, read_traces }) => {
- const test_id = rand();
- const response = await page.goto(`/tracing/redirect?test_id=${test_id}`);
- expect(response?.status()).toBe(200);
-
- const traces = read_traces(test_id);
- expect(traces).toHaveLength(2);
- const redirect_trace_id = traces[0].trace_id;
- const destination_trace_id = traces[1].trace_id;
-
- const redirect_trace = traces[0];
- const destination_trace = traces[1];
-
- // Verify redirect trace root span structure
- expect(redirect_trace).toEqual({
- name: 'sveltekit.handle.root',
- status: { code: 0 },
- start_time: [expect.any(Number), expect.any(Number)],
- end_time: [expect.any(Number), expect.any(Number)],
- attributes: {
- 'http.route': '/tracing/redirect',
- 'http.method': 'GET',
- 'http.url': expect.stringContaining(`/tracing/redirect?test_id=${test_id}`),
- 'sveltekit.is_data_request': false,
- 'sveltekit.is_sub_request': false,
- test_id
- },
- links: [],
- trace_id: redirect_trace_id,
- span_id: expect.any(String),
- children: expect.arrayContaining([
- expect.objectContaining({
- name: 'sveltekit.handle.sequenced.set_tracing_test_id',
- attributes: {}
- })
- ])
- });
-
- // Find and verify the redirect resolve.root span
- const redirect_resolve_root_span = find_resolve_root_span(redirect_trace);
- expect(redirect_resolve_root_span).not.toBeNull();
- expect(redirect_resolve_root_span).toEqual({
- name: 'sveltekit.resolve',
- status: { code: 0 },
- start_time: [expect.any(Number), expect.any(Number)],
- end_time: [expect.any(Number), expect.any(Number)],
- attributes: {
- 'http.route': '/tracing/redirect',
- 'http.response.status_code': 307,
- 'http.response.body.size': expect.stringMatching(/^\d+$|^unknown$/)
- },
- links: [],
- trace_id: redirect_trace_id,
- span_id: expect.any(String),
- parent_span_id: expect.any(String),
- children: expect.arrayContaining([
- expect.objectContaining({
- name: 'sveltekit.load',
- status: { code: 0 },
- attributes: expect.objectContaining({
- 'sveltekit.load.node_id': 'src/routes/tracing/redirect/+page.server.js',
- 'sveltekit.load.result.type': 'redirect',
- 'sveltekit.load.result.status': 307,
- 'sveltekit.load.result.location': `/tracing/one/two/three/four/five?test_id=${test_id}`
- })
- })
- ])
- });
-
- // Verify destination trace root span structure
- expect(destination_trace).toEqual({
- name: 'sveltekit.handle.root',
- status: { code: 0 },
- start_time: [expect.any(Number), expect.any(Number)],
- end_time: [expect.any(Number), expect.any(Number)],
- attributes: {
- 'http.route': '/tracing/one/two/three/[...four]',
- 'http.method': 'GET',
- 'http.url': expect.stringContaining(`/tracing/one/two/three/four/five?test_id=${test_id}`),
- 'sveltekit.is_data_request': false,
- 'sveltekit.is_sub_request': false,
- test_id
- },
- links: [],
- trace_id: destination_trace_id,
- span_id: expect.any(String),
- children: expect.arrayContaining([
- expect.objectContaining({
- name: 'sveltekit.handle.sequenced.set_tracing_test_id',
- attributes: {}
- })
- ])
- });
-
- // Find and verify the destination resolve.root span
- const destination_resolve_root_span = find_resolve_root_span(destination_trace);
- expect(destination_resolve_root_span).not.toBeNull();
- expect(destination_resolve_root_span).toEqual({
- name: 'sveltekit.resolve',
- status: { code: 0 },
- start_time: [expect.any(Number), expect.any(Number)],
- end_time: [expect.any(Number), expect.any(Number)],
- attributes: {
- 'http.route': '/tracing/one/two/three/[...four]',
- 'http.response.status_code': 200,
- 'http.response.body.size': expect.stringMatching(/^\d+$/)
- },
- links: [],
- trace_id: destination_trace_id,
- span_id: expect.any(String),
- parent_span_id: expect.any(String),
- children: [
- {
- name: 'sveltekit.load',
- status: { code: 0 },
- start_time: [expect.any(Number), expect.any(Number)],
- end_time: [expect.any(Number), expect.any(Number)],
- attributes: {
- 'sveltekit.load.node_id': 'src/routes/+layout.server.js',
- 'sveltekit.load.node_type': '+layout.server',
- 'sveltekit.load.environment': 'server',
- 'http.route': '/tracing/one/two/three/[...four]'
- },
- links: [],
- trace_id: destination_trace_id,
- span_id: expect.any(String),
- parent_span_id: expect.any(String),
- children: []
- },
- {
- name: 'sveltekit.load',
- status: { code: 0 },
- start_time: [expect.any(Number), expect.any(Number)],
- end_time: [expect.any(Number), expect.any(Number)],
- attributes: {
- 'sveltekit.load.node_id': 'src/routes/+layout.js',
- 'sveltekit.load.node_type': '+layout',
- 'sveltekit.load.environment': 'server',
- 'http.route': '/tracing/one/two/three/[...four]'
- },
- links: [],
- trace_id: destination_trace_id,
- span_id: expect.any(String),
- parent_span_id: expect.any(String),
- children: []
- }
- ]
- });
- });
-
- test('correct spans are created for regular Error', async ({ page, read_traces }) => {
- const test_id = rand();
- const response = await page.goto(`/tracing/regular-error?test_id=${test_id}`);
- expect(response?.status()).toBe(500);
-
- const traces = read_traces(test_id);
- const trace_id = traces[0].trace_id;
- const trace = traces[0];
-
- // Verify root span structure
- expect(trace).toEqual({
- name: 'sveltekit.handle.root',
- status: { code: 0 },
- start_time: [expect.any(Number), expect.any(Number)],
- end_time: [expect.any(Number), expect.any(Number)],
- attributes: {
- 'http.route': '/tracing/regular-error',
- 'http.method': 'GET',
- 'http.url': expect.stringContaining(`/tracing/regular-error?test_id=${test_id}`),
- 'sveltekit.is_data_request': false,
- 'sveltekit.is_sub_request': false,
- test_id
- },
- links: [],
- trace_id,
- span_id: expect.any(String),
- children: expect.arrayContaining([
- expect.objectContaining({
- name: 'sveltekit.handle.sequenced.set_tracing_test_id',
- attributes: {}
- })
- ])
- });
-
- // Find and verify the resolve.root span
- const resolve_root_span = find_resolve_root_span(trace);
- expect(resolve_root_span).not.toBeNull();
- expect(resolve_root_span).toEqual({
- name: 'sveltekit.resolve',
- status: { code: 0 },
- start_time: [expect.any(Number), expect.any(Number)],
- end_time: [expect.any(Number), expect.any(Number)],
- attributes: {
- 'http.route': '/tracing/regular-error',
- 'http.response.status_code': 500,
- 'http.response.body.size': expect.stringMatching(/^\d+$/)
- },
- links: [],
- trace_id,
- span_id: expect.any(String),
- parent_span_id: expect.any(String),
- children: expect.arrayContaining([
- expect.objectContaining({
- name: 'sveltekit.load',
- status: { code: 2, message: 'Regular error from tracing test' },
- attributes: expect.objectContaining({
- 'sveltekit.load.node_id': 'src/routes/tracing/regular-error/+page.server.js',
- 'sveltekit.load.result.type': 'unknown_error'
- })
- })
- ])
- });
- });
-
- test('correct spans are created for non-error object', async ({ page, read_traces }) => {
- const test_id = rand();
- const response = await page.goto(`/tracing/non-error-object?test_id=${test_id}`);
- expect(response?.status()).toBe(500);
-
- const traces = read_traces(test_id);
- const trace_id = traces[0].trace_id;
- const trace = traces[0];
-
- // Verify root span structure
- expect(trace).toEqual({
- name: 'sveltekit.handle.root',
- status: { code: 0 },
- start_time: [expect.any(Number), expect.any(Number)],
- end_time: [expect.any(Number), expect.any(Number)],
- attributes: {
- 'http.route': '/tracing/non-error-object',
- 'http.method': 'GET',
- 'http.url': expect.stringContaining(`/tracing/non-error-object?test_id=${test_id}`),
- 'sveltekit.is_data_request': false,
- 'sveltekit.is_sub_request': false,
- test_id
- },
- links: [],
- trace_id,
- span_id: expect.any(String),
- children: expect.arrayContaining([
- expect.objectContaining({
- name: 'sveltekit.handle.sequenced.set_tracing_test_id',
- attributes: {}
- })
- ])
- });
-
- // Find and verify the resolve.root span
- const resolve_root_span = find_resolve_root_span(trace);
- expect(resolve_root_span).not.toBeNull();
- expect(resolve_root_span).toEqual({
- name: 'sveltekit.resolve',
- status: { code: 0 },
- start_time: [expect.any(Number), expect.any(Number)],
- end_time: [expect.any(Number), expect.any(Number)],
- attributes: {
- 'http.route': '/tracing/non-error-object',
- 'http.response.status_code': 500,
- 'http.response.body.size': expect.stringMatching(/^\d+$/)
- },
- links: [],
- trace_id,
- span_id: expect.any(String),
- parent_span_id: expect.any(String),
- children: expect.arrayContaining([
- expect.objectContaining({
- name: 'sveltekit.load',
- status: { code: 2 },
- attributes: expect.objectContaining({
- 'sveltekit.load.node_id': 'src/routes/tracing/non-error-object/+page.server.js',
- 'sveltekit.load.result.type': 'unknown_error'
- })
- })
- ])
- });
- });
-});
-
-test.describe('asset preload', () => {
- test.skip(!!process.env.DEV);
-
- test('does not inject Link headers', async ({ request }) => {
- const response = await request.get('/asset-preload');
-
- const header = response.headers()['link'];
- expect(header).toBeUndefined();
- });
-
- test('injects tags', async ({ request }) => {
- const response = await request.get('/asset-preload');
-
- const body = await response.text();
-
- expect(body).toContain('rel="modulepreload"');
- expect(body).toContain('as="font"');
- expect(body).toMatch(/href="[^"]+\/shlop\.[^".]+\.woff2"/);
- expect(body).toMatch(/href="[^"]+\/shlop\.var\.[^".]+\.woff2"/);
- // the emitted file name is sanitized, but the filter matched on the original `shlop+bold.woff2`
- expect(body).toMatch(/href="[^"]+\/shlop_bold\.[^".]+\.woff2"/);
- });
-});
-
-test.describe('Streaming', () => {
- test("Discarded promises from server load functions don't hang SSR request", async ({
- request
- }) => {
- let error;
- try {
- await request.get('/streaming/discarded-promise');
- } catch (e) {
- error = e;
- }
-
- expect(error).toBeUndefined();
- });
});
diff --git a/packages/kit/test/apps/basics/unit-test/server.spec.js b/packages/kit/test/apps/basics/unit-test/server.spec.js
new file mode 100644
index 000000000000..246935153895
--- /dev/null
+++ b/packages/kit/test/apps/basics/unit-test/server.spec.js
@@ -0,0 +1,1598 @@
+/** @import { Server } from '@sveltejs/kit' */
+import { createHash, randomBytes } from 'node:crypto';
+import { once } from 'node:events';
+import fs from 'node:fs';
+import http from 'node:http';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { createReadableStream, getRequest, setResponse } from '@sveltejs/kit/node';
+import { createServer, loadEnv } from 'vite';
+import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
+import * as records from '../../../records.js';
+
+// the server half of the old no-js Playwright project, against the built server directly or,
+// with KIT_TEST_DEV set, Vite's dev server in middleware mode
+
+const root = path.resolve(import.meta.dirname, '..');
+const out = path.join(root, '.svelte-kit/output/server');
+const dev = !!process.env.KIT_TEST_DEV;
+
+/** @type {string} */
+let origin;
+
+/** @type {Server} */
+let server;
+
+/** @type {import('vite').ViteDevServer} */
+let vite;
+
+/** @type {http.Server} */
+let listener;
+
+beforeAll(async () => {
+ // the app writes test/errors.jsonl and test/spans.jsonl relative to cwd
+ process.chdir(root);
+
+ if (dev) {
+ vite = await createServer({ root, server: { middlewareMode: true }, logLevel: 'warn' });
+ listener = http.createServer(vite.middlewares);
+ } else {
+ // the preview server gets these from Vite's .env loading
+ Object.assign(process.env, loadEnv('production', root, ''));
+
+ const { manifest } = await import(pathToFileURL(path.join(out, 'manifest.js')).href);
+ const { Server } = await import(pathToFileURL(path.join(out, 'index.js')).href);
+
+ server = new Server(manifest);
+ await server.init({
+ env: process.env,
+ read: (file) =>
+ createReadableStream(
+ path.join(fs.existsSync(path.join(out, file)) ? out : `${root}/static`, file)
+ )
+ });
+
+ // the app's own fetches of unmatched paths leave the server, so give the origin a listener
+ listener = http.createServer(async (req, res) => {
+ const request = getRequest({ request: req, response: res, base: origin });
+ setResponse(res, await server.respond(request, { getClientAddress: () => '' }));
+ });
+ }
+
+ await once(listener.listen(0, 'localhost'), 'listening');
+ origin = `http://localhost:${/** @type {import('net').AddressInfo} */ (listener.address()).port}`;
+}, 30_000);
+
+afterAll(async () => {
+ await vite?.close();
+ await new Promise((resolve) => listener.close(resolve));
+});
+
+/**
+ * One request, redirects not followed; `load` follows them
+ * @param {string} pathname
+ * @param {RequestInit} [init]
+ */
+const get = async (pathname, init) => {
+ if (dev) return fetch(origin + pathname, { redirect: 'manual', ...init });
+
+ const request = new Request(origin + pathname, init);
+
+ // `fetch` implies this
+ if (!request.headers.has('accept')) {
+ request.headers.set('accept', '*/*');
+ }
+
+ return server.respond(request, { getClientAddress: () => '' });
+};
+
+/**
+ * Request a page, following redirects, and parse it
+ * @param {string} pathname
+ * @param {RequestInit} [init]
+ */
+async function load(pathname, init) {
+ let response = await get(pathname, init);
+
+ while (response.status >= 300 && response.status < 400) {
+ const location = /** @type {string} */ (response.headers.get('location'));
+ const redirected = new URL(location, origin + pathname);
+
+ response = await get(redirected.pathname + redirected.search, init);
+ }
+
+ const document = new DOMParser().parseFromString(await response.text(), 'text/html');
+ return { response, document };
+}
+
+const read_errors = (/** @type {string} */ pathname) =>
+ records.read_errors(path.join(root, 'test/errors.jsonl'), pathname);
+
+const read_traces = (/** @type {string} */ test_id) =>
+ records.read_traces(path.join(root, 'test/spans.jsonl'), test_id);
+
+/** @type {http.Server[]} */
+const servers = [];
+
+/**
+ * An external server for the tests that fetch across origins
+ * @param {(req: http.IncomingMessage, res: http.ServerResponse) => void} handler
+ */
+async function start_server(handler) {
+ const server = http.createServer(handler);
+ servers.push(server);
+ await once(server.listen(0, 'localhost'), 'listening');
+ return { port: /** @type {import('net').AddressInfo} */ (server.address()).port };
+}
+
+afterEach(async () => {
+ await Promise.all(servers.splice(0).map((server) => new Promise((r) => server.close(r))));
+});
+
+describe('Caching', () => {
+ test('caches pages', async () => {
+ const response = await get('/caching');
+ expect(response.headers.get('cache-control')).toBe('public, max-age=30');
+ });
+});
+
+describe('Content-Type', () => {
+ test('sets Content-Type on page', async () => {
+ const response = await get('/content-type-header');
+ expect(response.headers.get('content-type')).toBe('text/html');
+ });
+});
+
+describe('Content-Length', () => {
+ test('sets Content-Length on page', async () => {
+ const response = await get('/content-length-header');
+
+ expect(Number(response.headers.get('content-length'))).toBeGreaterThan(1000);
+ });
+});
+
+describe('Cookies', () => {
+ test('does not forward cookies from external domains', async () => {
+ const { port } = await start_server(async (req, res) => {
+ if (req.url === '/') {
+ res.writeHead(200, {
+ 'set-cookie': 'external=true',
+ 'access-control-allow-origin': '*'
+ });
+
+ res.end('ok');
+ } else {
+ res.writeHead(404);
+ res.end('not found');
+ }
+ });
+
+ const response = await get(`/load/fetch-external-no-cookies?port=${port}`);
+ expect(response.headers.getSetCookie().join('\n')).not.toContain('external=true');
+ });
+});
+
+describe.skipIf(dev)('CSRF', () => {
+ test('Blocks requests with incorrect origin', async () => {
+ const content_types = [
+ 'application/x-www-form-urlencoded',
+ 'multipart/form-data',
+ 'text/plain',
+ 'text/plaiN'
+ ];
+ const methods = ['POST', 'PUT', 'PATCH', 'DELETE'];
+ for (const method of methods) {
+ for (const content_type of content_types) {
+ const res = await get('/csrf', { method, headers: { 'content-type': content_type } });
+ const message = `request method: ${method}, content-type: ${content_type}`;
+ expect(res.status, message).toBe(403);
+ expect(await res.text(), message).toBe(
+ `Cross-site ${method} form submissions are forbidden`
+ );
+ }
+ }
+ });
+
+ test('Allows requests from same origin', async () => {
+ const res = await get('/csrf', {
+ method: 'POST',
+ headers: { 'content-type': 'application/x-www-form-urlencoded', origin }
+ });
+ expect(res.status).toBe(200);
+ expect(await res.text()).toBe('ok');
+ });
+
+ test('Allows requests from allowed origins', async () => {
+ // Test with trusted.example.com which is in trustedOrigins
+ const res1 = await get('/csrf', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/x-www-form-urlencoded',
+ origin: 'https://trusted.example.com'
+ }
+ });
+ expect(res1.status).toBe(200);
+ expect(await res1.text()).toBe('ok');
+
+ // Test with payment-gateway.test which is also in trustedOrigins
+ const res2 = await get('/csrf', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/x-www-form-urlencoded',
+ origin: 'https://payment-gateway.test'
+ }
+ });
+ expect(res2.status).toBe(200);
+ expect(await res2.text()).toBe('ok');
+ });
+
+ test('Blocks requests from non-allowed origins', async () => {
+ // Test with origin not in trustedOrigins list
+ const res1 = await get('/csrf', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/x-www-form-urlencoded',
+ origin: 'https://malicious-site.com'
+ }
+ });
+ expect(res1.status).toBe(403);
+ expect(await res1.text()).toBe('Cross-site POST form submissions are forbidden');
+
+ // Test with similar but not exact origin
+ const res2 = await get('/csrf', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/x-www-form-urlencoded',
+ origin: 'https://trusted.example.com.evil.com'
+ }
+ });
+ expect(res2.status).toBe(403);
+ expect(await res2.text()).toBe('Cross-site POST form submissions are forbidden');
+
+ // Test subdomain attack (should be blocked)
+ const res3 = await get('/csrf', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/x-www-form-urlencoded',
+ origin: 'https://evil.trusted.example.com'
+ }
+ });
+ expect(res3.status).toBe(403);
+ expect(await res3.text()).toBe('Cross-site POST form submissions are forbidden');
+ });
+
+ test('Allows GET requests regardless of origin', async () => {
+ const res = await get('/csrf', {
+ method: 'GET',
+ headers: {
+ 'content-type': 'application/x-www-form-urlencoded',
+ origin: 'https://any-origin.com'
+ }
+ });
+ expect(res.status).toBe(200);
+ });
+
+ test('Allows non-form content types regardless of origin', async () => {
+ const res = await get('/csrf', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ origin: 'https://any-origin.com'
+ }
+ });
+ expect(res.status).toBe(200);
+ });
+
+ test('Allows all protected HTTP methods from allowed origins', async () => {
+ const methods = ['POST', 'PUT', 'PATCH', 'DELETE'];
+ for (const method of methods) {
+ const res = await get('/csrf', {
+ method,
+ headers: {
+ 'content-type': 'application/x-www-form-urlencoded',
+ origin: 'https://trusted.example.com'
+ }
+ });
+ expect(res.status, `Method ${method} should be allowed from trusted origin`).toBe(200);
+ expect(await res.text(), `Method ${method} should return ok`).toBe('ok');
+ }
+ });
+
+ test('Handles undefined origin correctly', async () => {
+ // Some requests may have null origin (e.g., from certain mobile apps)
+ const res = await get('/csrf', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/x-www-form-urlencoded'
+ }
+ });
+ expect(res.status).toBe(403);
+ expect(await res.text()).toBe('Cross-site POST form submissions are forbidden');
+ });
+});
+
+describe('Endpoints', () => {
+ // the client goes away mid-stream, which nothing does to a direct call
+ test.skipIf(!dev)('stream can be canceled with TypeError', async () => {
+ const responseBefore = await get('/endpoint-output/stream-typeerror?what');
+ expect(await responseBefore.text()).toEqual('null');
+
+ await expect(get('/endpoint-output/stream-typeerror')).rejects.toThrow('fetch failed');
+
+ const responseAfter = await get('/endpoint-output/stream-typeerror?what');
+ expect(await responseAfter.text()).toEqual('TypeError');
+ });
+
+ test('HEAD with matching headers but without body', async () => {
+ const url = '/endpoint-output/body';
+
+ const responses = {
+ head: await get(url, { method: 'HEAD' }),
+ get: await get(url)
+ };
+
+ const headers = {
+ head: Object.fromEntries(responses.head.headers),
+ get: Object.fromEntries(responses.get.headers)
+ };
+
+ expect(responses.head.status).toBe(200);
+ expect(responses.get.status).toBe(200);
+ expect(await responses.head.text()).toBe('');
+ expect(await responses.get.text()).toBe('{}');
+
+ // the dev server's http layer adds these per connection
+ for (const name of ['connection', 'date', 'keep-alive']) {
+ delete headers.head[name];
+ delete headers.get[name];
+ }
+
+ expect(headers.head).toEqual(headers.get);
+ });
+
+ test('invalid request method returns allow header', async () => {
+ const response = await get('/endpoint-output/body', {
+ method: 'POST',
+ headers: { origin: 'https://trusted.example.com' }
+ });
+
+ expect(response.status).toBe(405);
+
+ const allow_header = response.headers.get('allow');
+ expect(allow_header).toMatch(/\bGET\b/);
+ expect(allow_header).toMatch(/\bHEAD\b/);
+ });
+
+ test('405 allow header has no duplicate methods listed', async () => {
+ const response = await get('/endpoint-output/head-handler', {
+ method: 'POST',
+ headers: { origin: 'https://trusted.example.com' }
+ });
+
+ expect(response.status).toBe(405);
+
+ const allow_header = /** @type {string} */ (response.headers.get('allow'));
+ const methods = allow_header.split(',').map((m) => m.trim());
+ const unique_methods = [...new Set(methods)];
+
+ expect(methods).toEqual(unique_methods);
+ });
+
+ test('serves page for GET request when endpoint has no GET handler', async () => {
+ const response = await get('/endpoint-output/post-only-with-page');
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get('content-type')).toContain('text/html');
+ expect(await response.text()).toContain('POST-only endpoint page');
+ });
+
+ test('serves page for HEAD request when endpoint has no HEAD or GET handler', async () => {
+ const response = await get('/endpoint-output/post-only-with-page', { method: 'HEAD' });
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get('content-type')).toContain('text/html');
+ expect(response.headers.get('x-sveltekit-page')).toBe('true');
+ expect(await response.text()).toBe('');
+ });
+
+ test('POST to post-only endpoint with sibling page still hits endpoint', async () => {
+ const response = await get('/endpoint-output/post-only-with-page', {
+ method: 'POST',
+ headers: { origin: 'https://trusted.example.com' }
+ });
+
+ expect(response.status).toBe(200);
+ expect(await response.text()).toBe('ok');
+ });
+
+ test('uses fallback handler instead of page when endpoint has no GET but has fallback', async () => {
+ const response = await get('/endpoint-output/fallback-with-page');
+
+ expect(response.status).toBe(200);
+ expect(await response.text()).toBe('catch-all');
+ });
+
+ test('content negotiation: API requests hit endpoint, browser requests hit page', async () => {
+ // application/json → endpoint
+ const api_response = await get('/routing/content-negotiation', {
+ headers: {
+ accept: 'application/json'
+ }
+ });
+
+ expect(api_response.status).toBe(200);
+ expect(await api_response.text()).toBe('GET');
+
+ // text/html → page
+ const html_response = await get('/routing/content-negotiation', {
+ headers: {
+ accept: 'text/html'
+ }
+ });
+
+ expect(html_response.status).toBe(200);
+ expect(html_response.headers.get('content-type')).toContain('text/html');
+ expect(html_response.headers.get('vary')).toBe('Accept');
+ expect(await html_response.text()).toContain('Hi');
+ });
+
+ test('multiple set-cookie on endpoints using GET', async () => {
+ const response = await get('/set-cookie');
+
+ const cookies = response.headers.getSetCookie();
+
+ expect(cookies).toEqual([
+ 'answer=42; HttpOnly',
+ 'problem=comma, separated, values; HttpOnly',
+ 'name=SvelteKit; path=/; HttpOnly'
+ ]);
+ });
+
+ test('body can be a binary ReadableStream', async () => {
+ await expect(
+ get('/endpoint-output/stream-throw-error').then((r) => r.arrayBuffer())
+ ).rejects.toThrow(dev ? 'fetch failed' : 'simulate error');
+
+ const response = await get('/endpoint-output/stream');
+ const body = Buffer.from(await response.arrayBuffer());
+ const digest = createHash('sha256').update(body).digest('base64url');
+ expect(response.headers.get('digest')).toEqual(`sha-256=${digest}`);
+ });
+
+ test('request body can be read slow', async () => {
+ const data = randomBytes(1024 * 256);
+ const digest = createHash('sha256').update(data).digest('base64url');
+ const response = await get('/endpoint-input/sha256', {
+ method: 'PUT',
+ headers: { 'content-type': 'application/octet-stream' },
+ body: data
+ });
+ expect(await response.text()).toEqual(digest);
+ });
+
+ test('OPTIONS handler', async () => {
+ const url = '/endpoint-output';
+
+ const response = await get(url, {
+ method: 'OPTIONS'
+ });
+
+ expect(response.status).toBe(200);
+ expect(await response.text()).toBe('ok');
+ });
+
+ test('HEAD handler', async () => {
+ const url = '/endpoint-output/head-handler';
+
+ const page_response = await get(url, {
+ method: 'HEAD',
+ headers: {
+ accept: 'text/html'
+ }
+ });
+
+ expect(page_response.status).toBe(200);
+ expect(await page_response.text()).toBe('');
+ expect(page_response.headers.get('x-sveltekit-page')).toBe('true');
+
+ const endpoint_response = await get(url, {
+ method: 'HEAD',
+ headers: {
+ accept: 'application/json'
+ }
+ });
+
+ expect(endpoint_response.status).toBe(200);
+ expect(await endpoint_response.text()).toBe('');
+ expect(endpoint_response.headers.get('x-sveltekit-head-endpoint')).toBe('true');
+ });
+
+ test('catch-all handler', async () => {
+ const url = '/endpoint-output/fallback';
+
+ let response = await get(url, {
+ method: 'GET'
+ });
+
+ expect(response.status).toBe(200);
+ expect(await response.text()).toBe('ok');
+
+ response = await get(url, {
+ method: 'MOVE' // also works with arcane methods
+ });
+
+ expect(response.status).toBe(200);
+ expect(await response.text()).toBe('catch-all');
+
+ response = await get(url, {
+ method: 'OPTIONS'
+ });
+
+ expect(response.status).toBe(200);
+ expect(await response.text()).toBe('catch-all');
+ });
+
+ test('QUERY handler', async () => {
+ const url = '/endpoint-output/query';
+
+ let response = await get(url, {
+ method: 'QUERY',
+ body: 'name=world'
+ });
+
+ expect(response.status).toBe(200);
+ expect(await response.text()).toBe('query: name=world');
+
+ response = await get(url, {
+ method: 'MOVE'
+ });
+
+ expect(response.status).toBe(405);
+ expect(response.headers.get('allow')).toBe('GET, QUERY, HEAD');
+ });
+
+ test('can get assets using relative path', async () => {
+ const response = await get('/endpoint-output/fetch-asset/relative');
+ expect(response.status).toBe(200);
+ expect(response.headers.get('content-type')).toBe('text/plain');
+ expect(await response.text()).toBe('Cos sie konczy, cos zaczyna');
+ });
+});
+
+describe('Errors', () => {
+ test('uses the handleError status for the fallback page served to error-page sub-requests', async () => {
+ const response = await get('/errors/handle-error-status-fallback', {
+ headers: { 'x-sveltekit-error': 'true' }
+ });
+ expect(response.status).toBe(503);
+ });
+
+ test('invalid route response is handled', async () => {
+ const response = await get('/errors/invalid-route-response');
+
+ expect(response.status).toBe(500);
+ expect(await response.text()).toMatch(
+ 'Invalid response from route /errors/invalid-route-response: handler should return a Response object'
+ );
+ });
+
+ test('unhandled http method', async () => {
+ const response = await get('/errors/invalid-route-response', {
+ method: 'PUT',
+ headers: { origin: 'https://trusted.example.com' }
+ });
+
+ expect(response.status).toBe(405);
+ expect(await response.text()).toMatch('PUT method not allowed');
+ });
+
+ test('error evaluating module', async () => {
+ const response = await get('/errors/init-error-endpoint');
+
+ expect(response.status).toBe(500);
+ expect(await response.text()).toMatch('thisvariableisnotdefined is not defined');
+ });
+
+ test('returns a lightweight 404 for subresource requests', async () => {
+ // distinct from the path used by the test below, which _does_ invoke the hook
+ const response = await get('/errors/does-not-exist-lightweight-subresource', {
+ headers: { 'sec-fetch-dest': 'image' }
+ });
+
+ expect(response.status).toBe(404);
+ expect(await response.text()).toBe('Not Found');
+ expect(response.headers.get('vary')).toContain('Sec-Fetch-Dest');
+
+ // lightweight 404s bypass the handleError hook
+ expect(read_errors('/errors/does-not-exist-lightweight-subresource')).toBe(undefined);
+ });
+
+ test('renders the error page for document and fetch requests', async () => {
+ for (const destination of ['document', null, 'empty']) {
+ const response = await get(
+ '/errors/does-not-exist-subresource',
+ destination ? { headers: { 'sec-fetch-dest': destination } } : {}
+ );
+
+ expect(response.status).toBe(404);
+ expect(await response.text()).toContain('This is your custom error page saying:');
+ }
+ });
+
+ test('stack traces are not fixed twice', async () => {
+ let { document } = await load('/errors/stack-trace');
+ expect(document.querySelector('#message')?.textContent).toBe(
+ 'This is your custom error page saying: "Cannot read properties of undefined (reading \'toUpperCase\') (500 Internal Error)"'
+ );
+
+ // check the stack wasn't mutated
+ ({ document } = await load('/errors/stack-trace'));
+ expect(document.querySelector('#message')?.textContent).toBe(
+ 'This is your custom error page saying: "Cannot read properties of undefined (reading \'toUpperCase\') (500 Internal Error)"'
+ );
+ });
+
+ test('error(...) in endpoint', async () => {
+ // HTML
+ {
+ const res = await get('/errors/endpoint-throw-error', {
+ headers: {
+ accept: 'text/html'
+ }
+ });
+
+ expect(read_errors('/errors/endpoint-throw-error')).toEqual({
+ kind: 'app',
+ error: { status: 401, message: 'You shall not pass' }
+ });
+
+ expect(res.status).toBe(401);
+ expect(await res.text()).toContain(
+ 'This is the static error page with the following message: You shall not pass'
+ );
+ }
+
+ // JSON (default)
+ {
+ const res = await get('/errors/endpoint-throw-error');
+
+ expect(read_errors('/errors/endpoint-throw-error')).toEqual({
+ kind: 'app',
+ error: { status: 401, message: 'You shall not pass' }
+ });
+
+ expect(res.status).toBe(401);
+ expect(await res.json()).toEqual({
+ message: 'You shall not pass',
+ status: 401
+ });
+ }
+ });
+
+ test('redirect(...) in endpoint', async () => {
+ const redirect = await get('/errors/endpoint-throw-redirect');
+ expect(redirect.status).toBe(302);
+ expect(redirect.headers.get('location')).toBe('/');
+
+ const { response, document } = await load('/errors/endpoint-throw-redirect');
+ expect(response.status).toBe(200);
+
+ const error = read_errors('/errors/endpoint-throw-redirect');
+ expect(error).toBe(undefined);
+
+ expect(document.querySelector('h1')?.textContent).toBe('the answer is 42');
+ });
+
+ test('POST to missing page endpoint', async () => {
+ const res = await get('/errors/missing-actions', {
+ method: 'POST',
+ headers: {
+ accept: 'text/html',
+ origin: 'https://trusted.example.com'
+ }
+ });
+ expect(res.status).toBe(405);
+
+ const res_json = await get('/errors/missing-actions', {
+ method: 'POST',
+ headers: {
+ accept: 'application/json',
+ origin: 'https://trusted.example.com'
+ }
+ });
+ expect(res_json.status).toBe(405);
+ expect(await res_json.json()).toEqual({
+ type: 'error',
+ location: '/errors/missing-actions',
+ error: {
+ message: 'Method Not Allowed (405 Method Not Allowed)',
+ status: 405
+ }
+ });
+ });
+
+ test('error thrown in handle results in a rendered error page or JSON response', async () => {
+ // HTML
+ {
+ const res = await get('/errors/error-in-handle', {
+ headers: {
+ accept: 'text/html'
+ }
+ });
+
+ expect(res.status).toBe(500);
+ expect(await res.text()).toContain(
+ 'This is the static error page with the following message: Error in handle'
+ );
+ }
+
+ // JSON (default)
+ {
+ const res = await get('/errors/error-in-handle');
+
+ const error = await res.json();
+
+ expect(error.stack).toBe(undefined);
+ expect(res.status).toBe(500);
+ expect(error).toEqual({
+ message: 'Error in handle (500 Internal Error)',
+ status: 500
+ });
+ }
+ });
+
+ test('expected error thrown in handle results in a rendered error page or JSON response', async () => {
+ // HTML
+ {
+ const res = await get('/errors/expected-error-in-handle', {
+ headers: {
+ accept: 'text/html'
+ }
+ });
+
+ expect(res.status).toBe(500);
+ expect(await res.text()).toContain(
+ 'This is the static error page with the following message: Expected error in handle'
+ );
+ }
+
+ // JSON (default)
+ {
+ const res = await get('/errors/expected-error-in-handle');
+
+ const error = await res.json();
+
+ expect(error.stack).toBe(undefined);
+ expect(res.status).toBe(500);
+ expect(error).toEqual({
+ message: 'Expected error in handle',
+ status: 500
+ });
+ }
+ });
+
+ test('error thrown from load on the server respects page options when rendering the error page', async () => {
+ const res = await get('/errors/load-error-page-options/csr');
+ expect(res.status).toBe(500);
+ const content = await res.text();
+ expect(content).toContain('Crashing now');
+ // the hydration script should not be present if the csr page option is respected
+ expect(content).not.toContain('kit.start(app');
+ });
+
+ test('returns root layout data for a missing route error page data request', async () => {
+ const data_response = await get(
+ '/this-route-does-not-exist/__data.json?x-sveltekit-invalidated=1'
+ );
+ expect(data_response.status).toBe(200);
+ expect(data_response.headers.get('content-type')).toContain('application/json');
+
+ const data = await data_response.json();
+ expect(data.type).toBe('data');
+ expect(data.nodes[0].type).toBe('data');
+ expect(data.nodes[0].data).toContain('rootlayout');
+
+ const page_response = await get('/this-route-does-not-exist/__data.json');
+ expect(page_response.status).toBe(404);
+ expect(page_response.headers.get('content-type')).toContain('text/html');
+
+ // a single-node request that invalidates nothing is not an error-page data request
+ const crafted_response = await get(
+ '/this-route-does-not-exist/__data.json?x-sveltekit-invalidated=0'
+ );
+ expect(crafted_response.status).toBe(404);
+ });
+
+ test('preserves metadata for a streamed missing route error page', async () => {
+ const response = await get('/non-existent-streamed-route', {
+ headers: { cookie: 'defer=true' }
+ });
+
+ expect(response.status).toBe(404);
+ expect(response.headers.get('etag')).toBeNull();
+
+ const body = await response.text();
+ expect(body).toMatch(/]*>404<\/h1>/);
+ expect(body).toContain('This is your custom error page');
+ });
+});
+
+describe('Load', () => {
+ test('fetch does not load a file with a # character', async () => {
+ const response = await get('/load/static-file-with-hash');
+ expect(await response.text()).toContain('status: 404');
+ });
+
+ test('fetching a non-existent resource in root layout fails without hanging', async () => {
+ const response = await get('/errors/error-in-layout');
+ expect(await response.text()).toContain('Error: 404');
+ });
+
+ test('fetch reads universal load assets on the server', async () => {
+ const { document } = await load('/load/fetch-asset');
+ expect(document.querySelector('p')?.textContent).toBe('1');
+ });
+
+ test('does not forward accept-language to internal fetch when the request has none', async () => {
+ // unlike browsers and fetch, a bare http client sends no accept-language header
+ const html = await new Promise((fulfil) => {
+ http.get(
+ `${origin}/load/fetch-request-headers`,
+ { headers: { accept: '*/*', 'user-agent': 'node' } },
+ (res) => {
+ let body = '';
+ res.on('data', (chunk) => (body += chunk));
+ res.on('end', () => fulfil(body));
+ }
+ );
+ });
+ const headers = JSON.parse(
+ /** @type {RegExpMatchArray} */ (/(.+?)<\/pre>/s.exec(html))[1]
+ );
+ expect(headers.accept).toBe('*/*');
+ expect(headers['accept-language']).toBeUndefined();
+ });
+
+ test('includes origin header on non-GET internal request', async () => {
+ const { document } = await load('/load/fetch-origin-internal');
+ expect(document.querySelector('h1')?.textContent).toBe(`origin: ${origin}`);
+ });
+
+ test('includes origin header on external request', async () => {
+ const { port } = await start_server((req, res) => {
+ if (req.url === '/') {
+ res.writeHead(200, {
+ 'content-type': 'application/json',
+ 'access-control-allow-origin': '*'
+ });
+
+ res.end(JSON.stringify({ origin: req.headers.origin }));
+ } else {
+ res.writeHead(404);
+ res.end('not found');
+ }
+ });
+
+ const { document } = await load(`/load/fetch-origin-external?port=${port}`);
+ expect(document.querySelector('h1')?.textContent).toBe(`origin: ${origin}`);
+ });
+
+ test('does not run when using invalid request methods', async () => {
+ const load_url = '/load';
+
+ let response = await get(load_url, {
+ method: 'OPTIONS'
+ });
+
+ expect(response.status).toBe(204);
+ expect(await response.text()).toBe('');
+ expect(response.headers.get('allow')).toBe('GET, HEAD, OPTIONS');
+
+ const actions_url = '/actions/enhance';
+ response = await get(actions_url, {
+ method: 'OPTIONS'
+ });
+
+ expect(response.status).toBe(204);
+ expect(await response.text()).toBe('');
+ expect(response.headers.get('allow')).toBe('GET, HEAD, OPTIONS, POST');
+ });
+
+ test('allows logging URL search params', async () => {
+ const { document } = await load('/load/server-log-search-param');
+
+ expect(document.querySelector('p')?.textContent).toBe('hello world');
+ });
+});
+
+describe('Routing', () => {
+ test('event.params are available in handle', async () => {
+ const response = await get('/routing/params-in-handle/banana');
+ expect(await response.json()).toStrictEqual({
+ key: '/routing/params-in-handle/[x]',
+ params: { x: 'banana' }
+ });
+ });
+
+ test('/favicon.ico is a valid route', async () => {
+ const response = await get('/favicon.ico');
+ expect(response.status).toBe(200);
+
+ const data = await response.json();
+ expect(data).toEqual({ surprise: 'lol' });
+ });
+
+ test('falls back to page actions if sibling endpoint has no POST handler', async () => {
+ const response = await get('/endpoint-output/actions-with-endpoint', {
+ method: 'POST',
+ body: new URLSearchParams(),
+ headers: {
+ accept: 'application/json',
+ origin
+ }
+ });
+
+ expect(response.status).toBe(200);
+ expect((await response.json()).type).toBe('success');
+ });
+});
+
+describe('Shadowed pages', () => {
+ test('Action can return undefined', async () => {
+ const response = await get('/shadowed/simple/post', {
+ method: 'POST',
+ body: new URLSearchParams(),
+ headers: {
+ accept: 'application/json',
+ origin
+ }
+ });
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({
+ type: 'success',
+ status: 204,
+ location: '/shadowed/simple/post'
+ });
+ });
+
+ test('action response includes the stripped landing location', async () => {
+ const response = await get('/actions/cross-page/destination?redirectTo=%2Fdashboard&/failure', {
+ method: 'POST',
+ body: new URLSearchParams({ username: 'paolo' }),
+ headers: {
+ accept: 'application/json',
+ origin
+ }
+ });
+
+ expect(response.status).toBe(400);
+ expect(await response.json()).toEqual({
+ type: 'failure',
+ status: 400,
+ location: '/actions/cross-page/destination?redirectTo=%2Fdashboard',
+ data: '[{"problem":1,"username":2},"invalid","paolo"]'
+ });
+ });
+
+ test('Action fail() returns matching HTTP status code', async () => {
+ const response = await get('/actions/form-errors', {
+ method: 'POST',
+ body: new URLSearchParams(),
+ headers: {
+ accept: 'application/json',
+ origin
+ }
+ });
+
+ expect(response.status).toBe(400);
+ const body = await response.json();
+ expect(body.type).toBe('failure');
+ expect(body.status).toBe(400);
+ });
+});
+
+describe('setHeaders', () => {
+ test('allows multiple set-cookie headers with different values', async () => {
+ const response = await get('/headers/set-cookie/sub');
+ const cookies = response.headers.getSetCookie().join('\n');
+
+ expect(cookies).toMatch('cookie1=value1');
+ expect(cookies).toMatch('cookie2=value2');
+ });
+});
+
+describe('cookies', () => {
+ test('cookie.stringifySetCookie created correct cookie header string', async () => {
+ const response = await get('/cookies/serialize');
+ const cookies = response.headers.getSetCookie().join('\n');
+
+ expect(cookies).toMatch('before=before');
+ expect(cookies).toMatch('after=after');
+ expect(cookies).toMatch('endpoint=endpoint');
+ });
+});
+
+describe('Miscellaneous', () => {
+ test('handles responses with immutable headers', async () => {
+ const response = await get('/immutable-headers');
+ expect(response.status).toBe(200);
+ expect(await response.text()).toBe('foo');
+ });
+
+ test('does not send x-sveltekit-version header on document responses', async () => {
+ const response = await get('/');
+ expect(response.headers.get('x-sveltekit-version')).toBeNull();
+ });
+
+ test('sends x-sveltekit-version header on data responses', async () => {
+ const response = await get('/__data.json');
+ expect(response.headers.get('x-sveltekit-version')).toBeTruthy();
+ });
+});
+
+describe('reroute', () => {
+ test('Apply reroute when directly accessing a page', async () => {
+ const { document } = await load('/reroute/basic/a');
+ expect(document.querySelector('h1')?.textContent).toContain(
+ 'Successfully rewritten, URL should still show a: /reroute/basic/a'
+ );
+ });
+
+ test('Apply async reroute when directly accessing a page', async () => {
+ const { document } = await load('/reroute/async/a', {
+ headers: { cookie: 'reroute-cookie=yes' }
+ });
+ expect(document.querySelector('h1')?.textContent).toContain(
+ 'Successfully rewritten, URL should still show a: /reroute/async/a'
+ );
+ });
+
+ test('Returns a 500 response if reroute throws an error on the server', async () => {
+ const response = await get('/reroute/error-handling/server-error');
+ expect(response.status).toBe(500);
+ });
+});
+
+describe('init', () => {
+ test('init server hook is called once before the load function', async () => {
+ let { document } = await load('/init-hooks');
+ expect(document.querySelector('p')?.textContent).toBe('1');
+ ({ document } = await load('/init-hooks'));
+ expect(document.querySelector('p')?.textContent).toBe('1');
+ });
+});
+
+describe('getRequestEvent', () => {
+ test('getRequestEvent works in server endpoints', async () => {
+ const response = await get('/get-request-event/endpoint');
+ expect(await response.text()).toBe('hello from hooks.server.js');
+ });
+});
+
+describe('$app/forms', () => {
+ test('deserialize works on the server', async () => {
+ const response = await get('/serialization-form-non-enhanced/server-deserialize');
+ expect(await response.json()).toEqual({ data: 'It works!' });
+ });
+});
+
+describe('$app/env', () => {
+ test.skipIf(dev)('treeshakes dev check', async () => {
+ const code = fs.readFileSync(
+ path.join(root, '.svelte-kit/output/server/entries/pages/treeshaking/dev/_page.svelte.js'),
+ 'utf-8'
+ );
+ expect(code).not.toContain('not prod');
+ });
+
+ test.skipIf(dev)('treeshakes browser check', async () => {
+ const code = fs.readFileSync(
+ path.join(
+ root,
+ '.svelte-kit/output/server/entries/pages/treeshaking/browser/_page.svelte.js'
+ ),
+ 'utf-8'
+ );
+ expect(code).not.toMatch('client');
+ });
+});
+
+describe('web workers', () => {
+ test.skipIf(dev)(
+ 'worker files emitted by the server build are copied to the client output',
+ () => {
+ const workers = path.join(root, '.svelte-kit/output/client/_app/immutable/workers');
+ expect(fs.readdirSync(workers).some((file) => file.startsWith('worker-'))).toBe(true);
+ }
+ );
+});
+
+describe('tracing', () => {
+ /**
+ * @param {import('../../../types.js').SpanTree} span
+ * @returns {import('../../../types.js').SpanTree | null}
+ */
+ function find_resolve_root_span(span) {
+ if (span.name === 'sveltekit.resolve') {
+ return span;
+ }
+ for (const child of span.children || []) {
+ const found = find_resolve_root_span(child);
+ if (found) return found;
+ }
+ return null;
+ }
+
+ test('correct spans are created for a regular navigation', async () => {
+ const test_id = randomBytes(8).toString('hex');
+ await get(`/tracing/one/two/three/four/five?test_id=${test_id}`);
+ const traces = read_traces(test_id);
+ expect(traces.length).toBeGreaterThan(0);
+
+ const trace = traces[0];
+ const trace_id = trace.trace_id;
+
+ // Verify root span structure
+ expect(trace).toEqual({
+ name: 'sveltekit.handle.root',
+ status: { code: 0 },
+ start_time: [expect.any(Number), expect.any(Number)],
+ end_time: [expect.any(Number), expect.any(Number)],
+ attributes: {
+ 'http.route': '/tracing/one/two/three/[...four]',
+ 'http.method': 'GET',
+ 'http.url': expect.stringContaining(`/tracing/one/two/three/four/five?test_id=${test_id}`),
+ 'sveltekit.is_data_request': false,
+ 'sveltekit.is_sub_request': false,
+ test_id
+ },
+ links: [],
+ trace_id,
+ span_id: expect.any(String),
+ children: expect.arrayContaining([
+ expect.objectContaining({
+ name: 'sveltekit.handle.sequenced.set_tracing_test_id',
+ attributes: {}
+ })
+ ])
+ });
+
+ // Find and verify the resolve.root span
+ const resolve_root_span = find_resolve_root_span(trace);
+ expect(resolve_root_span).not.toBeNull();
+ expect(resolve_root_span).toEqual({
+ name: 'sveltekit.resolve',
+ status: { code: 0 },
+ start_time: [expect.any(Number), expect.any(Number)],
+ end_time: [expect.any(Number), expect.any(Number)],
+ attributes: {
+ 'http.route': '/tracing/one/two/three/[...four]',
+ 'http.response.status_code': 200,
+ 'http.response.body.size': expect.stringMatching(/^\d+$/)
+ },
+ links: [],
+ trace_id,
+ span_id: expect.any(String),
+ parent_span_id: expect.any(String),
+ children: [
+ {
+ name: 'sveltekit.load',
+ status: { code: 0 },
+ start_time: [expect.any(Number), expect.any(Number)],
+ end_time: [expect.any(Number), expect.any(Number)],
+ attributes: {
+ 'sveltekit.load.node_id': 'src/routes/+layout.server.js',
+ 'sveltekit.load.node_type': '+layout.server',
+ 'sveltekit.load.environment': 'server',
+ 'http.route': '/tracing/one/two/three/[...four]'
+ },
+ links: [],
+ trace_id,
+ span_id: expect.any(String),
+ parent_span_id: expect.any(String),
+ children: []
+ },
+ {
+ name: 'sveltekit.load',
+ status: { code: 0 },
+ start_time: [expect.any(Number), expect.any(Number)],
+ end_time: [expect.any(Number), expect.any(Number)],
+ attributes: {
+ 'sveltekit.load.node_id': 'src/routes/+layout.js',
+ 'sveltekit.load.node_type': '+layout',
+ 'sveltekit.load.environment': 'server',
+ 'http.route': '/tracing/one/two/three/[...four]'
+ },
+ links: [],
+ trace_id,
+ span_id: expect.any(String),
+ parent_span_id: expect.any(String),
+ children: []
+ }
+ ]
+ });
+ });
+
+ test('correct spans are created for HttpError', async () => {
+ const test_id = randomBytes(8).toString('hex');
+ const response = await get(`/tracing/http-error?test_id=${test_id}`);
+ expect(response.status).toBe(500);
+
+ const traces = read_traces(test_id);
+ const trace_id = traces[0].trace_id;
+ const trace = traces[0];
+
+ // Verify root span structure
+ expect(trace).toEqual({
+ name: 'sveltekit.handle.root',
+ status: { code: 0 },
+ start_time: [expect.any(Number), expect.any(Number)],
+ end_time: [expect.any(Number), expect.any(Number)],
+ attributes: {
+ 'http.route': '/tracing/http-error',
+ 'http.method': 'GET',
+ 'http.url': expect.stringContaining(`/tracing/http-error?test_id=${test_id}`),
+ 'sveltekit.is_data_request': false,
+ 'sveltekit.is_sub_request': false,
+ test_id
+ },
+ links: [],
+ trace_id,
+ span_id: expect.any(String),
+ children: expect.arrayContaining([
+ expect.objectContaining({
+ name: 'sveltekit.handle.sequenced.set_tracing_test_id',
+ attributes: {}
+ })
+ ])
+ });
+
+ // Find and verify the resolve.root span
+ const resolve_root_span = find_resolve_root_span(trace);
+ expect(resolve_root_span).not.toBeNull();
+ expect(resolve_root_span).toEqual({
+ name: 'sveltekit.resolve',
+ status: { code: 0 },
+ start_time: [expect.any(Number), expect.any(Number)],
+ end_time: [expect.any(Number), expect.any(Number)],
+ attributes: {
+ 'http.route': '/tracing/http-error',
+ 'http.response.status_code': 500,
+ 'http.response.body.size': expect.stringMatching(/^\d+$/)
+ },
+ links: [],
+ trace_id,
+ span_id: expect.any(String),
+ parent_span_id: expect.any(String),
+ children: expect.arrayContaining([
+ expect.objectContaining({
+ name: 'sveltekit.load',
+ status: { code: 2, message: 'Internal server error from tracing test' },
+ attributes: expect.objectContaining({
+ 'sveltekit.load.node_id': 'src/routes/tracing/http-error/+page.server.js',
+ 'sveltekit.load.result.type': 'known_error',
+ 'sveltekit.load.result.status': 500,
+ 'sveltekit.load.result.message': 'Internal server error from tracing test'
+ })
+ })
+ ])
+ });
+ });
+
+ test('correct spans are created for Redirect', async () => {
+ const test_id = randomBytes(8).toString('hex');
+ const r1 = await get(`/tracing/redirect?test_id=${test_id}`);
+ expect(r1.status).toBe(307);
+
+ const r2 = await get(/** @type {string} */ (r1.headers.get('location')));
+ expect(r2.status).toBe(200);
+
+ const traces = read_traces(test_id);
+ expect(traces).toHaveLength(2);
+ const redirect_trace_id = traces[0].trace_id;
+ const destination_trace_id = traces[1].trace_id;
+
+ const redirect_trace = traces[0];
+ const destination_trace = traces[1];
+
+ // Verify redirect trace root span structure
+ expect(redirect_trace).toEqual({
+ name: 'sveltekit.handle.root',
+ status: { code: 0 },
+ start_time: [expect.any(Number), expect.any(Number)],
+ end_time: [expect.any(Number), expect.any(Number)],
+ attributes: {
+ 'http.route': '/tracing/redirect',
+ 'http.method': 'GET',
+ 'http.url': expect.stringContaining(`/tracing/redirect?test_id=${test_id}`),
+ 'sveltekit.is_data_request': false,
+ 'sveltekit.is_sub_request': false,
+ test_id
+ },
+ links: [],
+ trace_id: redirect_trace_id,
+ span_id: expect.any(String),
+ children: expect.arrayContaining([
+ expect.objectContaining({
+ name: 'sveltekit.handle.sequenced.set_tracing_test_id',
+ attributes: {}
+ })
+ ])
+ });
+
+ // Find and verify the redirect resolve.root span
+ const redirect_resolve_root_span = find_resolve_root_span(redirect_trace);
+ expect(redirect_resolve_root_span).not.toBeNull();
+ expect(redirect_resolve_root_span).toEqual({
+ name: 'sveltekit.resolve',
+ status: { code: 0 },
+ start_time: [expect.any(Number), expect.any(Number)],
+ end_time: [expect.any(Number), expect.any(Number)],
+ attributes: {
+ 'http.route': '/tracing/redirect',
+ 'http.response.status_code': 307,
+ 'http.response.body.size': expect.stringMatching(/^\d+$|^unknown$/)
+ },
+ links: [],
+ trace_id: redirect_trace_id,
+ span_id: expect.any(String),
+ parent_span_id: expect.any(String),
+ children: expect.arrayContaining([
+ expect.objectContaining({
+ name: 'sveltekit.load',
+ status: { code: 0 },
+ attributes: expect.objectContaining({
+ 'sveltekit.load.node_id': 'src/routes/tracing/redirect/+page.server.js',
+ 'sveltekit.load.result.type': 'redirect',
+ 'sveltekit.load.result.status': 307,
+ 'sveltekit.load.result.location': `/tracing/one/two/three/four/five?test_id=${test_id}`
+ })
+ })
+ ])
+ });
+
+ // Verify destination trace root span structure
+ expect(destination_trace).toEqual({
+ name: 'sveltekit.handle.root',
+ status: { code: 0 },
+ start_time: [expect.any(Number), expect.any(Number)],
+ end_time: [expect.any(Number), expect.any(Number)],
+ attributes: {
+ 'http.route': '/tracing/one/two/three/[...four]',
+ 'http.method': 'GET',
+ 'http.url': expect.stringContaining(`/tracing/one/two/three/four/five?test_id=${test_id}`),
+ 'sveltekit.is_data_request': false,
+ 'sveltekit.is_sub_request': false,
+ test_id
+ },
+ links: [],
+ trace_id: destination_trace_id,
+ span_id: expect.any(String),
+ children: expect.arrayContaining([
+ expect.objectContaining({
+ name: 'sveltekit.handle.sequenced.set_tracing_test_id',
+ attributes: {}
+ })
+ ])
+ });
+
+ // Find and verify the destination resolve.root span
+ const destination_resolve_root_span = find_resolve_root_span(destination_trace);
+ expect(destination_resolve_root_span).not.toBeNull();
+ expect(destination_resolve_root_span).toEqual({
+ name: 'sveltekit.resolve',
+ status: { code: 0 },
+ start_time: [expect.any(Number), expect.any(Number)],
+ end_time: [expect.any(Number), expect.any(Number)],
+ attributes: {
+ 'http.route': '/tracing/one/two/three/[...four]',
+ 'http.response.status_code': 200,
+ 'http.response.body.size': expect.stringMatching(/^\d+$/)
+ },
+ links: [],
+ trace_id: destination_trace_id,
+ span_id: expect.any(String),
+ parent_span_id: expect.any(String),
+ children: [
+ {
+ name: 'sveltekit.load',
+ status: { code: 0 },
+ start_time: [expect.any(Number), expect.any(Number)],
+ end_time: [expect.any(Number), expect.any(Number)],
+ attributes: {
+ 'sveltekit.load.node_id': 'src/routes/+layout.server.js',
+ 'sveltekit.load.node_type': '+layout.server',
+ 'sveltekit.load.environment': 'server',
+ 'http.route': '/tracing/one/two/three/[...four]'
+ },
+ links: [],
+ trace_id: destination_trace_id,
+ span_id: expect.any(String),
+ parent_span_id: expect.any(String),
+ children: []
+ },
+ {
+ name: 'sveltekit.load',
+ status: { code: 0 },
+ start_time: [expect.any(Number), expect.any(Number)],
+ end_time: [expect.any(Number), expect.any(Number)],
+ attributes: {
+ 'sveltekit.load.node_id': 'src/routes/+layout.js',
+ 'sveltekit.load.node_type': '+layout',
+ 'sveltekit.load.environment': 'server',
+ 'http.route': '/tracing/one/two/three/[...four]'
+ },
+ links: [],
+ trace_id: destination_trace_id,
+ span_id: expect.any(String),
+ parent_span_id: expect.any(String),
+ children: []
+ }
+ ]
+ });
+ });
+
+ test('correct spans are created for regular Error', async () => {
+ const test_id = randomBytes(8).toString('hex');
+ const response = await get(`/tracing/regular-error?test_id=${test_id}`);
+ expect(response.status).toBe(500);
+
+ const traces = read_traces(test_id);
+ const trace_id = traces[0].trace_id;
+ const trace = traces[0];
+
+ // Verify root span structure
+ expect(trace).toEqual({
+ name: 'sveltekit.handle.root',
+ status: { code: 0 },
+ start_time: [expect.any(Number), expect.any(Number)],
+ end_time: [expect.any(Number), expect.any(Number)],
+ attributes: {
+ 'http.route': '/tracing/regular-error',
+ 'http.method': 'GET',
+ 'http.url': expect.stringContaining(`/tracing/regular-error?test_id=${test_id}`),
+ 'sveltekit.is_data_request': false,
+ 'sveltekit.is_sub_request': false,
+ test_id
+ },
+ links: [],
+ trace_id,
+ span_id: expect.any(String),
+ children: expect.arrayContaining([
+ expect.objectContaining({
+ name: 'sveltekit.handle.sequenced.set_tracing_test_id',
+ attributes: {}
+ })
+ ])
+ });
+
+ // Find and verify the resolve.root span
+ const resolve_root_span = find_resolve_root_span(trace);
+ expect(resolve_root_span).not.toBeNull();
+ expect(resolve_root_span).toEqual({
+ name: 'sveltekit.resolve',
+ status: { code: 0 },
+ start_time: [expect.any(Number), expect.any(Number)],
+ end_time: [expect.any(Number), expect.any(Number)],
+ attributes: {
+ 'http.route': '/tracing/regular-error',
+ 'http.response.status_code': 500,
+ 'http.response.body.size': expect.stringMatching(/^\d+$/)
+ },
+ links: [],
+ trace_id,
+ span_id: expect.any(String),
+ parent_span_id: expect.any(String),
+ children: expect.arrayContaining([
+ expect.objectContaining({
+ name: 'sveltekit.load',
+ status: { code: 2, message: 'Regular error from tracing test' },
+ attributes: expect.objectContaining({
+ 'sveltekit.load.node_id': 'src/routes/tracing/regular-error/+page.server.js',
+ 'sveltekit.load.result.type': 'unknown_error'
+ })
+ })
+ ])
+ });
+ });
+
+ test('correct spans are created for non-error object', async () => {
+ const test_id = randomBytes(8).toString('hex');
+ const response = await get(`/tracing/non-error-object?test_id=${test_id}`);
+ expect(response.status).toBe(500);
+
+ const traces = read_traces(test_id);
+ const trace_id = traces[0].trace_id;
+ const trace = traces[0];
+
+ // Verify root span structure
+ expect(trace).toEqual({
+ name: 'sveltekit.handle.root',
+ status: { code: 0 },
+ start_time: [expect.any(Number), expect.any(Number)],
+ end_time: [expect.any(Number), expect.any(Number)],
+ attributes: {
+ 'http.route': '/tracing/non-error-object',
+ 'http.method': 'GET',
+ 'http.url': expect.stringContaining(`/tracing/non-error-object?test_id=${test_id}`),
+ 'sveltekit.is_data_request': false,
+ 'sveltekit.is_sub_request': false,
+ test_id
+ },
+ links: [],
+ trace_id,
+ span_id: expect.any(String),
+ children: expect.arrayContaining([
+ expect.objectContaining({
+ name: 'sveltekit.handle.sequenced.set_tracing_test_id',
+ attributes: {}
+ })
+ ])
+ });
+
+ // Find and verify the resolve.root span
+ const resolve_root_span = find_resolve_root_span(trace);
+ expect(resolve_root_span).not.toBeNull();
+ expect(resolve_root_span).toEqual({
+ name: 'sveltekit.resolve',
+ status: { code: 0 },
+ start_time: [expect.any(Number), expect.any(Number)],
+ end_time: [expect.any(Number), expect.any(Number)],
+ attributes: {
+ 'http.route': '/tracing/non-error-object',
+ 'http.response.status_code': 500,
+ 'http.response.body.size': expect.stringMatching(/^\d+$/)
+ },
+ links: [],
+ trace_id,
+ span_id: expect.any(String),
+ parent_span_id: expect.any(String),
+ children: expect.arrayContaining([
+ expect.objectContaining({
+ name: 'sveltekit.load',
+ status: { code: 2 },
+ attributes: expect.objectContaining({
+ 'sveltekit.load.node_id': 'src/routes/tracing/non-error-object/+page.server.js',
+ 'sveltekit.load.result.type': 'unknown_error'
+ })
+ })
+ ])
+ });
+ });
+});
+
+describe.skipIf(dev)('asset preload', () => {
+ test('does not inject Link headers', async () => {
+ const response = await get('/asset-preload');
+
+ const header = response.headers.get('link');
+ expect(header).toBeNull();
+ });
+
+ test('injects tags', async () => {
+ const response = await get('/asset-preload');
+
+ const body = await response.text();
+
+ expect(body).toContain('rel="modulepreload"');
+ expect(body).toContain('as="font"');
+ expect(body).toMatch(/href="[^"]+\/shlop\.[^".]+\.woff2"/);
+ expect(body).toMatch(/href="[^"]+\/shlop\.var\.[^".]+\.woff2"/);
+ // the emitted file name is sanitized, but the filter matched on the original `shlop+bold.woff2`
+ expect(body).toMatch(/href="[^"]+\/shlop_bold\.[^".]+\.woff2"/);
+ });
+});
+
+describe('Streaming', () => {
+ test("Discarded promises from server load functions don't hang SSR request", async () => {
+ let error;
+ try {
+ await (await get('/streaming/discarded-promise')).text();
+ } catch (e) {
+ error = e;
+ }
+
+ expect(error).toBeUndefined();
+ });
+});
diff --git a/packages/kit/test/apps/basics/vite.config.js b/packages/kit/test/apps/basics/vite.config.js
index 2f6ab39f9af3..fd8aed33a0b5 100644
--- a/packages/kit/test/apps/basics/vite.config.js
+++ b/packages/kit/test/apps/basics/vite.config.js
@@ -119,6 +119,6 @@ export default defineConfig({
],
headless: true
},
- include: ['unit-test/**/*.spec.js']
+ include: ['unit-test/client.spec.js']
}
});
diff --git a/packages/kit/test/apps/basics/vitest.server.config.js b/packages/kit/test/apps/basics/vitest.server.config.js
new file mode 100644
index 000000000000..9bf6e9d0a0bd
--- /dev/null
+++ b/packages/kit/test/apps/basics/vitest.server.config.js
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ name: 'kit-basics-server',
+ // for DOMParser
+ environment: 'jsdom',
+ include: ['unit-test/server.spec.js']
+ }
+});
diff --git a/packages/kit/test/records.js b/packages/kit/test/records.js
new file mode 100644
index 000000000000..bb6416cc9948
--- /dev/null
+++ b/packages/kit/test/records.js
@@ -0,0 +1,51 @@
+/** @import { SpanData, SpanTree } from './types.js' */
+import fs from 'node:fs';
+
+/**
+ * The most recent error a test app's `handleError` hook appended for `path`
+ * @param {string} file the app's errors.jsonl
+ * @param {string} path
+ */
+export function read_errors(file, path) {
+ if (!fs.existsSync(file)) return;
+
+ const records = fs.readFileSync(file, 'utf8').split('\n');
+ records.pop(); // ignore a trailing partial record if this races an append
+
+ const match = records.map((line) => JSON.parse(line)).findLast((error) => error.path === path);
+
+ if (match) {
+ const { path: _, ...error } = match;
+ return error;
+ }
+}
+
+/**
+ * The span trees a test app's instrumentation exported for `test_id`
+ * @param {string} file the app's spans.jsonl
+ * @param {string} test_id
+ */
+export function read_traces(file, test_id) {
+ const raw = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
+ const traces = /** @type {SpanData[]} */ (raw.map((line) => JSON.parse(line)));
+
+ return traces
+ .filter((t) => t.parent_span_id === undefined && t.attributes.test_id === test_id)
+ .map((root_trace) => {
+ const child_traces = traces.filter((span) => span.trace_id === root_trace.trace_id);
+ return build_span_tree(root_trace, child_traces);
+ });
+}
+
+/**
+ * @param {SpanData} current_span
+ * @param {SpanData[]} child_spans
+ * @returns {SpanTree}
+ */
+function build_span_tree(current_span, child_spans) {
+ const children = child_spans.filter((span) => span.parent_span_id === current_span.span_id);
+ return {
+ ...current_span,
+ children: children.map((child) => build_span_tree(child, child_spans))
+ };
+}
diff --git a/packages/kit/test/utils.js b/packages/kit/test/utils.js
index c1dcf6acd143..5241602291d8 100644
--- a/packages/kit/test/utils.js
+++ b/packages/kit/test/utils.js
@@ -1,4 +1,3 @@
-/** @import { SpanData, SpanTree } from './types.js' */
// This helps `pnpm check` pass in the test apps without having to include
// the ambient.d.ts file in each of their tsconfig.json files.
/** @import {} from './ambient.js' */
@@ -10,6 +9,7 @@ import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { defineConfig, test as base, devices } from '@playwright/test';
import { number_from_env } from '../../../test-utils/index.js';
+import { read_errors, read_traces } from './records.js';
/** @type {typeof import('./types.js')['test']} */
export const test = base.extend({
@@ -146,42 +146,12 @@ export const test = base.extend({
// eslint-disable-next-line no-empty-pattern -- Playwright doesn't let us use `_` as a parameter name. It must be a destructured object
read_errors: async ({}, use) => {
- /** @param {string} path */
- function read_errors(path) {
- if (!fs.existsSync('test/errors.jsonl')) return;
-
- const records = fs.readFileSync('test/errors.jsonl', 'utf8').split('\n');
- records.pop(); // ignore a trailing partial record if this races an append
-
- const match = records
- .map((line) => JSON.parse(line))
- .findLast((error) => error.path === path);
-
- if (match) {
- const { path: _, ...error } = match;
- return error;
- }
- }
-
- await use(read_errors);
+ await use((/** @type {string} */ path) => read_errors('test/errors.jsonl', path));
},
// eslint-disable-next-line no-empty-pattern -- Playwright doesn't let us use `_` as a parameter name. It must be a destructured object
read_traces: async ({}, use) => {
- /** @param {string} test_id */
- function read_traces(test_id) {
- const raw = fs.readFileSync('test/spans.jsonl', 'utf8').split('\n').filter(Boolean);
- const traces = /** @type {SpanData[]} */ (raw.map((line) => JSON.parse(line)));
-
- return traces
- .filter((t) => t.parent_span_id === undefined && t.attributes.test_id === test_id)
- .map((root_trace) => {
- const child_traces = traces.filter((span) => span.trace_id === root_trace.trace_id);
- return build_span_tree(root_trace, child_traces);
- });
- }
-
- await use(read_traces);
+ await use((/** @type {string} */ test_id) => read_traces('test/spans.jsonl', test_id));
},
// eslint-disable-next-line no-empty-pattern -- Playwright doesn't let us use `_` as a parameter name. It must be a destructured object
@@ -383,16 +353,3 @@ export const config = defineConfig({
testDir: 'test',
testMatch: /(.+\.)?(test|spec)\.[jt]s/
});
-
-/**
- * @param {SpanData} current_span
- * @param {SpanData[]} child_spans
- * @returns {SpanTree}
- */
-function build_span_tree(current_span, child_spans) {
- const children = child_spans.filter((span) => span.parent_span_id === current_span.span_id);
- return {
- ...current_span,
- children: children.map((child) => build_span_tree(child, child_spans))
- };
-}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0ef72ca60118..c3b2f2d798a7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -888,6 +888,9 @@ importers:
'@vitest/browser-playwright':
specifier: 'catalog:'
version: 4.1.10(playwright@1.62.1)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.4.2)(yaml@2.9.0))(vitest@4.1.10)
+ jsdom:
+ specifier: 'catalog:'
+ version: 30.0.1
svelte:
specifier: 'catalog:'
version: 5.57.0(@typescript-eslint/types@8.61.1)