From 35ad047a957ce568dc2aeb8042cd08db76e25e4c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:07:30 +0000 Subject: [PATCH 1/6] docs: Generate DevTools MCP and Vue testing skill references from docs The devtools-debugging skill reference (copied byte for byte into the React and Vue skills) and vue-test-utilities (a copy of packages/vue/src/test/README.md) were hand-written, so the build:skills drift check never covered them. - Move the DevTools MCP recipe to docs/core/guides/devtools-debugging.md (framework-aware) and generate both skills' copies from it - Add Vue "Unit testing components" (mountDataClient) and "Unit testing composables" (renderDataCompose) guides as .vue.md overrides of the React pages, and generate the vue-testing skill's references from them - Fix renderDataCompose examples in the vue-testing skill: it is async and `result` is the composable's return value (there is no `result.current`) - Point the test README and JSDoc @see links at the new guides Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EcBpA1YWwvXC94QHnzQG1H --- .../skills/data-client-react/references.json | 1 + .../references/devtools-debugging.md | 50 +-- .../references/Fixtures.vue.md | 2 +- .../skills/data-client-vue-testing/SKILL.md | 93 ++--- .../data-client-vue-testing/references.json | 4 +- .../references/Fixtures.md | 2 +- .../references/unit-testing-components.md | 221 +++++++++++ .../references/unit-testing-composables.md | 192 +++++++++ .../references/vue-test-utilities.md | 307 --------------- .../skills/data-client-vue/references.json | 1 + .../references/devtools-debugging.md | 50 +-- docs/core/api/Fixtures.md | 2 +- docs/core/getting-started/debugging.md | 2 + docs/core/guides/devtools-debugging.md | 371 ++++++++++++++++++ .../guides/unit-testing-components.vue.md | 223 +++++++++++ docs/core/guides/unit-testing-hooks.vue.md | 192 +++++++++ packages/vue/src/test/README.md | 315 +-------------- packages/vue/src/test/mountDataClient.ts | 1 + packages/vue/src/test/renderDataCompose.ts | 2 +- website/sidebars.json | 4 + 20 files changed, 1321 insertions(+), 714 deletions(-) create mode 100644 .agents/skills/data-client-vue-testing/references/unit-testing-components.md create mode 100644 .agents/skills/data-client-vue-testing/references/unit-testing-composables.md delete mode 100644 .agents/skills/data-client-vue-testing/references/vue-test-utilities.md create mode 100644 docs/core/guides/devtools-debugging.md create mode 100644 docs/core/guides/unit-testing-components.vue.md create mode 100644 docs/core/guides/unit-testing-hooks.vue.md diff --git a/.agents/skills/data-client-react/references.json b/.agents/skills/data-client-react/references.json index 7c223436f5d6..159ba2ea822a 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/guides/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..26d6eebb16f3 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 7255ee2f3dbd..231fc35f1930 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..5afe8f0dd910 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,8 +35,8 @@ 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 (a `ComputedRef` for useQuery/useCache, a Promise of one for useSuspense) - `controller` - Controller instance for manual actions - `wrapper` - Vue Test Utils wrapper - `cleanup()` - Cleanup function (always call in afterEach/after test) @@ -82,7 +82,7 @@ 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 @@ -102,19 +102,15 @@ await flushUntil(wrapper, () => wrapper.find('h3').exists()); await flushUntil(wrapper, () => wrapper.find('h3').text() === 'Expected Title'); ``` -**waitForNextUpdate (for composable tests):** +**Awaiting useSuspense() (for composable tests):** ```typescript -const { result, waitForNextUpdate } = renderDataCompose(() => useSuspense(...)); +const { result } = await renderDataCompose(() => useSuspense(...)); -// Initially suspended -expect(result.current).toBeUndefined(); - -// Wait for resolution -await waitForNextUpdate(); -expect(result.current).toBeInstanceOf(Promise); +// useSuspense() returns a Promise +expect(result).toBeInstanceOf(Promise); // Await the promise to get the reactive ComputedRef -const dataRef = await result.current; +const dataRef = await result; expect(dataRef.value.title).toBe('hi ho'); ``` @@ -123,7 +119,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 +129,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 +190,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 +202,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 +219,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'); @@ -312,21 +307,15 @@ For unsubscribe patterns, component-level polling tests, fake-timer-safe `flushU **useSuspense() returns Promise → ComputedRef:** ```typescript -const { result, waitForNextUpdate } = renderDataCompose(() => +const { result } = await 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); +// A Promise until the data is available +expect(result).toBeInstanceOf(Promise); // Await once to get reactive ComputedRef -const articleRef = await result.current; +const articleRef = await result; // The ref is reactive - updates automatically expect(articleRef.value.title).toBe('hi ho'); @@ -338,11 +327,11 @@ expect(articleRef.value.title).toBe('Updated'); // Auto-updated! **useQuery() returns ComputedRef directly:** ```typescript -const { result } = renderDataCompose(() => useQuery(Article, { id: 5 })); +const { result } = await renderDataCompose(() => useQuery(Article, { id: 5 }), { initialFixtures }); // Synchronously available (or undefined if not in store) -expect(result.current?.value).toBeDefined(); -expect(result.current?.value?.title).toBe('hi ho'); +expect(result.value).toBeDefined(); +expect(result.value?.title).toBe('hi ho'); // Also reactive - updates automatically ``` @@ -355,7 +344,7 @@ expect(result.current?.value?.title).toBe('hi ho'); - **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 +- **Await renderDataCompose()** - It resolves once the composable ran; `result` is its return value - **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 @@ -370,7 +359,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 +368,19 @@ 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); +const { result } = await renderDataCompose(() => useQuery(Article, { id: 5 })); +expect(result.value).toBe(undefined); ``` **Changing to non-existent entity:** ```typescript const props = reactive({ id: 1 }); // ... initial setup ... -expect(result.current?.value?.id).toBe(1); +expect(result.value?.id).toBe(1); props.id = 999; // Not in store -expect(result.current?.value).toBe(undefined); +await nextTick(); +expect(result.value).toBe(undefined); ``` **Testing nested collections:** @@ -398,11 +389,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 4ed2c4bd6c0f..dd737c134940 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..a102d1cae5b4 --- /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/getting-started/installation), +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() + +`mountDataClient()` mounts a component inside `DataClientPlugin` and ``. [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..c8f84a0d9b9d --- /dev/null +++ b/.agents/skills/data-client-vue-testing/references/unit-testing-composables.md @@ -0,0 +1,192 @@ + + +# 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/getting-started/installation) 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 }), + { + resolverFixtures: [ + { + endpoint: ArticleResource.get, + response: ({ id }) => ({ id, 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 + +| Member | Description | +| --------------------- | ---------------------------------------------------------------------------------------------------------- | +| `result` | What the composable returned | +| `controller` | The app's [Controller](https://dataclient.io/vue/api/Controller) | +| `wrapper` | Vue Test Utils [VueWrapper](https://test-utils.vuejs.org/api/#wrapper-methods) | +| `cleanup()` | Unmounts and stops the managers | +| `allSettled()` | Resolves once every in-flight fetch settles | +| `waitForNextUpdate()` | Waits for a pending Promise `result` to settle (at most 1 second); otherwise for `allSettled()` and a tick | + +`options` are the same as [mountDataClient()'s](./unit-testing-components.md#options). 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 35c246e63c15..c2dbb92a226b 100644 --- a/.agents/skills/data-client-vue/references.json +++ b/.agents/skills/data-client-vue/references.json @@ -12,6 +12,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/guides/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/devtools-debugging.md b/.agents/skills/data-client-vue/references/devtools-debugging.md index a6551fccbb53..47b7483a4336 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/api/Fixtures.md b/docs/core/api/Fixtures.md index c94c9a87c021..887ad443b3b3 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/debugging.md b/docs/core/getting-started/debugging.md index 2f2ab4996cbd..5165b074c19d 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](../guides/devtools-debugging.md). + ## Manual debugging If you prefer to inspect everything yourself, the browser devtools workflow below remains diff --git a/docs/core/guides/devtools-debugging.md b/docs/core/guides/devtools-debugging.md new file mode 100644 index 000000000000..1fef6f74077c --- /dev/null +++ b/docs/core/guides/devtools-debugging.md @@ -0,0 +1,371 @@ +--- +title: Debugging with Chrome DevTools MCP +sidebar_label: Debugging with agents +--- + +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..f92d9f473827 --- /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](../getting-started/installation.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() + +`mountDataClient()` mounts a component inside `DataClientPlugin` and ``. [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..460e1d0f196a --- /dev/null +++ b/docs/core/guides/unit-testing-hooks.vue.md @@ -0,0 +1,192 @@ +--- +title: 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](../getting-started/installation.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 }), + { + resolverFixtures: [ + { + endpoint: ArticleResource.get, + response: ({ id }) => ({ id, 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 + +| Member | Description | +| --------------------- | ------------------------------------------------------------------------------------ | +| `result` | What the composable returned | +| `controller` | The app's [Controller](../api/Controller.md) | +| `wrapper` | Vue Test Utils [VueWrapper](https://test-utils.vuejs.org/api/#wrapper-methods) | +| `cleanup()` | Unmounts and stops the managers | +| `allSettled()` | Resolves once every in-flight fetch settles | +| `waitForNextUpdate()` | Waits for a pending Promise `result` to settle (at most 1 second); otherwise for `allSettled()` and a tick | + +`options` are the same as [mountDataClient()'s](./unit-testing-components.md#options). diff --git a/packages/vue/src/test/README.md b/packages/vue/src/test/README.md index b9a9cd50af35..7097249848ad 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-hooks) +- `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..cf4f517c38ca 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-hooks * @param composable - The composable function to test * @param options - Configuration including optional reactive props ref, fixtures, managers, etc. */ diff --git a/website/sidebars.json b/website/sidebars.json index 0f7904dc18bd..75850d5c9180 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -89,6 +89,10 @@ { "type": "doc", "id": "guides/unit-testing-components" + }, + { + "type": "doc", + "id": "guides/devtools-debugging" } ] }, From 41576db3371590546b40c072de5bf5bafaa5dc80 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:11:10 +0000 Subject: [PATCH 2/6] docs: Simplify Vue testing skill and guides; link Vue testing guides Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EcBpA1YWwvXC94QHnzQG1H --- .../references/Controller.vue.md | 2 +- .../skills/data-client-vue-testing/SKILL.md | 76 +++---------------- .../references/unit-testing-composables.md | 17 ++--- .../data-client-vue/references/Controller.md | 2 +- docs/core/README.md | 4 +- docs/core/api/Controller.md | 2 +- docs/core/guides/unit-testing-hooks.vue.md | 17 ++--- 7 files changed, 30 insertions(+), 90 deletions(-) diff --git a/.agents/skills/data-client-manager/references/Controller.vue.md b/.agents/skills/data-client-manager/references/Controller.vue.md index cd8485af44f7..8bbf02185197 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-hooks) with `renderDataCompose()` from `@data-client/vue/test` ```ts class Controller { diff --git a/.agents/skills/data-client-vue-testing/SKILL.md b/.agents/skills/data-client-vue-testing/SKILL.md index 5afe8f0dd910..79a692de77d1 100644 --- a/.agents/skills/data-client-vue-testing/SKILL.md +++ b/.agents/skills/data-client-vue-testing/SKILL.md @@ -36,12 +36,12 @@ it('useQuery() should return cached data', async () => { - `managers`, `initialState`, `gcPolicy` - Custom configuration **Return values** (`renderDataCompose()` is async; always `await` it): -- `result` - Whatever the composable returned (a `ComputedRef` for useQuery/useCache, a Promise of one for useSuspense) +- `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(); }); @@ -86,20 +86,12 @@ it('should render article component', async () => { ## 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'); +await flushUntil(() => wrapper.find('h3').exists()); +await flushUntil(() => wrapper.find('h3').text() === 'Expected Title'); ``` **Awaiting useSuspense() (for composable tests):** @@ -137,6 +129,8 @@ await nextTick(); expect(result.value?.title).toBe('Second'); ``` +An id that isn't in the store gives `result.value === undefined`. + **Pattern 2: Conditional arguments (null handling):** ```typescript const props = reactive({ id: 1 as number | null }); @@ -303,55 +297,18 @@ 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 } = await renderDataCompose(() => - useSuspense(ArticleResource.get, { id: 5 }) -); - -// A Promise until the data is available -expect(result).toBeInstanceOf(Promise); - -// Await once to get reactive ComputedRef -const articleRef = await result; - -// 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 } = await renderDataCompose(() => useQuery(Article, { id: 5 }), { initialFixtures }); - -// Synchronously available (or undefined if not in store) -expect(result.value).toBeDefined(); -expect(result.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 - **Await renderDataCompose()** - It resolves once the composable ran; `result` is its return value - **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 @@ -372,17 +329,6 @@ const { result } = await renderDataCompose(() => useQuery(Article, { id: 5 })); expect(result.value).toBe(undefined); ``` -**Changing to non-existent entity:** -```typescript -const props = reactive({ id: 1 }); -// ... initial setup ... -expect(result.value?.id).toBe(1); - -props.id = 999; // Not in store -await nextTick(); -expect(result.value).toBe(undefined); -``` - **Testing nested collections:** ```typescript const userTodos = new Collection(new schema.Array(Todo), { 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 index c8f84a0d9b9d..a9be91044204 100644 --- a/.agents/skills/data-client-vue-testing/references/unit-testing-composables.md +++ b/.agents/skills/data-client-vue-testing/references/unit-testing-composables.md @@ -178,15 +178,12 @@ it('push() adds to the list', async () => { ### renderDataCompose(composable, options?) {#renderdatacompose-api} -Resolves (once the composable has run) to - -| Member | Description | -| --------------------- | ---------------------------------------------------------------------------------------------------------- | -| `result` | What the composable returned | -| `controller` | The app's [Controller](https://dataclient.io/vue/api/Controller) | -| `wrapper` | Vue Test Utils [VueWrapper](https://test-utils.vuejs.org/api/#wrapper-methods) | -| `cleanup()` | Unmounts and stops the managers | -| `allSettled()` | Resolves once every in-flight fetch settles | -| `waitForNextUpdate()` | Waits for a pending Promise `result` to settle (at most 1 second); otherwise for `allSettled()` and a tick | +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 | `options` are the same as [mountDataClient()'s](./unit-testing-components.md#options). diff --git a/.agents/skills/data-client-vue/references/Controller.md b/.agents/skills/data-client-vue/references/Controller.md index 7dc7e5a27f62..6b5ffce74fd1 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-hooks) with `renderDataCompose()` from `@data-client/vue/test` ```ts class Controller { 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 54620b5f21f2..f7e26bec56c3 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/guides/unit-testing-hooks.vue.md b/docs/core/guides/unit-testing-hooks.vue.md index 460e1d0f196a..c2ebf9b28ec6 100644 --- a/docs/core/guides/unit-testing-hooks.vue.md +++ b/docs/core/guides/unit-testing-hooks.vue.md @@ -178,15 +178,12 @@ it('push() adds to the list', async () => { ### renderDataCompose(composable, options?) {#renderdatacompose-api} -Resolves (once the composable has run) to - -| Member | Description | -| --------------------- | ------------------------------------------------------------------------------------ | -| `result` | What the composable returned | -| `controller` | The app's [Controller](../api/Controller.md) | -| `wrapper` | Vue Test Utils [VueWrapper](https://test-utils.vuejs.org/api/#wrapper-methods) | -| `cleanup()` | Unmounts and stops the managers | -| `allSettled()` | Resolves once every in-flight fetch settles | -| `waitForNextUpdate()` | Waits for a pending Promise `result` to settle (at most 1 second); otherwise for `allSettled()` and a tick | +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 | `options` are the same as [mountDataClient()'s](./unit-testing-components.md#options). From 81f39f0ef85ed30ccb64e04dd66b0f3553535795 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 18:00:38 +0000 Subject: [PATCH 3/6] docs: Link DevTools MCP debugging from Agent Skills Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EcBpA1YWwvXC94QHnzQG1H --- docs/core/getting-started/agent-skills.md | 7 +++++++ website/sidebars.json | 8 ++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/core/getting-started/agent-skills.md b/docs/core/getting-started/agent-skills.md index 169e187c0fbf..57e0a3cf43c3 100644 --- a/docs/core/getting-started/agent-skills.md +++ b/docs/core/getting-started/agent-skills.md @@ -58,6 +58,13 @@ To install every skill for your framework now instead, without letting your agen Browse the full catalog at [skills.sh/reactive/data-client](https://skills.sh/reactive/data-client). +## Debugging with agents + +With [Chrome DevTools MCP](https://github.com/ChromeDevTools/chrome-devtools-mcp) connected, the +:react[`/data-client-react`]:vue[`/data-client-vue`] skill can inspect your running app's store, +dispatched actions and network requests. See +[Debugging with Chrome DevTools MCP](../guides/devtools-debugging.md) for what it does. + ## Docs for LLMs Agents without skills can read these docs as plain markdown, following the [llms.txt](https://llmstxt.org) convention: diff --git a/website/sidebars.json b/website/sidebars.json index 75850d5c9180..1d7fb9ec5043 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -13,6 +13,10 @@ "type": "doc", "id": "getting-started/agent-skills" }, + { + "type": "doc", + "id": "guides/devtools-debugging" + }, { "type": "doc", "id": "getting-started/installation" @@ -89,10 +93,6 @@ { "type": "doc", "id": "guides/unit-testing-components" - }, - { - "type": "doc", - "id": "guides/devtools-debugging" } ] }, From 3a41b9e2d5990d4fede58ac89ab213f3bdadcaae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 18:05:35 +0000 Subject: [PATCH 4/6] internal(website): Regenerate playground core types for per-member Union rows #4179 changed set() row types after #4189 started checking that the playground editor types are current, so the editor-types check is red on master. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EcBpA1YWwvXC94QHnzQG1H --- .../editor-types/@data-client/core.d.ts | 29 ++++++------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/website/src/components/Playground/editor-types/@data-client/core.d.ts b/website/src/components/Playground/editor-types/@data-client/core.d.ts index 4ecd94b5f5dd..bceab8c9bba3 100644 --- a/website/src/components/Playground/editor-types/@data-client/core.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/core.d.ts @@ -446,24 +446,20 @@ type FunctionKeys = { /** Raw input for one field: numbers and strings coerce (literals stay exact); * objects are pre-normalize */ type SetField = T extends number ? number extends T ? T | string : T : T extends string ? string extends T ? T | number : T : T extends object ? unknown : T; -/** Non-function keys of any member of U */ -type FieldKeys = U extends unknown ? Exclude> : never; -/** Input for field K, from each member of U that has it */ -type MemberField = U extends unknown ? K extends keyof U ? SetField : never : never; -/** Fields of one row (or a coerced primitive); like EntityFields, but without - * key remapping (TS 4.0). A Union's members merge into one object type: checking - * a row against it costs one comparison instead of one per member. */ +/** Fields of one row (or a coerced primitive); like EntityFields, but distributive + * and without key remapping (TS 4.0). A Union gets one row per member, so a + * discriminator like `type` selects the member the other fields are checked against. */ type SetRow = 0 extends 1 & U ? { readonly [k: string]: any; -} : [U] extends [object] ? { - readonly [K in FieldKeys]?: MemberField; +} : U extends object ? { + readonly [K in Exclude>]?: SetField; } : SetField; /** Keeps S inferred from the schema alone: inferring it from the value too would * walk the value's type against every conditional in SetValue (TS 5.4 has NoInfer) */ type SkipInfer = [T][S extends unknown ? 0 : never]; type SetManyValue = S extends readonly (infer E)[] ? true extends IsUnion ? readonly { 'Use a Union schema for several Entity types': never; -}[] : readonly SetItem>[] : SetValue; +}[] : readonly SetRow>[] : SetValue; /** Raw input `set()` normalizes for a Queryable */ type SetValue = InputSchema>> extends infer N ? N extends EntityInterface ? SetRow> : SetInput> : never; /** Query normalizes with its inner schema; its process() output is not input @@ -478,16 +474,9 @@ type InputSchema = S extends ({ /** Raw input for a denormalized value, like a Collection's list or a Union's row */ type SetInput = 0 extends 1 & T ? any : [ T -] extends [readonly (infer U)[]] ? readonly SetItem[] : string extends keyof T ? { - readonly [k: string]: SetItem; -} : SetItem; -/** - * One member of a list or keyed object. Polymorphic rows may carry a - * discriminator that is not an Entity field. - */ -type SetItem = true extends IsUnion ? SetRow & { - readonly [k: string]: unknown; -} : SetRow; +] extends [readonly (infer U)[]] ? readonly SetRow[] : string extends keyof T ? { + readonly [k: string]: SetRow; +} : SetRow; type ResultEntry = E['schema'] extends undefined | null ? ResolveType : Normalize; type EndpointUpdateFunction = Record> = (source: ResultEntry, ...args: any) => { From 883c522a2eac5569f9c1635eeb767e0da255e49f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 18:58:14 +0000 Subject: [PATCH 5/6] docs: Simplify agent debugging links and Vue testing skill Move the DevTools MCP page into getting-started (it is listed there), link it from the framework skill bullets instead of a second "Debugging with agents" section, link DataClientPlugin to its API page, and drop examples the Vue testing skill repeated. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EcBpA1YWwvXC94QHnzQG1H --- .../skills/data-client-react/references.json | 2 +- .../references/devtools-debugging.md | 2 +- .../skills/data-client-vue-testing/SKILL.md | 26 ------------------- .../references/unit-testing-components.md | 4 +-- .../references/unit-testing-composables.md | 9 +++---- .../skills/data-client-vue/references.json | 2 +- .../references/devtools-debugging.md | 2 +- docs/core/getting-started/agent-skills.md | 11 ++------ docs/core/getting-started/debugging.md | 2 +- .../devtools-debugging.md | 2 +- .../guides/unit-testing-components.vue.md | 4 +-- docs/core/guides/unit-testing-hooks.vue.md | 9 +++---- website/sidebars.json | 2 +- 13 files changed, 21 insertions(+), 56 deletions(-) rename docs/core/{guides => getting-started}/devtools-debugging.md (99%) diff --git a/.agents/skills/data-client-react/references.json b/.agents/skills/data-client-react/references.json index 159ba2ea822a..a41c8ef7a71a 100644 --- a/.agents/skills/data-client-react/references.json +++ b/.agents/skills/data-client-react/references.json @@ -13,7 +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/guides/devtools-debugging.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 26d6eebb16f3..9f3d2fe5d5df 100644 --- a/.agents/skills/data-client-react/references/devtools-debugging.md +++ b/.agents/skills/data-client-react/references/devtools-debugging.md @@ -1,4 +1,4 @@ - + # Debugging with Chrome DevTools MCP diff --git a/.agents/skills/data-client-vue-testing/SKILL.md b/.agents/skills/data-client-vue-testing/SKILL.md index 79a692de77d1..c71f94280b55 100644 --- a/.agents/skills/data-client-vue-testing/SKILL.md +++ b/.agents/skills/data-client-vue-testing/SKILL.md @@ -94,18 +94,6 @@ await flushUntil(() => wrapper.find('h3').exists()); await flushUntil(() => wrapper.find('h3').text() === 'Expected Title'); ``` -**Awaiting useSuspense() (for composable tests):** -```typescript -const { result } = await renderDataCompose(() => useSuspense(...)); - -// useSuspense() returns a Promise -expect(result).toBeInstanceOf(Promise); - -// Await the promise to get the reactive ComputedRef -const dataRef = await result; -expect(dataRef.value.title).toBe('hi ho'); -``` - ## Reactive Props Testing **Pattern 1: Testing prop changes:** @@ -129,8 +117,6 @@ await nextTick(); expect(result.value?.title).toBe('Second'); ``` -An id that isn't in the store gives `result.value === undefined`. - **Pattern 2: Conditional arguments (null handling):** ```typescript const props = reactive({ id: 1 as number | null }); @@ -228,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). @@ -302,12 +278,10 @@ For unsubscribe patterns, component-level polling tests, fake-timer-safe `flushU - **Always call cleanup()** - Prevents memory leaks and test pollution - **Use reactive() for props** - Enables testing prop changes - **Use flushUntil() in component tests** - More reliable than fixed delays -- **Await renderDataCompose()** - It resolves once the composable ran; `result` is its return value - **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 - **Test both empty and populated states** - Verify undefined behavior -- **Test reactive prop changes** - Use `reactive()` and verify updates - **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 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 index a102d1cae5b4..650c86101064 100644 --- a/.agents/skills/data-client-vue-testing/references/unit-testing-components.md +++ b/.agents/skills/data-client-vue-testing/references/unit-testing-components.md @@ -10,7 +10,7 @@ > > Instead, load responses with [Fixtures](./Fixtures.md). -`@data-client/vue/test` mounts components with [DataClientPlugin](https://dataclient.io/vue/getting-started/installation), +`@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). @@ -26,7 +26,7 @@ npm install --save-dev @vue/test-utils jest-environment-jsdom ## mountDataClient() -`mountDataClient()` mounts a component inside `DataClientPlugin` and ``. [initialFixtures](#options) +[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. 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 index a9be91044204..c70533e5108f 100644 --- a/.agents/skills/data-client-vue-testing/references/unit-testing-composables.md +++ b/.agents/skills/data-client-vue-testing/references/unit-testing-composables.md @@ -4,7 +4,7 @@ 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/getting-started/installation) and [Fixtures](./Fixtures.md). +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). @@ -53,10 +53,11 @@ it('useSuspense() follows store updates', async () => { const { result, controller, cleanup } = await renderDataCompose( () => useSuspense(ArticleResource.get, { id: 5 }), { - resolverFixtures: [ + initialFixtures: [ { endpoint: ArticleResource.get, - response: ({ id }) => ({ id, title: 'hi ho', content: 'whatever' }), + args: [{ id: 5 }], + response: { id: 5, title: 'hi ho', content: 'whatever' }, }, ], }, @@ -185,5 +186,3 @@ Resolves, once the composable has run, to the same `controller`, `wrapper`, `cle | --------------------- | ------------------------------------------------------------------------------------------------------ | | `result` | What the composable returned | | `waitForNextUpdate()` | Prefer `await result` or `allSettled()`; this waits for a pending Promise `result` with a 1 second cap | - -`options` are the same as [mountDataClient()'s](./unit-testing-components.md#options). diff --git a/.agents/skills/data-client-vue/references.json b/.agents/skills/data-client-vue/references.json index 4f563280c88c..ad47c107f2fc 100644 --- a/.agents/skills/data-client-vue/references.json +++ b/.agents/skills/data-client-vue/references.json @@ -13,7 +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/guides/devtools-debugging.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/devtools-debugging.md b/.agents/skills/data-client-vue/references/devtools-debugging.md index 47b7483a4336..93a1e67d7411 100644 --- a/.agents/skills/data-client-vue/references/devtools-debugging.md +++ b/.agents/skills/data-client-vue/references/devtools-debugging.md @@ -1,4 +1,4 @@ - + # Debugging with Chrome DevTools MCP diff --git a/docs/core/getting-started/agent-skills.md b/docs/core/getting-started/agent-skills.md index 57e0a3cf43c3..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`. @@ -58,13 +58,6 @@ To install every skill for your framework now instead, without letting your agen Browse the full catalog at [skills.sh/reactive/data-client](https://skills.sh/reactive/data-client). -## Debugging with agents - -With [Chrome DevTools MCP](https://github.com/ChromeDevTools/chrome-devtools-mcp) connected, the -:react[`/data-client-react`]:vue[`/data-client-vue`] skill can inspect your running app's store, -dispatched actions and network requests. See -[Debugging with Chrome DevTools MCP](../guides/devtools-debugging.md) for what it does. - ## Docs for LLMs Agents without skills can read these docs as plain markdown, following the [llms.txt](https://llmstxt.org) convention: diff --git a/docs/core/getting-started/debugging.md b/docs/core/getting-started/debugging.md index 5165b074c19d..b4929780c0da 100644 --- a/docs/core/getting-started/debugging.md +++ b/docs/core/getting-started/debugging.md @@ -37,7 +37,7 @@ 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](../guides/devtools-debugging.md). +To run these steps yourself, or see exactly what the agent does, follow [Debugging with Chrome DevTools MCP](./devtools-debugging.md). ## Manual debugging diff --git a/docs/core/guides/devtools-debugging.md b/docs/core/getting-started/devtools-debugging.md similarity index 99% rename from docs/core/guides/devtools-debugging.md rename to docs/core/getting-started/devtools-debugging.md index 1fef6f74077c..f80930b5c459 100644 --- a/docs/core/guides/devtools-debugging.md +++ b/docs/core/getting-started/devtools-debugging.md @@ -1,6 +1,6 @@ --- title: Debugging with Chrome DevTools MCP -sidebar_label: Debugging with agents +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. diff --git a/docs/core/guides/unit-testing-components.vue.md b/docs/core/guides/unit-testing-components.vue.md index f92d9f473827..2795f1c0496c 100644 --- a/docs/core/guides/unit-testing-components.vue.md +++ b/docs/core/guides/unit-testing-components.vue.md @@ -14,7 +14,7 @@ Instead, load responses with [Fixtures](../api/Fixtures.md). ::: -`@data-client/vue/test` mounts components with [DataClientPlugin](../getting-started/installation.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). @@ -28,7 +28,7 @@ alongside your test runner. Jest needs the [jsdom environment](https://jestjs.io ## mountDataClient() -`mountDataClient()` mounts a component inside `DataClientPlugin` and ``. [initialFixtures](#options) +[initialFixtures](#options) fill the store before the first render, so a component that `await`s [useSuspense()](../api/useSuspense.md) renders its data right away. diff --git a/docs/core/guides/unit-testing-hooks.vue.md b/docs/core/guides/unit-testing-hooks.vue.md index c2ebf9b28ec6..3cd7155465c7 100644 --- a/docs/core/guides/unit-testing-hooks.vue.md +++ b/docs/core/guides/unit-testing-hooks.vue.md @@ -4,7 +4,7 @@ title: 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](../getting-started/installation.md) and [Fixtures](../api/Fixtures.md). +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). @@ -53,10 +53,11 @@ it('useSuspense() follows store updates', async () => { const { result, controller, cleanup } = await renderDataCompose( () => useSuspense(ArticleResource.get, { id: 5 }), { - resolverFixtures: [ + initialFixtures: [ { endpoint: ArticleResource.get, - response: ({ id }) => ({ id, title: 'hi ho', content: 'whatever' }), + args: [{ id: 5 }], + response: { id: 5, title: 'hi ho', content: 'whatever' }, }, ], }, @@ -185,5 +186,3 @@ Resolves, once the composable has run, to the same `controller`, `wrapper`, `cle | --------------------- | ------------------------------------------------------------------------------------------------- | | `result` | What the composable returned | | `waitForNextUpdate()` | Prefer `await result` or `allSettled()`; this waits for a pending Promise `result` with a 1 second cap | - -`options` are the same as [mountDataClient()'s](./unit-testing-components.md#options). diff --git a/website/sidebars.json b/website/sidebars.json index 2d806d4c68a5..a88c66a8e6eb 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -15,7 +15,7 @@ }, { "type": "doc", - "id": "guides/devtools-debugging" + "id": "getting-started/devtools-debugging" }, { "type": "doc", From 8943675e8af9630615f17755afc74874b52ed4bc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 19:14:07 +0000 Subject: [PATCH 6/6] docs(vue): Serve composables testing guide at /vue/guides/unit-testing-composables The Vue page keeps its unit-testing-hooks.vue.md filename so it shares the React page's doc id (sidebar and framework switcher), and sets a slug so its URL says composables. routeOf now honors absolute slugs so generated skill references link to the same URL. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EcBpA1YWwvXC94QHnzQG1H --- .../data-client-manager/references/Controller.vue.md | 2 +- .agents/skills/data-client-vue/references/Controller.md | 2 +- docs/core/guides/unit-testing-hooks.vue.md | 1 + packages/vue/src/test/README.md | 2 +- packages/vue/src/test/renderDataCompose.ts | 2 +- website/framework-docs/docsToMarkdown.mjs | 8 ++++---- 6 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.agents/skills/data-client-manager/references/Controller.vue.md b/.agents/skills/data-client-manager/references/Controller.vue.md index 6d8cfdad5675..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](https://dataclient.io/vue/guides/unit-testing-hooks) 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/Controller.md b/.agents/skills/data-client-vue/references/Controller.md index 73918d18bd8c..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](https://dataclient.io/vue/guides/unit-testing-hooks) 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/docs/core/guides/unit-testing-hooks.vue.md b/docs/core/guides/unit-testing-hooks.vue.md index 3cd7155465c7..8432311ec384 100644 --- a/docs/core/guides/unit-testing-hooks.vue.md +++ b/docs/core/guides/unit-testing-hooks.vue.md @@ -1,5 +1,6 @@ --- 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. diff --git a/packages/vue/src/test/README.md b/packages/vue/src/test/README.md index 7097249848ad..5d9d7c0601af 100644 --- a/packages/vue/src/test/README.md +++ b/packages/vue/src/test/README.md @@ -6,7 +6,7 @@ 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-hooks) + [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/renderDataCompose.ts b/packages/vue/src/test/renderDataCompose.ts index cf4f517c38ca..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/vue/guides/unit-testing-hooks + * @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$/, '/'); });