From d2e3cd6efc04f578ebee5a8585a1fe7ebc16e986 Mon Sep 17 00:00:00 2001 From: Liam Stanley Date: Wed, 14 May 2025 22:42:29 -0600 Subject: [PATCH 1/3] feat(nuxt-client): initial refactoring --- packages/client-nuxt/src/client.ts | 82 ++++++++++-------- packages/client-nuxt/src/index.ts | 1 + packages/client-nuxt/src/types.ts | 115 +++++++++++++++---------- packages/client-nuxt/src/utils.ts | 133 ++++++++++------------------- 4 files changed, 165 insertions(+), 166 deletions(-) diff --git a/packages/client-nuxt/src/client.ts b/packages/client-nuxt/src/client.ts index ccf257093..07191668b 100644 --- a/packages/client-nuxt/src/client.ts +++ b/packages/client-nuxt/src/client.ts @@ -4,18 +4,16 @@ import { useLazyAsyncData, useLazyFetch, } from 'nuxt/app'; -import { reactive, ref, watch } from 'vue'; import type { Client, Config } from './types'; import { buildUrl, createConfig, - executeFetchFn, + generateAuthParams, mergeConfigs, mergeHeaders, mergeInterceptors, serializeBody, - setAuthParams, } from './utils'; export const createClient = (config: Config = {}): Client => { @@ -34,28 +32,46 @@ export const createClient = (config: Config = {}): Client => { key, ...options }) => { + // Note: it's crucial that anything that might have reactive data (e.g. path params, query params, + // body, etc) or on-demand data (e.g. auth tokens), is only handled via interceptors. This is + // because Nuxt composables control when the request is sent (or re-sent) -- we don't invoke the + // request here. if we do logic outside of interceptors, it has the potential to become stale + // or cause hydration errors. const opts = { ..._config, ...options, $fetch: options.$fetch ?? _config.$fetch ?? $fetch, headers: mergeHeaders(_config.headers, options.headers), - onRequest: mergeInterceptors(_config.onRequest, options.onRequest), + onRequest: mergeInterceptors( + ({ options: req }) => + (req.body = serializeBody({ + ...options, + bodySerializer: options.bodySerializer ?? _config.bodySerializer, + })), + ({ options: req }) => { + // remove Content-Type header if body is empty to avoid sending invalid requests + if (req.body === undefined || req.body === '') { + req.headers.delete('Content-Type'); + } + }, + _config.onRequest, + options.onRequest, + ), onResponse: mergeInterceptors(_config.onResponse, options.onResponse), }; const { responseTransformer, responseValidator, security } = opts; if (security) { - // auth must happen in interceptors otherwise we'd need to require - // asyncContext enabled - // https://nuxt.com/docs/guide/going-further/experimental-features#asynccontext opts.onRequest = [ - async ({ options }) => { - await setAuthParams({ - auth: opts.auth, - headers: options.headers, - query: options.query, - security, - }); + async ({ options: req }) => { + const params = await generateAuthParams(security, opts.auth); + if (params.headers) { + req.headers = mergeHeaders(req.headers, params.headers); + } + req.query = { + ...req.query, + ...Object.fromEntries(params.query.entries()), + }; }, ...opts.onRequest, ]; @@ -84,33 +100,31 @@ export const createClient = (config: Config = {}): Client => { ]; } - // remove Content-Type header if body is empty to avoid sending invalid requests - if (opts.body === undefined || opts.body === '') { - opts.headers.delete('Content-Type'); - } - - const fetchFn = opts.$fetch; - if (composable === '$fetch') { - return executeFetchFn(opts, fetchFn); + return opts.$fetch( + buildUrl(opts), + // @ts-expect-error + opts, + ); } if (composable === 'useFetch' || composable === 'useLazyFetch') { - const bodyParams = reactive({ - body: opts.body, - bodySerializer: opts.bodySerializer, - }); - const body = ref(serializeBody(opts)); - opts.body = body; - watch(bodyParams, (changed) => { - body.value = serializeBody(changed); - }); + const fetchOpts = { + ...opts, + ...asyncDataOptions, + }; + return composable === 'useLazyFetch' - ? useLazyFetch(() => buildUrl(opts), opts) - : useFetch(() => buildUrl(opts), opts); + ? useLazyFetch(() => buildUrl(opts), fetchOpts) + : useFetch(() => buildUrl(opts), fetchOpts); } - const handler: any = () => executeFetchFn(opts, fetchFn); + const handler: any = () => + opts.$fetch( + buildUrl(opts), + // @ts-expect-error + opts, + ); if (composable === 'useAsyncData') { return key diff --git a/packages/client-nuxt/src/index.ts b/packages/client-nuxt/src/index.ts index 6f8f537b5..efce5ecf7 100644 --- a/packages/client-nuxt/src/index.ts +++ b/packages/client-nuxt/src/index.ts @@ -7,6 +7,7 @@ export type { CreateClientConfig, Options, OptionsLegacyParser, + RefTDataShape, RequestOptions, RequestResult, TDataShape, diff --git a/packages/client-nuxt/src/types.ts b/packages/client-nuxt/src/types.ts index 22249a2f8..9547f9451 100644 --- a/packages/client-nuxt/src/types.ts +++ b/packages/client-nuxt/src/types.ts @@ -12,7 +12,7 @@ import type { useLazyAsyncData, useLazyFetch, } from 'nuxt/app'; -import type { Ref } from 'vue'; +import type { MaybeRefOrGetter } from 'vue'; export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; @@ -24,28 +24,26 @@ export type QuerySerializer = ( query: Parameters[0]['query'], ) => string; -type WithRefs = { - [K in keyof TData]: NonNullable extends object - ? WithRefs> | Ref> - : NonNullable | Ref>; -}; - -// copied from Nuxt +/** + * KeysOf, copied from Nuxt, is used depending on the composable to "pick" the keys + * on the return type, so only a sub-set of the data is returned via hydration, + * making loading more efficient/faster. + */ export type KeysOf = Array< T extends T ? (keyof T extends string ? keyof T : never) : never >; export interface Config extends Omit< - FetchOptions, - 'baseURL' | 'body' | 'headers' | 'method' | 'query' + FetchOptions, + 'baseURL' | 'body' | 'headers' | 'method' | 'query' | 'path' >, - WithRefs, 'query'>>, - Omit { + Omit { /** * Base URL for all requests made by this client. */ baseURL?: T['baseURL']; + headers?: FetchOptions['headers']; /** * A function for serializing request query parameters. By default, arrays * will be exploded in form style, objects will be exploded in deepObject @@ -62,18 +60,15 @@ export interface RequestOptions< DefaultT = undefined, Url extends string = string, > extends Config, - WithRefs<{ - /** - * Any body that you want to add to your request. - * - * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} - */ - body?: unknown; - path?: FetchOptions['query']; - query?: FetchOptions['query']; - }> { + Pick, 'body' | 'path' | 'query'> { asyncDataOptions?: AsyncDataOptions, DefaultT>; + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ composable: TComposable; + headers?: FetchOptions['headers']; key?: string; /** * Security mechanism(s) to use for the request. @@ -82,6 +77,10 @@ export interface RequestOptions< url: Url; } +/** + * RequestResult is the return type of the request method, depending on the specific + * nuxt composable used. + */ export type RequestResult< TComposable extends Composable, ResT, @@ -102,13 +101,21 @@ export interface ClientOptions { baseURL?: string; } +/** + * MethodFn is the signature of the generic method function (e.g. client.get()). + */ type MethodFn = < TComposable extends Composable, ResT = unknown, TError = unknown, DefaultT = undefined, >( - options: Omit, 'method'>, + options: Omit< + RequestOptions, + 'method' | 'headers' + > & { + headers?: FetchOptions['headers']; + }, ) => RequestResult; type RequestFn = < @@ -134,53 +141,71 @@ export type CreateClientConfig = ( ) => Config & T>; export interface TDataShape { - body?: unknown; + body?: FetchOptions['body']; headers?: unknown; - path?: FetchOptions['query']; - query?: FetchOptions['query']; + path?: FetchOptions['query']; + query?: FetchOptions['query']; url: string; } +/** + * TDataShape is the base type for input data of the request. + */ +export interface RefTDataShape + extends Omit { + body?: T extends { body?: infer B } + ? MaybeRefOrGetter + : TDataShape['body']; + path?: T extends { path?: infer P } + ? MaybeRefOrGetter> + : TDataShape['path']; + query?: T extends { query?: infer Q } + ? MaybeRefOrGetter> + : TDataShape['query']; +} + export type BuildUrlOptions< - TData extends Omit = Omit, -> = Pick, 'path' | 'query'> & - Pick & + TData extends RefTDataShape = RefTDataShape, +> = Pick & Pick, 'baseURL' | 'querySerializer'>; -type BuildUrlFn = >( - options: BuildUrlOptions, -) => string; +type BuildUrlFn< + TData extends RefTDataShape = RefTDataShape, +> = (options: BuildUrlOptions) => string; export type Client = CoreClient; -type OmitKeys = Pick>; - export type Options< TComposable extends Composable, - TData extends TDataShape = TDataShape, + TData extends RefTDataShape = RefTDataShape, ResT = unknown, DefaultT = undefined, -> = OmitKeys< +> = Omit< RequestOptions, - 'body' | 'path' | 'query' | 'url' + 'body' | 'url' | 'query' | 'path' > & - WithRefs>; + Pick, 'body' | 'query' | 'path'> & { + headers?: FetchOptions['headers']; + }; export type OptionsLegacyParser = TData extends { body?: any } ? TData extends { headers?: any } - ? OmitKeys & TData - : OmitKeys & + ? Omit & TData + : Omit & TData & Pick : TData extends { headers?: any } - ? OmitKeys & + ? Omit & TData & Pick - : OmitKeys & TData; + : Omit & TData; -type FetchOptions = Omit< - UseFetchOptions, - keyof AsyncDataOptions +/** + * FetchOptions are the additional optional args that can be passed to $fetch. + */ +type FetchOptions = Omit< + UseFetchOptions, + keyof AsyncDataOptions >; export type Composable = diff --git a/packages/client-nuxt/src/utils.ts b/packages/client-nuxt/src/utils.ts index bf59614a9..e5aed79c1 100644 --- a/packages/client-nuxt/src/utils.ts +++ b/packages/client-nuxt/src/utils.ts @@ -6,8 +6,7 @@ import { serializeObjectParam, serializePrimitiveParam, } from '@hey-api/client-core'; -import type { ComputedRef, Ref } from 'vue'; -import { isRef, toValue, unref } from 'vue'; +import { toValue } from 'vue'; import type { ArraySeparatorStyle, @@ -23,10 +22,12 @@ type PathSerializer = Pick, 'path' | 'url'>; const PATH_PARAM_RE = /\{[^{}]+\}/g; -type MaybeArray = T | T[]; - const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { + const pathValue = toValue(toValue(path)); // TODO: this shouldn't be required. let url = _url; + + if (!pathValue) return url; + const matches = _url.match(PATH_PARAM_RE); if (matches) { for (const match of matches) { @@ -47,8 +48,7 @@ const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { style = 'matrix'; } - const value = toValue(toValue(path)[name]); - + const value = toValue(pathValue[name]); if (value === undefined || value === null) { continue; } @@ -155,44 +155,43 @@ export const createQuerySerializer = ({ return querySerializer; }; -export const setAuthParams = async ({ - security, - ...options -}: Pick, 'security'> & - Pick & { - headers: Headers; - }) => { - for (const auth of security) { - const token = await getAuthToken(auth, options.auth); +export const generateAuthParams = async ( + security: NonNullable, + auth: RequestOptions['auth'], +) => { + const results = { + headers: new Headers(), + query: new URLSearchParams(), + }; + + for (const sauth of security) { + const token = await getAuthToken(sauth, auth); if (!token) { continue; } - const name = auth.name ?? 'Authorization'; + const name = sauth.name ?? 'Authorization'; - switch (auth.in) { + switch (sauth.in) { case 'query': - if (!options.query) { - options.query = {}; - } - toValue(options.query)[name] = token; + results.query.append(name, token); break; case 'cookie': - options.headers.append('Cookie', `${name}=${token}`); + results.headers.append('Cookie', `${name}=${token}`); break; case 'header': default: - options.headers.set(name, token); + results.headers.set(name, token); break; } - - return; + return results; } + return results; }; -export const buildUrl: Client['buildUrl'] = (options) => { - const url = getUrl({ +export const buildUrl: Client['buildUrl'] = (options) => + getUrl({ baseUrl: options.baseURL as string, path: options.path, query: options.query, @@ -202,8 +201,6 @@ export const buildUrl: Client['buildUrl'] = (options) => { : createQuerySerializer(options.querySerializer), url: options.url, }); - return url; -}; export const getUrl = ({ baseUrl, @@ -239,20 +236,25 @@ export const mergeConfigs = (a: Config, b: Config): Config => { return config; }; +/** + * Merges headers from multiple sources. Lowercases header names, to match + * the behavior of ofetch/nuxt. + * @param headers - The headers to merge. + * @returns A new Headers object with the merged headers. + */ export const mergeHeaders = ( - ...headers: Array['headers'] | undefined> + ...headers: Array< + RequestOptions['headers'] | Record | undefined + > ): Headers => { const mergedHeaders = new Headers(); + for (const header of headers) { - if (!header || typeof header !== 'object') { + const h = toValue(header); + if (!h || typeof h !== 'object') { continue; } - let h: unknown = header; - if (isRef(h)) { - h = unref(h); - } - const iterator = h instanceof Headers ? h.entries() @@ -260,17 +262,17 @@ export const mergeHeaders = ( for (const [key, value] of iterator) { if (value === null) { - mergedHeaders.delete(key); + mergedHeaders.delete(key.toLowerCase()); } else if (Array.isArray(value)) { for (const v of value) { - mergedHeaders.append(key, unwrapRefs(v) as string); + mergedHeaders.append(key.toLowerCase(), toValue(v) as string); } } else if (value !== undefined) { - const v = unwrapRefs(value); + const v = toValue(value); // assume object headers are meant to be JSON stringified, i.e. their // content value in OpenAPI specification is 'application/json' mergedHeaders.set( - key, + key.toLowerCase(), typeof v === 'object' ? JSON.stringify(v) : (v as string), ); } @@ -279,7 +281,7 @@ export const mergeHeaders = ( return mergedHeaders; }; -export const mergeInterceptors = (...args: Array>): Array => +export const mergeInterceptors = (...args: Array): Array => args.reduce>((acc, item) => { if (typeof item === 'function') { acc.push(item); @@ -303,7 +305,7 @@ const defaultQuerySerializer = createQuerySerializer({ const defaultHeaders = { 'Content-Type': 'application/json', -}; +} as const; export const createConfig = ( override: Config & T> = {}, @@ -314,54 +316,11 @@ export const createConfig = ( ...override, }); -type UnwrapRefs = - T extends Ref - ? V - : T extends ComputedRef - ? V - : T extends Record // this doesn't handle functions well - ? { [K in keyof T]: UnwrapRefs } - : T; - -const unwrapRefs = (value: T): UnwrapRefs => { - if (value === null || typeof value !== 'object' || value instanceof Headers) { - return (isRef(value) ? unref(value) : value) as UnwrapRefs; - } - - if (Array.isArray(value)) { - return value.map((item) => unwrapRefs(item)) as UnwrapRefs; - } - - if (isRef(value)) { - return unwrapRefs(unref(value) as T); - } - - // unwrap into new object to avoid modifying the source - const result: Record = {}; - for (const key in value) { - result[key] = unwrapRefs(value[key] as T); - } - return result as UnwrapRefs; -}; - export const serializeBody = ( opts: Pick[0], 'body' | 'bodySerializer'>, ) => { if (opts.body && opts.bodySerializer) { - return opts.bodySerializer(opts.body); + return opts.bodySerializer(toValue(opts.body)); } - return opts.body; -}; - -export const executeFetchFn = ( - opts: Omit[0], 'composable'>, - fetchFn: Required['$fetch'], -) => { - const unwrappedOpts = unwrapRefs(opts); - unwrappedOpts.body = serializeBody(unwrappedOpts); - return fetchFn( - buildUrl(opts), - // @ts-expect-error - unwrappedOpts, - ); + return toValue(opts.body); }; From cfbde9031ef1d5697c431a0ef11a6aebcac50bc7 Mon Sep 17 00:00:00 2001 From: Liam Stanley Date: Sat, 17 May 2025 15:03:18 -0600 Subject: [PATCH 2/3] feat: initial implementation of msw testing; new internal test-utils package Signed-off-by: Liam Stanley --- examples/openapi-ts-nuxt/components/home.vue | 18 +- examples/openapi-ts-nuxt/nuxt.config.ts | 7 - packages/client-nuxt/package.json | 1 + .../client-nuxt/src/__tests__/client.test.ts | 478 +++++++++++++++- .../src/__tests__/reactive.test.ts | 154 +++++ .../client-nuxt/src/__tests__/utils.test.ts | 152 ----- packages/client-nuxt/src/__tests__/utils.ts | 35 ++ packages/client-nuxt/src/utils.ts | 4 +- packages/nuxt/src/module.ts | 7 + packages/test-utils/package.json | 28 + packages/test-utils/src/index.ts | 2 + packages/test-utils/src/mock-data.ts | 123 ++++ packages/test-utils/src/mock-http.ts | 183 ++++++ packages/test-utils/tsconfig.base.json | 14 + packages/test-utils/tsconfig.json | 7 + packages/test-utils/tsup.config.ts | 12 + pnpm-lock.yaml | 525 ++++++++++++++++-- 17 files changed, 1499 insertions(+), 251 deletions(-) create mode 100644 packages/client-nuxt/src/__tests__/reactive.test.ts delete mode 100644 packages/client-nuxt/src/__tests__/utils.test.ts create mode 100644 packages/client-nuxt/src/__tests__/utils.ts create mode 100644 packages/test-utils/package.json create mode 100644 packages/test-utils/src/index.ts create mode 100644 packages/test-utils/src/mock-data.ts create mode 100644 packages/test-utils/src/mock-http.ts create mode 100644 packages/test-utils/tsconfig.base.json create mode 100644 packages/test-utils/tsconfig.json create mode 100644 packages/test-utils/tsup.config.ts diff --git a/examples/openapi-ts-nuxt/components/home.vue b/examples/openapi-ts-nuxt/components/home.vue index 3a36efa96..75246627c 100644 --- a/examples/openapi-ts-nuxt/components/home.vue +++ b/examples/openapi-ts-nuxt/components/home.vue @@ -44,9 +44,9 @@ const asyncData = await getPetById({ }, composable: 'useAsyncData', key: 'item', - path: { - petId, - }, + path: computed(() => ({ + petId: petId.value, + })), }); watch(asyncData.data, (newPet) => { console.log('pet', newPet); @@ -91,13 +91,13 @@ await addPet({ asyncDataOptions: { watch: [name], }, - body: { + body: computed(() => ({ category: { id: BigInt(0), name: 'Cats', }, id: BigInt(0), - name, + name: name.value, photoUrls: ['string'], status: 'available', tags: [ @@ -106,7 +106,7 @@ await addPet({ name: 'pet', }, ], - }, + })), composable: 'useAsyncData', key: 'addPet', }); @@ -166,9 +166,9 @@ async function handleFetch() { console.log('onResponse: local'); }, ], - path: { - petId, - }, + path: computed(() => ({ + petId: petId.value, + })), }); console.log(result); } catch (error) { diff --git a/examples/openapi-ts-nuxt/nuxt.config.ts b/examples/openapi-ts-nuxt/nuxt.config.ts index 70c4ae51b..c548aca49 100644 --- a/examples/openapi-ts-nuxt/nuxt.config.ts +++ b/examples/openapi-ts-nuxt/nuxt.config.ts @@ -27,12 +27,5 @@ export default defineNuxtConfig({ ], }, }, - imports: { - transform: { - // Build was throwing an error. - // see https://github.com/nuxt/nuxt/issues/18823#issuecomment-1419704343 - exclude: [/\bclient-nuxt\b/], - }, - }, modules: ['@hey-api/nuxt'], }); diff --git a/packages/client-nuxt/package.json b/packages/client-nuxt/package.json index b01eb6d83..419635c4c 100644 --- a/packages/client-nuxt/package.json +++ b/packages/client-nuxt/package.json @@ -69,6 +69,7 @@ "@config/vite-base": "workspace:*", "@hey-api/client-core": "workspace:*", "@hey-api/openapi-ts": "workspace:*", + "@hey-api/test-utils": "workspace:*", "@nuxt/test-utils": "3.17.2", "vite": "6.2.6", "vitest": "3.1.1" diff --git a/packages/client-nuxt/src/__tests__/client.test.ts b/packages/client-nuxt/src/__tests__/client.test.ts index 441903954..c8d373d84 100644 --- a/packages/client-nuxt/src/__tests__/client.test.ts +++ b/packages/client-nuxt/src/__tests__/client.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it } from 'vitest'; +import * as http from '@hey-api/test-utils'; +import { describe, expect, it, test, vi } from 'vitest'; +import type { Auth } from '..'; import { createClient } from '../client'; +import type { Config, RequestOptions } from '../types'; describe('buildUrl', () => { const client = createClient(); @@ -9,42 +12,471 @@ describe('buildUrl', () => { options: Parameters[0]; url: string; }[] = [ + { options: { url: '' }, url: '/' }, + { options: { url: '/foo' }, url: '/foo' }, + { + options: { path: { fooId: 1 }, url: '/foo/{fooId}' }, + url: '/foo/1', + }, { options: { - url: '', + path: { fooId: 1 }, + query: { bar: 'baz' }, + url: '/foo/{fooId}', }, - url: '/', + url: '/foo/1?bar=baz', }, + ]; + + it.each(scenarios)('returns $url', ({ options, url }) => { + expect(client.buildUrl(options)).toBe(url); + }); +}); + +test('composables return matching results', async () => { + const client = createClient({ baseURL: 'https://localhost' }); + const server = http.newServer(http.mockPetHandlers('https://localhost')); + + const fetchResult = await client.get<'$fetch', http.Pet>({ + composable: '$fetch', + url: '/pets/1', + }); + expect(fetchResult).toEqual(http.petsData[0]); + + const useFetchResult = await client.get<'useFetch', http.Pet>({ + composable: 'useFetch', + url: '/pets/1', + }); + expect(useFetchResult.data.value).toEqual(http.petsData[0]); + + const useAsyncDataResult = await client.get<'useAsyncData', http.Pet>({ + composable: 'useAsyncData', + url: '/pets/1', + }); + expect(useAsyncDataResult.data.value).toEqual(http.petsData[0]); + + // Lazy* composables aren't actually lazy when invoked outside of Nuxt, so we can't + // validate the lazy logic itself (nor should we need to). + const useLazyAsyncDataResult = await client.get<'useLazyAsyncData', http.Pet>( { - options: { - url: '/foo', + composable: 'useLazyAsyncData', + url: '/pets/1', + }, + ); + expect(useLazyAsyncDataResult.data.value).toEqual(http.petsData[0]); + + const useLazyFetchResult = await client.get<'useLazyFetch', http.Pet>({ + composable: 'useLazyFetch', + url: '/pets/1', + }); + expect(useLazyFetchResult.data.value).toEqual(http.petsData[0]); + + expect(server.spy('get', '/pets/:id')).toHaveBeenCalledTimes(5); +}); + +describe('merge headers', async () => { + const base: Config = { + baseURL: 'https://localhost', + }; + + const tests: { + client: ReturnType; + inRequest: Record; + name: string; + required: Record; + security?: RequestOptions['security']; + }[] = [ + { + client: createClient({ ...base, headers: { 'X-From-Client': 'foo' } }), + inRequest: {}, + name: '1 from client, also in final.', + required: { 'X-From-Client': 'foo' }, + }, + { + client: createClient(base), + inRequest: { 'X-Request': 'example-value' }, + name: '1 from request, also in final.', + required: { 'X-Request': 'example-value' }, + }, + { + client: createClient({ + ...base, + auth: async () => 'x-auth-foo', + headers: { 'X-From-Client': 'foo' }, + }), + inRequest: { 'X-Request': 'example-value' }, + name: '1 from request, 1 from client, 1 from auth, all 3 exist in final.', + required: { + 'X-Auth-Foo': 'x-auth-foo', + 'X-From-Client': 'foo', + 'X-Request': 'example-value', }, - url: '/foo', + security: [{ in: 'header', name: 'X-Auth-Foo', type: 'http' }], }, { - options: { - path: { - fooId: 1, - }, - url: '/foo/{fooId}', + client: createClient({ + ...base, + headers: { 'X-From-Client': 'foo', 'X-From-Client-2': 'bar' }, + }), + inRequest: { + 'X-Request': 'example-value', + 'X-Request-2': 'example-value-2', + }, + name: '2 from client, 2 from request, all 4 exist in final.', + required: { + 'X-From-Client': 'foo', + 'X-From-Client-2': 'bar', + 'X-Request': 'example-value', + 'X-Request-2': 'example-value-2', }, - url: '/foo/1', }, { - options: { - path: { - fooId: 1, - }, - query: { - bar: 'baz', - }, - url: '/foo/{fooId}', + client: createClient({ + ...base, + headers: { 'X-Foo': 'foo', 'X-From-Client': 'foo' }, + }), + inRequest: { + 'X-Foo': 'bar', + 'X-Request': 'example-value', + }, + name: '2 from client, 2 from request, 1 overlap, and request overrides client.', + required: { + 'X-Foo': 'bar', + 'X-From-Client': 'foo', + 'X-Request': 'example-value', }, - url: '/foo/1?bar=baz', }, ]; - it.each(scenarios)('returns $url', ({ options, url }) => { - expect(client.buildUrl(options)).toBe(url); + it.each(tests)( + '$name', + async ({ client, inRequest: requested, required: expected, security }) => { + const server = http.newServer([http.mockVerboseHandler(base.baseURL!)]); + + const result = await client.get<'useFetch', http.VerboseResponse>({ + composable: 'useFetch', + headers: requested, + security, + url: '/verbose', + }); + + const headers = new Headers(result.data.value?.headers); + for (const [key, value] of Object.entries(expected)) { + expect(headers.get(key)).toBe(value); + } + server.expectAllCalled(); + }, + ); +}); + +test('interceptors invoked', async () => { + const onRequest = vi.fn(() => {}); + const onResponse = vi.fn(() => {}); + + const client = createClient({ + baseURL: 'https://localhost', + onRequest, + onResponse, + }); + + const server = http.newServer(http.mockPetHandlers('https://localhost')); + + const result = await client.get<'useFetch', http.Pet>({ + composable: 'useFetch', + url: '/pets/1', + }); + + expect(result.data.value).toEqual(http.petsData[0]); + expect(onRequest).toHaveBeenCalledTimes(1); + expect(onResponse).toHaveBeenCalledTimes(1); + expect(onResponse).toHaveBeenCalledAfter(onRequest); + expect(server.spy('get', '/pets/:id')).toHaveBeenCalledTimes(1); + onRequest.mockReset(); + onResponse.mockReset(); + + const result2 = await client.get<'useFetch', http.Pet>({ + composable: 'useFetch', + url: '/pets/1', + }); + + expect(result2.data.value).toEqual(http.petsData[0]); + expect(onRequest).toHaveBeenCalledTimes(1); + expect(onResponse).toHaveBeenCalledTimes(1); + expect(onResponse).toHaveBeenCalledAfter(onRequest); + expect(server.spy('get', '/pets/:id')).toHaveBeenCalledTimes(2); +}); + +test('response validators invoked', async () => { + const client = createClient({ + baseURL: 'https://localhost', + }); + + const server = http.newServer(http.mockPetHandlers('https://localhost')); + + const successResponseValidator = vi.fn(async () => {}); // no-op + const success = await client.get<'useFetch', http.Pet>({ + composable: 'useFetch', + responseValidator: successResponseValidator, + url: '/pets/1', + }); + + expect(success.data.value).toEqual(http.petsData[0]); + expect(successResponseValidator).toHaveBeenCalledTimes(1); + expect(server.spy('get', '/pets/:id')).toHaveBeenCalledTimes(1); + + const failResponseValidator = vi.fn(async () => { + throw new Error('testing'); + }); + const failed = await client.get<'useFetch', http.Pet>({ + composable: 'useFetch', + responseValidator: failResponseValidator, + url: '/pets/1', + }); + + expect(failed.error.value).toBeDefined(); + expect(failed.error.value?.message).toBe('testing'); + expect(failResponseValidator).toHaveBeenCalledTimes(1); + expect(server.spy('get', '/pets/:id')).toHaveBeenCalledTimes(2); +}); + +test('response transformers', async () => { + const responseTransformer = vi.fn(async (data: unknown) => { + if (typeof data === 'object' && data !== null) { + return { + ...data, + transformed: true, + }; + } + return data; + }); + + const client = createClient({ + baseURL: 'https://localhost', + responseTransformer, + }); + + const server = http.newServer(http.mockPetHandlers('https://localhost')); + + const result = await client.get<'useFetch', http.Pet>({ + composable: 'useFetch', + url: '/pets/1', + }); + + expect(responseTransformer).toHaveBeenCalledTimes(1); + expect(result.data.value).toEqual({ + ...http.petsData[0], + transformed: true, + }); + expect(server.spy('get', '/pets/:id')).toHaveBeenCalledTimes(1); +}); + +test('custom body serializer', async () => { + type Body = { + name: string; + serialized: boolean; + }; + + const customBodySerializer = vi.fn((data: Omit) => + JSON.stringify({ ...data, serialized: true }), + ); + + const client = createClient({ + baseURL: 'https://localhost', + bodySerializer: customBodySerializer, + }); + + const server = http.newServer([http.mockVerboseHandler('https://localhost')]); + + const result = await client.post<'useFetch', http.VerboseResponse>({ + body: { name: 'Custom Pet' }, + composable: 'useFetch', + url: '/verbose', + }); + + expect(result.data.value?.body).toEqual({ + name: 'Custom Pet', + serialized: true, + }); + expect(result.data.value?.headers['content-type']).toEqual( + 'application/json', + ); + expect(customBodySerializer).toHaveBeenCalledTimes(1); + expect(server.spy('post', '/verbose')).toHaveBeenCalledTimes(1); +}); + +test('custom query serializer', async () => { + type Response = { + limit: number; + tags: string[]; + }; + + const customQuerySerializer = { + allowReserved: false, + array: { explode: false, style: 'form' as const }, + object: { explode: true, style: 'deepObject' as const }, + }; + + const client = createClient({ + baseURL: 'https://localhost', + querySerializer: customQuerySerializer, + }); + + const server = http.newServer([ + http.handle( + 'get', + 'https://localhost/search', + ({ request }) => { + const { searchParams: params } = new URL(request.url); + return http.json({ + limit: Number(params.get('limit')), + tags: params.getAll('tags'), + }); + }, + ), + ]); + + const result = await client.get<'useFetch', Response>({ + composable: 'useFetch', + query: { limit: 10, tags: ['cat', 'dog', 'bird'] }, + url: '/search', + }); + + expect(result.data.value).toEqual({ + limit: 10, + tags: ['cat', 'dog', 'bird'], + }); + expect(server.spy('get', '/search')).toHaveBeenCalledTimes(1); +}); + +describe('authentication', () => { + const base = { baseURL: 'https://localhost' }; + + test('sets bearer token in headers', async () => { + const auth = vi.fn(async () => 'foo'); + const client = createClient({ ...base, auth }); + const server = http.newServer([http.mockVerboseHandler(base.baseURL!)]); + + const result = await client.get<'useFetch', http.VerboseResponse>({ + composable: 'useFetch', + security: [ + { + name: 'baz', + scheme: 'bearer', + type: 'http', + }, + ], + url: '/verbose', + }); + + expect(auth).toHaveBeenCalled(); + expect(result.data.value?.headers.baz).toBe('Bearer foo'); + server.expectAllCalled(); + }); + + test('sets access token in query', async () => { + const auth = vi.fn(async () => 'foo'); + const client = createClient({ ...base, auth }); + const server = http.newServer([http.mockVerboseHandler(base.baseURL!)]); + + const result = await client.get<'useFetch', http.VerboseResponse>({ + composable: 'useFetch', + security: [ + { + in: 'query', + name: 'baz', + scheme: 'bearer', + type: 'http', + }, + ], + url: '/verbose', + }); + + expect(auth).toHaveBeenCalled(); + expect(result.data.value?.query.baz).toBe('Bearer foo'); + server.expectAllCalled(); + }); + + test('sets Authorization header when `in` and `name` are undefined', async () => { + const auth = vi.fn(async () => 'foo'); + const client = createClient({ ...base, auth }); + const server = http.newServer([http.mockVerboseHandler(base.baseURL!)]); + + const result = await client.get<'useFetch', http.VerboseResponse>({ + composable: 'useFetch', + security: [ + { + type: 'http', + }, + ], + url: '/verbose', + }); + + expect(auth).toHaveBeenCalled(); + expect(result.data.value?.headers.authorization).toBe('foo'); + server.expectAllCalled(); + }); + + test('sets first scheme only', async () => { + const auth = vi.fn(async () => 'foo'); + const client = createClient({ ...base, auth }); + const server = http.newServer([http.mockVerboseHandler(base.baseURL!)]); + + const result = await client.get<'useFetch', http.VerboseResponse>({ + composable: 'useFetch', + security: [ + { + name: 'baz', + scheme: 'bearer', + type: 'http', + }, + { + in: 'query', + name: 'baz', + scheme: 'bearer', + type: 'http', + }, + ], + url: '/verbose', + }); + + expect(auth).toHaveBeenCalled(); + expect(result.data.value?.headers.baz).toBe('Bearer foo'); + expect(result.data.value?.query.baz).toBeUndefined(); + server.expectAllCalled(); + }); + + test('sets first scheme with token', async () => { + const auth = vi.fn((auth: Auth) => { + if (auth.type === 'apiKey') { + return; + } + return 'foo'; + }); + + const client = createClient({ ...base, auth }); + const server = http.newServer([http.mockVerboseHandler(base.baseURL!)]); + + const result = await client.get<'useFetch', http.VerboseResponse>({ + composable: 'useFetch', + query: { liam: 1 }, + security: [ + { + name: 'baz', + type: 'apiKey', + }, + { + in: 'query', + name: 'baz', + scheme: 'bearer', + type: 'http', + }, + ], + url: '/verbose', + }); + + expect(auth).toHaveBeenCalled(); + expect(result.data.value?.headers.baz).toBeUndefined(); + expect(result.data.value?.query.baz).toBe('Bearer foo'); + server.expectAllCalled(); }); }); diff --git a/packages/client-nuxt/src/__tests__/reactive.test.ts b/packages/client-nuxt/src/__tests__/reactive.test.ts new file mode 100644 index 000000000..8d4b7339f --- /dev/null +++ b/packages/client-nuxt/src/__tests__/reactive.test.ts @@ -0,0 +1,154 @@ +import * as http from '@hey-api/test-utils'; +import { describe, expect, it, test } from 'vitest'; +import { computed, ref } from 'vue'; + +import { createClient } from '../client'; +import { waitStatusFinished } from './utils'; + +describe('reactive buildUrl', () => { + const client = createClient(); + + const refExample = ref(1); + const computedExample = computed(() => refExample.value + 1); + + const scenarios: { + options: Parameters[0]; + update: () => void; + url1: string; + url2: string; + }[] = [ + { + options: { path: { fooId: refExample }, url: '/foo/{fooId}' }, + update: () => refExample.value++, + url1: '/foo/1', + url2: '/foo/2', + }, + { + options: { path: { fooId: computedExample }, url: '/foo/{fooId}' }, + update: () => refExample.value++, + url1: '/foo/3', + url2: '/foo/4', + }, + ]; + + it.each(scenarios)('returns $url', ({ options, update, url1, url2 }) => { + expect(client.buildUrl(options)).toBe(url1); + update(); + expect(client.buildUrl(options)).toBe(url2); + }); +}); + +describe('reactive path', () => { + const client = createClient({ baseURL: 'https://localhost' }); + + test('re-triggers GET on ref update', async () => { + const server = http.newServer(http.mockPetHandlers('https://localhost')); + + const petId = ref(1); + + const result = await client.get({ + composable: 'useFetch', + path: computed(() => ({ petId: petId.value })), + url: '/pets/{petId}', + }); + + expect(result.data.value).toEqual(http.petsData[0]); + expect(server.spy('get', '/pets/:id')).toHaveBeenCalledTimes(1); + + petId.value = 2; // update reactive ref, which should trigger a new GET. + await waitStatusFinished(result.status); + expect(result.data.value).toEqual(http.petsData[1]); + + await result.refresh(); // refetch and ensure it still uses 2. + await waitStatusFinished(result.status); + expect(result.data.value).toEqual(http.petsData[1]); + + expect(server.spy('get', '/pets/:id')).toHaveBeenCalledTimes(3); + }); + + test('does not trigger with immediate:false', async () => { + const server = http.newServer(http.mockPetHandlers('https://localhost')); + + const petId = ref(1); + + const result = await client.get({ + asyncDataOptions: { immediate: false }, + composable: 'useFetch', + path: computed(() => ({ petId: petId.value })), + url: '/pets/{petId}', + }); + + // not invoked initially. + expect(result.status.value).toBe('idle'); + expect(result.data.value).toBeNull(); + + // then trigger after just by updating the ref. + petId.value = 2; + await waitStatusFinished(result.status); + expect(result.data.value).toEqual(http.petsData[1]); + expect(server.spy('get', '/pets/:id')).toHaveBeenCalledTimes(1); + }); +}); + +test('reactive query re-triggers GET on query ref update', async () => { + const client = createClient({ baseURL: 'https://localhost' }); + const server = http.newServer(http.mockPetHandlers('https://localhost')); + + const species = ref('dog'); + + const result = await client.get({ + composable: 'useFetch', + query: computed(() => ({ species: species.value })), + url: '/pets', + }); + + expect(result.data.value).toEqual( + http.petsData.filter((pet) => pet.species === 'dog'), + ); + + species.value = 'cat'; + await waitStatusFinished(result.status); + expect(result.data.value).toEqual( + http.petsData.filter((pet) => pet.species === 'cat'), + ); + + species.value = null; // no filter + await waitStatusFinished(result.status); + expect(result.data.value).toEqual(http.petsData); + expect(server.spy('get', '/pets')).toHaveBeenCalledTimes(3); +}); + +test('reactive body re-triggers POST on body ref update', async () => { + const client = createClient({ baseURL: 'https://localhost' }); + const server = http.newServer(http.mockPetHandlers('https://localhost')); + + const newPet = ref>({ + age: 3, + name: 'Rex', + species: 'dog', + }); + + const result = await client.post<'useFetch', http.Pet>({ + body: newPet, + composable: 'useFetch', + url: '/pets', + }); + + expect(result.data.value?.name).toEqual(newPet.value.name); + expect(result.data.value?.age).toEqual(newPet.value.age); + expect(result.data.value?.species).toEqual(newPet.value.species); + expect(server.spy('post', '/pets')).toHaveBeenCalledTimes(1); + + // Update pet data + newPet.value = { + age: 1, + name: 'Mittens', + species: 'cat', + }; + + await waitStatusFinished(result.status); + expect(result.data.value?.name).toEqual(newPet.value.name); + expect(result.data.value?.age).toEqual(newPet.value.age); + expect(result.data.value?.species).toEqual(newPet.value.species); + expect(server.spy('post', '/pets')).toHaveBeenCalledTimes(2); +}); diff --git a/packages/client-nuxt/src/__tests__/utils.test.ts b/packages/client-nuxt/src/__tests__/utils.test.ts deleted file mode 100644 index 158a98271..000000000 --- a/packages/client-nuxt/src/__tests__/utils.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { Auth } from '@hey-api/client-core'; -import { describe, expect, it, vi } from 'vitest'; - -import { mergeInterceptors, setAuthParams } from '../utils'; - -describe('mergeInterceptors', () => { - it('handles no arguments', () => { - const result = mergeInterceptors(); - expect(result).toEqual([]); - }); - - it('handles interceptor function', () => { - const foo = () => {}; - const result = mergeInterceptors(foo); - expect(result).toEqual([foo]); - }); - - it('handles interceptors array', () => { - const foo = [() => {}]; - const result = mergeInterceptors(foo); - expect(result).toEqual([foo[0]]); - }); - - it('handles interceptors array and function', () => { - const foo = [() => {}, () => {}]; - const bar = () => {}; - const result = mergeInterceptors(foo, bar); - expect(result).toEqual([foo[0], foo[1], bar]); - }); -}); - -describe('setAuthParams', () => { - it('sets bearer token in headers', async () => { - const auth = vi.fn().mockReturnValue('foo'); - const headers = new Headers(); - const query: Record = {}; - await setAuthParams({ - auth, - headers, - query, - security: [ - { - name: 'baz', - scheme: 'bearer', - type: 'http', - }, - ], - }); - expect(auth).toHaveBeenCalled(); - expect(headers.get('baz')).toBe('Bearer foo'); - expect(Object.keys(query).length).toBe(0); - }); - - it('sets access token in query', async () => { - const auth = vi.fn().mockReturnValue('foo'); - const headers = new Headers(); - const query: Record = {}; - await setAuthParams({ - auth, - headers, - query, - security: [ - { - in: 'query', - name: 'baz', - scheme: 'bearer', - type: 'http', - }, - ], - }); - expect(auth).toHaveBeenCalled(); - expect(headers.get('baz')).toBeNull(); - expect(query.baz).toBe('Bearer foo'); - }); - - it('sets Authorization header when `in` and `name` are undefined', async () => { - const auth = vi.fn().mockReturnValue('foo'); - const headers = new Headers(); - const query: Record = {}; - await setAuthParams({ - auth, - headers, - query, - security: [ - { - type: 'http', - }, - ], - }); - expect(auth).toHaveBeenCalled(); - expect(headers.get('Authorization')).toBe('foo'); - expect(query).toEqual({}); - }); - - it('sets first scheme only', async () => { - const auth = vi.fn().mockReturnValue('foo'); - const headers = new Headers(); - const query: Record = {}; - await setAuthParams({ - auth, - headers, - query, - security: [ - { - name: 'baz', - scheme: 'bearer', - type: 'http', - }, - { - in: 'query', - name: 'baz', - scheme: 'bearer', - type: 'http', - }, - ], - }); - expect(auth).toHaveBeenCalled(); - expect(headers.get('baz')).toBe('Bearer foo'); - expect(Object.keys(query).length).toBe(0); - }); - - it('sets first scheme with token', async () => { - const auth = vi.fn().mockImplementation((auth: Auth) => { - if (auth.type === 'apiKey') { - return; - } - return 'foo'; - }); - const headers = new Headers(); - const query: Record = {}; - await setAuthParams({ - auth, - headers, - query, - security: [ - { - name: 'baz', - type: 'apiKey', - }, - { - in: 'query', - name: 'baz', - scheme: 'bearer', - type: 'http', - }, - ], - }); - expect(auth).toHaveBeenCalled(); - expect(headers.get('baz')).toBeNull(); - expect(query.baz).toBe('Bearer foo'); - }); -}); diff --git a/packages/client-nuxt/src/__tests__/utils.ts b/packages/client-nuxt/src/__tests__/utils.ts new file mode 100644 index 000000000..87449859e --- /dev/null +++ b/packages/client-nuxt/src/__tests__/utils.ts @@ -0,0 +1,35 @@ +import type { AsyncDataRequestStatus } from 'nuxt/app'; +import type { Ref } from 'vue'; + +/** + * Wait for the status to change from 'pending' to a non-pending value. + * @param status - The status to wait for. + * @returns A promise that resolves when the status is no longer 'pending'. + */ +export const waitStatusFinished = ( + status: Ref, +): Promise => + // sleep 50ms initially, see if status is no longer 'pending', sleep 100ms + // in a loop until it is. if more than 5s has passed, throw an error. + new Promise((resolve, reject) => { + setTimeout(() => { + if (status.value !== 'pending') { + resolve(); + return; + } + + const interval = setInterval(() => { + if (status.value !== 'pending') { + clearInterval(interval); + resolve(); + } + }, 50); + + setTimeout(() => { + clearInterval(interval); + reject( + new Error('Timed out waiting for status to change from "pending"'), + ); + }, 5000); + }, 50); + }); diff --git a/packages/client-nuxt/src/utils.ts b/packages/client-nuxt/src/utils.ts index e5aed79c1..65dac3694 100644 --- a/packages/client-nuxt/src/utils.ts +++ b/packages/client-nuxt/src/utils.ts @@ -23,7 +23,9 @@ type PathSerializer = Pick, 'path' | 'url'>; const PATH_PARAM_RE = /\{[^{}]+\}/g; const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { - const pathValue = toValue(toValue(path)); // TODO: this shouldn't be required. + // TODO: type has potentially double-wrapped Ref, even though I don't think + // it's actually double wrapped. Harmless, but should look into it. + const pathValue = toValue(toValue(path)); let url = _url; if (!pathValue) return url; diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 64a7e17d4..73d1bb077 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -42,6 +42,13 @@ export default defineNuxtModule({ async setup(options) { const nuxt = useNuxt(); + // ref: https://github.com/nuxt/nuxt/issues/18823#issuecomment-1419704343 + if (!nuxt.options.imports.transform) nuxt.options.imports.transform = {}; + nuxt.options.imports.transform.exclude = [ + ...(nuxt.options.imports.transform?.exclude || []), + /\bclient-nuxt\b/, + ]; + nuxt.options.build.transpile.push('@hey-api/client-nuxt'); const config = defu(options.config, { diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json new file mode 100644 index 000000000..1682d5499 --- /dev/null +++ b/packages/test-utils/package.json @@ -0,0 +1,28 @@ +{ + "name": "@hey-api/test-utils", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + ".": [ + "./dist/index.d.ts" + ] + } + }, + "sideEffects": false, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "msw": "2.8.2", + "vitest": "3.1.3" + } +} diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts new file mode 100644 index 000000000..03114b0e2 --- /dev/null +++ b/packages/test-utils/src/index.ts @@ -0,0 +1,2 @@ +export * from './mock-data'; +export * from './mock-http'; diff --git a/packages/test-utils/src/mock-data.ts b/packages/test-utils/src/mock-data.ts new file mode 100644 index 000000000..a99c4ced3 --- /dev/null +++ b/packages/test-utils/src/mock-data.ts @@ -0,0 +1,123 @@ +import { handle, json } from './mock-http'; + +// Some basic mock data. +export type Pet = { + age: number; + id: number; + name: string; + species?: 'dog' | 'cat' | 'bird' | 'fish'; +}; + +/** + * Base mock data for pets. This does not get updated directly by handlers. + */ +export const petsData: Pet[] = [ + { age: 5, id: 1, name: 'Fluffy', species: 'dog' }, + { age: 2, id: 2, name: 'Fido', species: 'dog' }, + { age: 1, id: 3, name: 'Whiskers', species: 'cat' }, + { age: 2, id: 4, name: 'Polly', species: 'bird' }, +] as const; + +/** + * Create a set of mock http handlers for listing, getting, creating, updating, + * and deleting pets. + * + * Registered handlers: + * - GET /pets + * - GET /pets/:id + * - PUT /pets/:id + * - DELETE /pets/:id + * - POST /pets + */ +export const mockPetHandlers = (baseURL: string) => { + baseURL = baseURL.replace(/\/$/, ''); + const pets = [...petsData]; + + return [ + handle('get', `${baseURL}/pets`, ({ request }) => { + const params = new URL(request.url).searchParams; + return json( + pets.filter((pet) => { + if (params.get('species')) { + return pet.species === params.get('species'); + } + return true; + }), + ); + }), + handle('get', `${baseURL}/pets/:id`, ({ params }) => { + const pet = pets.find((p) => p.id === Number(params.id)); + if (!pet) return json(null, { status: 404 }); + return json(pet); + }), + handle, Pet>( + 'put', + `${baseURL}/pets/:id`, + async ({ params, request }) => { + const pet = pets.find((p) => p.id === Number(params.id)); + if (!pet) return json(null, { status: 404 }); + const { age, name, species } = await request.json(); + if (name) pet.name = name; + if (species) pet.species = species; + if (age) pet.age = age; + return json(pet); + }, + ), + handle('delete', `${baseURL}/pets/:id`, ({ params }) => { + pets.splice( + pets.findIndex((p) => p.id === Number(params.id)), + 1, + ); + return new Response(null, { status: 204 }); + }), + handle, Pet>( + 'post', + `${baseURL}/pets`, + async ({ request }) => { + const newPet = { ...(await request.json()), id: pets.length + 1 }; + pets.push(newPet); + return json(newPet); + }, + ), + ]; +}; + +export type VerboseResponse = { + body?: any; + formData: Record; + headers: Record; + method: string; + query: Record; + url: string; +}; + +export const mockVerboseHandler = (baseURL: string) => + handle( + 'all', + `${baseURL}/verbose`, + async ({ request }) => { + const resp: VerboseResponse = { + formData: {}, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + query: Object.fromEntries(new URL(request.url).searchParams.entries()), + url: request.url, + }; + + switch (request.headers.get('Content-Type')) { + case 'application/json': + resp.body = await request.json(); + break; + case 'text/plain': + resp.body = await request.text(); + break; + case 'application/x-www-form-urlencoded': + resp.formData = Object.fromEntries( + (await request.formData()).entries(), + ); + break; + } + + return json(resp); + }, + ); diff --git a/packages/test-utils/src/mock-http.ts b/packages/test-utils/src/mock-http.ts new file mode 100644 index 000000000..942fd8ae7 --- /dev/null +++ b/packages/test-utils/src/mock-http.ts @@ -0,0 +1,183 @@ +import type { + DefaultBodyType, + HttpHandler, + HttpResponseResolver, + PathParams, +} from 'msw'; +import { http as mswhttp, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { expect, onTestFinished, vi } from 'vitest'; + +export const { error, formData, html, json, redirect, text } = HttpResponse; + +export type HandlerPair< + ReqT extends DefaultBodyType = DefaultBodyType, + ResT extends DefaultBodyType = DefaultBodyType, +> = { + handler: HttpResponseResolver, ReqT, ResT>; + method: + | 'all' + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put'; +}; + +export type SpyHandler = { + handler: HttpHandler; + spy: ReturnType; +}; + +// type ExtractPathValues

[]> = T[number]['path']; + +/** + * Create a mock handler -- automatically wraps the handler in a vitest spy + * and returns a handler that can be used with msw. + * @param method - The method to use. + * @param path - The path to use. + * @param handler - The handler to use. + * @returns A spy mock handler. + */ +export const handle = < + ReqT extends DefaultBodyType = DefaultBodyType, + ResT extends DefaultBodyType = DefaultBodyType, +>( + method: HandlerPair['method'], + path: string, + handler: HandlerPair['handler'], +): SpyHandler => { + const spy = vi.fn(handler); + let finalHandler: HttpHandler; + switch (method) { + case 'all': + finalHandler = mswhttp.all(path, spy); + break; + case 'delete': + finalHandler = mswhttp.delete(path, spy); + break; + case 'get': + finalHandler = mswhttp.get(path, spy); + break; + case 'head': + finalHandler = mswhttp.head(path, spy); + break; + case 'options': + finalHandler = mswhttp.options(path, spy); + break; + case 'patch': + finalHandler = mswhttp.patch(path, spy); + break; + case 'post': + finalHandler = mswhttp.post(path, spy); + break; + case 'put': + finalHandler = mswhttp.put(path, spy); + break; + default: + throw new Error(`Unknown method: ${method}`); + } + + return { + handler: finalHandler, + spy, + }; +}; + +/** + * Create a mock server that uses service workers (msw) to intercept requests. + * Automatically starts the server, and cleans up after the test. Note that this + * can only be invoked by default when inside of a vitest `test()` or `it()` + * block. + * + * @param handlers - The handlers to use. + * @param opts - The options to use. + * @returns A mock server. Started automatically. + */ +export const newServer = ( + handlers: SpyHandler[], + opts?: { errOnUnknown: boolean; noStart: boolean }, +) => { + if (!handlers.length) { + throw new Error('No handlers provided'); + } + + const server = setupServer(...handlers.map((h) => h.handler)); + + server.events.on('request:start', ({ request }) => { + console.log( + `[${expect.getState().currentTestName ?? 'unknown'}] request started: ${request.method} ${request.url}`, + ); + }); + server.events.on('response:mocked', ({ request, response }) => { + console.log( + `[${expect.getState().currentTestName ?? 'unknown'}] request ended: ${request.method} ${request.url} - ${response.status}`, + ); + }); + server.events.on('unhandledException', ({ error, request }) => { + console.error( + `[${expect.getState().currentTestName ?? 'unknown'}] request exception: ${request.method} ${request.url}: ${error}`, + ); + }); + + const listen = () => { + server.listen({ + onUnhandledRequest: !opts || opts.errOnUnknown ? 'error' : 'bypass', + }); + onTestFinished(() => server.close()); + }; + + if (!opts?.noStart) listen(); + + return { + /** + * Expect all handlers to have been called, or fail. + */ + expectAllCalled: () => { + handlers.forEach((h) => { + expect( + h.spy, + h.handler.info.method + ' ' + h.handler.info.path, + ).toHaveBeenCalled(); + }); + }, + handlers, + /** + * Reset all spy watchers. + */ + resetAllSpy: () => handlers.forEach((h) => h.spy.mockReset()), + server, + /** + * Get the spy for a handler. + * @param method - The method to match against. + * @param path - The path to use (don't include base url). + * @returns A spy for the handler. + */ + spy: (method: HandlerPair['method'], path: string) => { + const handler = handlers.find((h) => { + const hpath = new URL(h.handler.info.path.toString()).pathname; + + if ( + hpath === path && + (h.handler.info.method.toString().toLowerCase() === + method.toString() || + h.handler.info.method.toString() == '/.+/') + ) { + return true; + } + }); + if (!handler) { + throw new Error('Handler not found'); + } + return handler.spy; + }, + /** + * Start the server. This is not required by default. Can only be invoked + * inside of vitest `test()` or `it()` blocks, as it's setup to auto-cleanup + * after test completion. + */ + start: listen, + }; +}; diff --git a/packages/test-utils/tsconfig.base.json b/packages/test-utils/tsconfig.base.json new file mode 100644 index 000000000..4ae154dda --- /dev/null +++ b/packages/test-utils/tsconfig.base.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "declaration": true, + "esModuleInterop": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "strict": true, + "target": "ES2022", + "useUnknownInCatchVariables": false + } +} diff --git a/packages/test-utils/tsconfig.json b/packages/test-utils/tsconfig.json new file mode 100644 index 000000000..faaf0992f --- /dev/null +++ b/packages/test-utils/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "declaration": false, + "esModuleInterop": true + } +} diff --git a/packages/test-utils/tsup.config.ts b/packages/test-utils/tsup.config.ts new file mode 100644 index 000000000..726c5ac56 --- /dev/null +++ b/packages/test-utils/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig((options) => ({ + clean: true, + dts: true, + entry: ['src/index.ts'], + format: ['esm'], + minify: !options.watch, + shims: false, + sourcemap: true, + treeshake: true, +})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 244decfe2..a913fc390 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,7 +43,7 @@ importers: version: 8.29.1(@typescript-eslint/parser@8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3) '@vitest/coverage-v8': specifier: 3.1.1 - version: 3.1.1(vitest@3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0)) + version: 3.1.1(vitest@3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0)) eslint: specifier: 9.17.0 version: 9.17.0(jiti@2.4.2) @@ -97,7 +97,7 @@ importers: version: 8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3) vitest: specifier: 3.1.1 - version: 3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) + version: 3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) docs: dependencies: @@ -220,7 +220,7 @@ importers: version: 6.2.6(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) vitest: specifier: 3.1.1 - version: 3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) + version: 3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) examples/openapi-ts-fetch: dependencies: @@ -656,7 +656,7 @@ importers: version: 6.2.6(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) vitest: specifier: 3.1.1 - version: 3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) + version: 3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) examples/openapi-ts-tanstack-vue-query: dependencies: @@ -750,7 +750,7 @@ importers: version: 7.7.0(rollup@4.39.0)(vite@6.2.6(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.3)) vitest: specifier: 3.1.1 - version: 3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) + version: 3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) vue-tsc: specifier: 2.2.0 version: 2.2.0(typescript@5.8.3) @@ -830,15 +830,18 @@ importers: '@hey-api/openapi-ts': specifier: workspace:* version: link:../openapi-ts + '@hey-api/test-utils': + specifier: workspace:* + version: link:../test-utils '@nuxt/test-utils': specifier: 3.17.2 - version: 3.17.2(@types/node@22.10.5)(@vue/test-utils@2.4.6)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(magicast@0.3.5)(sass@1.85.0)(terser@5.39.0)(typescript@5.8.3)(vitest@3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))(yaml@2.7.0) + version: 3.17.2(@types/node@22.10.5)(@vue/test-utils@2.4.6)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(magicast@0.3.5)(sass@1.85.0)(terser@5.39.0)(typescript@5.8.3)(vitest@3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))(yaml@2.7.0) vite: specifier: 6.2.6 version: 6.2.6(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) vitest: specifier: 3.1.1 - version: 3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) + version: 3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) packages/config-vite-base: dependencies: @@ -847,7 +850,7 @@ importers: version: 6.2.6(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) vitest: specifier: ^3.1.1 - version: 3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) + version: 3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) devDependencies: typescript: specifier: ^5.8.3 @@ -888,7 +891,7 @@ importers: version: 3.16.2 '@nuxt/test-utils': specifier: 3.17.2 - version: 3.17.2(@types/node@22.10.5)(@vue/test-utils@2.4.6)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(magicast@0.3.5)(sass@1.85.0)(terser@5.39.0)(typescript@5.8.3)(vitest@3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))(yaml@2.7.0) + version: 3.17.2(@types/node@22.10.5)(@vue/test-utils@2.4.6)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(magicast@0.3.5)(sass@1.85.0)(terser@5.39.0)(typescript@5.8.3)(vitest@3.1.3(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))(yaml@2.7.0) vite: specifier: 6.2.6 version: 6.2.6(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) @@ -1080,6 +1083,15 @@ importers: specifier: 3.23.8 version: 3.23.8 + packages/test-utils: + dependencies: + msw: + specifier: 2.8.2 + version: 2.8.2(@types/node@22.10.5)(typescript@5.8.3) + vitest: + specifier: 3.1.3 + version: 3.1.3(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) + packages/vite-plugin: devDependencies: '@hey-api/openapi-ts': @@ -1955,6 +1967,15 @@ packages: '@braidai/lang@1.1.0': resolution: {integrity: sha512-xyJYkiyNQtTyCLeHxZmOs7rnB94D+N1IjKNArQIh8+8lTBOY7TFgwEV+Ow5a1uaBi5j2w9fLbWcJFTWLDItl5g==} + '@bundled-es-modules/cookie@2.0.1': + resolution: {integrity: sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==} + + '@bundled-es-modules/statuses@1.0.1': + resolution: {integrity: sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==} + + '@bundled-es-modules/tough-cookie@0.1.6': + resolution: {integrity: sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==} + '@changesets/apply-release-plan@7.0.10': resolution: {integrity: sha512-wNyeIJ3yDsVspYvHnEz1xQDq18D9ifed3lI+wxRQRK4pArUcuHgCTrHv0QRnnwjhVCQACxZ+CBih3wgOct6UXw==} @@ -3264,6 +3285,10 @@ packages: cpu: [x64] os: [win32] + '@mswjs/interceptors@0.37.6': + resolution: {integrity: sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==} + engines: {node: '>=18'} + '@napi-rs/nice-android-arm-eabi@1.0.1': resolution: {integrity: sha512-5qpvOu5IGwDo7MEKVqqyAxF90I6aLj4n07OzpARdgDRfz8UbBztTByBp0RC59r3J1Ij8uzYi6jI7r5Lws7nn6w==} engines: {node: '>= 10'} @@ -3589,6 +3614,15 @@ packages: '@one-ini/wasm@0.1.1': resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@parcel/watcher-android-arm64@2.5.1': resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} engines: {node: '>= 10.0.0'} @@ -5056,6 +5090,9 @@ packages: '@types/sockjs@0.3.36': resolution: {integrity: sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==} + '@types/statuses@2.0.5': + resolution: {integrity: sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==} + '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} @@ -5214,6 +5251,9 @@ packages: '@vitest/expect@3.1.1': resolution: {integrity: sha512-q/zjrW9lgynctNbwvFtQkGK9+vvHA5UzVi2V8APrp1C6fG6/MuYYkmlx4FubuqLycCeSdHD5aadWfua/Vr0EUA==} + '@vitest/expect@3.1.3': + resolution: {integrity: sha512-7FTQQuuLKmN1Ig/h+h/GO+44Q1IlglPlR2es4ab7Yvfx+Uk5xsv+Ykk+MEt/M2Yn/xGmzaLKxGw2lgy2bwuYqg==} + '@vitest/mocker@3.1.1': resolution: {integrity: sha512-bmpJJm7Y7i9BBELlLuuM1J1Q6EQ6K5Ye4wcyOpOMXMcePYKSIYlpcrCm4l/O6ja4VJA5G2aMJiuZkZdnxlC3SA==} peerDependencies: @@ -5225,21 +5265,47 @@ packages: vite: optional: true + '@vitest/mocker@3.1.3': + resolution: {integrity: sha512-PJbLjonJK82uCWHjzgBJZuR7zmAOrSvKk1QBxrennDIgtH4uK0TB1PvYmc0XBCigxxtiAVPfWtAdy4lpz8SQGQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@3.1.1': resolution: {integrity: sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA==} + '@vitest/pretty-format@3.1.3': + resolution: {integrity: sha512-i6FDiBeJUGLDKADw2Gb01UtUNb12yyXAqC/mmRWuYl+m/U9GS7s8us5ONmGkGpUUo7/iAYzI2ePVfOZTYvUifA==} + '@vitest/runner@3.1.1': resolution: {integrity: sha512-X/d46qzJuEDO8ueyjtKfxffiXraPRfmYasoC4i5+mlLEJ10UvPb0XH5M9C3gWuxd7BAQhpK42cJgJtq53YnWVA==} + '@vitest/runner@3.1.3': + resolution: {integrity: sha512-Tae+ogtlNfFei5DggOsSUvkIaSuVywujMj6HzR97AHK6XK8i3BuVyIifWAm/sE3a15lF5RH9yQIrbXYuo0IFyA==} + '@vitest/snapshot@3.1.1': resolution: {integrity: sha512-bByMwaVWe/+1WDf9exFxWWgAixelSdiwo2p33tpqIlM14vW7PRV5ppayVXtfycqze4Qhtwag5sVhX400MLBOOw==} + '@vitest/snapshot@3.1.3': + resolution: {integrity: sha512-XVa5OPNTYUsyqG9skuUkFzAeFnEzDp8hQu7kZ0N25B1+6KjGm4hWLtURyBbsIAOekfWQ7Wuz/N/XXzgYO3deWQ==} + '@vitest/spy@3.1.1': resolution: {integrity: sha512-+EmrUOOXbKzLkTDwlsc/xrwOlPDXyVk3Z6P6K4oiCndxz7YLpp/0R0UsWVOKT0IXWjjBJuSMk6D27qipaupcvQ==} + '@vitest/spy@3.1.3': + resolution: {integrity: sha512-x6w+ctOEmEXdWaa6TO4ilb7l9DxPR5bwEb6hILKuxfU1NqWT2mpJD9NJN7t3OTfxmVlOMrvtoFJGdgyzZ605lQ==} + '@vitest/utils@3.1.1': resolution: {integrity: sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg==} + '@vitest/utils@3.1.3': + resolution: {integrity: sha512-2Ltrpht4OmHO9+c/nmHtF09HWiyWdworqnHIwjfvDyWjuwKbdkcS9AnhsDn+8E2RM4x++foD1/tNuLPVvWG1Rg==} + '@volar/language-core@2.4.12': resolution: {integrity: sha512-RLrFdXEaQBWfSnYGVxvR2WrO6Bub0unkdHYIdC31HzIEqATIuuhRRzYu76iGPZ6OtA4Au1SnW0ZwIqPP217YhA==} @@ -6761,6 +6827,9 @@ packages: es-module-lexer@1.6.0: resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -7195,6 +7264,14 @@ packages: picomatch: optional: true + fdir@6.4.4: + resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fetch-blob@3.2.0: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} @@ -7480,6 +7557,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + graphql@16.11.0: + resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + gzip-size@7.0.0: resolution: {integrity: sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -7535,6 +7616,9 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -7847,6 +7931,9 @@ packages: resolution: {integrity: sha512-tUdRRAnhT+OtCZR/LxZelH/C7QtjtFrTu5tXCA8pl55eTUElUHT+GPYV8MBMBvea/j+NxQqVt3LbWMRir7Gx9g==} engines: {node: '>=16'} + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-number-object@1.1.1: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} @@ -8661,6 +8748,16 @@ packages: msgpackr@1.11.2: resolution: {integrity: sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==} + msw@2.8.2: + resolution: {integrity: sha512-ugu8RBgUj6//RD0utqDDPdS+QIs36BKYkDAM6u59hcMVtFM4PM0vW4l3G1R+1uCWP2EWFUG8reT/gPXVEtx7/w==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} @@ -9041,6 +9138,9 @@ packages: outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -9200,6 +9300,9 @@ packages: path-to-regexp@0.1.12: resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -10394,6 +10497,9 @@ packages: std-env@3.8.1: resolution: {integrity: sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==} + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + streamroller@3.1.5: resolution: {integrity: sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==} engines: {node: '>=8.0'} @@ -10405,6 +10511,9 @@ packages: streamx@2.22.0: resolution: {integrity: sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==} + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -10673,6 +10782,10 @@ packages: resolution: {integrity: sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.13: + resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} + engines: {node: '>=12.0.0'} + tinypool@1.0.2: resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -11219,6 +11332,11 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + vite-node@3.1.3: + resolution: {integrity: sha512-uHV4plJ2IxCl4u1up1FQRrqclylKAogbtBfOTwcuJ28xFi+89PZ57BRh+naIRvH70HPwxy5QHYzg1OrEaC7AbA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite-plugin-checker@0.8.0: resolution: {integrity: sha512-UA5uzOGm97UvZRTdZHiQVYFnd86AVn8EVaD4L3PoVzxH+IZSfaAw14WGFwX9QS23UW3lV/5bVKZn6l0w+q9P0g==} engines: {node: '>=14.16'} @@ -11467,6 +11585,34 @@ packages: jsdom: optional: true + vitest@3.1.3: + resolution: {integrity: sha512-188iM4hAHQ0km23TN/adso1q5hhwKqUpv+Sd6p5sOuh6FhQnRNW3IsiIpvxqahtBabsJ2SLZgmGSpcYK4wQYJw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.1.3 + '@vitest/ui': 3.1.3 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@2.0.1: resolution: {integrity: sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==} engines: {node: '>=0.10.0'} @@ -11981,7 +12127,7 @@ snapshots: dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.1902.0(chokidar@4.0.3) - '@angular-devkit/build-webpack': 0.1902.0(chokidar@4.0.3)(webpack-dev-server@5.2.0(webpack@5.98.0(esbuild@0.25.2)))(webpack@5.98.0(esbuild@0.25.0)) + '@angular-devkit/build-webpack': 0.1902.0(chokidar@4.0.3)(webpack-dev-server@5.2.0(webpack@5.98.0(esbuild@0.25.0)))(webpack@5.98.0(esbuild@0.25.0)) '@angular-devkit/core': 19.2.0(chokidar@4.0.3) '@angular/build': 19.2.0(@angular/compiler-cli@19.2.0(@angular/compiler@19.2.0(@angular/core@19.2.0(rxjs@7.8.1)(zone.js@0.15.0)))(typescript@5.8.3))(@angular/compiler@19.2.0(@angular/core@19.2.0(rxjs@7.8.1)(zone.js@0.15.0)))(@types/node@22.10.5)(chokidar@4.0.3)(jiti@2.4.2)(karma@6.4.4)(less@4.2.2)(postcss@8.5.2)(tailwindcss@3.4.9(ts-node@10.9.2(@types/node@22.10.5)(typescript@5.8.3)))(terser@5.39.0)(typescript@5.8.3)(yaml@2.7.0) '@angular/compiler-cli': 19.2.0(@angular/compiler@19.2.0(@angular/core@19.2.0(rxjs@7.8.1)(zone.js@0.15.0)))(typescript@5.8.3) @@ -12031,9 +12177,9 @@ snapshots: tree-kill: 1.2.2 tslib: 2.8.1 typescript: 5.8.3 - webpack: 5.98.0(esbuild@0.25.0) - webpack-dev-middleware: 7.4.2(webpack@5.98.0(esbuild@0.25.2)) - webpack-dev-server: 5.2.0(webpack@5.98.0(esbuild@0.25.2)) + webpack: 5.98.0(esbuild@0.25.2) + webpack-dev-middleware: 7.4.2(webpack@5.98.0(esbuild@0.25.0)) + webpack-dev-server: 5.2.0(webpack@5.98.0(esbuild@0.25.0)) webpack-merge: 6.0.1 webpack-subresource-integrity: 5.1.0(webpack@5.98.0(esbuild@0.25.0)) optionalDependencies: @@ -12067,7 +12213,7 @@ snapshots: dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.1902.0(chokidar@4.0.3) - '@angular-devkit/build-webpack': 0.1902.0(chokidar@4.0.3)(webpack-dev-server@5.2.0(webpack@5.98.0(esbuild@0.25.2)))(webpack@5.98.0(esbuild@0.25.0)) + '@angular-devkit/build-webpack': 0.1902.0(chokidar@4.0.3)(webpack-dev-server@5.2.0(webpack@5.98.0(esbuild@0.25.0)))(webpack@5.98.0(esbuild@0.25.0)) '@angular-devkit/core': 19.2.0(chokidar@4.0.3) '@angular/build': 19.2.0(@angular/compiler-cli@19.2.0(@angular/compiler@19.2.0(@angular/core@19.2.0(rxjs@7.8.1)(zone.js@0.15.0)))(typescript@5.8.3))(@angular/compiler@19.2.0(@angular/core@19.2.0(rxjs@7.8.1)(zone.js@0.15.0)))(@types/node@22.10.5)(chokidar@4.0.3)(jiti@2.4.2)(karma@6.4.4)(less@4.2.2)(postcss@8.5.2)(tailwindcss@3.4.9(ts-node@10.9.2(@types/node@22.10.5)(typescript@5.8.3)))(terser@5.39.0)(typescript@5.8.3)(yaml@2.7.0) '@angular/compiler-cli': 19.2.0(@angular/compiler@19.2.0(@angular/core@19.2.0(rxjs@7.8.1)(zone.js@0.15.0)))(typescript@5.8.3) @@ -12117,9 +12263,9 @@ snapshots: tree-kill: 1.2.2 tslib: 2.8.1 typescript: 5.8.3 - webpack: 5.98.0(esbuild@0.25.0) - webpack-dev-middleware: 7.4.2(webpack@5.98.0(esbuild@0.25.2)) - webpack-dev-server: 5.2.0(webpack@5.98.0(esbuild@0.25.2)) + webpack: 5.98.0(esbuild@0.25.2) + webpack-dev-middleware: 7.4.2(webpack@5.98.0(esbuild@0.25.0)) + webpack-dev-server: 5.2.0(webpack@5.98.0(esbuild@0.25.0)) webpack-merge: 6.0.1 webpack-subresource-integrity: 5.1.0(webpack@5.98.0(esbuild@0.25.0)) optionalDependencies: @@ -12149,12 +12295,12 @@ snapshots: - webpack-cli - yaml - '@angular-devkit/build-webpack@0.1902.0(chokidar@4.0.3)(webpack-dev-server@5.2.0(webpack@5.98.0(esbuild@0.25.2)))(webpack@5.98.0(esbuild@0.25.0))': + '@angular-devkit/build-webpack@0.1902.0(chokidar@4.0.3)(webpack-dev-server@5.2.0(webpack@5.98.0(esbuild@0.25.0)))(webpack@5.98.0(esbuild@0.25.0))': dependencies: '@angular-devkit/architect': 0.1902.0(chokidar@4.0.3) rxjs: 7.8.1 - webpack: 5.98.0(esbuild@0.25.0) - webpack-dev-server: 5.2.0(webpack@5.98.0(esbuild@0.25.2)) + webpack: 5.98.0(esbuild@0.25.2) + webpack-dev-server: 5.2.0(webpack@5.98.0(esbuild@0.25.0)) transitivePeerDependencies: - chokidar @@ -13144,6 +13290,19 @@ snapshots: '@braidai/lang@1.1.0': {} + '@bundled-es-modules/cookie@2.0.1': + dependencies: + cookie: 0.7.2 + + '@bundled-es-modules/statuses@1.0.1': + dependencies: + statuses: 2.0.1 + + '@bundled-es-modules/tough-cookie@0.1.6': + dependencies: + '@types/tough-cookie': 4.0.5 + tough-cookie: 4.1.4 + '@changesets/apply-release-plan@7.0.10': dependencies: '@changesets/config': 3.1.1 @@ -14159,6 +14318,15 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': optional: true + '@mswjs/interceptors@0.37.6': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + '@napi-rs/nice-android-arm-eabi@1.0.1': optional: true @@ -14274,7 +14442,7 @@ snapshots: dependencies: '@angular/compiler-cli': 19.2.0(@angular/compiler@19.2.0(@angular/core@19.2.0(rxjs@7.8.1)(zone.js@0.15.0)))(typescript@5.8.3) typescript: 5.8.3 - webpack: 5.98.0(esbuild@0.25.0) + webpack: 5.98.0(esbuild@0.25.2) '@nodelib/fs.scandir@2.1.5': dependencies: @@ -14660,7 +14828,54 @@ snapshots: - magicast - supports-color - '@nuxt/test-utils@3.17.2(@types/node@22.10.5)(@vue/test-utils@2.4.6)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(magicast@0.3.5)(sass@1.85.0)(terser@5.39.0)(typescript@5.8.3)(vitest@3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))(yaml@2.7.0)': + '@nuxt/test-utils@3.17.2(@types/node@22.10.5)(@vue/test-utils@2.4.6)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(magicast@0.3.5)(sass@1.85.0)(terser@5.39.0)(typescript@5.8.3)(vitest@3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))(yaml@2.7.0)': + dependencies: + '@nuxt/kit': 3.16.2(magicast@0.3.5) + '@nuxt/schema': 3.16.2 + c12: 3.0.2(magicast@0.3.5) + consola: 3.4.0 + defu: 6.1.4 + destr: 2.0.3 + estree-walker: 3.0.3 + fake-indexeddb: 6.0.0 + get-port-please: 3.1.2 + h3: 1.15.1 + local-pkg: 1.1.1 + magic-string: 0.30.17 + node-fetch-native: 1.6.6 + node-mock-http: 1.0.0 + ofetch: 1.4.1 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + radix3: 1.1.2 + scule: 1.3.0 + std-env: 3.8.1 + tinyexec: 0.3.2 + ufo: 1.5.4 + unplugin: 2.2.0 + vite: 6.2.6(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) + vitest-environment-nuxt: 1.0.1(@types/node@22.10.5)(@vue/test-utils@2.4.6)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(magicast@0.3.5)(sass@1.85.0)(terser@5.39.0)(typescript@5.8.3)(vitest@3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))(yaml@2.7.0) + vue: 3.5.13(typescript@5.8.3) + optionalDependencies: + '@vue/test-utils': 2.4.6 + jsdom: 23.0.0 + vitest: 3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - magicast + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - yaml + + '@nuxt/test-utils@3.17.2(@types/node@22.10.5)(@vue/test-utils@2.4.6)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(magicast@0.3.5)(sass@1.85.0)(terser@5.39.0)(typescript@5.8.3)(vitest@3.1.3(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))(yaml@2.7.0)': dependencies: '@nuxt/kit': 3.16.2(magicast@0.3.5) '@nuxt/schema': 3.16.2 @@ -14686,12 +14901,12 @@ snapshots: ufo: 1.5.4 unplugin: 2.2.0 vite: 6.2.6(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) - vitest-environment-nuxt: 1.0.1(@types/node@22.10.5)(@vue/test-utils@2.4.6)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(magicast@0.3.5)(sass@1.85.0)(terser@5.39.0)(typescript@5.8.3)(vitest@3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))(yaml@2.7.0) + vitest-environment-nuxt: 1.0.1(@types/node@22.10.5)(@vue/test-utils@2.4.6)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(magicast@0.3.5)(sass@1.85.0)(terser@5.39.0)(typescript@5.8.3)(vitest@3.1.3(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))(yaml@2.7.0) vue: 3.5.13(typescript@5.8.3) optionalDependencies: '@vue/test-utils': 2.4.6 jsdom: 23.0.0 - vitest: 3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) + vitest: 3.1.3(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) transitivePeerDependencies: - '@types/node' - jiti @@ -14829,6 +15044,15 @@ snapshots: '@one-ini/wasm@0.1.1': {} + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + '@parcel/watcher-android-arm64@2.5.1': optional: true @@ -16303,6 +16527,8 @@ snapshots: dependencies: '@types/node': 22.10.5 + '@types/statuses@2.0.5': {} + '@types/tough-cookie@4.0.5': {} '@types/unist@3.0.3': {} @@ -16547,7 +16773,7 @@ snapshots: vite: 6.2.6(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) vue: 3.5.13(typescript@5.8.3) - '@vitest/coverage-v8@3.1.1(vitest@3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))': + '@vitest/coverage-v8@3.1.1(vitest@3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -16561,7 +16787,7 @@ snapshots: std-env: 3.8.1 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) + vitest: 3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) transitivePeerDependencies: - supports-color @@ -16572,39 +16798,81 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.1.1(vite@6.2.6(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))': + '@vitest/expect@3.1.3': + dependencies: + '@vitest/spy': 3.1.3 + '@vitest/utils': 3.1.3 + chai: 5.2.0 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.1.1(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(vite@6.2.6(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))': dependencies: '@vitest/spy': 3.1.1 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: + msw: 2.8.2(@types/node@22.10.5)(typescript@5.8.3) + vite: 6.2.6(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) + + '@vitest/mocker@3.1.3(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(vite@6.2.6(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))': + dependencies: + '@vitest/spy': 3.1.3 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + msw: 2.8.2(@types/node@22.10.5)(typescript@5.8.3) vite: 6.2.6(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) '@vitest/pretty-format@3.1.1': dependencies: tinyrainbow: 2.0.0 + '@vitest/pretty-format@3.1.3': + dependencies: + tinyrainbow: 2.0.0 + '@vitest/runner@3.1.1': dependencies: '@vitest/utils': 3.1.1 pathe: 2.0.3 + '@vitest/runner@3.1.3': + dependencies: + '@vitest/utils': 3.1.3 + pathe: 2.0.3 + '@vitest/snapshot@3.1.1': dependencies: '@vitest/pretty-format': 3.1.1 magic-string: 0.30.17 pathe: 2.0.3 + '@vitest/snapshot@3.1.3': + dependencies: + '@vitest/pretty-format': 3.1.3 + magic-string: 0.30.17 + pathe: 2.0.3 + '@vitest/spy@3.1.1': dependencies: tinyspy: 3.0.2 + '@vitest/spy@3.1.3': + dependencies: + tinyspy: 3.0.2 + '@vitest/utils@3.1.1': dependencies: '@vitest/pretty-format': 3.1.1 loupe: 3.1.3 tinyrainbow: 2.0.0 + '@vitest/utils@3.1.3': + dependencies: + '@vitest/pretty-format': 3.1.3 + loupe: 3.1.3 + tinyrainbow: 2.0.0 + '@volar/language-core@2.4.12': dependencies: '@volar/source-map': 2.4.12 @@ -17253,7 +17521,7 @@ snapshots: '@babel/core': 7.26.9 find-cache-dir: 4.0.0 schema-utils: 4.3.0 - webpack: 5.98.0(esbuild@0.25.0) + webpack: 5.98.0(esbuild@0.25.2) babel-plugin-polyfill-corejs2@0.4.12(@babel/core@7.26.9): dependencies: @@ -17785,7 +18053,7 @@ snapshots: normalize-path: 3.0.0 schema-utils: 4.3.0 serialize-javascript: 6.0.2 - webpack: 5.98.0(esbuild@0.25.0) + webpack: 5.98.0(esbuild@0.25.2) core-js-compat@3.41.0: dependencies: @@ -17857,7 +18125,7 @@ snapshots: postcss-value-parser: 4.2.0 semver: 7.7.1 optionalDependencies: - webpack: 5.98.0(esbuild@0.25.0) + webpack: 5.98.0(esbuild@0.25.2) css-select@5.1.0: dependencies: @@ -18309,6 +18577,8 @@ snapshots: es-module-lexer@1.6.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -18497,8 +18767,8 @@ snapshots: '@typescript-eslint/parser': 8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3) eslint: 9.17.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.5(eslint-plugin-import@2.31.0)(eslint@9.17.0(jiti@2.4.2)) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.5)(eslint@9.17.0(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.8.5(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.5(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.17.0(jiti@2.4.2)) eslint-plugin-react: 7.37.4(eslint@9.17.0(jiti@2.4.2)) eslint-plugin-react-hooks: 5.2.0(eslint@9.17.0(jiti@2.4.2)) @@ -18521,7 +18791,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.8.5(eslint-plugin-import@2.31.0)(eslint@9.17.0(jiti@2.4.2)): + eslint-import-resolver-typescript@3.8.5(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.0(supports-color@9.4.0) @@ -18532,22 +18802,22 @@ snapshots: stable-hash: 0.0.4 tinyglobby: 0.2.12 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.5)(eslint@9.17.0(jiti@2.4.2)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.5(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.5)(eslint@9.17.0(jiti@2.4.2)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.5(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3) eslint: 9.17.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.5(eslint-plugin-import@2.31.0)(eslint@9.17.0(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.8.5(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.5)(eslint@9.17.0(jiti@2.4.2)): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.5(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -18558,7 +18828,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.17.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.5)(eslint@9.17.0(jiti@2.4.2)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.5(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.29.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)))(eslint@9.17.0(jiti@2.4.2)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -19051,6 +19321,10 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + fdir@6.4.4(picomatch@4.0.2): + optionalDependencies: + picomatch: 4.0.2 + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 @@ -19394,6 +19668,8 @@ snapshots: graphemer@1.4.0: {} + graphql@16.11.0: {} + gzip-size@7.0.0: dependencies: duplexer: 0.1.2 @@ -19465,6 +19741,8 @@ snapshots: he@1.2.0: {} + headers-polyfill@4.0.3: {} + highlight.js@10.7.3: {} hookable@5.5.3: {} @@ -19783,6 +20061,8 @@ snapshots: is-network-error@1.1.0: {} + is-node-process@1.2.0: {} + is-number-object@1.1.1: dependencies: call-bound: 1.0.4 @@ -20184,7 +20464,7 @@ snapshots: dependencies: less: 4.2.2 optionalDependencies: - webpack: 5.98.0(esbuild@0.25.0) + webpack: 5.98.0(esbuild@0.25.2) less@4.2.2: dependencies: @@ -20209,7 +20489,7 @@ snapshots: dependencies: webpack-sources: 3.2.3 optionalDependencies: - webpack: 5.98.0(esbuild@0.25.0) + webpack: 5.98.0(esbuild@0.25.2) light-my-request@6.6.0: dependencies: @@ -20537,7 +20817,7 @@ snapshots: dependencies: schema-utils: 4.3.0 tapable: 2.2.1 - webpack: 5.98.0(esbuild@0.25.0) + webpack: 5.98.0(esbuild@0.25.2) minimalistic-assert@1.0.1: {} @@ -20668,6 +20948,31 @@ snapshots: msgpackr-extract: 3.0.3 optional: true + msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3): + dependencies: + '@bundled-es-modules/cookie': 2.0.1 + '@bundled-es-modules/statuses': 1.0.1 + '@bundled-es-modules/tough-cookie': 0.1.6 + '@inquirer/confirm': 5.1.7(@types/node@22.10.5) + '@mswjs/interceptors': 0.37.6 + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/until': 2.1.0 + '@types/cookie': 0.6.0 + '@types/statuses': 2.0.5 + graphql: 16.11.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + strict-event-emitter: 0.5.1 + type-fest: 4.37.0 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - '@types/node' + muggle-string@0.4.1: {} multicast-dns@7.2.5: @@ -21402,6 +21707,8 @@ snapshots: outdent@0.5.0: {} + outvariant@1.4.3: {} + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -21578,6 +21885,8 @@ snapshots: path-to-regexp@0.1.12: {} + path-to-regexp@6.3.0: {} + path-type@4.0.0: {} path-type@6.0.0: {} @@ -21740,7 +22049,7 @@ snapshots: postcss: 8.5.2 semver: 7.7.1 optionalDependencies: - webpack: 5.98.0(esbuild@0.25.0) + webpack: 5.98.0(esbuild@0.25.2) transitivePeerDependencies: - typescript @@ -22476,7 +22785,7 @@ snapshots: neo-async: 2.6.2 optionalDependencies: sass: 1.85.0 - webpack: 5.98.0(esbuild@0.25.0) + webpack: 5.98.0(esbuild@0.25.2) sass@1.85.0: dependencies: @@ -22816,7 +23125,7 @@ snapshots: dependencies: iconv-lite: 0.6.3 source-map-js: 1.2.1 - webpack: 5.98.0(esbuild@0.25.0) + webpack: 5.98.0(esbuild@0.25.2) source-map-support@0.5.21: dependencies: @@ -22902,6 +23211,8 @@ snapshots: std-env@3.8.1: {} + std-env@3.9.0: {} + streamroller@3.1.5: dependencies: date-format: 4.0.14 @@ -22919,6 +23230,8 @@ snapshots: optionalDependencies: bare-events: 2.5.4 + strict-event-emitter@0.5.1: {} + string-argv@0.3.2: {} string-width@4.2.3: @@ -23197,16 +23510,16 @@ snapshots: term-size@2.2.1: {} - terser-webpack-plugin@5.3.14(esbuild@0.25.0)(webpack@5.98.0(esbuild@0.25.2)): + terser-webpack-plugin@5.3.14(esbuild@0.25.2)(webpack@5.98.0(esbuild@0.25.0)): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 4.3.0 serialize-javascript: 6.0.2 terser: 5.39.0 - webpack: 5.98.0(esbuild@0.25.0) + webpack: 5.98.0(esbuild@0.25.2) optionalDependencies: - esbuild: 0.25.0 + esbuild: 0.25.2 terser@5.39.0: dependencies: @@ -23261,6 +23574,11 @@ snapshots: fdir: 6.4.3(picomatch@4.0.2) picomatch: 4.0.2 + tinyglobby@0.2.13: + dependencies: + fdir: 6.4.4(picomatch@4.0.2) + picomatch: 4.0.2 + tinypool@1.0.2: {} tinyrainbow@2.0.0: {} @@ -23924,6 +24242,27 @@ snapshots: - tsx - yaml + vite-node@3.1.3(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0): + dependencies: + cac: 6.7.14 + debug: 4.4.0(supports-color@9.4.0) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.2.6(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-plugin-checker@0.8.0(eslint@9.17.0(jiti@2.4.2))(optionator@0.9.4)(typescript@5.8.3)(vite@5.4.16(@types/node@22.10.5)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)): dependencies: '@babel/code-frame': 7.26.2 @@ -24117,9 +24456,9 @@ snapshots: - typescript - universal-cookie - vitest-environment-nuxt@1.0.1(@types/node@22.10.5)(@vue/test-utils@2.4.6)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(magicast@0.3.5)(sass@1.85.0)(terser@5.39.0)(typescript@5.8.3)(vitest@3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))(yaml@2.7.0): + vitest-environment-nuxt@1.0.1(@types/node@22.10.5)(@vue/test-utils@2.4.6)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(magicast@0.3.5)(sass@1.85.0)(terser@5.39.0)(typescript@5.8.3)(vitest@3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))(yaml@2.7.0): dependencies: - '@nuxt/test-utils': 3.17.2(@types/node@22.10.5)(@vue/test-utils@2.4.6)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(magicast@0.3.5)(sass@1.85.0)(terser@5.39.0)(typescript@5.8.3)(vitest@3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))(yaml@2.7.0) + '@nuxt/test-utils': 3.17.2(@types/node@22.10.5)(@vue/test-utils@2.4.6)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(magicast@0.3.5)(sass@1.85.0)(terser@5.39.0)(typescript@5.8.3)(vitest@3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))(yaml@2.7.0) transitivePeerDependencies: - '@cucumber/cucumber' - '@jest/globals' @@ -24145,10 +24484,38 @@ snapshots: - vitest - yaml - vitest@3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0): + vitest-environment-nuxt@1.0.1(@types/node@22.10.5)(@vue/test-utils@2.4.6)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(magicast@0.3.5)(sass@1.85.0)(terser@5.39.0)(typescript@5.8.3)(vitest@3.1.3(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))(yaml@2.7.0): + dependencies: + '@nuxt/test-utils': 3.17.2(@types/node@22.10.5)(@vue/test-utils@2.4.6)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(magicast@0.3.5)(sass@1.85.0)(terser@5.39.0)(typescript@5.8.3)(vitest@3.1.3(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0))(yaml@2.7.0) + transitivePeerDependencies: + - '@cucumber/cucumber' + - '@jest/globals' + - '@playwright/test' + - '@testing-library/vue' + - '@types/node' + - '@vitest/ui' + - '@vue/test-utils' + - happy-dom + - jiti + - jsdom + - less + - lightningcss + - magicast + - playwright-core + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - vitest + - yaml + + vitest@3.1.1(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0): dependencies: '@vitest/expect': 3.1.1 - '@vitest/mocker': 3.1.1(vite@6.2.6(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0)) + '@vitest/mocker': 3.1.1(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(vite@6.2.6(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0)) '@vitest/pretty-format': 3.1.1 '@vitest/runner': 3.1.1 '@vitest/snapshot': 3.1.1 @@ -24184,6 +24551,46 @@ snapshots: - tsx - yaml + vitest@3.1.3(@types/node@22.10.5)(jiti@2.4.2)(jsdom@23.0.0)(less@4.2.2)(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0): + dependencies: + '@vitest/expect': 3.1.3 + '@vitest/mocker': 3.1.3(msw@2.8.2(@types/node@22.10.5)(typescript@5.8.3))(vite@6.2.6(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0)) + '@vitest/pretty-format': 3.1.3 + '@vitest/runner': 3.1.3 + '@vitest/snapshot': 3.1.3 + '@vitest/spy': 3.1.3 + '@vitest/utils': 3.1.3 + chai: 5.2.0 + debug: 4.4.0(supports-color@9.4.0) + expect-type: 1.2.1 + magic-string: 0.30.17 + pathe: 2.0.3 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.13 + tinypool: 1.0.2 + tinyrainbow: 2.0.0 + vite: 6.2.6(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) + vite-node: 3.1.3(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.10.5 + jsdom: 23.0.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + void-elements@2.0.1: {} vscode-jsonrpc@6.0.0: {} @@ -24283,7 +24690,7 @@ snapshots: webidl-conversions@7.0.0: {} - webpack-dev-middleware@7.4.2(webpack@5.98.0(esbuild@0.25.2)): + webpack-dev-middleware@7.4.2(webpack@5.98.0(esbuild@0.25.0)): dependencies: colorette: 2.0.20 memfs: 4.17.0 @@ -24292,9 +24699,9 @@ snapshots: range-parser: 1.2.1 schema-utils: 4.3.0 optionalDependencies: - webpack: 5.98.0(esbuild@0.25.0) + webpack: 5.98.0(esbuild@0.25.2) - webpack-dev-server@5.2.0(webpack@5.98.0(esbuild@0.25.2)): + webpack-dev-server@5.2.0(webpack@5.98.0(esbuild@0.25.0)): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -24321,10 +24728,10 @@ snapshots: serve-index: 1.9.1 sockjs: 0.3.24 spdy: 4.0.2 - webpack-dev-middleware: 7.4.2(webpack@5.98.0(esbuild@0.25.2)) + webpack-dev-middleware: 7.4.2(webpack@5.98.0(esbuild@0.25.0)) ws: 8.18.1 optionalDependencies: - webpack: 5.98.0(esbuild@0.25.0) + webpack: 5.98.0(esbuild@0.25.2) transitivePeerDependencies: - bufferutil - debug @@ -24342,11 +24749,11 @@ snapshots: webpack-subresource-integrity@5.1.0(webpack@5.98.0(esbuild@0.25.0)): dependencies: typed-assert: 1.0.9 - webpack: 5.98.0(esbuild@0.25.0) + webpack: 5.98.0(esbuild@0.25.2) webpack-virtual-modules@0.6.2: {} - webpack@5.98.0(esbuild@0.25.0): + webpack@5.98.0(esbuild@0.25.2): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.6 @@ -24368,7 +24775,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.14(esbuild@0.25.0)(webpack@5.98.0(esbuild@0.25.2)) + terser-webpack-plugin: 5.3.14(esbuild@0.25.2)(webpack@5.98.0(esbuild@0.25.0)) watchpack: 2.4.2 webpack-sources: 3.2.3 transitivePeerDependencies: From c4e72cc153dcc871057cd89300ccb779a5d0c6a5 Mon Sep 17 00:00:00 2001 From: Liam Stanley Date: Sat, 17 May 2025 20:25:01 -0600 Subject: [PATCH 3/3] fix(client-nuxt): support reactive headers Signed-off-by: Liam Stanley --- .../client-nuxt/src/__tests__/client.test.ts | 1 - .../src/__tests__/reactive.test.ts | 29 +++++++++++++++++++ packages/client-nuxt/src/client.ts | 18 +++++++++--- packages/client-nuxt/src/types.ts | 27 ++++++++--------- packages/client-nuxt/src/utils.ts | 15 +++++++--- 5 files changed, 66 insertions(+), 24 deletions(-) diff --git a/packages/client-nuxt/src/__tests__/client.test.ts b/packages/client-nuxt/src/__tests__/client.test.ts index c8d373d84..6f5a3f317 100644 --- a/packages/client-nuxt/src/__tests__/client.test.ts +++ b/packages/client-nuxt/src/__tests__/client.test.ts @@ -458,7 +458,6 @@ describe('authentication', () => { const result = await client.get<'useFetch', http.VerboseResponse>({ composable: 'useFetch', - query: { liam: 1 }, security: [ { name: 'baz', diff --git a/packages/client-nuxt/src/__tests__/reactive.test.ts b/packages/client-nuxt/src/__tests__/reactive.test.ts index 8d4b7339f..0033ca6f2 100644 --- a/packages/client-nuxt/src/__tests__/reactive.test.ts +++ b/packages/client-nuxt/src/__tests__/reactive.test.ts @@ -152,3 +152,32 @@ test('reactive body re-triggers POST on body ref update', async () => { expect(result.data.value?.species).toEqual(newPet.value.species); expect(server.spy('post', '/pets')).toHaveBeenCalledTimes(2); }); + +test('reactive headers re-triggers GET on header ref update', async () => { + const client = createClient({ baseURL: 'https://localhost' }); + const server = http.newServer([http.mockVerboseHandler('https://localhost')]); + + const species = ref( + new Headers({ + 'X-Example-Header': 'example', + }), + ); + + const result = await client.get<'useFetch', http.VerboseResponse>({ + composable: 'useFetch', + headers: species, + url: '/verbose', + }); + + const received = new Headers(result.data.value?.headers); + expect(received.get('X-Example-Header')).toEqual('example'); + + species.value = new Headers({ + 'X-Example-Header': 'example2', + }); + await waitStatusFinished(result.status); + const received2 = new Headers(result.data.value?.headers); + expect(received2.get('X-Example-Header')).toEqual('example2'); + + server.expectAllCalled(); +}); diff --git a/packages/client-nuxt/src/client.ts b/packages/client-nuxt/src/client.ts index 07191668b..5ee587f37 100644 --- a/packages/client-nuxt/src/client.ts +++ b/packages/client-nuxt/src/client.ts @@ -13,6 +13,7 @@ import { mergeConfigs, mergeHeaders, mergeInterceptors, + removeNullHeaders, serializeBody, } from './utils'; @@ -37,18 +38,27 @@ export const createClient = (config: Config = {}): Client => { // because Nuxt composables control when the request is sent (or re-sent) -- we don't invoke the // request here. if we do logic outside of interceptors, it has the potential to become stale // or cause hydration errors. + const opts = { ..._config, ...options, $fetch: options.$fetch ?? _config.$fetch ?? $fetch, - headers: mergeHeaders(_config.headers, options.headers), + // headers here can be ignored, just here to make sure nuxt keeps reactivity. all headers + // will be merged in interceptors. + headers: removeNullHeaders(options.headers), onRequest: mergeInterceptors( ({ options: req }) => - (req.body = serializeBody({ + (req.headers = mergeHeaders( + _config.headers, + req.headers, + options.headers, + )), + ({ options: req }) => { + req.body = serializeBody({ ...options, bodySerializer: options.bodySerializer ?? _config.bodySerializer, - })), - ({ options: req }) => { + }); + // remove Content-Type header if body is empty to avoid sending invalid requests if (req.body === undefined || req.body === '') { req.headers.delete('Content-Type'); diff --git a/packages/client-nuxt/src/types.ts b/packages/client-nuxt/src/types.ts index 9547f9451..3b50ca8e4 100644 --- a/packages/client-nuxt/src/types.ts +++ b/packages/client-nuxt/src/types.ts @@ -12,7 +12,7 @@ import type { useLazyAsyncData, useLazyFetch, } from 'nuxt/app'; -import type { MaybeRefOrGetter } from 'vue'; +import type { MaybeRef, MaybeRefOrGetter } from 'vue'; export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; @@ -24,6 +24,11 @@ export type QuerySerializer = ( query: Parameters[0]['query'], ) => string; +export type FetchHeaders = + | HeadersInit + | MaybeRef + | Record; + /** * KeysOf, copied from Nuxt, is used depending on the composable to "pick" the keys * on the return type, so only a sub-set of the data is returned via hydration, @@ -43,7 +48,7 @@ export interface Config * Base URL for all requests made by this client. */ baseURL?: T['baseURL']; - headers?: FetchOptions['headers']; + headers?: FetchHeaders; /** * A function for serializing request query parameters. By default, arrays * will be exploded in form style, objects will be exploded in deepObject @@ -60,7 +65,7 @@ export interface RequestOptions< DefaultT = undefined, Url extends string = string, > extends Config, - Pick, 'body' | 'path' | 'query'> { + Pick, 'body' | 'path' | 'query' | 'headers'> { asyncDataOptions?: AsyncDataOptions, DefaultT>; /** * Any body that you want to add to your request. @@ -68,7 +73,6 @@ export interface RequestOptions< * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} */ composable: TComposable; - headers?: FetchOptions['headers']; key?: string; /** * Security mechanism(s) to use for the request. @@ -110,12 +114,7 @@ type MethodFn = < TError = unknown, DefaultT = undefined, >( - options: Omit< - RequestOptions, - 'method' | 'headers' - > & { - headers?: FetchOptions['headers']; - }, + options: Omit, 'method'>, ) => RequestResult; type RequestFn = < @@ -142,7 +141,7 @@ export type CreateClientConfig = ( export interface TDataShape { body?: FetchOptions['body']; - headers?: unknown; + headers?: FetchHeaders; path?: FetchOptions['query']; query?: FetchOptions['query']; url: string; @@ -182,11 +181,9 @@ export type Options< DefaultT = undefined, > = Omit< RequestOptions, - 'body' | 'url' | 'query' | 'path' + 'body' | 'url' | 'query' | 'path' | 'headers' > & - Pick, 'body' | 'query' | 'path'> & { - headers?: FetchOptions['headers']; - }; + Pick, 'body' | 'query' | 'path' | 'headers'>; export type OptionsLegacyParser = TData extends { body?: any } ? TData extends { headers?: any } diff --git a/packages/client-nuxt/src/utils.ts b/packages/client-nuxt/src/utils.ts index a89813e22..901971728 100644 --- a/packages/client-nuxt/src/utils.ts +++ b/packages/client-nuxt/src/utils.ts @@ -6,7 +6,7 @@ import { serializeObjectParam, serializePrimitiveParam, } from '@hey-api/client-core'; -import { toValue } from 'vue'; +import { isRef, toValue } from 'vue'; import type { ArraySeparatorStyle, @@ -235,9 +235,7 @@ export const mergeConfigs = (a: Config, b: Config): Config => { * @returns A new Headers object with the merged headers. */ export const mergeHeaders = ( - ...headers: Array< - RequestOptions['headers'] | Record | undefined - > + ...headers: Array ): Headers => { const mergedHeaders = new Headers(); @@ -273,6 +271,15 @@ export const mergeHeaders = ( return mergedHeaders; }; +/** + * removeNullHeaders removes null values from headers, which are accepted to allow + * unsetting a previously set header. + */ +export const removeNullHeaders = ( + headers: RequestOptions['headers'], +): Exclude> => + isRef(headers) ? headers : mergeHeaders(headers); + export const mergeInterceptors = (...args: Array): Array => args.reduce>((acc, item) => { if (typeof item === 'function') {