diff --git a/.agents/skills/data-client-manager/references/Controller.vue.md b/.agents/skills/data-client-manager/references/Controller.vue.md index 20b4e1b4105e..bb8f26e7f818 100644 --- a/.agents/skills/data-client-manager/references/Controller.vue.md +++ b/.agents/skills/data-client-manager/references/Controller.vue.md @@ -10,7 +10,7 @@ and retrieval performance. - [Managers](./Manager.vue.md) as the first argument in [Manager.middleware](./Manager.vue.md#middleware) - Vue with [useController()](https://dataclient.io/vue/api/useController) -- Unit testing composables with `renderDataCompose()` from `@data-client/vue/test` +- [Unit testing composables](https://dataclient.io/vue/guides/unit-testing-composables) with `renderDataCompose()` from `@data-client/vue/test` ```ts class Controller { diff --git a/.agents/skills/data-client-react/references.json b/.agents/skills/data-client-react/references.json index 7c223436f5d6..a41c8ef7a71a 100644 --- a/.agents/skills/data-client-react/references.json +++ b/.agents/skills/data-client-react/references.json @@ -13,6 +13,7 @@ "_useLive.md": "docs/core/shared/_useLive.mdx", "_useLoading.md": "docs/core/shared/_useLoading.mdx", "data-dependency.md": "docs/core/getting-started/data-dependency.md", + "devtools-debugging.md": "docs/core/getting-started/devtools-debugging.md", "mutations.md": "docs/core/getting-started/mutations.md", "useCache.md": "docs/core/api/useCache.md", "useController.md": "docs/core/api/useController.md", diff --git a/.agents/skills/data-client-react/references/devtools-debugging.md b/.agents/skills/data-client-react/references/devtools-debugging.md index a6551fccbb53..9f3d2fe5d5df 100644 --- a/.agents/skills/data-client-react/references/devtools-debugging.md +++ b/.agents/skills/data-client-react/references/devtools-debugging.md @@ -1,12 +1,16 @@ -# Debugging Data Client with Chrome DevTools MCP + -Debug `@data-client/react` or `@data-client/vue` state and actions programmatically via Chrome DevTools MCP `evaluate_script`. The app's `DevToolsManager` exposes the Controller on `globalThis.__DC_CONTROLLERS__` (a `Map` keyed by `devtoolsName`) in dev mode. +# Debugging with Chrome DevTools MCP + +Debug `@data-client/react` state and actions programmatically via [Chrome DevTools MCP](https://github.com/ChromeDevTools/chrome-devtools-mcp) `evaluate_script`. The app's [DevToolsManager](https://dataclient.io/docs/api/DevToolsManager) exposes the [Controller](./Controller.md) on [`globalThis.__DC_CONTROLLERS__`](https://dataclient.io/docs/api/DevToolsManager#controllers) (a `Map` keyed by `devtoolsName`) in dev mode. + +Agents with the [`data-client-react` skill](https://skills.sh/reactive/data-client/data-client-react) already follow these steps ([how agent debugging works](https://dataclient.io/docs/getting-started/debugging#how-agent-debugging-works)); this page is the full recipe. ## Prerequisites 1. Dev server running with `NODE_ENV !== 'production'` 2. Chrome DevTools MCP connected and page loaded -3. `DevToolsManager` included in `DataProvider` (React) or `DataClientPlugin` (Vue) managers (default in dev mode) +3. `DevToolsManager` included in `DataProvider` managers (default in dev mode) ## Step 1: Access the Controller @@ -26,7 +30,7 @@ Debug `@data-client/react` or `@data-client/vue` state and actions programmatica ### Get a controller by key -Use the key from discovery. Always use `.get(devtoolsName)` with the actual key — not `.values().next().value` — so you target the correct store when multiple `DataProvider`s or Vue apps exist. +Use the key from discovery. Always use `.get(devtoolsName)` with the actual key — not `.values().next().value` — so you target the correct store when multiple `DataProvider`s exist. The snippets below use the first controller for brevity. ```js // evaluate_script @@ -298,30 +302,30 @@ then cross-reference with endpoint keys in state. ## Action Types Reference -| Type | Controller Method | Description | -|---|---|---| -| `rdc/fetch` | `fetch()` | Network request initiated | -| `rdc/setresponse` | `setResponse()` | Response written to cache | -| `rdc/set` | `set()` | Direct entity value set | -| `rdc/optimistic` | (automatic) | Optimistic update applied | -| `rdc/invalidate` | `invalidate()` | Single endpoint invalidated | -| `rdc/invalidateall` | `invalidateAll()` | Bulk invalidation by key test | -| `rdc/expireall` | `expireAll()` | Bulk mark-stale by key test | -| `rdc/reset` | `resetEntireStore()` | Full store reset | -| `rdc/subscribe` | `subscribe()` | Subscription registered | -| `rdc/unsubscribe` | `unsubscribe()` | Subscription removed | -| `rdc/gc` | (automatic) | Garbage collection | +| Type | Controller Method | Description | +| ------------------- | -------------------- | ----------------------------- | +| `rdc/fetch` | `fetch()` | Network request initiated | +| `rdc/setresponse` | `setResponse()` | Response written to cache | +| `rdc/set` | `set()` | Direct entity value set | +| `rdc/optimistic` | (automatic) | Optimistic update applied | +| `rdc/invalidate` | `invalidate()` | Single endpoint invalidated | +| `rdc/invalidateall` | `invalidateAll()` | Bulk invalidation by key test | +| `rdc/expireall` | `expireAll()` | Bulk mark-stale by key test | +| `rdc/reset` | `resetEntireStore()` | Full store reset | +| `rdc/subscribe` | `subscribe()` | Subscription registered | +| `rdc/unsubscribe` | `unsubscribe()` | Subscription removed | +| `rdc/gc` | (automatic) | Garbage collection | ## Controller State Readers All take `state` (from `ctrl.getState()`) as the **last** argument. -| Method | Signature | Returns | -|---|---|---| -| `getResponse` | `(endpoint, ...args, state)` | `{ data, expiryStatus, expiresAt }` — denormalized through schema | -| `getError` | `(endpoint, ...args, state)` | `ErrorTypes \| undefined` | -| `get` | `(schema, ...args, state)` | `Denormalized \| undefined` for any Queryable schema | -| `getQueryMeta` | `(schema, ...args, state)` | `{ data, countRef }` | +| Method | Signature | Returns | +| -------------- | ---------------------------- | ----------------------------------------------------------------- | +| `getResponse` | `(endpoint, ...args, state)` | `{ data, expiryStatus, expiresAt }` — denormalized through schema | +| `getError` | `(endpoint, ...args, state)` | `ErrorTypes \| undefined` | +| `get` | `(schema, ...args, state)` | `Denormalized \| undefined` for any Queryable schema | +| `getQueryMeta` | `(schema, ...args, state)` | `{ data, countRef }` | `expiryStatus` values: `1` = Invalid, `2` = InvalidIfStale, `3` = Valid. diff --git a/.agents/skills/data-client-rest/references/Fixtures.vue.md b/.agents/skills/data-client-rest/references/Fixtures.vue.md index ded882848005..cefb6692aa34 100644 --- a/.agents/skills/data-client-rest/references/Fixtures.vue.md +++ b/.agents/skills/data-client-rest/references/Fixtures.vue.md @@ -174,7 +174,7 @@ Determines what the response for this mock should be. If a function it will be r Function running is called 'collapsing' after the mechanism in [Quantum Mechanics](https://www.wondriumdaily.com/copenhagen-interpretation-of-quantum-mechanics/) -`this` can be used to store simulated server-side data. It is initialized using `getInitialInterceptorData`. It's important to not use arrow functions when using this as they disallow `this` binding. +`this` can be used to store simulated server-side data. It is initialized using [getInitialInterceptorData](https://dataclient.io/vue/guides/unit-testing-components#options). It's important to not use arrow functions when using this as they disallow `this` binding. ### fetchResponse(input, init) {#fetchResponse} diff --git a/.agents/skills/data-client-vue-testing/SKILL.md b/.agents/skills/data-client-vue-testing/SKILL.md index ea213db27516..c71f94280b55 100644 --- a/.agents/skills/data-client-vue-testing/SKILL.md +++ b/.agents/skills/data-client-vue-testing/SKILL.md @@ -12,8 +12,8 @@ license: Apache 2.0 import { renderDataCompose } from '@data-client/vue/test'; import { reactive, computed } from 'vue'; -it('useQuery() should return cached data', () => { - const { result } = renderDataCompose( +it('useQuery() should return cached data', async () => { + const { result } = await renderDataCompose( () => useQuery(Article, { id: 5 }), { initialFixtures: [ @@ -25,7 +25,7 @@ it('useQuery() should return cached data', () => { ], }, ); - expect(result.current?.value).toEqual(Article.fromJS({ id: 5, title: 'hi ho', content: 'whatever' })); + expect(result.value).toEqual(Article.fromJS({ id: 5, title: 'hi ho', content: 'whatever' })); }); ``` @@ -35,13 +35,13 @@ it('useQuery() should return cached data', () => { - `props` - Reactive props object (use `reactive()`) - `managers`, `initialState`, `gcPolicy` - Custom configuration -**Return values:** -- `result.current` - Composable return value (undefined when suspended, Promise when resolved for useSuspense) +**Return values** (`renderDataCompose()` is async; always `await` it): +- `result` - Whatever the composable returned: useQuery/useCache give a `ComputedRef` (`.value` is `undefined` when not in the store); useSuspense gives a Promise of one, so `await result` once, then read `.value`, which stays reactive - `controller` - Controller instance for manual actions - `wrapper` - Vue Test Utils wrapper - `cleanup()` - Cleanup function (always call in afterEach/after test) - `allSettled()` - Wait for all pending promises -- `waitForNextUpdate()` - Wait for composable to resolve from suspended state +- `waitForNextUpdate()` - Wait for a pending Promise `result` (1 second cap); prefer `await result` ## Component Testing with mountDataClient() @@ -73,7 +73,7 @@ it('should render article component', async () => { ], }); - await flushUntil(wrapper, () => wrapper.find('h3').exists()); + await flushUntil(() => wrapper.find('h3').exists()); expect(wrapper.find('h3').text()).toBe('hi ho'); cleanup(); }); @@ -82,40 +82,16 @@ it('should render article component', async () => { **Features:** - Suspense is automatically integrated (shows fallback while loading) - Use `data-testid="suspense-fallback"` to test loading state -- Returns same utilities as `renderDataCompose()` plus `wrapper` +- Synchronous; returns `wrapper`, `controller`, `app`, `cleanup()` and `allSettled()` ## Async Waiting Patterns -**flushUntil helper (for component tests):** +**flushUntil(predicate) (for component tests):** copy the helper from +[waiting for renders](references/unit-testing-components.md#waiting-for-renders); it throws if the +condition never holds. ```typescript -async function flushUntil(wrapper: any, predicate: () => boolean, tries = 100) { - for (let i = 0; i < tries; i++) { - if (predicate()) return; - await Promise.resolve(); - await nextTick(); - await new Promise(resolve => setTimeout(resolve, 0)); - } -} - -// Usage: -await flushUntil(wrapper, () => wrapper.find('h3').exists()); -await flushUntil(wrapper, () => wrapper.find('h3').text() === 'Expected Title'); -``` - -**waitForNextUpdate (for composable tests):** -```typescript -const { result, waitForNextUpdate } = renderDataCompose(() => useSuspense(...)); - -// Initially suspended -expect(result.current).toBeUndefined(); - -// Wait for resolution -await waitForNextUpdate(); -expect(result.current).toBeInstanceOf(Promise); - -// Await the promise to get the reactive ComputedRef -const dataRef = await result.current; -expect(dataRef.value.title).toBe('hi ho'); +await flushUntil(() => wrapper.find('h3').exists()); +await flushUntil(() => wrapper.find('h3').text() === 'Expected Title'); ``` ## Reactive Props Testing @@ -123,7 +99,7 @@ expect(dataRef.value.title).toBe('hi ho'); **Pattern 1: Testing prop changes:** ```typescript const props = reactive({ id: 1 }); -const { result } = renderDataCompose( +const { result } = await renderDataCompose( () => useQuery(Article, computed(() => ({ id: props.id }))), { initialFixtures: [ @@ -133,24 +109,24 @@ const { result } = renderDataCompose( }, ); -expect(result.current?.value?.title).toBe('First'); +expect(result.value?.title).toBe('First'); -// Change props - result automatically updates +// Change props - result updates after a tick props.id = 2; -expect(result.current?.value?.title).toBe('Second'); +await nextTick(); +expect(result.value?.title).toBe('Second'); ``` **Pattern 2: Conditional arguments (null handling):** ```typescript const props = reactive({ id: 1 as number | null }); -const { result } = renderDataCompose( +const { result } = await renderDataCompose( (props: { id: number | null }) => useSuspense(ArticleResource.get, computed(() => props.id !== null ? { id: props.id } : null)), { props }, ); -await waitForNextUpdate(); -const articleRef = await result.current; +const articleRef = await result; expect(articleRef.value).toBeDefined(); // Set to null - becomes undefined @@ -194,7 +170,7 @@ resolverFixtures: [ ```typescript it('should update collection when pushed', async () => { - const { result, controller, waitForNextUpdate } = renderDataCompose( + const { result, controller } = await renderDataCompose( () => useQuery(ArticleResource.getList.schema, {}), { initialFixtures: [ @@ -206,16 +182,16 @@ it('should update collection when pushed', async () => { }, ); - expect(result.current?.value?.length).toBe(1); + expect(result.value?.length).toBe(1); await controller.fetch(ArticleResource.getList.push, { id: 2, title: 'Second', content: 'new', }); - await waitForNextUpdate(); + await nextTick(); - expect(result.current?.value?.length).toBe(2); + expect(result.value?.length).toBe(2); }); ``` @@ -223,9 +199,8 @@ it('should update collection when pushed', async () => { **setResponse() for instant updates:** ```typescript -const { controller } = renderDataCompose(...); -await waitForNextUpdate(); -const dataRef = await result.current; +const { result, controller } = await renderDataCompose(() => useSuspense(...)); +const dataRef = await result; expect(dataRef.value.title).toBe('Original'); @@ -239,16 +214,6 @@ await nextTick(); expect(dataRef.value.title).toBe('Updated'); // Reactive! ``` -**fetch() for mutations:** -```typescript -await controller.fetch( - ArticleResource.update, - { id: 5 }, - { title: 'Mutated', content: 'mutated content' } -); -await nextTick(); -``` - ## Testing with nock (HTTP Mocking) Use nock when a test must exercise the real fetch path — verifying URL construction, headers, request bodies, retries, or anything in your `RestEndpoint`/`Resource` networking layer. For pure store/state behavior, prefer `initialFixtures`/`resolverFixtures` (lighter and faster). @@ -308,61 +273,16 @@ cleanup(); For unsubscribe patterns, component-level polling tests, fake-timer-safe `flushUntil`, polling via nock, and common pitfalls, see [references/polling-subscriptions.md](references/polling-subscriptions.md). -## Vue Suspense Behavior - -**useSuspense() returns Promise → ComputedRef:** -```typescript -const { result, waitForNextUpdate } = renderDataCompose(() => - useSuspense(ArticleResource.get, { id: 5 }) -); - -// Initially suspended (undefined) -expect(result.current).toBeUndefined(); - -// Wait for resolution -await waitForNextUpdate(); - -// Now it's a Promise -expect(result.current).toBeInstanceOf(Promise); - -// Await once to get reactive ComputedRef -const articleRef = await result.current; - -// The ref is reactive - updates automatically -expect(articleRef.value.title).toBe('hi ho'); - -// After controller.setResponse() or controller.fetch(): -await nextTick(); -expect(articleRef.value.title).toBe('Updated'); // Auto-updated! -``` - -**useQuery() returns ComputedRef directly:** -```typescript -const { result } = renderDataCompose(() => useQuery(Article, { id: 5 })); - -// Synchronously available (or undefined if not in store) -expect(result.current?.value).toBeDefined(); -expect(result.current?.value?.title).toBe('hi ho'); - -// Also reactive - updates automatically -``` - ## Best Practices - **Always call cleanup()** - Prevents memory leaks and test pollution -- **Use renderDataCompose()** for composables (useQuery, useSuspense, useLive) -- **Use mountDataClient()** for components - **Use reactive() for props** - Enables testing prop changes -- **Use computed() when passing reactive props to composables** - Ensures proper reactivity tracking - **Use flushUntil() in component tests** - More reliable than fixed delays -- **Use waitForNextUpdate() in composable tests** - Wait for suspension to resolve - **Remember nextTick()** - After mutations/setResponse to allow Vue reactivity to propagate - **Use initialFixtures for initial state** - Pre-populate the store - **Use resolverFixtures for dynamic responses** - Intercept requests with functions -- **useSuspense returns Promise → ComputedRef** - Await once, then access `.value` - **Test both empty and populated states** - Verify undefined behavior -- **Test reactive prop changes** - Use `reactive()` and verify updates -- **Pass prop-derived args as getters** - Async setup runs once, so `useSuspense(Resource.get, () => ({ id: props.id }))` follows prop changes; a plain `{ id: props.id }` is read once +- **Pass prop-derived args as getters or computed()** - Async setup runs once, so `useSuspense(Resource.get, () => ({ id: props.id }))` follows prop changes; a plain `{ id: props.id }` is read once ## References @@ -370,7 +290,8 @@ For detailed API documentation, see the [references](references/) directory: - [Fixtures](references/Fixtures.md) - Fixture format reference - [mockInitialState](references/mockInitialState.md) - Create initial state for `DataClientPlugin` -- [vue-test-utilities](references/vue-test-utilities.md) - `renderDataCompose()` and `mountDataClient()` guide +- [unit-testing-components](references/unit-testing-components.md) - `mountDataClient()` guide, setup and options +- [unit-testing-composables](references/unit-testing-composables.md) - `renderDataCompose()` guide - [nock-http-mocking](references/nock-http-mocking.md) - Full nock setup, dynamic server state, request spying, errors, pitfalls - [polling-subscriptions](references/polling-subscriptions.md) - Fake-timer patterns for `useLive`/`useSubscription`/`pollFrequency`, unsubscribe verification, polling via nock @@ -378,18 +299,8 @@ For detailed API documentation, see the [references](references/) directory: **Empty state test:** ```typescript -const { result } = renderDataCompose(() => useQuery(Article, { id: 5 }), {}); -expect(result.current?.value).toBe(undefined); -``` - -**Changing to non-existent entity:** -```typescript -const props = reactive({ id: 1 }); -// ... initial setup ... -expect(result.current?.value?.id).toBe(1); - -props.id = 999; // Not in store -expect(result.current?.value).toBe(undefined); +const { result } = await renderDataCompose(() => useQuery(Article, { id: 5 })); +expect(result.value).toBe(undefined); ``` **Testing nested collections:** @@ -398,11 +309,11 @@ const userTodos = new Collection(new schema.Array(Todo), { argsKey: ({ userId }) => ({ userId }), }); -const { result } = renderDataCompose( +const { result } = await renderDataCompose( () => useQuery(userTodos, { userId: '1' }), { initialFixtures: [/* ... */] }, ); -expect(result.current?.value?.length).toBe(2); -expect(result.current?.value?.[0]).toBeInstanceOf(Todo); +expect(result.value?.length).toBe(2); +expect(result.value?.[0]).toBeInstanceOf(Todo); ``` diff --git a/.agents/skills/data-client-vue-testing/references.json b/.agents/skills/data-client-vue-testing/references.json index ff3e4008d5d5..9d1e5b86ca26 100644 --- a/.agents/skills/data-client-vue-testing/references.json +++ b/.agents/skills/data-client-vue-testing/references.json @@ -4,6 +4,8 @@ ], "docs": { "Fixtures.md": "docs/core/api/Fixtures.md", - "mockInitialState.md": "docs/core/api/mockInitialState.md" + "mockInitialState.md": "docs/core/api/mockInitialState.md", + "unit-testing-components.md": "docs/core/guides/unit-testing-components.md", + "unit-testing-composables.md": "docs/core/guides/unit-testing-hooks.md" } } diff --git a/.agents/skills/data-client-vue-testing/references/Fixtures.md b/.agents/skills/data-client-vue-testing/references/Fixtures.md index cb152786d5ac..ed542d8b7502 100644 --- a/.agents/skills/data-client-vue-testing/references/Fixtures.md +++ b/.agents/skills/data-client-vue-testing/references/Fixtures.md @@ -174,7 +174,7 @@ Determines what the response for this mock should be. If a function it will be r Function running is called 'collapsing' after the mechanism in [Quantum Mechanics](https://www.wondriumdaily.com/copenhagen-interpretation-of-quantum-mechanics/) -`this` can be used to store simulated server-side data. It is initialized using `getInitialInterceptorData`. It's important to not use arrow functions when using this as they disallow `this` binding. +`this` can be used to store simulated server-side data. It is initialized using [getInitialInterceptorData](./unit-testing-components.md#options). It's important to not use arrow functions when using this as they disallow `this` binding. ### fetchResponse(input, init) {#fetchResponse} diff --git a/.agents/skills/data-client-vue-testing/references/unit-testing-components.md b/.agents/skills/data-client-vue-testing/references/unit-testing-components.md new file mode 100644 index 000000000000..650c86101064 --- /dev/null +++ b/.agents/skills/data-client-vue-testing/references/unit-testing-components.md @@ -0,0 +1,221 @@ + + +# Unit testing components + +> **Warning** +> +> Be careful when using [jest.mock](https://jestjs.io/docs/jest-object#jestmockmodulename-factory-options) on modules like Reactive Data Client. Eliminating expected +> exports can lead to hard-to trace +> errors like `TypeError: Class extends value undefined is not a function or null`. +> +> Instead, load responses with [Fixtures](./Fixtures.md). + +`@data-client/vue/test` mounts components with [DataClientPlugin](https://dataclient.io/vue/api/DataClientPlugin), +a `` boundary and [Fixtures](./Fixtures.md), so tests can check what a component +renders without a network fetch cycle. For composables on their own, see +[Unit testing composables](./unit-testing-composables.md). + +## Setup + +The test utilities are built on [Vue Test Utils](https://test-utils.vuejs.org/), so install it +alongside your test runner. Jest needs the [jsdom environment](https://jestjs.io/docs/configuration#testenvironment-string). + +```bash +npm install --save-dev @vue/test-utils jest-environment-jsdom +``` + +## mountDataClient() + +[initialFixtures](#options) +fill the store before the first render, so a component that `await`s +[useSuspense()](https://dataclient.io/vue/api/useSuspense) renders its data right away. + +```typescript +import { mountDataClient } from '@data-client/vue/test'; +import { useSuspense } from '@data-client/vue'; +import { defineComponent, h } from 'vue'; +import { ArticleResource } from './resources'; + +const ArticleDetail = defineComponent({ + props: { id: { type: Number, required: true } }, + async setup(props) { + // a getter so the fetch follows props.id + const article = await useSuspense(ArticleResource.get, () => ({ + id: props.id, + })); + return () => h('h3', article.value.title); + }, +}); + +it('renders the article', async () => { + const { wrapper, cleanup } = mountDataClient(ArticleDetail, { + props: { id: 5 }, + initialFixtures: [ + { + endpoint: ArticleResource.get, + args: [{ id: 5 }], + response: { id: 5, title: 'hi ho', content: 'whatever' }, + }, + ], + }); + + await flushUntil(() => wrapper.find('h3').exists()); + expect(wrapper.find('h3').text()).toBe('hi ho'); + + cleanup(); +}); +``` + +Always call `cleanup()` at the end of a test; it unmounts the app and its managers. + +### Waiting for renders + +Async `setup()` and Suspense resolve over several microtasks and ticks, so wait for what the +test expects rather than for a fixed number of ticks: + +```typescript title="flushUntil.ts" +import { nextTick } from 'vue'; + +export async function flushUntil(predicate: () => boolean, tries = 100) { + for (let i = 0; i < tries; i++) { + if (predicate()) return; + await nextTick(); + await new Promise(resolve => setTimeout(resolve, 0)); + } + throw new Error('flushUntil: condition never became true'); +} +``` + +### Loading state + +While a component is suspended, `mountDataClient()` renders an element with +`data-testid="suspense-fallback"`. Use [resolverFixtures](#options) to answer fetches the store +doesn't have yet: + +```typescript +it('shows the fallback until the fetch resolves', async () => { + const { wrapper, cleanup } = mountDataClient(ArticleDetail, { + props: { id: 5 }, + resolverFixtures: [ + { + endpoint: ArticleResource.get, + response: ({ id }) => ({ id, title: `Article ${id}`, content: '' }), + }, + ], + }); + + expect(wrapper.find('[data-testid="suspense-fallback"]').exists()).toBe(true); + + await flushUntil(() => wrapper.find('h3').exists()); + expect(wrapper.find('h3').text()).toBe('Article 5'); + + cleanup(); +}); +``` + +An interceptor's `response` receives the endpoint's arguments, so one fixture can answer every +`id`. Add `error: true` to a fixture to make it reject; see [Fixtures](./Fixtures.md) for every +fixture shape. + +### Changing props + +Pass a `reactive()` object as `props`, then set its members to change the component's props: + +```typescript +import { reactive } from 'vue'; + +it('fetches the new article when id changes', async () => { + const props = reactive({ id: 1 }); + const { wrapper, cleanup } = mountDataClient(ArticleDetail, { + props, + resolverFixtures: [ + { + endpoint: ArticleResource.get, + response: ({ id }) => ({ id, title: `Article ${id}`, content: '' }), + }, + ], + }); + + await flushUntil(() => wrapper.text() === 'Article 1'); + + props.id = 2; + await flushUntil(() => wrapper.text() === 'Article 2'); + + cleanup(); +}); +``` + +An async `setup()` runs once per component instance, so pass prop-derived arguments as a getter +(`() => ({ id: props.id })`) or `computed()`. A plain `{ id: props.id }` is read once and won't +follow prop changes. + +### Mutations + +The returned `controller` is the app's [Controller](https://dataclient.io/vue/api/Controller). Run mutations or +[setResponse()](https://dataclient.io/vue/api/Controller#setResponse) with it, then wait for the component to update: + +```typescript +it('re-renders when the store changes', async () => { + const { wrapper, controller, cleanup } = mountDataClient(ArticleDetail, { + props: { id: 5 }, + initialFixtures: [ + { + endpoint: ArticleResource.get, + args: [{ id: 5 }], + response: { id: 5, title: 'hi ho', content: 'whatever' }, + }, + ], + }); + await flushUntil(() => wrapper.find('h3').exists()); + + controller.setResponse( + ArticleResource.get, + { id: 5 }, + { id: 5, title: 'updated', content: 'whatever' }, + ); + + await flushUntil(() => wrapper.find('h3').text() === 'updated'); + cleanup(); +}); +``` + +## API + +### mountDataClient(component, options?) {#mountdataclient-api} + +Returns + +| Member | Description | +| -------------- | -------------------------------------------------------------------------------------------------- | +| `wrapper` | Vue Test Utils [VueWrapper](https://test-utils.vuejs.org/api/#wrapper-methods) of the mounted tree | +| `controller` | The app's [Controller](https://dataclient.io/vue/api/Controller) | +| `app` | The Vue app instance | +| `cleanup()` | Unmounts and stops the managers | +| `allSettled()` | Resolves once every in-flight fetch settles | + +### Options + +`mountDataClient()` and [renderDataCompose()](./unit-testing-composables.md) take the same options. + +```typescript +interface RenderDataClientOptions

{ + /** Props for the component; pass a reactive() object to change them */ + props?: Reactive

; + /** Responses in the store before the first render */ + initialFixtures?: readonly Fixture[]; + /** Answer fetches made during the test */ + resolverFixtures?: readonly (Fixture | Interceptor)[]; + /** Initial `this` for interceptors */ + getInitialInterceptorData?: () => any; + /** Replace the default NetworkManager and SubscriptionManager */ + managers?: Manager[]; + /** Replace the state built from initialFixtures */ + initialState?: State; + gcPolicy?: GCInterface; + /** Component rendered around the tested component (receives the same props) */ + wrapper?: Component; +} +``` + +[mockInitialState()](./mockInitialState.md) builds `initialState` from fixtures, for +mounting with your own `DataClientPlugin` setup. diff --git a/.agents/skills/data-client-vue-testing/references/unit-testing-composables.md b/.agents/skills/data-client-vue-testing/references/unit-testing-composables.md new file mode 100644 index 000000000000..c70533e5108f --- /dev/null +++ b/.agents/skills/data-client-vue-testing/references/unit-testing-composables.md @@ -0,0 +1,188 @@ + + +# Unit testing composables + +Composables pull data logic out of components, so they are often the easiest place to test it. +`renderDataCompose()` from `@data-client/vue/test` runs a composable inside a component mounted +with [DataClientPlugin](https://dataclient.io/vue/api/DataClientPlugin) and [Fixtures](./Fixtures.md). +It takes the same [options](./unit-testing-components.md#options) and needs the same +[setup](./unit-testing-components.md#setup) as [mountDataClient()](./unit-testing-components.md). + +## renderDataCompose() + +`renderDataCompose()` is async; `await` it before reading `result`, which is whatever the +composable returned. [useQuery()](https://dataclient.io/vue/api/useQuery) and [useCache()](https://dataclient.io/vue/api/useCache) return a +`ComputedRef`, so their data is in `result.value`: + +```typescript +import { renderDataCompose } from '@data-client/vue/test'; +import { useQuery } from '@data-client/vue'; +import { Article, ArticleResource } from './resources'; + +it('useQuery() returns cached data', async () => { + const { result, cleanup } = await renderDataCompose( + () => useQuery(Article, { id: 5 }), + { + initialFixtures: [ + { + endpoint: ArticleResource.get, + args: [{ id: 5 }], + response: { id: 5, title: 'hi ho', content: 'whatever' }, + }, + ], + }, + ); + + expect(result.value?.title).toBe('hi ho'); + cleanup(); +}); +``` + +Always call `cleanup()` at the end of a test; it unmounts the app and its managers. + +### useSuspense() + +[useSuspense()](https://dataclient.io/vue/api/useSuspense) returns a Promise that resolves to a `ComputedRef` once the +data is available. `await` it once, then read `.value`; the ref stays reactive as the store changes. + +```typescript +import { useSuspense } from '@data-client/vue'; +import { nextTick } from 'vue'; + +it('useSuspense() follows store updates', async () => { + const { result, controller, cleanup } = await renderDataCompose( + () => useSuspense(ArticleResource.get, { id: 5 }), + { + initialFixtures: [ + { + endpoint: ArticleResource.get, + args: [{ id: 5 }], + response: { id: 5, title: 'hi ho', content: 'whatever' }, + }, + ], + }, + ); + + const article = await result; + expect(article.value.title).toBe('hi ho'); + + controller.setResponse( + ArticleResource.get, + { id: 5 }, + { id: 5, title: 'updated', content: 'whatever' }, + ); + await nextTick(); + expect(article.value.title).toBe('updated'); + + cleanup(); +}); +``` + +Add `error: true` to a fixture to test failures; the Promise then rejects with that error: + +```typescript +it('rejects on a failed fetch', async () => { + const { result, cleanup } = await renderDataCompose( + () => useSuspense(ArticleResource.get, { id: 5 }), + { + resolverFixtures: [ + { + endpoint: ArticleResource.get, + args: [{ id: 5 }], + response: { status: 404 }, + error: true, + }, + ], + }, + ); + + await expect(result).rejects.toMatchObject({ status: 404 }); + cleanup(); +}); +``` + +### Changing props + +The composable receives `props`. Pass a `reactive()` object, and hand the composable a getter or +`computed()` built from it so it follows changes: + +```typescript +import { reactive } from 'vue'; + +it('follows prop changes', async () => { + const props = reactive({ id: 1 }); + const { result, cleanup } = await renderDataCompose( + (props: { id: number }) => useQuery(Article, () => ({ id: props.id })), + { + props, + initialFixtures: [ + { + endpoint: ArticleResource.get, + args: [{ id: 1 }], + response: { id: 1, title: 'First', content: '' }, + }, + { + endpoint: ArticleResource.get, + args: [{ id: 2 }], + response: { id: 2, title: 'Second', content: '' }, + }, + ], + }, + ); + expect(result.value?.title).toBe('First'); + + props.id = 2; + await nextTick(); + expect(result.value?.title).toBe('Second'); + + cleanup(); +}); +``` + +### Mutations + +The returned `controller` is the app's [Controller](https://dataclient.io/vue/api/Controller). After a mutation, +`await` it and then `nextTick()` before checking the result: + +```typescript +it('push() adds to the list', async () => { + const { result, controller, cleanup } = await renderDataCompose( + () => useQuery(ArticleResource.getList.schema), + { + initialFixtures: [ + { + endpoint: ArticleResource.getList, + args: [], + response: [{ id: 1, title: 'First', content: '' }], + }, + ], + resolverFixtures: [ + { endpoint: ArticleResource.getList.push, response: body => body }, + ], + }, + ); + expect(result.value).toHaveLength(1); + + await controller.fetch(ArticleResource.getList.push, { + id: 2, + title: 'Second', + content: '', + }); + await nextTick(); + expect(result.value).toHaveLength(2); + + cleanup(); +}); +``` + +## API + +### renderDataCompose(composable, options?) {#renderdatacompose-api} + +Resolves, once the composable has run, to the same `controller`, `wrapper`, `cleanup()` and +`allSettled()` as [mountDataClient()](./unit-testing-components.md#mountdataclient-api), plus: + +| Member | Description | +| --------------------- | ------------------------------------------------------------------------------------------------------ | +| `result` | What the composable returned | +| `waitForNextUpdate()` | Prefer `await result` or `allSettled()`; this waits for a pending Promise `result` with a 1 second cap | diff --git a/.agents/skills/data-client-vue-testing/references/vue-test-utilities.md b/.agents/skills/data-client-vue-testing/references/vue-test-utilities.md deleted file mode 100644 index b9a9cd50af35..000000000000 --- a/.agents/skills/data-client-vue-testing/references/vue-test-utilities.md +++ /dev/null @@ -1,307 +0,0 @@ -# Vue Data Client Testing Utilities - -This package provides testing utilities for Vue applications using `@data-client/vue`, similar to `@data-client/test` for React. - -## Installation - -```bash -npm install @data-client/vue -``` - -## Usage - -### Basic Component Testing - -```typescript -import { mountDataClient } from '@data-client/vue/test'; -import { defineComponent, h } from 'vue'; -import { MyResource } from './resources'; - -const TestComponent = defineComponent({ - setup() { - return () => h('div', 'Hello World'); - }, -}); - -const { wrapper, controller, cleanup } = mountDataClient(TestComponent, { - initialFixtures: [ - { - endpoint: MyResource.get, - args: [{ id: 1 }], - response: { id: 1, name: 'Test' }, - }, - ], -}); - -// Your tests here -expect(wrapper.text()).toBe('Hello World'); - -// Clean up after test -cleanup(); -``` - -### Testing with Suspense (Automatic) - -Suspense is automatically integrated into `mountDataClient`. Your components will automatically suspend and show a fallback while data is loading: - -```typescript -import { mountDataClient } from '@data-client/vue/test'; -import { defineComponent, h } from 'vue'; -import { MyResource } from './resources'; - -const AsyncComponent = defineComponent({ - async setup() { - const data = await useSuspense(MyResource.get, { id: 1 }); - return () => h('div', data.value.name); - }, -}); - -const { wrapper, controller, cleanup } = mountDataClient(AsyncComponent); - -// Initially shows suspense fallback -expect(wrapper.find('[data-testid="suspense-fallback"]').exists()).toBe(true); - -// Wait for data to load -await flushUntil(wrapper, () => wrapper.find('div').exists()); - -// Now shows the actual content -expect(wrapper.text()).toBe('Test'); - -cleanup(); -``` - -### Composable Testing - -```typescript -import { renderDataCompose } from '@data-client/vue/test'; -import { reactive } from 'vue'; - -const useMyComposable = (props: { id: number }) => { - const data = useSuspense(MyResource.get, { id: props.id }); - return { data, isLoading: false }; -}; - -const props = reactive({ id: 1 }); -const { result, controller, cleanup, waitForNextUpdate } = renderDataCompose(useMyComposable, { - props, - initialFixtures: [ - { - endpoint: MyResource.get, - args: [{ id: 1 }], - response: { id: 1, name: 'Test' }, - }, - ], -}); - -// Initially suspended -expect(result.current).toBeUndefined(); - -// Wait for the composable to resolve -await waitForNextUpdate(); - -// Now should have the actual data (useSuspense returns a Promise that resolves to ComputedRef) -expect(result.current).toBeDefined(); -expect(result.current).toBeInstanceOf(Promise); - -// Access the actual data -const dataRef = await result.current; -expect(dataRef.value).toBeDefined(); - -// Update props reactively -props.id = 2; - -cleanup(); -``` - -### Testing with Reactive Props - -Components can receive reactive props that can be updated during tests: - -```typescript -import { mountDataClient } from '@data-client/vue/test'; -import { defineComponent, h, reactive } from 'vue'; -import { MyResource } from './resources'; - -const ArticleComponent = defineComponent({ - props: { - id: { - type: Number, - required: true, - }, - }, - async setup(props) { - // a getter so the fetch follows props.id - const article = await useSuspense(MyResource.get, () => ({ id: props.id })); - return () => h('div', article.value.title); - }, -}); - -// Create a reactive props ref -const props = reactive({ id: 1 }); - -const { wrapper, cleanup } = mountDataClient(ArticleComponent, { - props, - resolverFixtures: [ - { - endpoint: MyResource.get, - response: (request) => ({ - id: request.args[0].id, - title: `Article ${request.args[0].id}`, - }), - }, - ], -}); - -// Wait for initial render -await flushUntil(wrapper, () => wrapper.find('div').exists()); -expect(wrapper.text()).toBe('Article 1'); - -// Update props reactively - component will re-render with new data -props.id = 2; -await nextTick(); - -// Wait for new data to load -await flushUntil(wrapper, () => wrapper.text() === 'Article 2'); -expect(wrapper.text()).toBe('Article 2'); - -cleanup(); -``` - -### Using Resolver Fixtures - -```typescript -const { wrapper, controller, cleanup } = mountDataClient(TestComponent, { - resolverFixtures: [ - { - endpoint: MyResource.get, - response: (request) => { - return { - id: request.args[0].id, - name: `Dynamic ${request.args[0].id}`, - }; - }, - }, - ], -}); - -// Test dynamic responses -const result = await controller.fetch(MyResource.get, { id: 123 }); -expect(result.name).toBe('Dynamic 123'); -``` - -## API Reference - -### `mountDataClient(component, options)` - -Renders a Vue component with DataClient provider for testing. - -**Parameters:** -- `component`: Vue component to render -- `options`: Configuration options - -**Returns:** -- `wrapper`: Vue Test Utils wrapper -- `controller`: DataClient controller instance -- `app`: Vue app instance -- `cleanup`: Function to clean up resources -- `allSettled`: Function to wait for all pending promises - -### `renderDataCompose(composable, options)` - -Renders a Vue composable with DataClient provider for testing. - -**Parameters:** -- `composable`: Vue composable function -- `options`: Configuration options - -**Returns:** -- `result`: Object with `current` property containing the composable's return value (`undefined` when suspended, Promise when resolved) -- `wrapper`: Vue Test Utils wrapper -- `controller`: DataClient controller instance -- `cleanup`: Function to clean up resources -- `allSettled`: Function to wait for all pending promises -- `waitForNextUpdate`: Function to wait for the composable to resolve from suspended state - -### Options - -```typescript -interface RenderDataClientOptions

{ - props?: Reactive

; // Reactive props ref to pass to component - initialFixtures?: readonly Fixture[]; // Initial data fixtures - resolverFixtures?: readonly (Fixture | Interceptor)[]; // Dynamic response fixtures - getInitialInterceptorData?: () => any; // Initial data for interceptors - managers?: Manager[]; // Custom managers - initialState?: State; // Custom initial state - gcPolicy?: GCInterface; // Custom garbage collection policy - wrapper?: any; // Custom wrapper component -} -``` - -### Fixture Types - -```typescript -interface Fixture { - endpoint: FixtureEndpoint; - args: any[]; - response: any; - error?: boolean; -} - -interface Interceptor { - endpoint: FixtureEndpoint; - response: (request: { - body?: any; - headers?: Record; - url: string; - method: string; - args: any[]; - }) => any | Promise; - error?: boolean; -} -``` - -## Best Practices - -1. **Always call cleanup()** after your tests to prevent memory leaks -2. **Use fixtures** instead of mocking network requests when possible -3. **Test both success and error cases** using the `error` property in fixtures -4. **Use resolver fixtures** for dynamic responses based on request parameters -5. **Leverage the controller** for testing mutations and state updates -6. **Suspense is automatic** - no need to manually wrap components in Suspense -7. **Use reactive props** - Pass a `reactive` in the `props` option and set its members to change component props -8. **Vue Suspense behavior** - Vue's `useSuspense` returns a Promise that suspends when data is missing, then resolves to a ComputedRef -9. **Reactive props with async setup** - Async setup runs once per component instance, so pass prop-derived arguments as a getter (`() => ({ id: props.id })`); a plain object is read once and won't follow prop changes. - -## Migration from Manual Setup - -If you were previously setting up DataClient manually in tests: - -```typescript -// Before -const wrapper = mount(MyComponent, { - global: { - plugins: [ - [ - DataClientPlugin, - { - managers: [new NetworkManager()], - initialState: mockState, - }, - ], - ], - }, -}); - -// After -const { wrapper, controller, cleanup } = mountDataClient(MyComponent, { - initialFixtures: [ - { - endpoint: MyResource.get, - args: [{ id: 1 }], - response: { id: 1, name: 'Test' }, - }, - ], -}); -``` - -This approach is more declarative, easier to maintain, and provides better test isolation. diff --git a/.agents/skills/data-client-vue/references.json b/.agents/skills/data-client-vue/references.json index dbdea536c6e9..ad47c107f2fc 100644 --- a/.agents/skills/data-client-vue/references.json +++ b/.agents/skills/data-client-vue/references.json @@ -13,6 +13,7 @@ "_useLoading.md": "docs/core/shared/_useLoading.mdx", "_vueArgs.md": "docs/core/shared/_vueArgs.mdx", "data-dependency.md": "docs/core/getting-started/data-dependency.md", + "devtools-debugging.md": "docs/core/getting-started/devtools-debugging.md", "getDefaultManagers.md": "docs/core/api/getDefaultManagers.md", "installation.md": "docs/core/getting-started/installation.md", "mutations.md": "docs/core/getting-started/mutations.md", diff --git a/.agents/skills/data-client-vue/references/Controller.md b/.agents/skills/data-client-vue/references/Controller.md index 8f807c80f3b1..278b087a2bad 100644 --- a/.agents/skills/data-client-vue/references/Controller.md +++ b/.agents/skills/data-client-vue/references/Controller.md @@ -10,7 +10,7 @@ and retrieval performance. - [Managers](https://dataclient.io/vue/api/Manager) as the first argument in [Manager.middleware](https://dataclient.io/vue/api/Manager#middleware) - Vue with [useController()](./useController.md) -- Unit testing composables with `renderDataCompose()` from `@data-client/vue/test` +- [Unit testing composables](https://dataclient.io/vue/guides/unit-testing-composables) with `renderDataCompose()` from `@data-client/vue/test` ```ts class Controller { diff --git a/.agents/skills/data-client-vue/references/devtools-debugging.md b/.agents/skills/data-client-vue/references/devtools-debugging.md index a6551fccbb53..93a1e67d7411 100644 --- a/.agents/skills/data-client-vue/references/devtools-debugging.md +++ b/.agents/skills/data-client-vue/references/devtools-debugging.md @@ -1,12 +1,16 @@ -# Debugging Data Client with Chrome DevTools MCP + -Debug `@data-client/react` or `@data-client/vue` state and actions programmatically via Chrome DevTools MCP `evaluate_script`. The app's `DevToolsManager` exposes the Controller on `globalThis.__DC_CONTROLLERS__` (a `Map` keyed by `devtoolsName`) in dev mode. +# Debugging with Chrome DevTools MCP + +Debug `@data-client/vue` state and actions programmatically via [Chrome DevTools MCP](https://github.com/ChromeDevTools/chrome-devtools-mcp) `evaluate_script`. The app's [DevToolsManager](https://dataclient.io/vue/api/DevToolsManager) exposes the [Controller](./Controller.md) on [`globalThis.__DC_CONTROLLERS__`](https://dataclient.io/vue/api/DevToolsManager#controllers) (a `Map` keyed by `devtoolsName`) in dev mode. + +Agents with the [`data-client-vue` skill](https://skills.sh/reactive/data-client/data-client-vue) already follow these steps ([how agent debugging works](https://dataclient.io/vue/getting-started/debugging#how-agent-debugging-works)); this page is the full recipe. ## Prerequisites 1. Dev server running with `NODE_ENV !== 'production'` 2. Chrome DevTools MCP connected and page loaded -3. `DevToolsManager` included in `DataProvider` (React) or `DataClientPlugin` (Vue) managers (default in dev mode) +3. `DevToolsManager` included in `DataClientPlugin` managers (default in dev mode) ## Step 1: Access the Controller @@ -26,7 +30,7 @@ Debug `@data-client/react` or `@data-client/vue` state and actions programmatica ### Get a controller by key -Use the key from discovery. Always use `.get(devtoolsName)` with the actual key — not `.values().next().value` — so you target the correct store when multiple `DataProvider`s or Vue apps exist. +Use the key from discovery. Always use `.get(devtoolsName)` with the actual key — not `.values().next().value` — so you target the correct store when multiple Vue apps exist. The snippets below use the first controller for brevity. ```js // evaluate_script @@ -298,30 +302,30 @@ then cross-reference with endpoint keys in state. ## Action Types Reference -| Type | Controller Method | Description | -|---|---|---| -| `rdc/fetch` | `fetch()` | Network request initiated | -| `rdc/setresponse` | `setResponse()` | Response written to cache | -| `rdc/set` | `set()` | Direct entity value set | -| `rdc/optimistic` | (automatic) | Optimistic update applied | -| `rdc/invalidate` | `invalidate()` | Single endpoint invalidated | -| `rdc/invalidateall` | `invalidateAll()` | Bulk invalidation by key test | -| `rdc/expireall` | `expireAll()` | Bulk mark-stale by key test | -| `rdc/reset` | `resetEntireStore()` | Full store reset | -| `rdc/subscribe` | `subscribe()` | Subscription registered | -| `rdc/unsubscribe` | `unsubscribe()` | Subscription removed | -| `rdc/gc` | (automatic) | Garbage collection | +| Type | Controller Method | Description | +| ------------------- | -------------------- | ----------------------------- | +| `rdc/fetch` | `fetch()` | Network request initiated | +| `rdc/setresponse` | `setResponse()` | Response written to cache | +| `rdc/set` | `set()` | Direct entity value set | +| `rdc/optimistic` | (automatic) | Optimistic update applied | +| `rdc/invalidate` | `invalidate()` | Single endpoint invalidated | +| `rdc/invalidateall` | `invalidateAll()` | Bulk invalidation by key test | +| `rdc/expireall` | `expireAll()` | Bulk mark-stale by key test | +| `rdc/reset` | `resetEntireStore()` | Full store reset | +| `rdc/subscribe` | `subscribe()` | Subscription registered | +| `rdc/unsubscribe` | `unsubscribe()` | Subscription removed | +| `rdc/gc` | (automatic) | Garbage collection | ## Controller State Readers All take `state` (from `ctrl.getState()`) as the **last** argument. -| Method | Signature | Returns | -|---|---|---| -| `getResponse` | `(endpoint, ...args, state)` | `{ data, expiryStatus, expiresAt }` — denormalized through schema | -| `getError` | `(endpoint, ...args, state)` | `ErrorTypes \| undefined` | -| `get` | `(schema, ...args, state)` | `Denormalized \| undefined` for any Queryable schema | -| `getQueryMeta` | `(schema, ...args, state)` | `{ data, countRef }` | +| Method | Signature | Returns | +| -------------- | ---------------------------- | ----------------------------------------------------------------- | +| `getResponse` | `(endpoint, ...args, state)` | `{ data, expiryStatus, expiresAt }` — denormalized through schema | +| `getError` | `(endpoint, ...args, state)` | `ErrorTypes \| undefined` | +| `get` | `(schema, ...args, state)` | `Denormalized \| undefined` for any Queryable schema | +| `getQueryMeta` | `(schema, ...args, state)` | `{ data, countRef }` | `expiryStatus` values: `1` = Invalid, `2` = InvalidIfStale, `3` = Valid. diff --git a/docs/core/README.md b/docs/core/README.md index a403069fd5ed..6ef97ba28cf9 100644 --- a/docs/core/README.md +++ b/docs/core/README.md @@ -834,8 +834,8 @@ const incrementInterceptor: Interceptor = { - :react[[Mock data for storybook](./guides/storybook.md) with [MockResolver](./api/MockResolver.md)]:vue[Mock data with `MockPlugin` from `@data-client/vue/test`] -- :react[[Test hooks](./guides/unit-testing-hooks.md) with [renderDataHook()](./api/renderDataHook.md)]:vue[Test composables with `renderDataCompose()`] -- :react[[Test components](./guides/unit-testing-components.md) with [MockResolver](./api/MockResolver.md)]:vue[Test components with `mountDataClient()`] and [mockInitialState()](./api/mockInitialState.md) +- :react[[Test hooks](./guides/unit-testing-hooks.md) with [renderDataHook()](./api/renderDataHook.md)]:vue[[Test composables](./guides/unit-testing-hooks.md) with `renderDataCompose()`] +- :react[[Test components](./guides/unit-testing-components.md) with [MockResolver](./api/MockResolver.md)]:vue[[Test components](./guides/unit-testing-components.md) with `mountDataClient()`] and [mockInitialState()](./api/mockInitialState.md) ## Demo diff --git a/docs/core/api/Controller.md b/docs/core/api/Controller.md index aeb77b9dee37..725a8c65af68 100644 --- a/docs/core/api/Controller.md +++ b/docs/core/api/Controller.md @@ -23,7 +23,7 @@ and retrieval performance. - [Managers](./Manager.md) as the first argument in [Manager.middleware](./Manager.md#middleware) - :react[React]:vue[Vue] with [useController()](./useController.md) -- :react[[Unit testing hooks](../guides/unit-testing-hooks.md) with [renderDataHook()](./renderDataHook.md#controller)]:vue[Unit testing composables with `renderDataCompose()` from `@data-client/vue/test`] +- :react[[Unit testing hooks](../guides/unit-testing-hooks.md) with [renderDataHook()](./renderDataHook.md#controller)]:vue[[Unit testing composables](../guides/unit-testing-hooks.md) with `renderDataCompose()` from `@data-client/vue/test`] ```ts class Controller { diff --git a/docs/core/api/Fixtures.md b/docs/core/api/Fixtures.md index 02dfeddddd37..3e8c0d986819 100644 --- a/docs/core/api/Fixtures.md +++ b/docs/core/api/Fixtures.md @@ -193,7 +193,7 @@ Determines what the response for this mock should be. If a function it will be r Function running is called 'collapsing' after the mechanism in [Quantum Mechanics](https://www.wondriumdaily.com/copenhagen-interpretation-of-quantum-mechanics/) -`this` can be used to store simulated server-side data. It is initialized using :react[[getInitialInterceptorData](./MockResolver.md#getinitialinterceptordata)]:vue[`getInitialInterceptorData`]. It's important to not use arrow functions when using this as they disallow `this` binding. +`this` can be used to store simulated server-side data. It is initialized using :react[[getInitialInterceptorData](./MockResolver.md#getinitialinterceptordata)]:vue[[getInitialInterceptorData](../guides/unit-testing-components.md#options)]. It's important to not use arrow functions when using this as they disallow `this` binding. ### fetchResponse(input, init) {#fetchResponse} diff --git a/docs/core/getting-started/agent-skills.md b/docs/core/getting-started/agent-skills.md index 169e187c0fbf..a8fbad548759 100644 --- a/docs/core/getting-started/agent-skills.md +++ b/docs/core/getting-started/agent-skills.md @@ -41,7 +41,7 @@ To install every skill for your framework now instead, without letting your agen :::react - [**`/data-client-react`**](https://skills.sh/reactive/data-client/data-client-react) — uses `useSuspense`, `useFetch`, `useQuery`, `useLive`, - and mutation hooks. + and mutation hooks, and [debugs your running app](./devtools-debugging.md) with Chrome DevTools MCP. - [**`/data-client-react-testing`**](https://skills.sh/reactive/data-client/data-client-react-testing) — writes React tests with `renderDataHook`, fixtures, interceptors, and `nock`. @@ -50,7 +50,7 @@ To install every skill for your framework now instead, without letting your agen :::vue - [**`/data-client-vue`**](https://skills.sh/reactive/data-client/data-client-vue) — uses `useSuspense`, `useFetch`, `useQuery`, `useLive`, - and mutation composables with `DataClientPlugin`. + and mutation composables with `DataClientPlugin`, and [debugs your running app](./devtools-debugging.md) with Chrome DevTools MCP. - [**`/data-client-vue-testing`**](https://skills.sh/reactive/data-client/data-client-vue-testing) — writes Vue tests with `renderDataCompose`, `mountDataClient`, fixtures, and `nock`. diff --git a/docs/core/getting-started/debugging.md b/docs/core/getting-started/debugging.md index 2f2ab4996cbd..b4929780c0da 100644 --- a/docs/core/getting-started/debugging.md +++ b/docs/core/getting-started/debugging.md @@ -37,6 +37,8 @@ This is useful when you want a quick answer to questions like "why didn't this r "what is in the cache right now?", or "which action updated this entity?" without manually clicking through each inspector panel. +To run these steps yourself, or see exactly what the agent does, follow [Debugging with Chrome DevTools MCP](./devtools-debugging.md). + ## Manual debugging If you prefer to inspect everything yourself, the browser devtools workflow below remains diff --git a/docs/core/getting-started/devtools-debugging.md b/docs/core/getting-started/devtools-debugging.md new file mode 100644 index 000000000000..f80930b5c459 --- /dev/null +++ b/docs/core/getting-started/devtools-debugging.md @@ -0,0 +1,371 @@ +--- +title: Debugging with Chrome DevTools MCP +sidebar_label: Chrome DevTools MCP +--- + +Debug :react[`@data-client/react`]:vue[`@data-client/vue`] state and actions programmatically via [Chrome DevTools MCP](https://github.com/ChromeDevTools/chrome-devtools-mcp) `evaluate_script`. The app's [DevToolsManager](../api/DevToolsManager.md) exposes the [Controller](../api/Controller.md) on [`globalThis.__DC_CONTROLLERS__`](../api/DevToolsManager.md#controllers) (a `Map` keyed by `devtoolsName`) in dev mode. + +Agents with the :react[[`data-client-react` skill](https://skills.sh/reactive/data-client/data-client-react)]:vue[[`data-client-vue` skill](https://skills.sh/reactive/data-client/data-client-vue)] already follow these steps ([how agent debugging works](../getting-started/debugging.md#how-agent-debugging-works)); this page is the full recipe. + +## Prerequisites + +1. Dev server running with `NODE_ENV !== 'production'` +2. Chrome DevTools MCP connected and page loaded +3. `DevToolsManager` included in :react[`DataProvider`]:vue[`DataClientPlugin`] managers (default in dev mode) + +## Step 1: Access the Controller + +`DevToolsManager` registers controllers in `globalThis.__DC_CONTROLLERS__` keyed by +`devtoolsName` — defaults to `"Data Client: "`. + +### Discover available controllers + +```js +// evaluate_script +() => { + const m = globalThis.__DC_CONTROLLERS__; + if (!m || m.size === 0) return 'no controllers registered'; + return [...m.keys()]; +} +``` + +### Get a controller by key + +Use the key from discovery. Always use `.get(devtoolsName)` with the actual key — not `.values().next().value` — so you target the correct store when multiple :react[`DataProvider`s]:vue[Vue apps] exist. The snippets below use the first controller for brevity. + +```js +// evaluate_script +() => { + const ctrl = globalThis.__DC_CONTROLLERS__?.get('Data Client: My App'); + if (!ctrl) return 'not found'; + return { ok: true, stateKeys: Object.keys(ctrl.getState()) }; +} +``` + +## Step 2: Install the Debug Shim + +Run this **once** after the page loads. It wraps dispatch to capture all actions in a circular buffer. + +```js +// evaluate_script +(() => { + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + if (!ctrl) return { error: 'No controller found' }; + + globalThis.__DC_ACTION_LOG__ = []; + const MAX_LOG = 200; + + const origDispatch = ctrl._dispatch.bind(ctrl); + ctrl._dispatch = (action) => { + const entry = { + type: action.type, + key: action.key, + ts: Date.now(), + }; + + if (action.endpoint) entry.endpoint = action.endpoint.name || action.endpoint.key; + if (action.args) entry.args = JSON.parse(JSON.stringify(action.args)); + if (action.meta?.date) entry.date = action.meta.date; + if (action.error) entry.error = true; + + globalThis.__DC_ACTION_LOG__.push(entry); + if (globalThis.__DC_ACTION_LOG__.length > MAX_LOG) { + globalThis.__DC_ACTION_LOG__ = globalThis.__DC_ACTION_LOG__.slice(-MAX_LOG / 2); + } + + return origDispatch(action); + }; + + return { ok: true, stateKeys: Object.keys(ctrl.getState()) }; +})() +``` + +## Step 3: Read State + +### High-level queries (denormalized, schema-aware) + +Controller provides `getResponse`, `getError`, and `get` that denormalize through schemas — pass `ctrl.getState()` as the last argument. + +```js +// evaluate_script — get denormalized response for an endpoint +async () => { + const mod = await import('/src/resources/Todo.ts'); + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + const state = ctrl.getState(); + const { data, expiryStatus, expiresAt } = ctrl.getResponse( + mod.TodoResource.getList, + {}, + state, + ); + return { data, expiryStatus, expiresAt }; +} +``` + +```js +// evaluate_script — check if an endpoint has an error +async () => { + const mod = await import('/src/resources/Todo.ts'); + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + const state = ctrl.getState(); + const error = ctrl.getError( + mod.TodoResource.get, + { id: '5' }, + state, + ); + return { error: error?.message ?? null }; +} +``` + +```js +// evaluate_script — query a Queryable schema (Entity, Collection, Query) +async () => { + const mod = await import('/src/resources/Todo.ts'); + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + const state = ctrl.getState(); + const result = ctrl.get(mod.Todo, { id: '5' }, state); + return result; +} +``` + +### Raw normalized state inspection + +Use these when you need to see the raw cache structure without denormalization. + +```js +// evaluate_script — state overview +() => { + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + const state = ctrl.getState(); + return { + entityTypes: Object.keys(state.entities), + endpointCount: Object.keys(state.endpoints).length, + metaCount: Object.keys(state.meta).length, + optimisticCount: state.optimistic.length, + lastReset: state.lastReset, + }; +} +``` + +```js +// evaluate_script — inspect specific entity type by key +() => { + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + const entities = ctrl.getState().entities['Todo']; + if (!entities) return { error: 'Entity not found' }; + const pks = Object.keys(entities); + return { + count: pks.length, + samplePKs: pks.slice(0, 10), + sample: pks.length > 0 ? entities[pks[0]] : null, + }; +} +``` + +```js +// evaluate_script — find endpoint cache entries by path substring +() => { + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + const state = ctrl.getState(); + const keys = Object.keys(state.endpoints).filter(k => k.includes('/todos')); + return keys.map(k => ({ + key: k, + value: state.endpoints[k], + meta: state.meta[k], + })); +} +``` + +### Inspect a specific entity by pk + +```js +// evaluate_script +() => { + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + return ctrl.getState().entities?.['Todo']?.['5']; +} +``` + +### Check endpoint metadata (expiry, errors, invalidation) + +```js +// evaluate_script +() => { + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + const state = ctrl.getState(); + const key = Object.keys(state.meta).find(k => k.includes('/todos')); + return key ? { key, ...state.meta[key] } : 'no meta found'; +} +``` + +## Step 4: Track Actions + +After installing the debug shim (Step 2): + +### Read recent actions + +```js +// evaluate_script +() => globalThis.__DC_ACTION_LOG__?.slice(-20) ?? [] +``` + +### Filter by action type + +```js +// evaluate_script — track only fetches +() => (globalThis.__DC_ACTION_LOG__ ?? []) + .filter(a => a.type === 'rdc/fetch' || a.type === 'rdc/setresponse') + .slice(-20) +``` + +### Filter errors + +```js +// evaluate_script +() => (globalThis.__DC_ACTION_LOG__ ?? []).filter(a => a.error) +``` + +### Clear action log + +```js +// evaluate_script +() => { globalThis.__DC_ACTION_LOG__ = []; return { cleared: true }; } +``` + +## Step 5: Mutate State via Controller + +Use Controller methods — **never** dispatch raw actions. + +### Invalidate an endpoint (force refetch) + +```js +// evaluate_script — triggers refetch for subscribed components +async () => { + const mod = await import('/src/resources/Todo.ts'); + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + await ctrl.invalidate(mod.TodoResource.get, { id: '5' }); + return { invalidated: true }; +} +``` + +### Invalidate endpoints matching a pattern + +```js +// evaluate_script +() => { + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + ctrl.invalidateAll({ testKey: key => key.includes('/todos') }); + return 'todo endpoints invalidated'; +} +``` + +### Expire endpoints (mark stale, refetch on next use) + +```js +// evaluate_script +() => { + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + ctrl.expireAll({ testKey: key => key.includes('/todos') }); + return 'todo endpoints expired'; +} +``` + +### Set a value directly + +```js +// evaluate_script — use setResponse to inject mock data +async () => { + const mod = await import('/src/resources/Todo.ts'); + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + await ctrl.setResponse( + mod.TodoResource.getList, + {}, + [{ id: 1, title: 'Mock Todo', completed: false }], + ); + return { set: true }; +} +``` + +### Reset entire store + +```js +// evaluate_script +() => { + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + ctrl.resetEntireStore(); + return { reset: true }; +} +``` + +## Correlating with Network Requests + +Use `list_network_requests` with `resourceTypes: ["fetch", "xhr"]` to see API calls, +then cross-reference with endpoint keys in state. + +## Action Types Reference + +| Type | Controller Method | Description | +|---|---|---| +| `rdc/fetch` | `fetch()` | Network request initiated | +| `rdc/setresponse` | `setResponse()` | Response written to cache | +| `rdc/set` | `set()` | Direct entity value set | +| `rdc/optimistic` | (automatic) | Optimistic update applied | +| `rdc/invalidate` | `invalidate()` | Single endpoint invalidated | +| `rdc/invalidateall` | `invalidateAll()` | Bulk invalidation by key test | +| `rdc/expireall` | `expireAll()` | Bulk mark-stale by key test | +| `rdc/reset` | `resetEntireStore()` | Full store reset | +| `rdc/subscribe` | `subscribe()` | Subscription registered | +| `rdc/unsubscribe` | `unsubscribe()` | Subscription removed | +| `rdc/gc` | (automatic) | Garbage collection | + +## Controller State Readers + +All take `state` (from `ctrl.getState()`) as the **last** argument. + +| Method | Signature | Returns | +|---|---|---| +| `getResponse` | `(endpoint, ...args, state)` | `{ data, expiryStatus, expiresAt }` — denormalized through schema | +| `getError` | `(endpoint, ...args, state)` | `ErrorTypes \| undefined` | +| `get` | `(schema, ...args, state)` | `Denormalized \| undefined` for any Queryable schema | +| `getQueryMeta` | `(schema, ...args, state)` | `{ data, countRef }` | + +`expiryStatus` values: `1` = Invalid, `2` = InvalidIfStale, `3` = Valid. + +## State Shape Reference + +```ts +State = { + entities: { [entityKey: string]: { [pk: string]: EntityInstance } }, + endpoints: { [cacheKey: string]: PK | PK[] | unknown }, + indexes: { [entityKey: string]: { [indexName: string]: { [lookupValue: string]: PK } } }, + meta: { + [key: string]: { + date, fetchedAt, expiresAt, + prevExpiresAt?, error?, invalidated?, errorPolicy?: 'hard' | 'soft' + } + }, + entitiesMeta: { [entityKey: string]: { [pk: string]: { date, expiresAt, fetchedAt } } }, + optimistic: (SetResponseAction | OptimisticAction)[], + lastReset: number, +} +``` + +## Polling Pattern + +For monitoring ongoing activity, poll with short intervals: + +1. Install shim (Step 2) +2. Trigger the user action or navigation +3. Wait 2–3 seconds +4. Read actions (Step 4) — check for `rdc/fetch` then `rdc/setresponse` pairs +5. If needed, read entity state (Step 3) to verify cache contents +6. Repeat if watching for subscription updates + +## Debugging Checklist + +1. **Verify controller exists**: Check `__DC_CONTROLLERS__` map size +2. **Inspect state shape**: Get entity types and endpoint count +3. **Check specific data**: Look up entities by type and pk +4. **Review endpoint metadata**: Check expiry, errors, invalidation status +5. **Track actions**: Read the action log for recent dispatches +6. **Correlate network**: Compare `list_network_requests` with endpoint keys +7. **Force refresh**: Use `invalidateAll` or `expireAll` to trigger refetches diff --git a/docs/core/guides/unit-testing-components.vue.md b/docs/core/guides/unit-testing-components.vue.md new file mode 100644 index 000000000000..2795f1c0496c --- /dev/null +++ b/docs/core/guides/unit-testing-components.vue.md @@ -0,0 +1,223 @@ +--- +title: Unit testing components +--- + +import PkgTabs from '@site/src/components/PkgTabs'; + +:::warning + +Be careful when using [jest.mock](https://jestjs.io/docs/jest-object#jestmockmodulename-factory-options) on modules like Reactive Data Client. Eliminating expected +exports can lead to hard-to trace +errors like `TypeError: Class extends value undefined is not a function or null`. + +Instead, load responses with [Fixtures](../api/Fixtures.md). + +::: + +`@data-client/vue/test` mounts components with [DataClientPlugin](../api/DataClientPlugin.md), +a `` boundary and [Fixtures](../api/Fixtures.md), so tests can check what a component +renders without a network fetch cycle. For composables on their own, see +[Unit testing composables](./unit-testing-hooks.md). + +## Setup + +The test utilities are built on [Vue Test Utils](https://test-utils.vuejs.org/), so install it +alongside your test runner. Jest needs the [jsdom environment](https://jestjs.io/docs/configuration#testenvironment-string). + + + +## mountDataClient() + +[initialFixtures](#options) +fill the store before the first render, so a component that `await`s +[useSuspense()](../api/useSuspense.md) renders its data right away. + +```typescript +import { mountDataClient } from '@data-client/vue/test'; +import { useSuspense } from '@data-client/vue'; +import { defineComponent, h } from 'vue'; +import { ArticleResource } from './resources'; + +const ArticleDetail = defineComponent({ + props: { id: { type: Number, required: true } }, + async setup(props) { + // a getter so the fetch follows props.id + const article = await useSuspense(ArticleResource.get, () => ({ + id: props.id, + })); + return () => h('h3', article.value.title); + }, +}); + +it('renders the article', async () => { + const { wrapper, cleanup } = mountDataClient(ArticleDetail, { + props: { id: 5 }, + initialFixtures: [ + { + endpoint: ArticleResource.get, + args: [{ id: 5 }], + response: { id: 5, title: 'hi ho', content: 'whatever' }, + }, + ], + }); + + await flushUntil(() => wrapper.find('h3').exists()); + expect(wrapper.find('h3').text()).toBe('hi ho'); + + cleanup(); +}); +``` + +Always call `cleanup()` at the end of a test; it unmounts the app and its managers. + +### Waiting for renders + +Async `setup()` and Suspense resolve over several microtasks and ticks, so wait for what the +test expects rather than for a fixed number of ticks: + +```typescript title="flushUntil.ts" +import { nextTick } from 'vue'; + +export async function flushUntil(predicate: () => boolean, tries = 100) { + for (let i = 0; i < tries; i++) { + if (predicate()) return; + await nextTick(); + await new Promise(resolve => setTimeout(resolve, 0)); + } + throw new Error('flushUntil: condition never became true'); +} +``` + +### Loading state + +While a component is suspended, `mountDataClient()` renders an element with +`data-testid="suspense-fallback"`. Use [resolverFixtures](#options) to answer fetches the store +doesn't have yet: + +```typescript +it('shows the fallback until the fetch resolves', async () => { + const { wrapper, cleanup } = mountDataClient(ArticleDetail, { + props: { id: 5 }, + resolverFixtures: [ + { + endpoint: ArticleResource.get, + response: ({ id }) => ({ id, title: `Article ${id}`, content: '' }), + }, + ], + }); + + expect(wrapper.find('[data-testid="suspense-fallback"]').exists()).toBe(true); + + await flushUntil(() => wrapper.find('h3').exists()); + expect(wrapper.find('h3').text()).toBe('Article 5'); + + cleanup(); +}); +``` + +An interceptor's `response` receives the endpoint's arguments, so one fixture can answer every +`id`. Add `error: true` to a fixture to make it reject; see [Fixtures](../api/Fixtures.md) for every +fixture shape. + +### Changing props + +Pass a `reactive()` object as `props`, then set its members to change the component's props: + +```typescript +import { reactive } from 'vue'; + +it('fetches the new article when id changes', async () => { + const props = reactive({ id: 1 }); + const { wrapper, cleanup } = mountDataClient(ArticleDetail, { + props, + resolverFixtures: [ + { + endpoint: ArticleResource.get, + response: ({ id }) => ({ id, title: `Article ${id}`, content: '' }), + }, + ], + }); + + await flushUntil(() => wrapper.text() === 'Article 1'); + + props.id = 2; + await flushUntil(() => wrapper.text() === 'Article 2'); + + cleanup(); +}); +``` + +An async `setup()` runs once per component instance, so pass prop-derived arguments as a getter +(`() => ({ id: props.id })`) or `computed()`. A plain `{ id: props.id }` is read once and won't +follow prop changes. + +### Mutations + +The returned `controller` is the app's [Controller](../api/Controller.md). Run mutations or +[setResponse()](../api/Controller.md#setResponse) with it, then wait for the component to update: + +```typescript +it('re-renders when the store changes', async () => { + const { wrapper, controller, cleanup } = mountDataClient(ArticleDetail, { + props: { id: 5 }, + initialFixtures: [ + { + endpoint: ArticleResource.get, + args: [{ id: 5 }], + response: { id: 5, title: 'hi ho', content: 'whatever' }, + }, + ], + }); + await flushUntil(() => wrapper.find('h3').exists()); + + controller.setResponse( + ArticleResource.get, + { id: 5 }, + { id: 5, title: 'updated', content: 'whatever' }, + ); + + await flushUntil(() => wrapper.find('h3').text() === 'updated'); + cleanup(); +}); +``` + +## API + +### mountDataClient(component, options?) {#mountdataclient-api} + +Returns + +| Member | Description | +| -------------- | ------------------------------------------------------------ | +| `wrapper` | Vue Test Utils [VueWrapper](https://test-utils.vuejs.org/api/#wrapper-methods) of the mounted tree | +| `controller` | The app's [Controller](../api/Controller.md) | +| `app` | The Vue app instance | +| `cleanup()` | Unmounts and stops the managers | +| `allSettled()` | Resolves once every in-flight fetch settles | + +### Options + +`mountDataClient()` and [renderDataCompose()](./unit-testing-hooks.md) take the same options. + +```typescript +interface RenderDataClientOptions

{ + /** Props for the component; pass a reactive() object to change them */ + props?: Reactive

; + /** Responses in the store before the first render */ + initialFixtures?: readonly Fixture[]; + /** Answer fetches made during the test */ + resolverFixtures?: readonly (Fixture | Interceptor)[]; + /** Initial `this` for interceptors */ + getInitialInterceptorData?: () => any; + /** Replace the default NetworkManager and SubscriptionManager */ + managers?: Manager[]; + /** Replace the state built from initialFixtures */ + initialState?: State; + gcPolicy?: GCInterface; + /** Component rendered around the tested component (receives the same props) */ + wrapper?: Component; +} +``` + +[mockInitialState()](../api/mockInitialState.md) builds `initialState` from fixtures, for +mounting with your own `DataClientPlugin` setup. diff --git a/docs/core/guides/unit-testing-hooks.vue.md b/docs/core/guides/unit-testing-hooks.vue.md new file mode 100644 index 000000000000..8432311ec384 --- /dev/null +++ b/docs/core/guides/unit-testing-hooks.vue.md @@ -0,0 +1,189 @@ +--- +title: Unit testing composables +slug: /guides/unit-testing-composables +--- + +Composables pull data logic out of components, so they are often the easiest place to test it. +`renderDataCompose()` from `@data-client/vue/test` runs a composable inside a component mounted +with [DataClientPlugin](../api/DataClientPlugin.md) and [Fixtures](../api/Fixtures.md). +It takes the same [options](./unit-testing-components.md#options) and needs the same +[setup](./unit-testing-components.md#setup) as [mountDataClient()](./unit-testing-components.md). + +## renderDataCompose() + +`renderDataCompose()` is async; `await` it before reading `result`, which is whatever the +composable returned. [useQuery()](../api/useQuery.md) and [useCache()](../api/useCache.md) return a +`ComputedRef`, so their data is in `result.value`: + +```typescript +import { renderDataCompose } from '@data-client/vue/test'; +import { useQuery } from '@data-client/vue'; +import { Article, ArticleResource } from './resources'; + +it('useQuery() returns cached data', async () => { + const { result, cleanup } = await renderDataCompose( + () => useQuery(Article, { id: 5 }), + { + initialFixtures: [ + { + endpoint: ArticleResource.get, + args: [{ id: 5 }], + response: { id: 5, title: 'hi ho', content: 'whatever' }, + }, + ], + }, + ); + + expect(result.value?.title).toBe('hi ho'); + cleanup(); +}); +``` + +Always call `cleanup()` at the end of a test; it unmounts the app and its managers. + +### useSuspense() + +[useSuspense()](../api/useSuspense.md) returns a Promise that resolves to a `ComputedRef` once the +data is available. `await` it once, then read `.value`; the ref stays reactive as the store changes. + +```typescript +import { useSuspense } from '@data-client/vue'; +import { nextTick } from 'vue'; + +it('useSuspense() follows store updates', async () => { + const { result, controller, cleanup } = await renderDataCompose( + () => useSuspense(ArticleResource.get, { id: 5 }), + { + initialFixtures: [ + { + endpoint: ArticleResource.get, + args: [{ id: 5 }], + response: { id: 5, title: 'hi ho', content: 'whatever' }, + }, + ], + }, + ); + + const article = await result; + expect(article.value.title).toBe('hi ho'); + + controller.setResponse( + ArticleResource.get, + { id: 5 }, + { id: 5, title: 'updated', content: 'whatever' }, + ); + await nextTick(); + expect(article.value.title).toBe('updated'); + + cleanup(); +}); +``` + +Add `error: true` to a fixture to test failures; the Promise then rejects with that error: + +```typescript +it('rejects on a failed fetch', async () => { + const { result, cleanup } = await renderDataCompose( + () => useSuspense(ArticleResource.get, { id: 5 }), + { + resolverFixtures: [ + { + endpoint: ArticleResource.get, + args: [{ id: 5 }], + response: { status: 404 }, + error: true, + }, + ], + }, + ); + + await expect(result).rejects.toMatchObject({ status: 404 }); + cleanup(); +}); +``` + +### Changing props + +The composable receives `props`. Pass a `reactive()` object, and hand the composable a getter or +`computed()` built from it so it follows changes: + +```typescript +import { reactive } from 'vue'; + +it('follows prop changes', async () => { + const props = reactive({ id: 1 }); + const { result, cleanup } = await renderDataCompose( + (props: { id: number }) => useQuery(Article, () => ({ id: props.id })), + { + props, + initialFixtures: [ + { + endpoint: ArticleResource.get, + args: [{ id: 1 }], + response: { id: 1, title: 'First', content: '' }, + }, + { + endpoint: ArticleResource.get, + args: [{ id: 2 }], + response: { id: 2, title: 'Second', content: '' }, + }, + ], + }, + ); + expect(result.value?.title).toBe('First'); + + props.id = 2; + await nextTick(); + expect(result.value?.title).toBe('Second'); + + cleanup(); +}); +``` + +### Mutations + +The returned `controller` is the app's [Controller](../api/Controller.md). After a mutation, +`await` it and then `nextTick()` before checking the result: + +```typescript +it('push() adds to the list', async () => { + const { result, controller, cleanup } = await renderDataCompose( + () => useQuery(ArticleResource.getList.schema), + { + initialFixtures: [ + { + endpoint: ArticleResource.getList, + args: [], + response: [{ id: 1, title: 'First', content: '' }], + }, + ], + resolverFixtures: [ + { endpoint: ArticleResource.getList.push, response: body => body }, + ], + }, + ); + expect(result.value).toHaveLength(1); + + await controller.fetch(ArticleResource.getList.push, { + id: 2, + title: 'Second', + content: '', + }); + await nextTick(); + expect(result.value).toHaveLength(2); + + cleanup(); +}); +``` + +## API + +### renderDataCompose(composable, options?) {#renderdatacompose-api} + +Resolves, once the composable has run, to the same `controller`, `wrapper`, `cleanup()` and +`allSettled()` as [mountDataClient()](./unit-testing-components.md#mountdataclient-api), plus: + +| Member | Description | +| --------------------- | ------------------------------------------------------------------------------------------------- | +| `result` | What the composable returned | +| `waitForNextUpdate()` | Prefer `await result` or `allSettled()`; this waits for a pending Promise `result` with a 1 second cap | diff --git a/packages/vue/src/test/README.md b/packages/vue/src/test/README.md index b9a9cd50af35..5d9d7c0601af 100644 --- a/packages/vue/src/test/README.md +++ b/packages/vue/src/test/README.md @@ -1,307 +1,12 @@ # Vue Data Client Testing Utilities -This package provides testing utilities for Vue applications using `@data-client/vue`, similar to `@data-client/test` for React. - -## Installation - -```bash -npm install @data-client/vue -``` - -## Usage - -### Basic Component Testing - -```typescript -import { mountDataClient } from '@data-client/vue/test'; -import { defineComponent, h } from 'vue'; -import { MyResource } from './resources'; - -const TestComponent = defineComponent({ - setup() { - return () => h('div', 'Hello World'); - }, -}); - -const { wrapper, controller, cleanup } = mountDataClient(TestComponent, { - initialFixtures: [ - { - endpoint: MyResource.get, - args: [{ id: 1 }], - response: { id: 1, name: 'Test' }, - }, - ], -}); - -// Your tests here -expect(wrapper.text()).toBe('Hello World'); - -// Clean up after test -cleanup(); -``` - -### Testing with Suspense (Automatic) - -Suspense is automatically integrated into `mountDataClient`. Your components will automatically suspend and show a fallback while data is loading: - -```typescript -import { mountDataClient } from '@data-client/vue/test'; -import { defineComponent, h } from 'vue'; -import { MyResource } from './resources'; - -const AsyncComponent = defineComponent({ - async setup() { - const data = await useSuspense(MyResource.get, { id: 1 }); - return () => h('div', data.value.name); - }, -}); - -const { wrapper, controller, cleanup } = mountDataClient(AsyncComponent); - -// Initially shows suspense fallback -expect(wrapper.find('[data-testid="suspense-fallback"]').exists()).toBe(true); - -// Wait for data to load -await flushUntil(wrapper, () => wrapper.find('div').exists()); - -// Now shows the actual content -expect(wrapper.text()).toBe('Test'); - -cleanup(); -``` - -### Composable Testing - -```typescript -import { renderDataCompose } from '@data-client/vue/test'; -import { reactive } from 'vue'; - -const useMyComposable = (props: { id: number }) => { - const data = useSuspense(MyResource.get, { id: props.id }); - return { data, isLoading: false }; -}; - -const props = reactive({ id: 1 }); -const { result, controller, cleanup, waitForNextUpdate } = renderDataCompose(useMyComposable, { - props, - initialFixtures: [ - { - endpoint: MyResource.get, - args: [{ id: 1 }], - response: { id: 1, name: 'Test' }, - }, - ], -}); - -// Initially suspended -expect(result.current).toBeUndefined(); - -// Wait for the composable to resolve -await waitForNextUpdate(); - -// Now should have the actual data (useSuspense returns a Promise that resolves to ComputedRef) -expect(result.current).toBeDefined(); -expect(result.current).toBeInstanceOf(Promise); - -// Access the actual data -const dataRef = await result.current; -expect(dataRef.value).toBeDefined(); - -// Update props reactively -props.id = 2; - -cleanup(); -``` - -### Testing with Reactive Props - -Components can receive reactive props that can be updated during tests: - -```typescript -import { mountDataClient } from '@data-client/vue/test'; -import { defineComponent, h, reactive } from 'vue'; -import { MyResource } from './resources'; - -const ArticleComponent = defineComponent({ - props: { - id: { - type: Number, - required: true, - }, - }, - async setup(props) { - // a getter so the fetch follows props.id - const article = await useSuspense(MyResource.get, () => ({ id: props.id })); - return () => h('div', article.value.title); - }, -}); - -// Create a reactive props ref -const props = reactive({ id: 1 }); - -const { wrapper, cleanup } = mountDataClient(ArticleComponent, { - props, - resolverFixtures: [ - { - endpoint: MyResource.get, - response: (request) => ({ - id: request.args[0].id, - title: `Article ${request.args[0].id}`, - }), - }, - ], -}); - -// Wait for initial render -await flushUntil(wrapper, () => wrapper.find('div').exists()); -expect(wrapper.text()).toBe('Article 1'); - -// Update props reactively - component will re-render with new data -props.id = 2; -await nextTick(); - -// Wait for new data to load -await flushUntil(wrapper, () => wrapper.text() === 'Article 2'); -expect(wrapper.text()).toBe('Article 2'); - -cleanup(); -``` - -### Using Resolver Fixtures - -```typescript -const { wrapper, controller, cleanup } = mountDataClient(TestComponent, { - resolverFixtures: [ - { - endpoint: MyResource.get, - response: (request) => { - return { - id: request.args[0].id, - name: `Dynamic ${request.args[0].id}`, - }; - }, - }, - ], -}); - -// Test dynamic responses -const result = await controller.fetch(MyResource.get, { id: 123 }); -expect(result.name).toBe('Dynamic 123'); -``` - -## API Reference - -### `mountDataClient(component, options)` - -Renders a Vue component with DataClient provider for testing. - -**Parameters:** -- `component`: Vue component to render -- `options`: Configuration options - -**Returns:** -- `wrapper`: Vue Test Utils wrapper -- `controller`: DataClient controller instance -- `app`: Vue app instance -- `cleanup`: Function to clean up resources -- `allSettled`: Function to wait for all pending promises - -### `renderDataCompose(composable, options)` - -Renders a Vue composable with DataClient provider for testing. - -**Parameters:** -- `composable`: Vue composable function -- `options`: Configuration options - -**Returns:** -- `result`: Object with `current` property containing the composable's return value (`undefined` when suspended, Promise when resolved) -- `wrapper`: Vue Test Utils wrapper -- `controller`: DataClient controller instance -- `cleanup`: Function to clean up resources -- `allSettled`: Function to wait for all pending promises -- `waitForNextUpdate`: Function to wait for the composable to resolve from suspended state - -### Options - -```typescript -interface RenderDataClientOptions

{ - props?: Reactive

; // Reactive props ref to pass to component - initialFixtures?: readonly Fixture[]; // Initial data fixtures - resolverFixtures?: readonly (Fixture | Interceptor)[]; // Dynamic response fixtures - getInitialInterceptorData?: () => any; // Initial data for interceptors - managers?: Manager[]; // Custom managers - initialState?: State; // Custom initial state - gcPolicy?: GCInterface; // Custom garbage collection policy - wrapper?: any; // Custom wrapper component -} -``` - -### Fixture Types - -```typescript -interface Fixture { - endpoint: FixtureEndpoint; - args: any[]; - response: any; - error?: boolean; -} - -interface Interceptor { - endpoint: FixtureEndpoint; - response: (request: { - body?: any; - headers?: Record; - url: string; - method: string; - args: any[]; - }) => any | Promise; - error?: boolean; -} -``` - -## Best Practices - -1. **Always call cleanup()** after your tests to prevent memory leaks -2. **Use fixtures** instead of mocking network requests when possible -3. **Test both success and error cases** using the `error` property in fixtures -4. **Use resolver fixtures** for dynamic responses based on request parameters -5. **Leverage the controller** for testing mutations and state updates -6. **Suspense is automatic** - no need to manually wrap components in Suspense -7. **Use reactive props** - Pass a `reactive` in the `props` option and set its members to change component props -8. **Vue Suspense behavior** - Vue's `useSuspense` returns a Promise that suspends when data is missing, then resolves to a ComputedRef -9. **Reactive props with async setup** - Async setup runs once per component instance, so pass prop-derived arguments as a getter (`() => ({ id: props.id })`); a plain object is read once and won't follow prop changes. - -## Migration from Manual Setup - -If you were previously setting up DataClient manually in tests: - -```typescript -// Before -const wrapper = mount(MyComponent, { - global: { - plugins: [ - [ - DataClientPlugin, - { - managers: [new NetworkManager()], - initialState: mockState, - }, - ], - ], - }, -}); - -// After -const { wrapper, controller, cleanup } = mountDataClient(MyComponent, { - initialFixtures: [ - { - endpoint: MyResource.get, - args: [{ id: 1 }], - response: { id: 1, name: 'Test' }, - }, - ], -}); -``` - -This approach is more declarative, easier to maintain, and provides better test isolation. +`@data-client/vue/test` provides testing utilities for Vue applications using `@data-client/vue`, +similar to `@data-client/test` for React. + +- `mountDataClient()` mounts a component with `DataClientPlugin`, `` and fixtures: + [Unit testing components](https://dataclient.io/vue/guides/unit-testing-components) +- `renderDataCompose()` runs a composable the same way: + [Unit testing composables](https://dataclient.io/vue/guides/unit-testing-composables) +- `mockInitialState()` builds store state from fixtures: + [mockInitialState](https://dataclient.io/vue/api/mockInitialState) +- Fixture and interceptor formats: [Fixtures](https://dataclient.io/vue/api/Fixtures) diff --git a/packages/vue/src/test/mountDataClient.ts b/packages/vue/src/test/mountDataClient.ts index 2fae8fd77dbd..1bfdfd912513 100644 --- a/packages/vue/src/test/mountDataClient.ts +++ b/packages/vue/src/test/mountDataClient.ts @@ -45,6 +45,7 @@ export interface RenderDataClientResult { /** * Renders a Vue component with DataClient plugin and fixtures for testing * + * @see https://dataclient.io/vue/guides/unit-testing-components * @param component - The Vue component to test * @param options - Configuration including optional reactive props ref, fixtures, managers, etc. */ diff --git a/packages/vue/src/test/renderDataCompose.ts b/packages/vue/src/test/renderDataCompose.ts index e14516e1cbfa..0b0167d1f712 100644 --- a/packages/vue/src/test/renderDataCompose.ts +++ b/packages/vue/src/test/renderDataCompose.ts @@ -10,7 +10,7 @@ import { /** * Renders a Vue composable with DataClient provider for testing * - * @see https://dataclient.io/docs/api/renderDataCompose + * @see https://dataclient.io/vue/guides/unit-testing-composables * @param composable - The composable function to test * @param options - Configuration including optional reactive props ref, fixtures, managers, etc. */ diff --git a/website/framework-docs/docsToMarkdown.mjs b/website/framework-docs/docsToMarkdown.mjs index c9b2b4058752..23f2c6b13151 100644 --- a/website/framework-docs/docsToMarkdown.mjs +++ b/website/framework-docs/docsToMarkdown.mjs @@ -111,15 +111,15 @@ export const routeOf = memoize((file, framework) => { const relPath = rel(file).replace(/\.(react|vue)(\.mdx?)$/, '$2'); const instance = instanceOf(relPath, framework); if (!instance) return; - const docId = docIdOf( - relPath.slice(instance.path.length + 1), - contentFor(file, framework), - ); + const content = contentFor(file, framework); + const docId = docIdOf(relPath.slice(instance.path.length + 1), content); // Vue links to pages without a Vue version go to the React docs const { routeBasePath } = instance.framework === 'vue' && !vueIds.has(docId) ? frameworkInstance('react') : instance; + const slug = frontMatterValue(content, 'slug'); + if (slug?.startsWith('/')) return `/${routeBasePath}${slug}`; return `/${routeBasePath}/${docId}`.replace(/\/index$/, '/'); }); diff --git a/website/sidebars.json b/website/sidebars.json index 92cdbe5fd0bb..a88c66a8e6eb 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -13,6 +13,10 @@ "type": "doc", "id": "getting-started/agent-skills" }, + { + "type": "doc", + "id": "getting-started/devtools-debugging" + }, { "type": "doc", "id": "getting-started/installation"