diff --git a/.changeset/nice-mirrors-scream.md b/.changeset/nice-mirrors-scream.md new file mode 100644 index 000000000000..eded4ba724cc --- /dev/null +++ b/.changeset/nice-mirrors-scream.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: add official test utils for remote functions diff --git a/documentation/docs/30-advanced/75-testing.md b/documentation/docs/30-advanced/75-testing.md new file mode 100644 index 000000000000..df61fba71114 --- /dev/null +++ b/documentation/docs/30-advanced/75-testing.md @@ -0,0 +1,304 @@ +--- +title: Testing +--- + +SvelteKit provides test utilities in `@sveltejs/kit/test` for unit testing [remote functions](remote-functions) and components that use them. The core utilities (`createTestEvent`, `withRequestContext`, `callRemote`, `setLocals`, `mockRemote`) are test-runner agnostic. + +A [Vitest](https://vitest.dev) plugin is also provided that supplies automatic request context, virtual module resolution, and `.remote.ts` file transforms. + +## Testing remote function logic + +Test the server-side business logic of your remote functions — call them directly and assert on the result. + +### With the Vitest plugin + +The `svelteKitTest` plugin handles virtual module resolution and establishes a request context automatically for each test: + +```js +/// file: vitest.config.js +import { svelteKitTest } from '@sveltejs/kit/test/vitest'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [+++svelteKitTest()+++] +}); +``` + +Remote functions can then be called directly — no wrappers needed: + +```js +/// file: src/routes/blog/data.remote.test.ts +import { getPosts } from './data.remote'; + +test('returns blog posts', async () => { + const posts = await getPosts(); + expect(posts.length).toBeGreaterThan(0); + expect(posts[0]).toHaveProperty('title'); +}); +``` + +Use `setLocals` to configure `event.locals` for the current test: + +```js +/// file: src/routes/dashboard/data.remote.test.ts +import { setLocals } from '@sveltejs/kit/test'; +import { getDashboard } from './data.remote'; + +beforeEach(() => { + setLocals({ user: { id: '123', role: 'admin' } }); +}); + +test('returns dashboard for authenticated user', async () => { + const dashboard = await getDashboard(); + // getRequestEvent().locals.user is available inside the remote function + expect(dashboard.role).toBe('admin'); +}); +``` + +### Without the Vitest plugin + +Use `createTestEvent` and `withRequestContext` directly with any test runner: + +```js +import { createTestEvent, withRequestContext } from '@sveltejs/kit/test'; +import { getPosts } from './data.remote'; + +test('returns blog posts', async () => { + const event = createTestEvent({ locals: { user: { id: '123' } } }); + const posts = await withRequestContext(event, () => getPosts()); + expect(posts.length).toBeGreaterThan(0); +}); +``` + +You will need to configure virtual module aliases manually in your test runner for `$app/server` and its dependencies. + +### callRemote + +`callRemote` is a convenience wrapper that auto-detects the function type and sets the HTTP method accordingly (GET for queries, POST for commands and forms): + +```js +import { callRemote } from '@sveltejs/kit/test'; +import { getPosts, addPost } from './data.remote'; + +// queries auto-detect GET +const posts = await callRemote(getPosts); + +// commands auto-detect POST +const result = await callRemote(addPost, { title: 'Hello', content: '...' }); +``` + +For forms, `callRemote` invokes the internal handler and returns the full output: + +```js +import { callRemote } from '@sveltejs/kit/test'; +import { contactForm } from './contact.remote'; + +const output = await callRemote(contactForm, { name: 'Alice', message: 'Hi' }); +// output.result — the handler's return value +// output.issues — validation issues (if any) +``` + +### Validation errors + +When a remote function's schema validation fails, an [`HttpValidationError`](@sveltejs-kit-test#HttpValidationError) is thrown with typed `.issues`: + +```js +import { HttpValidationError } from '@sveltejs/kit/test'; +import { addPost } from './data.remote'; + +test('rejects invalid input', async () => { + try { + await addPost({ title: '' }); // schema requires non-empty title + } catch (e) { + if (e instanceof HttpValidationError) { + expect(e.status).toBe(400); + expect(e.issues[0].message).toBe('Title is required'); + } + } +}); +``` + +`HttpValidationError` extends `HttpError`, so existing `instanceof HttpError` checks still pass. + +## Testing components + +Test Svelte components that use remote functions by controlling what data they receive, without executing server logic. + +> [!NOTE] Component testing with `mockRemote` requires the `svelteKitTest({ mode: 'component' })` Vitest plugin. The plugin transforms `.remote.ts` imports to use a mock runtime that reads from the `mockRemote` registry — without it, the imports resolve to server-side code and mocks have no effect. + +### Setup + +Use the plugin in component mode. This transforms `.remote.ts` imports to use a mock runtime with reactive objects instead of making HTTP requests: + +```js +/// file: vitest.config.js +import { sveltekit } from '@sveltejs/kit/vite'; +import { svelteKitTest } from '@sveltejs/kit/test/vitest'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [sveltekit(), svelteKitTest({ +++mode: 'component'+++ })] +}); +``` + +### Mocking queries and commands + +Given a component that renders data from a remote query: + +```svelte + + + +{#if posts.loading} +

Loading...

+{:else} + +{/if} +``` + +Use `mockRemote` to control what data the component receives: + +```js +/// file: src/routes/blog/+page.component.test.ts +import { mockRemote } from '@sveltejs/kit/test'; +import { getPosts } from './data.remote'; + +test('renders blog posts', async () => { + mockRemote(getPosts).returns([ + { title: 'First post' }, + { title: 'Second post' } + ]); + + // render the component and assert on the output +}); +``` + +Mock queries provide the same reactive interface components expect (`.current`, `.loading`, `.ready`, `.error`), backed by Svelte 5 `$state`. + +You can also simulate errors: + +```js +mockRemote(getPosts).throws(500, { message: 'Database unavailable' }); +``` + +Or provide a dynamic resolver: + +```js +mockRemote(getPosts).resolves(() => [{ title: 'Dynamic post' }]); +``` + +### Mocking forms + +Given a component with a form: + +```svelte + + + +
+ + + {#if contactForm.fields.name.issues()} + {#each contactForm.fields.name.issues() as issue} + {issue.message} + {/each} + {/if} + + {#if contactForm.result} +

Message sent!

+ {/if} + + +
+``` + +Use `mockRemote` with form-specific methods to control field values, validation issues, and submission results: + +```js +/// file: src/routes/contact/+page.component.test.ts +import { mockRemote } from '@sveltejs/kit/test'; +import { contactForm } from './contact.remote'; + +test('shows validation errors', async () => { + mockRemote(contactForm).withFieldIssues({ + name: [{ message: 'Name is required' }] + }); + + // render the component — the error message should be visible +}); + +test('shows success after submission', async () => { + mockRemote(contactForm).returns({ sent: true }); + + // render the component — "Message sent!" should be visible +}); + +test('pre-populates form fields', async () => { + mockRemote(contactForm).withFieldValues({ + name: 'Alice', + message: 'Hello!' + }); + + // render the component — inputs should have the mocked values +}); +``` + +Methods are chainable: + +```js +mockRemote(contactForm) + .returns({ sent: true }) + .withFieldValues({ name: 'Alice' }) + .withFieldIssues({ message: [{ message: 'Too short' }] }); +``` + +The mock form implements the [`RemoteForm`](@sveltejs-kit#RemoteForm) interface: +- `form.fields.name.as('text')` returns input props with the mocked value +- `form.fields.name.value()` returns the mocked field value +- `form.fields.name.issues()` returns the mocked validation issues +- `form.result` returns the mocked submission result + +### Using both server + component modes + +A project may need both server-mode tests (for remote function logic) and component-mode tests (for rendering). One option is to use [Vitest projects](https://vitest.dev/guide/workspace): + +```js +/// file: vitest.config.js +import { sveltekit } from '@sveltejs/kit/vite'; +import { svelteKitTest } from '@sveltejs/kit/test/vitest'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [sveltekit()], + test: { + projects: [ + { + extends: true, + plugins: [svelteKitTest({ mode: 'server' })], + test: { + name: 'server', + include: ['src/**/*.test.ts'] + } + }, + { + extends: true, + plugins: [svelteKitTest({ mode: 'component' })], + test: { + name: 'component', + include: ['src/**/*.component.test.ts'] + } + } + ] + } +}); +``` diff --git a/packages/kit/kit.vitest.config.js b/packages/kit/kit.vitest.config.js index 6683a04297c4..08292351de3e 100644 --- a/packages/kit/kit.vitest.config.js +++ b/packages/kit/kit.vitest.config.js @@ -1,8 +1,10 @@ -import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; +import { svelteKitTest } from './src/exports/test/vitest.js'; // this file needs a custom name so that the numerous test subprojects don't all pick it up export default defineConfig({ + // dogfood our own plugin + plugins: [svelteKitTest()], define: { __SVELTEKIT_SERVER_TRACING_ENABLED__: false }, @@ -12,9 +14,6 @@ export default defineConfig({ } }, test: { - alias: { - '__sveltekit/paths': fileURLToPath(new URL('./test/mocks/path.js', import.meta.url)) - }, pool: 'threads', maxWorkers: 1, include: ['src/**/*.spec.js'], diff --git a/packages/kit/package.json b/packages/kit/package.json index 622537590943..cdfafab5fda1 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -126,6 +126,14 @@ "./vite": { "types": "./types/index.d.ts", "import": "./src/exports/vite/index.js" + }, + "./test": { + "types": "./types/index.d.ts", + "import": "./src/exports/test/index.js" + }, + "./test/vitest": { + "types": "./types/index.d.ts", + "import": "./src/exports/test/vitest.js" } }, "types": "types/index.d.ts", diff --git a/packages/kit/scripts/generate-dts.js b/packages/kit/scripts/generate-dts.js index b470d6d63494..04adffd704f1 100644 --- a/packages/kit/scripts/generate-dts.js +++ b/packages/kit/scripts/generate-dts.js @@ -9,6 +9,8 @@ await createBundle({ '@sveltejs/kit/node': 'src/exports/node/index.js', '@sveltejs/kit/node/polyfills': 'src/exports/node/polyfills.js', '@sveltejs/kit/vite': 'src/exports/vite/index.js', + '@sveltejs/kit/test': 'src/exports/test/index.js', + '@sveltejs/kit/test/vitest': 'src/exports/test/vitest.js', '$app/environment': 'src/runtime/app/environment/types.d.ts', '$app/forms': 'src/runtime/app/forms.js', '$app/navigation': 'src/runtime/app/navigation.js', diff --git a/packages/kit/src/exports/internal/event.js b/packages/kit/src/exports/internal/event.js index 9d658494e1be..825c56f92ea0 100644 --- a/packages/kit/src/exports/internal/event.js +++ b/packages/kit/src/exports/internal/event.js @@ -83,3 +83,27 @@ export function with_request_store(store, fn) { } } } + +/** + * Sets a persistent request store for the current async context using `als.enterWith()`. + * Unlike `with_request_store` (which scopes to a callback), this persists through + * the remainder of the current execution — including across nested `with_request_store` + * calls, which properly restore the outer ALS context when they complete. + * + * Intended for usage in test environments (e.g. called from `beforeEach` + * in the svelteKitTest plugin setup). + * + * @param {RequestStore} store + */ +export function __test_set_request_store(store) { + als?.enterWith(store); +} + +/** + * Clears the request store set by `__test_set_request_store`. + * Intended for usage in test environments (e.g. called from `afterEach` + * in the svelteKitTest plugin setup). + */ +export function __test_clear_request_store() { + als?.enterWith(/** @type {any} */ (null)); +} diff --git a/packages/kit/src/exports/internal/server.js b/packages/kit/src/exports/internal/server.js index ed425062637f..52ea7a2104e0 100644 --- a/packages/kit/src/exports/internal/server.js +++ b/packages/kit/src/exports/internal/server.js @@ -18,5 +18,7 @@ export { with_request_store, getRequestEvent, get_request_store, - try_get_request_store + try_get_request_store, + __test_set_request_store, + __test_clear_request_store } from './event.js'; diff --git a/packages/kit/src/exports/test/fixtures/sample.remote.js b/packages/kit/src/exports/test/fixtures/sample.remote.js new file mode 100644 index 000000000000..396235193fa9 --- /dev/null +++ b/packages/kit/src/exports/test/fixtures/sample.remote.js @@ -0,0 +1,24 @@ +import { query, command, form } from '$app/server'; + +export const echo = query('unchecked', (/** @type {string} */ value) => value); + +export const say_ok = command(() => { + return 'ok'; +}); + +const name_schema = + /** @type {import('@standard-schema/spec').StandardSchemaV1<{ name: string }>} */ ({ + '~standard': { + validate: (/** @type {unknown} */ value) => { + const v = /** @type {any} */ (value); + if (typeof v === 'object' && v !== null && typeof v.name === 'string') { + return { value }; + } + return { issues: [{ message: 'name is required', path: [{ key: 'name' }] }] }; + } + } + }); + +export const greeting_form = form(name_schema, (/** @type {{ name: string }} */ data) => { + return { greeting: `Hello, ${data.name}!` }; +}); diff --git a/packages/kit/src/exports/test/index.js b/packages/kit/src/exports/test/index.js new file mode 100644 index 000000000000..326b46ca329e --- /dev/null +++ b/packages/kit/src/exports/test/index.js @@ -0,0 +1,338 @@ +/** @import { RequestEvent, Cookies, RemoteQueryFunction, RemoteCommand, RemoteForm, RemoteFormInput } from '@sveltejs/kit' */ +/** @import { RequestState, RequestStore } from 'types' */ +/** @import { StandardSchemaV1 } from '@standard-schema/spec' */ + +import { with_request_store, try_get_request_store } from '@sveltejs/kit/internal/server'; +import { HttpError } from '@sveltejs/kit/internal'; +import { noop_span } from '../../runtime/telemetry/noop.js'; +import { get_cookies } from '../../runtime/server/cookie.js'; + +/** + * An `HttpError` subclass thrown when a remote function's schema validation fails + * during testing. Extends `HttpError` so `instanceof HttpError` checks still pass, + * but also exposes the Standard Schema `.issues` for test assertions. + * + * @example + * ```js + * import { HttpValidationError } from '@sveltejs/kit/test'; + * + * try { + * await myQuery(invalidArg); + * } catch (e) { + * if (e instanceof HttpValidationError) { + * console.log(e.status); // 400 + * console.log(e.issues); // [{ message: 'Expected a string' }] + * } + * } + * ``` + */ +export class HttpValidationError extends HttpError { + /** @type {StandardSchemaV1.Issue[]} */ + issues; + + /** + * @param {number} status + * @param {App.Error} body + * @param {StandardSchemaV1.Issue[]} issues + */ + constructor(status, body, issues) { + super(status, body); + this.issues = issues; + } +} + +/** + * Creates a mock `RequestEvent` for use in test environments. + * + * @example + * ```js + * import { createTestEvent } from '@sveltejs/kit/test'; + * + * const event = createTestEvent({ + * url: 'http://localhost/blog/hello', + * method: 'POST', + * locals: { user: { id: '123' } } + * }); + * ``` + * + * @param {object} [options] + * @param {string} [options.url] The URL of the request. Defaults to `'http://localhost/'`. + * @param {string} [options.method] The HTTP method. Defaults to `'GET'`. + * @param {Record} [options.headers] Request headers. + * @param {App.Locals} [options.locals] Custom data for `event.locals`. + * @param {Record} [options.params] Route parameters. + * @param {Record} [options.cookies] Initial cookies as name-value pairs. + * @param {Cookies} [options.cookiesObject] A full Cookies implementation (overrides `cookies`). + * @param {string | null} [options.routeId] The route ID. Defaults to `'/'`. + * @param {typeof fetch} [options.fetch] Custom fetch implementation. + * @param {() => string} [options.getClientAddress] Custom client address function. + * @param {Readonly} [options.platform] Platform-specific data. + * @param {BodyInit | null} [options.body] Request body. + * @returns {RequestEvent} + */ +export function createTestEvent(options = {}) { + const url = new URL(options.url ?? 'http://localhost/'); + const method = options.method ?? 'GET'; + + // build cookie header from the initial cookies map, if provided + const cookie_header = options.cookies + ? Object.entries(options.cookies) + .map(([k, v]) => `${k}=${v}`) + .join('; ') + : ''; + + const incoming_headers = new Headers(options.headers); + if (cookie_header) { + incoming_headers.set('cookie', cookie_header); + } + + const request = new Request(url, { + method, + headers: incoming_headers, + body: options.body ?? null + }); + + let cookies; + if (options.cookiesObject) { + cookies = options.cookiesObject; + } else { + const cookie_state = get_cookies(request, url); + cookie_state.set_trailing_slash('never'); + cookies = cookie_state.cookies; + } + + return /** @type {RequestEvent} */ ({ + cookies, + fetch: options.fetch ?? globalThis.fetch, + getClientAddress: options.getClientAddress ?? (() => '127.0.0.1'), + locals: /** @type {App.Locals} */ (options.locals ?? {}), + params: options.params ?? {}, + platform: options.platform, + request, + route: { id: options.routeId ?? '/' }, + setHeaders: () => {}, + url, + isDataRequest: false, + isSubRequest: false, + isRemoteRequest: false, + tracing: { + enabled: false, + root: noop_span, + current: noop_span + } + }); +} + +/** + * Creates a default `RequestState` suitable for test environments. + * + * The `handleValidationError` hook throws `HttpValidationError` directly, + * short-circuiting the framework's `error(400, ...)` call. Since + * `HttpValidationError` extends `HttpError`, existing `instanceof HttpError` + * checks still pass — the only difference is the `.issues` property is + * available for test assertions. This works identically regardless of whether + * context was established via `withRequestContext` or auto-context. + * + * @param {object} [options] + * @param {Record any, decode: (value: any) => any }>} [options.transport] + * @returns {RequestState} + */ +export function createTestState(options = {}) { + return /** @type {RequestState} */ ({ + prerendering: undefined, + transport: options.transport ?? {}, + handleValidationError: ({ issues }) => { + throw new HttpValidationError(400, { message: 'Bad Request' }, issues); + }, + tracing: { + record_span: ({ fn }) => fn(noop_span) + }, + remote: { + data: null, + forms: null, + refreshes: null, + requested: null, + validated: null + }, + is_in_remote_function: false, + is_in_render: false, + is_in_universal_load: false + }); +} + +/** + * Wraps a function call in a SvelteKit request context, making `getRequestEvent()` + * and remote functions (`query`, `command`, `form`) work inside the callback. + * + * @example + * ```js + * import { createTestEvent, withRequestContext } from '@sveltejs/kit/test'; + * import { getRequestEvent } from '$app/server'; + * + * const event = createTestEvent({ locals: { user: { id: '123' } } }); + * const locals = withRequestContext(event, () => getRequestEvent().locals); + * // locals === { user: { id: '123' } } + * ``` + * + * @template T + * @param {RequestEvent} event The mock request event (use `createTestEvent` to create one) + * @param {() => T} fn The function to execute within the request context + * @param {object} [options] + * @param {Record any, decode: (value: any) => any }>} [options.transport] Custom transport encoders/decoders + * @returns {T} + */ +export function withRequestContext(event, fn, options = {}) { + /** @type {RequestStore} */ + const store = { + event, + state: createTestState(options) + }; + + return with_request_store(store, fn); +} + +const MUTATIVE_TYPES = ['command', 'form']; + +/** @typedef {object} CallRemoteOptions + * @property {string} [url] The URL of the request + * @property {string} [method] Override the auto-detected HTTP method + * @property {Record} [headers] Request headers + * @property {App.Locals} [locals] Custom data for `event.locals` + * @property {Record} [params] Route parameters + * @property {Record} [cookies] Initial cookies + * @property {string | null} [routeId] The route ID + * @property {Record any, decode: (value: any) => any }>} [transport] Custom transport + */ + +/** + * Calls a RemoteQueryFunction with a test request context. + * + * If a remote function's schema validation fails, the resulting `HttpError` is caught + * and rethrown as an `HttpValidationError` with the Standard Schema `.issues` attached. + * + * @template QueryOutput + * @overload + * @param {RemoteQueryFunction} fn + * @param {void} [arg] + * @param {CallRemoteOptions} [options] + * @returns {Promise} + */ +/** + * @template QueryInput + * @template QueryOutput + * @overload + * @param {RemoteQueryFunction} fn + * @param {QueryInput} arg + * @param {CallRemoteOptions} [options] + * @returns {Promise} + */ + +/** + * Calls a RemoteCommand with a test request context. + * + * If a remote function's schema validation fails, the resulting `HttpError` is caught + * and rethrown as an `HttpValidationError` with the Standard Schema `.issues` attached. + * + * @template CommandOutput + * @overload + * @param {RemoteCommand} fn + * @param {void} [arg] + * @param {CallRemoteOptions} [options] + * @returns {Promise} + */ +/** + * @template CommandInput + * @template CommandOutput + * @overload + * @param {RemoteCommand} fn + * @param {CommandInput} arg + * @param {CallRemoteOptions} [options] + * @returns {Promise} + */ + +/** + * Calls a RemoteForm's handler with a test request context. + * + * If a remote function's schema validation fails, issues are + * returned in output object (not thrown). + * + * @template FormOutput + * @overload + * @param {RemoteForm} fn + * @param {void} [arg] + * @param {CallRemoteOptions} [options] + * @returns {Promise<{ submission: true, result?: FormOutput, issues?: import('@sveltejs/kit').RemoteFormIssue[] }>} + */ +/** + * @template {RemoteFormInput} FormInput + * @template FormOutput + * @overload + * @param {RemoteForm} fn + * @param {Record} arg + * @param {CallRemoteOptions} [options] + * @returns {Promise<{ submission: true, result?: FormOutput, issues?: import('@sveltejs/kit').RemoteFormIssue[] }>} + */ + +/** + * Calls a remote function with a test request context. Auto-detects the HTTP + * method from the function type (GET for queries, POST for commands and forms). + * + * @example + * ```js + * import { callRemote } from '@sveltejs/kit/test'; + * import { myQuery, myCommand, myForm } from './data.remote.ts'; + * + * const value = await callRemote(myQuery, 'arg'); + * const result = await callRemote(myCommand, { name: 'Alice' }); + * const output = await callRemote(myForm, { name: 'Alice' }); + * // output.result, output.issues + * ``` + * + * @param {any} fn + * @param {any} [arg] + * @param {CallRemoteOptions} [options] + * @returns {Promise} + */ +export async function callRemote(fn, arg, options = {}) { + const type = fn.__?.type; + const method = options.method ?? (MUTATIVE_TYPES.includes(type) ? 'POST' : 'GET'); + const event = createTestEvent({ ...options, method }); + + if (type === 'form') { + // Forms aren't callable — invoke the internal handler directly with + // form data as a POJO. Returns { submission, result, issues? } matching + // actual form behavior (e.g. forms don't throw on validation failure). + return withRequestContext(event, () => fn.__.fn(arg ?? {}, {}, null), options); + } + + return withRequestContext(event, () => fn(arg), options); +} + +/** + * Sets `event.locals` on the current test's request context. + * Can be called inside `withRequestContext`, or inside a test when + * auto-context is active via the svelteKitTest Vitest plugin. + * + * @example + * ```js + * import { setLocals } from '@sveltejs/kit/test'; + * import { getRequestEvent } from '$app/server'; + * + * setLocals({ user: { id: '123' } }); + * const { locals } = getRequestEvent(); + * // locals.user.id === '123' + * ``` + * + * @param {App.Locals} locals + */ +export function setLocals(locals) { + const store = try_get_request_store(); + if (!store) { + throw new Error( + 'No request context found. Call setLocals inside withRequestContext or ensure auto-context is active.' + ); + } + Object.assign(store.event.locals, locals); +} + +export { mockRemote } from './mock-remote.js'; diff --git a/packages/kit/src/exports/test/index.spec.js b/packages/kit/src/exports/test/index.spec.js new file mode 100644 index 000000000000..3680d91a29b4 --- /dev/null +++ b/packages/kit/src/exports/test/index.spec.js @@ -0,0 +1,155 @@ +import { assert, expect, test, describe } from 'vitest'; +import { + createTestEvent, + withRequestContext, + callRemote, + setLocals, + mockRemote, + HttpValidationError +} from './index.js'; +import { getRequestEvent } from '@sveltejs/kit/internal/server'; +import { query } from '../../runtime/app/server/remote/query.js'; +import { command } from '../../runtime/app/server/remote/command.js'; +import { HttpError } from '@sveltejs/kit/internal'; + +describe('createTestEvent', () => { + test('produces a valid RequestEvent with defaults', () => { + const event = createTestEvent(); + + assert.ok(event.url instanceof URL); + assert.equal(event.url.href, 'http://localhost/'); + assert.equal(event.request.method, 'GET'); + assert.equal(event.isDataRequest, false); + assert.equal(event.isSubRequest, false); + assert.equal(event.isRemoteRequest, false); + assert.equal(event.route.id, '/'); + assert.equal(typeof event.getClientAddress, 'function'); + assert.equal(event.getClientAddress(), '127.0.0.1'); + assert.equal(typeof event.setHeaders, 'function'); + assert.equal(typeof event.fetch, 'function'); + assert.ok(event.cookies); + assert.ok(event.tracing); + assert.equal(event.tracing.enabled, false); + }); + + test('applies custom options', () => { + const event = createTestEvent({ + url: 'http://example.com/blog/hello', + method: 'POST', + locals: { user: { id: '123' } }, + params: { slug: 'hello' }, + cookies: { session: 'abc' }, + routeId: '/blog/[slug]' + }); + + assert.equal(event.url.pathname, '/blog/hello'); + assert.equal(event.request.method, 'POST'); + expect(event.locals).toEqual({ user: { id: '123' } }); + expect(event.params).toEqual({ slug: 'hello' }); + assert.equal(event.cookies.get('session'), 'abc'); + assert.equal(event.route.id, '/blog/[slug]'); + }); +}); + +describe('withRequestContext', () => { + test('overrides auto-context with custom event', () => { + // auto-context provides a default event (empty locals) + // withRequestContext should use our custom event instead + const event = createTestEvent({ locals: { test_value: 42 } }); + + const result = withRequestContext(event, () => { + const req = getRequestEvent(); + return req.locals; + }); + + expect(result).toEqual({ test_value: 42 }); + }); + + test('propagates return value', () => { + const event = createTestEvent(); + + const result = withRequestContext(event, () => 'hello from test'); + + assert.equal(result, 'hello from test'); + }); + + test('works with async', async () => { + const event = createTestEvent({ locals: { async_test: true } }); + + const result = await withRequestContext(event, async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + const req = getRequestEvent(); + return req.locals; + }); + + expect(result).toEqual({ async_test: true }); + }); + + test('surfaces validation errors from schema-validated remote functions', async () => { + // avoid a dev dependency on a validation library + const schema = /** @type {import('@standard-schema/spec').StandardSchemaV1} */ ({ + '~standard': { + validate: (/** @type {unknown} */ value) => { + if (typeof value !== 'string') { + return { issues: [{ message: 'Expected a string' }] }; + } + return { value }; + } + } + }); + + const validated_query = query(schema, (arg) => { + return arg.toUpperCase(); + }); + + const event = createTestEvent(); + + // valid input succeeds + const result = await withRequestContext(event, () => validated_query('hello')); + assert.equal(result, 'HELLO'); + + // invalid input throws an HttpValidationError with status 400 and typed issues + try { + await withRequestContext(event, () => validated_query(/** @type {any} */ (123))); + assert.fail('should have thrown'); + } catch (e) { + // HttpValidationError extends HttpError, so both checks pass + assert.ok(e instanceof HttpValidationError); + assert.ok(e instanceof HttpError); + assert.equal(e.status, 400); + assert.equal(e.body.message, 'Bad Request'); + expect(e.issues).toEqual([{ message: 'Expected a string' }]); + } + }); +}); + +describe('callRemote', () => { + test('auto-detects GET for queries', async () => { + const my_query = query('unchecked', (/** @type {string} */ val) => val.toUpperCase()); + const result = await callRemote(my_query, 'hello'); + assert.equal(result, 'HELLO'); + }); + + test('auto-detects POST for commands', async () => { + const my_command = command('unchecked', (/** @type {number} */ n) => n * 2); + const result = await callRemote(my_command, 5); + assert.equal(result, 10); + }); +}); + +describe('setLocals', () => { + test('modifies the current request context event', () => { + const event = createTestEvent(); + withRequestContext(event, () => { + setLocals({ custom_value: 'from setLocals' }); + expect(getRequestEvent().locals).toEqual({ custom_value: 'from setLocals' }); + }); + }); +}); + +describe('mockRemote', () => { + test('throws with a clear error if function has no ID', () => { + assert.throws(() => mockRemote({}), /not a remote function/); + assert.throws(() => mockRemote(null), /not a remote function/); + }); +}); diff --git a/packages/kit/src/exports/test/mock-registry.js b/packages/kit/src/exports/test/mock-registry.js new file mode 100644 index 000000000000..37c66f59043a --- /dev/null +++ b/packages/kit/src/exports/test/mock-registry.js @@ -0,0 +1,46 @@ +/** + * Central registry for mock remote function data. + * Used by mockRemote() to register mocks, and by the mock remote + * runtime to look up data during component rendering. + */ + +/** + * @typedef {object} MockConfig + * @property {any} [data] Result data (queries, commands, form result) + * @property {{ status: number, body: any }} [error] Error to throw (queries, commands) + * @property {(arg: any) => any} [resolver] Dynamic resolver function + * @property {number} [delay] Delay in ms before resolving + * @property {Record} [fieldValues] Form field values + * @property {Record }>>} [fieldIssues] Form field validation issues + */ + +/** @type {Map} */ +const registry = new Map(); + +/** + * Gets the existing config for an ID, or creates a new empty one. + * This allows chainable builders to incrementally add properties. + * + * @param {string} id + * @returns {MockConfig} + */ +export function getOrCreateMock(id) { + let config = registry.get(id); + if (!config) { + config = {}; + registry.set(id, config); + } + return config; +} + +/** + * @param {string} id + * @returns {MockConfig | undefined} + */ +export function getMock(id) { + return registry.get(id); +} + +export function resetMocks() { + registry.clear(); +} diff --git a/packages/kit/src/exports/test/mock-remote.js b/packages/kit/src/exports/test/mock-remote.js new file mode 100644 index 000000000000..e304caad2b73 --- /dev/null +++ b/packages/kit/src/exports/test/mock-remote.js @@ -0,0 +1,90 @@ +import { getOrCreateMock } from './mock-registry.js'; + +/** + * Registers mock data for a remote function in component tests. + * The mock is keyed by the function's ID and read by the mock + * remote runtime during component rendering. + * + * Methods are chainable — call multiple to build up the mock state: + * + * @example + * ```js + * import { mockRemote } from '@sveltejs/kit/test'; + * import { getUser } from './data.remote.ts'; + * import { myForm } from './form.remote.ts'; + * + * // Query/command — set return data + * mockRemote(getUser).returns({ name: 'Alice' }); + * + * // Form — set result, field values, and validation issues + * mockRemote(myForm) + * .returns({ success: true }) + * .withFieldValues({ email: 'alice@example.com' }) + * .withFieldIssues({ name: [{ message: 'Required' }] }); + * ``` + * + * @param {any} fn The remote function to mock (imported from a .remote.ts file) + */ +export function mockRemote(fn) { + const id = fn?.__mock_id ?? fn?.__?.id; + if (!id) { + throw new Error( + 'mockRemote: argument is not a remote function (no ID found). ' + + 'Make sure you are importing from a .remote.ts file with the svelteKitTest({ mode: "component" }) plugin active.' + ); + } + + const config = getOrCreateMock(id); + + const builder = { + /** + * Mock the function to return this data when called (or set form result) + * @param {any} data + * @param {{ delay?: number }} [options] + */ + returns(data, options) { + config.data = data; + if (options?.delay) config.delay = options.delay; + return builder; + }, + /** + * Mock the function to throw an HttpError with this status and body + * @param {number} status + * @param {any} body + * @param {{ delay?: number }} [options] + */ + throws(status, body, options) { + config.error = { status, body }; + if (options?.delay) config.delay = options.delay; + return builder; + }, + /** + * Mock the function to call this resolver with the argument + * @param {(arg: any) => any} fn + * @param {{ delay?: number }} [options] + */ + resolves(fn, options) { + config.resolver = fn; + if (options?.delay) config.delay = options.delay; + return builder; + }, + /** + * Set form field values (for pre-populated forms or edit scenarios) + * @param {Record} values + */ + withFieldValues(values) { + config.fieldValues = values; + return builder; + }, + /** + * Set form field validation issues + * @param {Record }>>} issues + */ + withFieldIssues(issues) { + config.fieldIssues = issues; + return builder; + } + }; + + return builder; +} diff --git a/packages/kit/src/exports/test/setup-component.js b/packages/kit/src/exports/test/setup-component.js new file mode 100644 index 000000000000..598bfd0c15c5 --- /dev/null +++ b/packages/kit/src/exports/test/setup-component.js @@ -0,0 +1,14 @@ +/** + * Component test setup file, injected by svelteKitTest({ mode: 'component' }). + * Resets the mock registry between tests to ensure isolation. + */ +import { beforeEach, afterEach } from 'vitest'; +import { resetMocks } from './mock-registry.js'; + +beforeEach(() => { + resetMocks(); +}); + +afterEach(() => { + resetMocks(); +}); diff --git a/packages/kit/src/exports/test/setup.js b/packages/kit/src/exports/test/setup.js new file mode 100644 index 000000000000..9c1ce2949b9b --- /dev/null +++ b/packages/kit/src/exports/test/setup.js @@ -0,0 +1,25 @@ +/** + * Auto-context setup file, injected by the svelteKitTest() Vitest plugin. + * + * Establishes a default request store before each test using als.enterWith(), + * so remote functions can be called directly without withRequestContext wrappers. + * The ALS context survives nested with_request_store calls (used internally by + * run_remote_function) because AsyncLocalStorage maintains a context stack. + */ +import { beforeEach, afterEach } from 'vitest'; +import { + __test_set_request_store, + __test_clear_request_store +} from '@sveltejs/kit/internal/server'; +import { createTestEvent, createTestState } from '@sveltejs/kit/test'; + +beforeEach(() => { + __test_set_request_store({ + event: createTestEvent(), + state: createTestState() + }); +}); + +afterEach(() => { + __test_clear_request_store(); +}); diff --git a/packages/kit/src/exports/test/stubs/environment.js b/packages/kit/src/exports/test/stubs/environment.js new file mode 100644 index 000000000000..e38d4e06c625 --- /dev/null +++ b/packages/kit/src/exports/test/stubs/environment.js @@ -0,0 +1,7 @@ +// Stub for the __sveltekit/environment virtual module. +// Remote functions only read `prerendering` (in query.js); the rest are unused in tests. +export const prerendering = false; +export const building = false; +export const version = 'test'; +export function set_building() {} +export function set_prerendering() {} diff --git a/packages/kit/src/exports/test/stubs/paths.js b/packages/kit/src/exports/test/stubs/paths.js new file mode 100644 index 000000000000..6dfd481b78b4 --- /dev/null +++ b/packages/kit/src/exports/test/stubs/paths.js @@ -0,0 +1,5 @@ +// Stub for the __sveltekit/paths virtual module (also aliased from $app/paths). +// Remote functions only read `base` and `app_dir`; defaults are sufficient for tests. +export const base = ''; +export const assets = ''; +export const app_dir = '_app'; diff --git a/packages/kit/src/exports/test/stubs/remote.svelte.js b/packages/kit/src/exports/test/stubs/remote.svelte.js new file mode 100644 index 000000000000..c0125a3f9ade --- /dev/null +++ b/packages/kit/src/exports/test/stubs/remote.svelte.js @@ -0,0 +1,378 @@ +/** + * Mock version of the internal remote module for component testing. + * + * Provides query(), command(), form(), prerender() factories that create + * simplified reactive objects backed by the mock registry. These objects + * implement the same interface components expect (.current, .loading, + * .ready, .error, Promise protocol) but resolve from mockRemote() data + * instead of making HTTP requests. + */ +import { tick } from 'svelte'; +import { getMock, getOrCreateMock } from '../mock-registry.js'; +import { HttpError } from '@sveltejs/kit/internal'; + +/** + * @param {string} id + */ +export function query(id) { + /** @param {any} [arg] */ + function factory(arg) { + return new MockQueryProxy(id, arg); + } + factory.__mock_id = id; + return factory; +} + +// query.batch uses the same interface as query for component consumers +query.batch = query; + +/** + * @param {string} id + */ +export function command(id) { + let pending_count = $state(0); + + /** @param {any} [arg] */ + async function cmd(arg) { + pending_count++; + try { + await tick(); + const mock = getMock(id); + if (!mock) { + throw new Error( + `No mock registered for command "${id}". Call mockRemote(fn).returns(data).` + ); + } + if (mock.delay) await new Promise((r) => setTimeout(r, mock.delay)); + if (mock.error) throw new HttpError(mock.error.status, mock.error.body); + if (mock.resolver) return await mock.resolver(arg); + return mock.data; + } finally { + pending_count--; + } + } + + Object.defineProperty(cmd, 'pending', { get: () => pending_count }); + cmd.__mock_id = id; + + // stub .updates() — in production this refreshes queries after a command + const original = cmd; + /** @param {any} [arg] */ + function cmd_with_updates(arg) { + const promise = original(arg); + /** @type {any} */ (promise).updates = (/** @type {any[]} */ ..._queries) => promise; + return promise; + } + Object.defineProperty(cmd_with_updates, 'pending', { get: () => pending_count }); + cmd_with_updates.__mock_id = id; + return cmd_with_updates; +} + +/** + * Creates a recursive proxy for form field access. + * Supports deep nesting: form.fields.nested.deep.field.as('text') + * + * @param {string} id — the mock registry ID + * @param {string[]} path — accumulated property path + */ +function create_field_proxy(id, path = []) { + const path_key = path.join('.'); + + return new Proxy(/** @type {any} */ ({}), { + get(_target, prop) { + // form.fields.value() — returns all field values + if (prop === 'value' && path.length === 0) { + return () => getMock(id)?.fieldValues ?? {}; + } + + // form.fields.allIssues() — returns all issues + if (prop === 'allIssues' && path.length === 0) { + return () => { + const issues = getMock(id)?.fieldIssues; + if (!issues) return undefined; + return Object.entries(issues).flatMap(([field, field_issues]) => + field_issues.map((issue) => ({ + ...issue, + path: issue.path ?? [{ key: field }] + })) + ); + }; + } + + // form.fields.set({...}) — set all field values + if (prop === 'set' && path.length === 0) { + return (/** @type {Record} */ values) => { + const config = getOrCreateMock(id); + config.fieldValues = { ...(config.fieldValues ?? {}), ...values }; + }; + } + + // field.as(type) — returns input props matching the real RemoteFormField.as() shape. + // The real implementation conditionally includes `type` (omitted for 'text'/'select'), + // and uses the type to determine name prefixes and value handling. + if (prop === 'as') { + return (/** @type {string} */ type, /** @type {any} */ value) => { + const field_values = getMock(id)?.fieldValues ?? {}; + const field_issues = getMock(id)?.fieldIssues; + const current_value = get_nested(field_values, path); + + /** @type {Record} */ + const props = { + name: path_key, + get 'aria-invalid'() { + return field_issues?.[path_key] ? 'true' : undefined; + } + }; + + // Real implementation only adds type for non-text, non-select inputs + if (type !== 'text' && type !== 'select' && type !== 'select multiple') { + props.type = type === 'file multiple' ? 'file' : type; + } + + Object.defineProperty(props, 'value', { + get: () => value ?? current_value, + set: () => {}, + enumerable: true + }); + + return props; + }; + } + + // field.value() — returns current field value + if (prop === 'value') { + return () => { + const field_values = getMock(id)?.fieldValues ?? {}; + return get_nested(field_values, path); + }; + } + + // field.set(value) — updates field value + if (prop === 'set') { + return (/** @type {any} */ value) => { + const config = getOrCreateMock(id); + config.fieldValues = config.fieldValues ?? {}; + set_nested(config.fieldValues, path, value); + }; + } + + // field.issues() — returns validation issues for this field + if (prop === 'issues') { + return () => { + const issues = getMock(id)?.fieldIssues; + return issues?.[path_key]; + }; + } + + // Otherwise, descend into nested field: form.fields.nested.deeper + return create_field_proxy(id, [...path, String(prop)]); + } + }); +} + +/** + * @param {Record} obj + * @param {string[]} path + * @returns {any} + */ +function get_nested(obj, path) { + let current = obj; + for (const key of path) { + if (current == null) return undefined; + current = current[key]; + } + return current; +} + +/** + * @param {Record} obj + * @param {string[]} path + * @param {any} value + */ +function set_nested(obj, path, value) { + let current = obj; + for (let i = 0; i < path.length - 1; i++) { + current[path[i]] = current[path[i]] ?? {}; + current = current[path[i]]; + } + current[path[path.length - 1]] = value; +} + +/** + * @param {string} id + */ +export function form(id) { + let pending_count = $state(0); + + // Only method and action should be enumerable — these are what + // {...form} spreads onto the
element. All other properties + // are non-enumerable to avoid setAttribute errors in the DOM. + const instance = { + method: /** @type {const} */ ('POST'), + action: `?/remote=${encodeURIComponent(id)}` + }; + + Object.defineProperties(instance, { + enhance: { + value: () => ({ method: 'POST', action: instance.action }), + enumerable: false + }, + for: { + value: (/** @type {any} */ key) => { + const keyed = form(`${id}/${encodeURIComponent(JSON.stringify(key))}`); + /** @type {any} */ (keyed).__mock_id = id; + return keyed; + }, + enumerable: false + }, + preflight: { + value: () => instance, + enumerable: false + }, + validate: { + value: async () => {}, + enumerable: false + }, + result: { + get() { + return getMock(id)?.data; + }, + enumerable: false + }, + pending: { + get() { + return pending_count; + }, + enumerable: false + }, + fields: { + value: create_field_proxy(id), + enumerable: false + }, + __mock_id: { + value: id, + enumerable: false + } + }); + + return instance; +} + +/** + * @param {string} id + */ +export function prerender(id) { + // prerender behaves like query for component consumers + return query(id); +} + +class MockQueryProxy { + #id; + #arg; + #loading = $state(true); + #ready = $state(false); + /** @type {any} */ + #current = $state.raw(undefined); + /** @type {any} */ + #error = $state.raw(undefined); + #promise; + + /** + * @param {string} id + * @param {any} arg + */ + constructor(id, arg) { + this.#id = id; + this.#arg = arg; + this._key = id; + this.#promise = this.#resolve(); + } + + async #resolve() { + const mock = getMock(this.#id); + if (!mock) { + const err = new Error( + `No mock registered for query "${this.#id}". Call mockRemote(fn).returns(data).` + ); + this.#error = err; + this.#loading = false; + throw err; + } + + if (mock.delay) { + await new Promise((r) => setTimeout(r, mock.delay)); + } else { + await tick(); + } + + if (mock.error) { + const err = new HttpError(mock.error.status, mock.error.body); + this.#error = err; + this.#loading = false; + throw err; + } + + const data = mock.resolver ? await mock.resolver(this.#arg) : mock.data; + this.#current = data; + this.#ready = true; + this.#loading = false; + return data; + } + + get current() { + return this.#current; + } + + get loading() { + return this.#loading; + } + + get ready() { + return this.#ready; + } + + get error() { + return this.#error; + } + + /** @type {Promise['then']} */ + then(onfulfilled, onrejected) { + return this.#promise.then(onfulfilled, onrejected); + } + + /** @type {Promise['catch']} */ + catch(onrejected) { + return this.#promise.catch(onrejected); + } + + /** @type {Promise['finally']} */ + finally(onfinally) { + return this.#promise.finally(onfinally); + } + + run() { + return this.#promise; + } + + refresh() { + this.#loading = true; + this.#promise = this.#resolve(); + return this.#promise.then(() => {}); + } + + /** @param {any} value */ + set(value) { + this.#current = value; + this.#ready = true; + this.#loading = false; + } + + /** @param {(current: any) => any} _update */ + withOverride(_update) { + return { _key: this._key, release: () => {} }; + } + + get [Symbol.toStringTag]() { + return 'MockQueryProxy'; + } +} diff --git a/packages/kit/src/exports/test/stubs/server.js b/packages/kit/src/exports/test/stubs/server.js new file mode 100644 index 000000000000..c692a907336b --- /dev/null +++ b/packages/kit/src/exports/test/stubs/server.js @@ -0,0 +1,6 @@ +// Stub for the __sveltekit/server virtual module. +// Remote functions don't use this — it's only needed so $app/server/index.js can import it. +export let read_implementation = null; +export let manifest = null; +export function set_read_implementation() {} +export function set_manifest() {} diff --git a/packages/kit/src/exports/test/vitest.js b/packages/kit/src/exports/test/vitest.js new file mode 100644 index 000000000000..43daf4603176 --- /dev/null +++ b/packages/kit/src/exports/test/vitest.js @@ -0,0 +1,171 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { hash } from '../../utils/hash.js'; +import { posixify } from '../../utils/filesystem.js'; + +const VIRTUAL_PREFIX = '\0sveltekit-test-mock:'; + +/** + * Vitest plugin for testing SvelteKit remote functions. + * + * **Server mode** (default): + * - Resolves virtual modules (`$app/server` and its internal dependencies) + * - Transforms `.remote.ts/.remote.js` files to append `init_remote_functions()` + * - Injects a setup file that establishes a request context per test, + * so remote functions work without `withRequestContext` wrappers + * + * **Component mode** (`{ mode: 'component' }`): + * - Redirects `.remote.ts/.remote.js` imports to virtual modules that bypass + * the production sveltekit() plugin's transform + * - Resolves the internal remote module to a mock runtime with reactive objects + * - Use `mockRemote(fn).returns(data)` to control what data components receive + * + * @example + * ```js + * // Server mode (test remote function logic directly) + * import { svelteKitTest } from '@sveltejs/kit/test/vitest'; + * export default defineConfig({ plugins: [svelteKitTest()] }); + * + * // Component mode (test components that use remote functions) + * import { sveltekit } from '@sveltejs/kit/vite'; + * export default defineConfig({ + * plugins: [sveltekit(), svelteKitTest({ mode: 'component' })] + * }); + * ``` + * + * @param {{ mode?: 'server' | 'component' }} [options] + * @returns {import('vite').Plugin} + */ +export function svelteKitTest(options = {}) { + const mode = options.mode ?? 'server'; + const stubs_dir = fileURLToPath(new URL('./stubs', import.meta.url)); + const app_server = fileURLToPath(new URL('../../runtime/app/server/index.js', import.meta.url)); + + const server_setup = fileURLToPath(new URL('./setup.js', import.meta.url)); + const component_setup = fileURLToPath(new URL('./setup-component.js', import.meta.url)); + + // Maps virtual module hash → real file path (component mode only) + /** @type {Map} */ + const virtual_to_real = new Map(); + + return { + name: 'sveltekit-test', + // Run before sveltekit() so our resolveId intercepts .remote.ts + // imports before the production plugin sees them + enforce: /** @type {const} */ ('pre'), + + /** @returns {import('vite').UserConfig} */ + config() { + if (mode === 'component') { + return { + resolve: { + alias: { + // mock runtime replaces the real client-side remote functions + '__sveltekit/remote': path.join(stubs_dir, 'remote.svelte.js') + } + }, + test: { + setupFiles: [component_setup] + } + }; + } + + // server mode (default) + return { + resolve: { + alias: { + // resolve virtual modules for $app/server and its transitive deps + '$app/server': app_server, + '$app/paths/internal/server': path.join(stubs_dir, 'paths.js'), + '$app/paths': path.join(stubs_dir, 'paths.js'), + '__sveltekit/environment': path.join(stubs_dir, 'environment.js'), + '__sveltekit/server': path.join(stubs_dir, 'server.js') + } + }, + test: { + // inject auto-context setup: establishes a default request store + // per test via als.enterWith(), so remote functions work without + // explicit withRequestContext wrappers + setupFiles: [server_setup] + } + }; + }, + + async resolveId(source, importer, opts) { + if (mode !== 'component') return; + if (!/\.remote\.(js|ts)$/.test(source)) return; + + // Resolve the real file path via Vite's normal resolution + const resolved = await this.resolve(source, importer, { ...opts, skipSelf: true }); + if (!resolved) return; + + // Redirect to a virtual module ID that does NOT end with .remote.ts, + // so the sveltekit() plugin's transform won't match it. + const real_path = resolved.id; + const virtual_hash = hash(real_path); + virtual_to_real.set(virtual_hash, real_path); + + return VIRTUAL_PREFIX + virtual_hash; + }, + + load(id) { + if (!id.startsWith(VIRTUAL_PREFIX)) return; + + const virtual_hash = id.slice(VIRTUAL_PREFIX.length); + const real_path = virtual_to_real.get(virtual_hash); + if (!real_path) return; + + // Read the original .remote.ts source and generate client stubs + const code = readFileSync(real_path, 'utf-8'); + const file = posixify(path.relative(process.cwd(), real_path)); + const remote_hash = hash(file); + + const remote_exports = []; + const re = /export\s+const\s+(\w+)\s*=\s*(query|command|form|prerender)(?:\.batch)?\s*[.(]/g; + let match; + while ((match = re.exec(code)) !== null) { + const name = match[1]; + const type = match[2]; + const is_batch = code.slice(match.index, match.index + match[0].length).includes('.batch'); + remote_exports.push({ name, type: is_batch ? 'query_batch' : type }); + } + + if (remote_exports.length === 0) return; + + let result = `import * as __remote from '__sveltekit/remote';\n\n`; + for (const { name, type } of remote_exports) { + const factory = type === 'query_batch' ? 'query' : type; + result += `export const ${name} = __remote.${factory}('${remote_hash}/${name}');\n`; + } + + return { code: result }; + }, + + transform(code, id) { + // Component mode uses resolveId + load instead of transform + if (mode === 'component') return; + + if (!/\.remote\.(js|ts)$/.test(id)) return; + + // Server mode: append init_remote_functions() to set __.id and __.name. + // Mirrors the production SSR transform in exports/vite/index.js. + const file = posixify(path.relative(process.cwd(), id)); + const remote_hash = hash(file); + + const init_code = + '\n\n' + + `import * as $$_self_$$ from './${path.basename(id)}';\n` + + `import { init_remote_functions as $$_init_$$ } from '@sveltejs/kit/internal';\n` + + '\n' + + `$$_init_$$($$_self_$$, ${JSON.stringify(file)}, ${JSON.stringify(remote_hash)});\n` + + '\n' + + `for (const [name, fn] of Object.entries($$_self_$$)) {\n` + + `\tfn.__.id = ${JSON.stringify(remote_hash)} + '/' + name;\n` + + `\tfn.__.name = name;\n` + + `}\n`; + + return code + init_code; + } + }; +} diff --git a/packages/kit/src/exports/test/vitest.spec.js b/packages/kit/src/exports/test/vitest.spec.js new file mode 100644 index 000000000000..b70f65afe159 --- /dev/null +++ b/packages/kit/src/exports/test/vitest.spec.js @@ -0,0 +1,118 @@ +import { assert, expect, test, describe } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { createTestEvent, withRequestContext, callRemote } from './index.js'; +import { svelteKitTest } from './vitest.js'; +import { echo, say_ok, greeting_form } from './fixtures/sample.remote.js'; + +describe('svelteKitTest plugin', () => { + test('can import and call remote functions from a .remote.js file', async () => { + const event = createTestEvent(); + + const echo_result = await withRequestContext(event, () => echo('hello')); + assert.equal(echo_result, 'hello'); + }); + + test('transform sets __.name so error messages include function names', () => { + const event = createTestEvent({ method: 'GET' }); + + // commands require a mutative method (POST/PUT/PATCH/DELETE) — calling with + // GET throws an error that includes the function name + try { + withRequestContext(event, () => say_ok()); + assert.fail('should have thrown'); + } catch (e) { + assert.ok(e instanceof Error); + // Without the transform, __.name is '' and the error reads: + // "Cannot call a command (`()`) from a GET handler" + // With the transform, the function name is injected: + // "Cannot call a command (`say_ok()`) from a GET handler" + assert.match(e.message, /Cannot call a command \(`say_ok\(\)`\)/); + } + }); + + test('callRemote handles form submission with valid data', async () => { + const output = await callRemote(greeting_form, { name: 'Alice' }); + + assert.equal(output.submission, true); + expect(output.result).toEqual({ greeting: 'Hello, Alice!' }); + assert.equal(output.issues, undefined); + }); + + test('callRemote handles form validation failure without throwing', async () => { + // Forms don't throw on validation failure — they return issues on the output. + // This matches actual form behavior (inline validation errors in UI). + const output = await callRemote(greeting_form, { bad: 'data' }); + + assert.equal(output.submission, true); + assert.ok(output.issues); + assert.ok(output.issues.length > 0); + assert.equal(output.issues[0].message, 'name is required'); + }); +}); + +describe('auto-context', () => { + test('allows calling remote functions directly without wrappers', async () => { + // no withRequestContext or callRemote needed — auto-context handles it + const result = await echo('hello'); + assert.equal(result, 'hello'); + }); + + test('survives multiple remote function calls in the same test', async () => { + // Two calls are intentional: a single call would pass even if auto-context + // used sync_store instead of als.enterWith(). The second call catches that + // because with_request_store's finally block resets sync_store = null, + // which would leave the second call without context. + const result1 = await echo('first'); + const result2 = await echo('second'); + + assert.equal(result1, 'first'); + assert.equal(result2, 'second'); + }); +}); + +describe('component mode transform', () => { + const fixture_path = fileURLToPath(new URL('./fixtures/sample.remote.js', import.meta.url)); + + test('load generates client stubs matching export names and types', async () => { + // Cast to any — we're calling plugin hooks directly outside Vite's framework + const plugin = /** @type {any} */ (svelteKitTest({ mode: 'component' })); + + // Simulate the resolveId → load pipeline: + // resolveId intercepts the .remote.js import and returns a virtual ID + const resolved = await plugin.resolveId.call( + { resolve: async () => ({ id: fixture_path }) }, + './sample.remote.js', + '/some/importer.js', + {} + ); + assert.ok(resolved, 'resolveId should return a virtual ID for .remote.js'); + assert.ok(typeof resolved === 'string' && resolved.startsWith('\0')); + + // load reads the original source via the virtual ID and generates client stubs + const result = plugin.load(resolved); + assert.ok(result, 'load should return a result for the virtual module'); + const code = result.code; + + // should import from the mock remote runtime + assert.ok(code.includes("from '__sveltekit/remote'")); + + // should generate stubs for each export with the correct factory + assert.ok(code.includes('__remote.query('), 'echo should use query factory'); + assert.ok(code.includes('__remote.command('), 'say_ok should use command factory'); + assert.ok(code.includes('__remote.form('), 'greeting_form should use form factory'); + + // should NOT contain the original source code (entire file is replaced) + assert.ok(!code.includes("from '$app/server'"), 'should not contain original imports'); + }); + + test('resolveId ignores non-remote files', async () => { + const plugin = /** @type {any} */ (svelteKitTest({ mode: 'component' })); + const result = await plugin.resolveId.call( + { resolve: async () => ({ id: '/project/src/lib/utils.js' }) }, + './utils.js', + '/some/importer.js', + {} + ); + assert.equal(result, undefined); + }); +}); diff --git a/packages/kit/test/apps/async/package.json b/packages/kit/test/apps/async/package.json index 0a2094fca12e..140a0a695ac1 100644 --- a/packages/kit/test/apps/async/package.json +++ b/packages/kit/test/apps/async/package.json @@ -9,17 +9,22 @@ "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && tsc && svelte-check", - "test": "pnpm test:dev && pnpm test:build", + "test": "pnpm test:unit && pnpm test:dev && pnpm test:build", + "test:unit": "vitest run --config vitest.config.js", "test:dev": "DEV=true playwright test", "test:build": "playwright test" }, "devDependencies": { "@sveltejs/kit": "workspace:^", "@sveltejs/vite-plugin-svelte": "catalog:", + "@vitest/browser": "catalog:", + "@vitest/browser-playwright": "catalog:", "svelte": "catalog:", "svelte-check": "catalog:", "typescript": "catalog:", "valibot": "catalog:", - "vite": "catalog:" + "vite": "catalog:", + "vitest": "catalog:", + "vitest-browser-svelte": "catalog:" } } diff --git a/packages/kit/test/apps/async/test/FormFixture.svelte b/packages/kit/test/apps/async/test/FormFixture.svelte new file mode 100644 index 000000000000..cc3876b05f8d --- /dev/null +++ b/packages/kit/test/apps/async/test/FormFixture.svelte @@ -0,0 +1,28 @@ + + + + + + + {#if set_message.fields.message.issues()} + {#each set_message.fields.message.issues() as issue} + {issue.message} + {/each} + {/if} + + {#if set_message.result} +

{set_message.result}

+ {/if} + + + diff --git a/packages/kit/test/apps/async/test/mock.component.test.js b/packages/kit/test/apps/async/test/mock.component.test.js new file mode 100644 index 000000000000..1011f7361887 --- /dev/null +++ b/packages/kit/test/apps/async/test/mock.component.test.js @@ -0,0 +1,129 @@ +import { assert, expect, test, describe } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import { mockRemote } from '@sveltejs/kit/test'; +import { echo, set_count } from '../src/routes/remote/query-command.remote.js'; +import { set_message } from '../src/routes/remote/form/[test_name]/form.remote.ts'; +import FormFixture from './FormFixture.svelte'; + +describe('mockRemote end-to-end', () => { + test('mock query resolves with registered data', async () => { + mockRemote(echo).returns('mocked value'); + + const result = echo('any arg'); + const value = await result; + + assert.equal(value, 'mocked value'); + }); + + test('mock query exposes reactive interface', async () => { + mockRemote(echo).returns('reactive test'); + + const result = echo('arg'); + + assert.equal(result.loading, true); + assert.equal(result.ready, false); + assert.equal(result.current, undefined); + + await result; + + assert.equal(result.loading, false); + assert.equal(result.ready, true); + assert.equal(result.current, 'reactive test'); + }); + + test('mock query surfaces errors', async () => { + mockRemote(echo).throws(404, { message: 'Not found' }); + + const result = echo('arg'); + + try { + await result; + assert.fail('should have thrown'); + } catch (e) { + assert.equal(result.error?.status, 404); + assert.equal(result.loading, false); + } + }); + + test('mock command resolves with registered data', async () => { + mockRemote(set_count).returns(20); + + const result = await set_count({ c: 10 }); + + assert.equal(result, 20); + }); + + test('mock form result is accessible after mockRemote', () => { + assert.equal(set_message.result, undefined); + + mockRemote(set_message).returns('submitted successfully'); + assert.equal(set_message.result, 'submitted successfully'); + }); + + test('mock form fields expose values set via withFieldValues', () => { + mockRemote(set_message).withFieldValues({ + test_name: 'my-test', + message: 'hello' + }); + + assert.equal(set_message.fields.test_name.value(), 'my-test'); + assert.equal(set_message.fields.message.value(), 'hello'); + }); + + test('mock form fields expose issues set via withFieldIssues', () => { + mockRemote(set_message).withFieldIssues({ + message: [{ message: 'message is invalid' }] + }); + + assert.equal(set_message.fields.message.issues()?.[0].message, 'message is invalid'); + assert.equal(set_message.fields.test_name.issues(), undefined); + }); + + test('mock form supports chaining result, values, and issues', () => { + mockRemote(set_message) + .returns('success') + .withFieldValues({ message: 'hello' }) + .withFieldIssues({ test_name: [{ message: 'Required' }] }); + + assert.equal(set_message.result, 'success'); + assert.equal(set_message.fields.message.value(), 'hello'); + assert.equal(set_message.fields.test_name.issues()?.[0].message, 'Required'); + }); + + test('mock form fields generate input props via .as()', () => { + mockRemote(set_message).withFieldValues({ message: 'hello' }); + + const props = set_message.fields.message.as('text'); + assert.equal(props.name, 'message'); + assert.equal(props.value, 'hello'); + }); +}); + +describe('form component rendering', () => { + test('renders input with mocked field value', async () => { + mockRemote(set_message).withFieldValues({ message: 'hello world' }); + + await render(FormFixture); + + await expect.element(page.getByRole('textbox')).toHaveValue('hello world'); + }); + + test('renders validation errors from mocked issues', async () => { + mockRemote(set_message).withFieldIssues({ + message: [{ message: 'message is invalid' }] + }); + + await render(FormFixture); + + await expect.element(page.getByText('message is invalid')).toBeVisible(); + }); + + test('renders result after mocked submission', async () => { + mockRemote(set_message).returns('form submitted'); + + await render(FormFixture); + + await expect.element(page.getByText('form submitted')).toBeVisible(); + }); +}); diff --git a/packages/kit/test/apps/async/vitest.config.js b/packages/kit/test/apps/async/vitest.config.js new file mode 100644 index 000000000000..66bde1333210 --- /dev/null +++ b/packages/kit/test/apps/async/vitest.config.js @@ -0,0 +1,17 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { svelteKitTest } from '@sveltejs/kit/test/vitest'; +import { playwright } from '@vitest/browser-playwright'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [sveltekit(), svelteKitTest({ mode: 'component' })], + test: { + include: ['test/**/*.component.test.{js,ts}'], + browser: { + enabled: true, + provider: playwright(), + headless: true, + instances: [{ browser: 'chromium' }] + } + } +}); diff --git a/packages/kit/test/types/test-utils.test.ts b/packages/kit/test/types/test-utils.test.ts new file mode 100644 index 000000000000..884eb0f4f2d1 --- /dev/null +++ b/packages/kit/test/types/test-utils.test.ts @@ -0,0 +1,140 @@ +/** + * Type tests for @sveltejs/kit/test utilities. + * + * These tests validate that the exported functions have correct type inference + * by assigning results to explicitly typed variables and using @ts-expect-error + * to confirm that incorrect types are rejected. These have no runtime execution, + * and are instead are checked by `tsc` as part of `pnpm check`. + */ +import { query, command, form } from '$app/server'; +import { callRemote, createTestEvent, withRequestContext } from '@sveltejs/kit/test'; +import { StandardSchemaV1 } from '@standard-schema/spec'; +import { RequestEvent } from '@sveltejs/kit'; + +const schema: StandardSchemaV1<{ name: string }> = null as any; + +// ----------------------- +// --- createTestEvent --- +// ----------------------- + +function test_createTestEvent() { + // assert: createTestEvent returns a RequestEvent + const event: RequestEvent = createTestEvent(); + void event; + + // assert: all options are optional + createTestEvent({}); + createTestEvent({ url: 'http://localhost/' }); + createTestEvent({ method: 'POST', locals: { foo: 'bar' } }); +} +void test_createTestEvent; + +// -------------------------- +// --- withRequestContext --- +// -------------------------- + +function test_withRequestContext() { + const event = createTestEvent(); + + // assert: return type matches the callback's return type + const str: string = withRequestContext(event, () => 'hello'); + void str; + + // assert: return type matches the callback's return type + const num: number = withRequestContext(event, () => 42); + void num; + + // assert: mismatched return type is rejected + // @ts-expect-error + const wrong: number = withRequestContext(event, () => 'hello'); + void wrong; +} +void test_withRequestContext; + +// ------------------------- +// --- callRemote: query --- +// ------------------------- + +async function test_callRemote_query_no_args() { + const q = query(() => 'hello'); + + // assert: output type is inferred from the query handler + const result: string = await callRemote(q); + void result; + + // assert: wrong output type is rejected + // @ts-expect-error + const wrong: number = await callRemote(q); + void wrong; +} +void test_callRemote_query_no_args; + +async function test_callRemote_query_with_args() { + const q = query('unchecked', (arg: string) => arg.length); + + // assert: output type is inferred and arg type is enforced + const result: number = await callRemote(q, 'hello'); + void result; + + // assert: wrong arg type is rejected + // @ts-expect-error + await callRemote(q, 123); +} +void test_callRemote_query_with_args; + +// --------------------------- +// --- callRemote: command --- +// --------------------------- + +async function test_callRemote_command_no_args() { + const c = command(() => 42); + + // assert: output type is inferred from the command handler + const result: number = await callRemote(c); + void result; + + // assert: wrong output type is rejected + // @ts-expect-error + const wrong: string = await callRemote(c); + void wrong; +} +void test_callRemote_command_no_args; + +async function test_callRemote_command_with_args() { + const c = command('unchecked', (arg: { n: number }) => arg.n * 2); + + // assert: output type is inferred and arg type is enforced + const result: number = await callRemote(c, { n: 5 }); + void result; + + // assert: wrong arg type is rejected + // @ts-expect-error + await callRemote(c, 'wrong'); +} +void test_callRemote_command_with_args; + +// ------------------------ +// --- callRemote: form --- +// ------------------------ + +async function test_callRemote_form() { + const f = form(schema, (data) => ({ greeting: `Hello, ${data.name}!` })); + const output = await callRemote(f, { name: 'Alice' }); + + // assert: submission is always true + const sub: true = output.submission; + void sub; + + // assert: result type is inferred from the form handler (optional — undefined when validation fails) + if (output.result) { + const greeting: string = output.result.greeting; + void greeting; + } + + // assert: issues are typed as RemoteFormIssue[] (optional — present when validation fails) + if (output.issues) { + const message: string = output.issues[0].message; + void message; + } +} +void test_callRemote_form; diff --git a/packages/kit/test/utils.js b/packages/kit/test/utils.js index 20208e07a9ed..6512df001d35 100644 --- a/packages/kit/test/utils.js +++ b/packages/kit/test/utils.js @@ -315,7 +315,10 @@ export const config = defineConfig({ ] : 'list', testDir: 'test', - testMatch: /(.+\.)?(test|spec)\.[jt]s/ + testMatch: /(.+\.)?(test|spec)\.[jt]s/, + // Exclude component tests (*.component.test.js) — these run + // via their own vitest config, not through Playwright + testIgnore: [/\.component\.test\.[jt]s$/] }); /** diff --git a/packages/kit/tsconfig.json b/packages/kit/tsconfig.json index 629019586024..4623e623b5ce 100644 --- a/packages/kit/tsconfig.json +++ b/packages/kit/tsconfig.json @@ -16,6 +16,7 @@ "@sveltejs/kit/node/polyfills": ["./src/exports/node/polyfills.js"], "@sveltejs/kit/internal": ["./src/exports/internal/index.js"], "@sveltejs/kit/internal/server": ["./src/exports/internal/server.js"], + "@sveltejs/kit/test": ["./src/exports/test/index.js"], "$app/paths": ["./src/runtime/app/paths/public.d.ts"], "$app/paths/internal/client": ["./src/runtime/app/paths/internal/client.js"], "$app/paths/internal/server": ["./src/runtime/app/paths/internal/server.js"], diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index d23a0a4b7380..2622e1fbe6ca 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2943,6 +2943,486 @@ declare module '@sveltejs/kit/vite' { export {}; } +declare module '@sveltejs/kit/test' { + import type { Cookies, RequestEvent, RemoteQueryFunction, RemoteCommand, RemoteForm, RemoteFormInput, Config, Handle, HandleServerError, KitConfig, HandleFetch, Reroute, Adapter, ServerInit, Transport, HandleValidationError } from '@sveltejs/kit'; + import type { StandardSchemaV1 } from '@standard-schema/spec'; + import type { Span } from '@opentelemetry/api'; + /** + * Creates a mock `RequestEvent` for use in test environments. + * + * @example + * ```js + * import { createTestEvent } from '@sveltejs/kit/test'; + * + * const event = createTestEvent({ + * url: 'http://localhost/blog/hello', + * method: 'POST', + * locals: { user: { id: '123' } } + * }); + * ``` + * + * */ + export function createTestEvent(options?: { + url?: string | undefined; + method?: string | undefined; + headers?: Record | undefined; + locals?: App.Locals | undefined; + params?: Record | undefined; + cookies?: Record | undefined; + cookiesObject?: Cookies | undefined; + routeId?: string | null | undefined; + fetch?: typeof fetch | undefined; + getClientAddress?: (() => string) | undefined; + platform?: Readonly | undefined; + body?: BodyInit | null | undefined; + }): RequestEvent; + /** + * Creates a default `RequestState` suitable for test environments. + * + * The `handleValidationError` hook throws `HttpValidationError` directly, + * short-circuiting the framework's `error(400, ...)` call. Since + * `HttpValidationError` extends `HttpError`, existing `instanceof HttpError` + * checks still pass — the only difference is the `.issues` property is + * available for test assertions. This works identically regardless of whether + * context was established via `withRequestContext` or auto-context. + * + * */ + export function createTestState(options?: { + transport?: Record any; + decode: (value: any) => any; + }> | undefined; + }): RequestState; + /** + * Wraps a function call in a SvelteKit request context, making `getRequestEvent()` + * and remote functions (`query`, `command`, `form`) work inside the callback. + * + * @example + * ```js + * import { createTestEvent, withRequestContext } from '@sveltejs/kit/test'; + * import { getRequestEvent } from '$app/server'; + * + * const event = createTestEvent({ locals: { user: { id: '123' } } }); + * const locals = withRequestContext(event, () => getRequestEvent().locals); + * // locals === { user: { id: '123' } } + * ``` + * + * @param event The mock request event (use `createTestEvent` to create one) + * @param fn The function to execute within the request context + * */ + export function withRequestContext(event: RequestEvent, fn: () => T, options?: { + transport?: Record any; + decode: (value: any) => any; + }> | undefined; + }): T; + /** + * Calls a RemoteQueryFunction with a test request context. + * + * If a remote function's schema validation fails, the resulting `HttpError` is caught + * and rethrown as an `HttpValidationError` with the Standard Schema `.issues` attached. + * + * */ + export function callRemote(fn: RemoteQueryFunction, arg?: void | undefined, options?: CallRemoteOptions | undefined): Promise; + + export function callRemote(fn: RemoteQueryFunction, arg: QueryInput, options?: CallRemoteOptions | undefined): Promise; + /** + * Calls a RemoteCommand with a test request context. + * + * If a remote function's schema validation fails, the resulting `HttpError` is caught + * and rethrown as an `HttpValidationError` with the Standard Schema `.issues` attached. + * + * */ + export function callRemote(fn: RemoteCommand, arg?: void | undefined, options?: CallRemoteOptions | undefined): Promise; + + export function callRemote(fn: RemoteCommand, arg: CommandInput, options?: CallRemoteOptions | undefined): Promise; + /** + * Calls a RemoteForm's handler with a test request context. + * + * If a remote function's schema validation fails, issues are + * returned in output object (not thrown). + * + * */ + export function callRemote(fn: RemoteForm, arg?: void | undefined, options?: CallRemoteOptions | undefined): Promise<{ + submission: true; + result?: FormOutput; + issues?: import("@sveltejs/kit").RemoteFormIssue[]; + }>; + + export function callRemote(fn: RemoteForm, arg: Record, options?: CallRemoteOptions | undefined): Promise<{ + submission: true; + result?: FormOutput; + issues?: import("@sveltejs/kit").RemoteFormIssue[]; + }>; + /** + * Sets `event.locals` on the current test's request context. + * Can be called inside `withRequestContext`, or inside a test when + * auto-context is active via the svelteKitTest Vitest plugin. + * + * @example + * ```js + * import { setLocals } from '@sveltejs/kit/test'; + * import { getRequestEvent } from '$app/server'; + * + * setLocals({ user: { id: '123' } }); + * const { locals } = getRequestEvent(); + * // locals.user.id === '123' + * ``` + * + * */ + export function setLocals(locals: App.Locals): void; + /** + * An `HttpError` subclass thrown when a remote function's schema validation fails + * during testing. Extends `HttpError` so `instanceof HttpError` checks still pass, + * but also exposes the Standard Schema `.issues` for test assertions. + * + * @example + * ```js + * import { HttpValidationError } from '@sveltejs/kit/test'; + * + * try { + * await myQuery(invalidArg); + * } catch (e) { + * if (e instanceof HttpValidationError) { + * console.log(e.status); // 400 + * console.log(e.issues); // [{ message: 'Expected a string' }] + * } + * } + * ``` + */ + export class HttpValidationError extends HttpError { + + constructor(status: number, body: App.Error, issues: StandardSchemaV1.Issue[]); + + issues: StandardSchemaV1.Issue[]; + } + export type CallRemoteOptions = { + /** + * The URL of the request + */ + url?: string | undefined; + /** + * Override the auto-detected HTTP method + */ + method?: string | undefined; + /** + * Request headers + */ + headers?: Record | undefined; + /** + * Custom data for `event.locals` + */ + locals?: App.Locals | undefined; + /** + * Route parameters + */ + params?: Record | undefined; + /** + * Initial cookies + */ + cookies?: Record | undefined; + /** + * The route ID + */ + routeId?: string | null | undefined; + /** + * Custom transport + */ + transport?: Record any; + decode: (value: any) => any; + }> | undefined; + }; + /** + * Registers mock data for a remote function in component tests. + * The mock is keyed by the function's ID and read by the mock + * remote runtime during component rendering. + * + * Methods are chainable — call multiple to build up the mock state: + * + * @example + * ```js + * import { mockRemote } from '@sveltejs/kit/test'; + * import { getUser } from './data.remote.ts'; + * import { myForm } from './form.remote.ts'; + * + * // Query/command — set return data + * mockRemote(getUser).returns({ name: 'Alice' }); + * + * // Form — set result, field values, and validation issues + * mockRemote(myForm) + * .returns({ success: true }) + * .withFieldValues({ email: 'alice@example.com' }) + * .withFieldIssues({ name: [{ message: 'Required' }] }); + * ``` + * + * @param fn The remote function to mock (imported from a .remote.ts file) + */ + export function mockRemote(fn: any): { + /** + * Mock the function to return this data when called (or set form result) + * + */ + returns(data: any, options?: { + delay?: number; + }): /*elided*/ any; + /** + * Mock the function to throw an HttpError with this status and body + * + */ + throws(status: number, body: any, options?: { + delay?: number; + }): /*elided*/ any; + /** + * Mock the function to call this resolver with the argument + * + */ + resolves(fn: (arg: any) => any, options?: { + delay?: number; + }): /*elided*/ any; + /** + * Set form field values (for pre-populated forms or edit scenarios) + * */ + withFieldValues(values: Record): /*elided*/ any; + /** + * Set form field validation issues + * */ + withFieldIssues(issues: Record; + }>>): /*elided*/ any; + }; + interface ServerHooks { + handleFetch: HandleFetch; + handle: Handle; + handleError: HandleServerError; + handleValidationError: HandleValidationError; + reroute: Reroute; + transport: Transport; + init?: ServerInit; + } + + interface PrerenderDependency { + response: Response; + body: null | string | Uint8Array; + } + + interface PrerenderOptions { + cache?: string; // including this here is a bit of a hack, but it makes it easy to add + fallback?: boolean; + dependencies: Map; + /** + * For each key the (possibly still pending) result of a prerendered remote function. + * Used to deduplicate requests to the same remote function with the same arguments. + */ + remote_responses: Map>; + /** True for the duration of a call to the `reroute` hook */ + inside_reroute?: boolean; + } + + type RecursiveRequired = { + // Recursive implementation of TypeScript's Required utility type. + // Will recursively continue until it reaches a primitive or Function + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + [K in keyof T]-?: Extract extends never // If it does not have a Function type + ? RecursiveRequired // recursively continue through. + : T[K]; // Use the exact type for everything else + }; + + interface SSRComponent { + default: { + render( + props: Record, + opts: { context: Map; csp?: { nonce?: string; hash?: boolean } } + ): { + html: string; + head: string; + css: { + code: string; + map: any; // TODO + }; + /** Until we require all Svelte versions that support hashes, this might not be defined */ + hashes?: { + script: Array<`sha256-${string}`>; + }; + }; + }; + } + + interface SSROptions { + app_template_contains_nonce: boolean; + async: boolean; + csp: ValidatedConfig['kit']['csp']; + csrf_check_origin: boolean; + csrf_trusted_origins: string[]; + embedded: boolean; + env_public_prefix: string; + env_private_prefix: string; + hash_routing: boolean; + hooks: ServerHooks; + preload_strategy: ValidatedConfig['kit']['output']['preloadStrategy']; + root: SSRComponent['default']; + service_worker: boolean; + service_worker_options: RegistrationOptions; + server_error_boundaries: boolean; + templates: { + app(values: { + head: string; + body: string; + assets: string; + nonce: string; + env: Record; + }): string; + error(values: { message: string; status: number }): string; + }; + version_hash: string; + } + type RemotePrerenderInputsGenerator = () => MaybePromise; + + type ValidatedConfig = Config & { + kit: ValidatedKitConfig; + extensions: string[]; + }; + + type ValidatedKitConfig = Omit, 'adapter'> & { + adapter?: Adapter; + }; + + type BinaryFormMeta = { + remote_refreshes?: string[]; + validate_only?: boolean; + }; + + interface BaseRemoteInternals { + type: string; + id: string; + name: string; + } + + interface RemoteQueryInternals extends BaseRemoteInternals { + type: 'query'; + validate: (arg?: any) => MaybePromise; + } + interface RemoteQueryLiveInternals extends BaseRemoteInternals { + type: 'query_live'; + run( + event: RequestEvent, + state: RequestState, + arg: any + ): Promise<{ iterator: AsyncIterator; cancel: () => void }>; + } + + interface RemoteQueryBatchInternals extends BaseRemoteInternals { + type: 'query_batch'; + run: (args: any[], options: SSROptions) => Promise; + } + + interface RemoteCommandInternals extends BaseRemoteInternals { + type: 'command'; + } + + interface RemoteFormInternals extends BaseRemoteInternals { + type: 'form'; + fn(body: Record, meta: BinaryFormMeta, form_data: FormData | null): Promise; + } + + interface RemotePrerenderInternals extends BaseRemoteInternals { + type: 'prerender'; + has_arg: boolean; + dynamic?: boolean; + inputs?: RemotePrerenderInputsGenerator; + } + + type RemoteInternals = + | RemoteQueryInternals + | RemoteQueryLiveInternals + | RemoteQueryBatchInternals + | RemoteCommandInternals + | RemoteFormInternals + | RemotePrerenderInternals; + + type RecordSpan = (options: { + name: string; + attributes: Record; + fn: (current: Span) => Promise; + }) => Promise; + + /** + * Internal state associated with the current `RequestEvent`, + * used for tracking things like remote function calls + */ + interface RequestState { + readonly prerendering: PrerenderOptions | undefined; + readonly transport: ServerHooks['transport']; + readonly handleValidationError: ServerHooks['handleValidationError']; + readonly tracing: { + record_span: RecordSpan; + }; + readonly remote: { + data: null | Map< + RemoteInternals, + Record }> + >; + forms: null | Map; + refreshes: null | Record>; + requested: null | Map; + validated: null | Map>; + }; + readonly is_in_remote_function: boolean; + readonly is_in_render: boolean; + readonly is_in_universal_load: boolean; + } + class HttpError { + + constructor(status: number, body: { + message: string; + } extends App.Error ? (App.Error | string | undefined) : App.Error); + status: number; + body: App.Error; + toString(): string; + } + type MaybePromise = T | Promise; + + export {}; +} + +declare module '@sveltejs/kit/test/vitest' { + /** + * Vitest plugin for testing SvelteKit remote functions. + * + * **Server mode** (default): + * - Resolves virtual modules (`$app/server` and its internal dependencies) + * - Transforms `.remote.ts/.remote.js` files to append `init_remote_functions()` + * - Injects a setup file that establishes a request context per test, + * so remote functions work without `withRequestContext` wrappers + * + * **Component mode** (`{ mode: 'component' }`): + * - Redirects `.remote.ts/.remote.js` imports to virtual modules that bypass + * the production sveltekit() plugin's transform + * - Resolves the internal remote module to a mock runtime with reactive objects + * - Use `mockRemote(fn).returns(data)` to control what data components receive + * + * @example + * ```js + * // Server mode (test remote function logic directly) + * import { svelteKitTest } from '@sveltejs/kit/test/vitest'; + * export default defineConfig({ plugins: [svelteKitTest()] }); + * + * // Component mode (test components that use remote functions) + * import { sveltekit } from '@sveltejs/kit/vite'; + * export default defineConfig({ + * plugins: [sveltekit(), svelteKitTest({ mode: 'component' })] + * }); + * ``` + * + * */ + export function svelteKitTest(options?: { + mode?: "server" | "component"; + }): import("vite").Plugin; + + export {}; +} + declare module '$app/environment' { /** * `true` if the app is running in the browser. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77b7de834b87..91db39e87979 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ catalogs: '@types/set-cookie-parser': specifier: ^2.4.7 version: 2.4.7 + '@vitest/browser': + specifier: ^4.0.0 + version: 4.0.16 '@vitest/browser-playwright': specifier: ^4.0.0 version: 4.0.16 @@ -114,6 +117,9 @@ catalogs: vitest: specifier: ^4.0.0 version: 4.0.16 + vitest-browser-svelte: + specifier: ^2.0.0 + version: 2.1.0 wrangler: specifier: ^4.14.3 version: 4.14.4 @@ -644,6 +650,12 @@ importers: '@sveltejs/vite-plugin-svelte': specifier: 'catalog:' version: 6.2.4(svelte@5.53.12)(vite@6.4.1(@types/node@18.19.119)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.3)) + '@vitest/browser': + specifier: 'catalog:' + version: 4.0.16(vite@6.4.1(@types/node@18.19.119)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.3))(vitest@4.0.16) + '@vitest/browser-playwright': + specifier: 'catalog:' + version: 4.0.16(playwright@1.59.1)(vite@6.4.1(@types/node@18.19.119)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.3))(vitest@4.0.16) svelte: specifier: 'catalog:' version: 5.53.12 @@ -659,6 +671,12 @@ importers: vite: specifier: 'catalog:' version: 6.4.1(@types/node@18.19.119)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.3) + vitest: + specifier: 'catalog:' + version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@18.19.119)(@vitest/browser-playwright@4.0.16)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.3) + vitest-browser-svelte: + specifier: 'catalog:' + version: 2.1.0(svelte@5.53.12)(vitest@4.0.16) packages/kit/test/apps/basics: devDependencies: @@ -3187,6 +3205,12 @@ packages: resolution: {integrity: sha512-08eKiDAjj4zLug1taXSIJ0kGL5cawjVCyJkBb6EWSg5fEPX6L+Wtr0CH2If4j5KYylz85iaZiFlUItvgJvll5g==} engines: {node: ^14.13.1 || ^16.0.0 || >=18} + '@testing-library/svelte-core@1.0.0': + resolution: {integrity: sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==} + engines: {node: '>=16'} + peerDependencies: + svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 + '@tsconfig/node10@1.0.12': resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} @@ -5880,6 +5904,12 @@ packages: vite: optional: true + vitest-browser-svelte@2.1.0: + resolution: {integrity: sha512-Uqcqn9gKhYoNOn5uGOQHSPIEGHgIz25zPP6R63LQ5+yEVHfDXdOKBMba9pBlPIgp31AxYbV9h43j9+W+5M5y+A==} + peerDependencies: + svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 + vitest: ^4.0.0 + vitest@4.0.16: resolution: {integrity: sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -7710,6 +7740,10 @@ snapshots: transitivePeerDependencies: - encoding + '@testing-library/svelte-core@1.0.0(svelte@5.53.12)': + dependencies: + svelte: 5.53.12 + '@tsconfig/node10@1.0.12': optional: true @@ -10535,6 +10569,13 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@18.19.119)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.3) + vitest-browser-svelte@2.1.0(svelte@5.53.12)(vitest@4.0.16): + dependencies: + '@playwright/test': 1.59.1 + '@testing-library/svelte-core': 1.0.0(svelte@5.53.12) + svelte: 5.53.12 + vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@18.19.119)(@vitest/browser-playwright@4.0.16)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.3) + vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@18.19.119)(@vitest/browser-playwright@4.0.16)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.3): dependencies: '@vitest/expect': 4.0.16 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5f8d03aa17d8..b903e84ab6d8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -35,6 +35,7 @@ catalog: '@types/node': ^18.19.119 '@types/semver': ^7.5.6 '@types/set-cookie-parser': ^2.4.7 + '@vitest/browser': ^4.0.0 '@vitest/browser-playwright': ^4.0.0 dropcss: ^1.0.16 esbuild: ^0.25.4 @@ -52,6 +53,7 @@ catalog: valibot: ^1.1.0 vite: ^6.3.5 vitest: ^4.0.0 + vitest-browser-svelte: ^2.0.0 wrangler: ^4.14.3 catalogs: vite-baseline: