diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0ff727f1c..3f89bfd5a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -44,6 +44,39 @@ src +-- component-b.ts ``` +### API Mocking + +API responses are mocked using MSW with `AutoAPIMock` fixtures. See `docs/mocks.md` for full documentation and `renderer/src/common/mocks/` for implementation. + +- Fixtures are in `renderer/src/common/mocks/fixtures/` and use named exports +- Override responses in tests with `.override()` or `.overrideHandler()` +- Overrides reset automatically before each test +- Use `recordRequests()` from `@/common/mocks/node` to assert on API calls + +```typescript +import { mockedGetApiV1BetaGroups } from '@mocks/fixtures/groups/get' +import { recordRequests } from '@/common/mocks/node' +import { HttpResponse } from 'msw' + +// Override data (type-safe) +mockedGetApiV1BetaGroups.override(() => ({ groups: [] })) + +// Return errors +mockedGetApiV1BetaGroups.overrideHandler(() => + HttpResponse.json({ error: 'Server error' }, { status: 500 }) +) + +// Access default data +const defaultData = mockedGetApiV1BetaGroups.defaultValue + +// Record and assert on requests +const rec = recordRequests() +// ... actions ... +expect(rec.recordedRequests).toContainEqual( + expect.objectContaining({ method: 'POST', pathname: '/api/v1beta/groups' }) +) +``` + ## Project structure Most of the code lives in the `src/` folder and looks something like this: diff --git a/docs/mocks.md b/docs/mocks.md new file mode 100644 index 000000000..bff3fdd17 --- /dev/null +++ b/docs/mocks.md @@ -0,0 +1,208 @@ +# MSW Auto-Mocker + +> **Source files:** The mocking system is implemented in `renderer/src/common/mocks/`. See the source files directly for implementation details: +> +> - `autoAPIMock.ts` - The `AutoAPIMock` wrapper implementation +> - `mocker.ts` - Auto-generation of fixtures from OpenAPI schema +> - `node.ts` - MSW server setup and request recording + +- Handlers: `renderer/src/common/mocks/handlers.ts` combines custom handlers and auto-generated mocks. +- Custom handlers: add hand-written handlers in `renderer/src/common/mocks/customHandlers/index.ts`. These take precedence over schema-based mocks. +- Auto-generated: `renderer/src/common/mocks/mocker.ts` reads `api/openapi.json` and creates fixtures under `renderer/src/common/mocks/fixtures` on first run. + +## Usage + +- Vitest: tests initialize MSW in `vitest.setup.ts`. Run `pnpm test`. + +## Generating fixtures + +- To create a new fixture for an endpoint, simply run a Vitest test that calls that endpoint. The auto-mocker will generate `renderer/src/common/mocks/fixtures//.ts` on first use using schema-based fake data. +- To customize the response, edit the generated TypeScript file. This is preferred over writing a custom handler for simple data tweaks (e.g., replacing lorem ipsum with realistic text). Custom handlers are intended for behavior overrides or endpoints without schema. + +## Regeneration + +- Delete a fixture file to re-generate it on next request. + +## Failure behavior (always strict) + +- If a schema is missing or faker fails, the handler responds 500 and does not write a placeholder. +- Invalid fixtures respond 500. + +## Types + +- Fixtures use strict types via the `AutoAPIMock` wrapper. Generated modules import response types from `@api/types.gen` and pass them as generic parameters to `AutoAPIMock` for type safety. +- The `@mocks` path alias points to `renderer/src/common/mocks`. + +## Test-Scoped Overrides with AutoAPIMock + +Each fixture is wrapped in `AutoAPIMock`, which provides test-scoped override capabilities. + +### Fixture Structure + +Generated fixtures use named exports with a consistent naming convention: + +```typescript +// renderer/src/common/mocks/fixtures/groups/get.ts +import type { GetApiV1BetaGroupsResponse } from '@api/types.gen' +import { AutoAPIMock } from '@mocks' + +export const mockedGetApiV1BetaGroups = AutoAPIMock( + { + groups: [ + { name: 'default', registered_clients: ['client-a'] }, + { name: 'research', registered_clients: ['client-b'] }, + ], + } +) +``` + +### Overriding in Tests + +Use `.override()` for type-safe response modifications, or `.overrideHandler()` for full control (errors, network failures): + +```typescript +import { HttpResponse } from 'msw' +import { mockedGetApiV1BetaGroups } from '@mocks/fixtures/groups/get' + +// Type-safe data override +mockedGetApiV1BetaGroups.override(() => ({ + groups: [], +})) + +// Modify default data +mockedGetApiV1BetaGroups.override((data) => ({ + ...data, + groups: data.groups?.slice(0, 1), +})) + +// Error responses (use overrideHandler) +mockedGetApiV1BetaGroups.overrideHandler(() => + HttpResponse.json({ error: 'Server error' }, { status: 500 }) +) + +// Network error +mockedGetApiV1BetaGroups.overrideHandler(() => HttpResponse.error()) +``` + +Overrides are automatically reset before each test via `resetAllAutoAPIMocks()` in `vitest.setup.ts`. + +### Accessing Default Data + +Use `.defaultValue` to access the fixture's default data: + +```typescript +import { mockedGetApiV1BetaGroups } from '@mocks/fixtures/groups/get' + +const defaultGroups = mockedGetApiV1BetaGroups.defaultValue +// Use in custom server.use() handlers or assertions +``` + +### Reusable Scenarios + +Define named scenarios in your fixture for commonly used test states: + +```typescript +// renderer/src/common/mocks/fixtures/groups/get.ts +import type { GetApiV1BetaGroupsResponse } from '@api/types.gen' +import { AutoAPIMock } from '@mocks' +import { HttpResponse } from 'msw' + +export const mockedGetApiV1BetaGroups = AutoAPIMock( + { + groups: [{ name: 'default', registered_clients: ['client-a'] }], + } +) + .scenario('empty', (self) => + self.override(() => ({ + groups: [], + })) + ) + .scenario('server-error', (self) => + self.overrideHandler(() => + HttpResponse.json({ error: 'Internal Server Error' }, { status: 500 }) + ) + ) +``` + +Then use them in tests: + +```typescript +import { MockScenarios } from '@mocks' +import { mockedGetApiV1BetaGroups } from '@mocks/fixtures/groups/get' + +describe('groups', () => { + it('handles empty groups', async () => { + mockedGetApiV1BetaGroups.activateScenario(MockScenarios.Empty) + + // Test empty state... + }) + + it('handles server error', async () => { + mockedGetApiV1BetaGroups.activateScenario(MockScenarios.ServerError) + + // Test error handling... + }) +}) +``` + +### Global Scenario Activation + +Use `activateMockScenario` to activate a scenario across all registered mocks at once. This is useful for setting up a consistent state across multiple endpoints: + +```typescript +import { activateMockScenario, MockScenarios } from '@mocks' +import { mockedGetApiV1BetaGroups } from '@mocks/fixtures/groups/get' + +describe('error handling', () => { + it('shows error page when all APIs fail', async () => { + // Activate "server-error" on all mocks that define it + // Mocks without this scenario will use their default response + activateMockScenario(MockScenarios.ServerError) + + // Test that the app handles the error state correctly + }) + + it('handles partial failures gracefully', async () => { + // Start with all APIs returning errors + activateMockScenario(MockScenarios.ServerError) + + // Then reset specific endpoints to use their default response + mockedGetApiV1BetaGroups.reset() + + // Now only other endpoints return errors, groups endpoint works + }) +}) +``` + +Scenario names are defined in `renderer/src/common/mocks/scenarioNames.ts` via the `MockScenarios` object, which provides autocomplete and JSDoc documentation. Global scenarios are automatically reset before each test via `resetAllAutoAPIMocks()` in the test setup. + +## Request Recording + +Use `recordRequests()` from `@/common/mocks/node` to capture API requests for assertions. This is the preferred way to verify that your code sends the correct API calls. + +```typescript +import { recordRequests } from '@/common/mocks/node' + +it('sends correct payload when creating a group', async () => { + const rec = recordRequests() + + // ... perform actions that trigger API calls ... + + const request = rec.recordedRequests.find( + (r) => r.method === 'POST' && r.pathname === '/api/v1beta/groups' + ) + expect(request).toBeDefined() + expect(request?.payload).toMatchObject({ + name: 'my-group', + }) +}) +``` + +Each recorded request contains: + +- `pathname` - The URL path (e.g., `/api/v1beta/groups`) +- `method` - HTTP method (e.g., `GET`, `POST`) +- `payload` - Parsed JSON body (if present) +- `search` - Query parameters as key-value pairs + +Calling `recordRequests()` clears previous recordings, so each test starts fresh. diff --git a/package.json b/package.json index c4cc2c161..11956dc03 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,6 @@ "@types/unzipper": "^0.10.11", "@vitejs/plugin-react-swc": "^4.0.0", "@vitest/coverage-istanbul": "^4.0.0", - "ajv": "^8.17.1", "autoprefixer": "^10.4.21", "dotenv": "^17.0.0", "electron": "39.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69bb5bc7a..d0d32791a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -276,9 +276,6 @@ importers: '@vitest/coverage-istanbul': specifier: ^4.0.0 version: 4.0.4(vitest@4.0.4(@types/debug@4.1.12)(@types/node@22.19.1)(jiti@2.6.1)(jsdom@27.2.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@22.19.1)(typescript@5.9.3))(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) - ajv: - specifier: ^8.17.1 - version: 8.17.1 autoprefixer: specifier: ^10.4.21 version: 10.4.22(postcss@8.5.6) @@ -3464,12 +3461,6 @@ packages: peerDependencies: ai: ^5.0.97 - ai@5.0.106: - resolution: {integrity: sha512-M5obwavxSJJ3tGlAFqI6eltYNJB0D20X6gIBCFx/KVorb/X1fxVVfiZZpZb+Gslu4340droSOjT0aKQFCarNVg==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - ai@5.0.108: resolution: {integrity: sha512-Jex3Lb7V41NNpuqJHKgrwoU6BCLHdI1Pg4qb4GJH4jRIDRXUBySJErHjyN4oTCwbiYCeb/8II9EnqSRPq9EifA==} engines: {node: '>=18'} @@ -11460,14 +11451,6 @@ snapshots: transitivePeerDependencies: - zod - ai@5.0.106(zod@4.1.13): - dependencies: - '@ai-sdk/gateway': 2.0.18(zod@4.1.13) - '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.18(zod@4.1.13) - '@opentelemetry/api': 1.9.0 - zod: 4.1.13 - ai@5.0.108(zod@4.1.13): dependencies: '@ai-sdk/gateway': 2.0.18(zod@4.1.13) diff --git a/renderer/src/common/mocks/autoAPIMock.ts b/renderer/src/common/mocks/autoAPIMock.ts new file mode 100644 index 000000000..9fdbb6136 --- /dev/null +++ b/renderer/src/common/mocks/autoAPIMock.ts @@ -0,0 +1,140 @@ +import type { HttpResponseResolver, JsonBodyType } from 'msw' +import { HttpResponse } from 'msw' +import type { MockScenarioName } from './scenarioNames' + +const SCENARIO_HEADER = 'x-mock-scenario' + +type ResponseResolverInfo = Parameters[0] + +type OverrideHandlerFn = (data: T, info: ResponseResolverInfo) => Response +type OverrideFn = (data: T, info: ResponseResolverInfo) => T +type ScenarioFn = ( + instance: AutoAPIMockInstance +) => AutoAPIMockInstance + +export interface ActivateScenarioOptions { + /** If true, silently falls back to default when scenario doesn't exist. Default: false (throws) */ + fallbackToDefault?: boolean +} + +export interface AutoAPIMockInstance { + /** MSW handler to use in handler registration. Respects overrides and scenarios. */ + generatedHandler: HttpResponseResolver + + /** Override response data with type safety. Preferred for simple data changes. */ + override: (fn: OverrideFn) => AutoAPIMockInstance + + /** Override the full handler. Use for errors, network failures, or invalid data. */ + overrideHandler: (fn: OverrideHandlerFn) => AutoAPIMockInstance + + /** Define a reusable named scenario for this mock. */ + scenario: ( + name: MockScenarioName, + fn: ScenarioFn + ) => AutoAPIMockInstance + + /** Activate a named scenario for the current test. */ + activateScenario: ( + name: MockScenarioName, + options?: ActivateScenarioOptions + ) => AutoAPIMockInstance + + /** Reset to default behavior. Called automatically before each test. */ + reset: () => AutoAPIMockInstance + + /** The default fixture data. */ + defaultValue: T +} + +// Registry to track all instances for bulk reset +const registry: Set> = new Set() + +export function AutoAPIMock(defaultValue: T): AutoAPIMockInstance { + let overrideHandlerFn: OverrideHandlerFn | null = null + const scenarios = new Map>() + + const instance: AutoAPIMockInstance = { + defaultValue, + + generatedHandler(info: ResponseResolverInfo) { + // Check for header-based scenario activation (for browser/dev testing) + const headerScenario = info.request.headers.get(SCENARIO_HEADER) + if (headerScenario) { + const scenarioFn = scenarios.get(headerScenario as MockScenarioName) + if (scenarioFn) { + // Temporarily apply scenario and get the handler + const previousHandler = overrideHandlerFn + scenarioFn(instance) + const result = overrideHandlerFn + ? overrideHandlerFn(defaultValue, info) + : HttpResponse.json(defaultValue as JsonBodyType) + // Restore previous state + overrideHandlerFn = previousHandler + return result + } + } + + if (overrideHandlerFn) { + return overrideHandlerFn(defaultValue, info) + } + return HttpResponse.json(defaultValue as JsonBodyType) + }, + + override(fn: OverrideFn) { + return instance.overrideHandler((data, info) => + HttpResponse.json(fn(data, info) as JsonBodyType) + ) + }, + + overrideHandler(fn: OverrideHandlerFn) { + overrideHandlerFn = fn + return instance + }, + + scenario(name: MockScenarioName, fn: ScenarioFn) { + scenarios.set(name, fn) + return instance + }, + + activateScenario( + name: MockScenarioName, + options?: ActivateScenarioOptions + ) { + const scenarioFn = scenarios.get(name) + if (!scenarioFn) { + if (options?.fallbackToDefault) { + return instance + } + throw new Error( + `Scenario "${name}" not found. Available scenarios: ${[...scenarios.keys()].join(', ') || '(none)'}` + ) + } + return scenarioFn(instance) + }, + + reset() { + overrideHandlerFn = null + return instance + }, + } + + registry.add(instance as AutoAPIMockInstance) + + return instance +} + +export function resetAllAutoAPIMocks(): void { + for (const instance of registry) { + instance.reset() + } +} + +/** + * Activate a scenario globally across all registered mocks. + * Mocks that don't have the scenario defined will silently use their default. + */ +export function activateMockScenario(name: MockScenarioName): void { + for (const instance of registry) { + instance.activateScenario(name, { fallbackToDefault: true }) + } +} diff --git a/renderer/src/common/mocks/fixtures/README.md b/renderer/src/common/mocks/fixtures/README.md deleted file mode 100644 index ebc6763dd..000000000 --- a/renderer/src/common/mocks/fixtures/README.md +++ /dev/null @@ -1,8 +0,0 @@ -This folder contains TypeScript fixtures (default exports) for mocking API -responses in MSW handlers. - -Notes: - -- Files are auto-generated on first run as `.ts` modules with - `export default { ... }`. -- You can edit fixtures manually; subsequent runs will reuse your edits. diff --git a/renderer/src/common/mocks/fixtures/clients_register/post.ts b/renderer/src/common/mocks/fixtures/clients_register/post.ts index 8b04c5b11..2f72cad55 100644 --- a/renderer/src/common/mocks/fixtures/clients_register/post.ts +++ b/renderer/src/common/mocks/fixtures/clients_register/post.ts @@ -1,8 +1,10 @@ import type { PostApiV1BetaClientsRegisterResponse } from '@api/types.gen' +import { AutoAPIMock } from '@mocks' -export default [ - { - groups: ['default', 'research', 'archive', 'my group'], - name: 'vscode', - }, -] satisfies PostApiV1BetaClientsRegisterResponse +export const mockedPostApiV1BetaClientsRegister = + AutoAPIMock([ + { + groups: ['default', 'research', 'archive', 'my group'], + name: 'vscode', + }, + ]) diff --git a/renderer/src/common/mocks/fixtures/discovery_clients/get.ts b/renderer/src/common/mocks/fixtures/discovery_clients/get.ts index 401e48539..c24e02540 100644 --- a/renderer/src/common/mocks/fixtures/discovery_clients/get.ts +++ b/renderer/src/common/mocks/fixtures/discovery_clients/get.ts @@ -1,12 +1,14 @@ import type { GetApiV1BetaDiscoveryClientsResponse } from '@api/types.gen' +import { AutoAPIMock } from '@mocks' -export default { - clients: [ - { client_type: 'roo-code', installed: false, registered: false }, - { client_type: 'cline', installed: true, registered: false }, - { client_type: 'vscode-insider', installed: true, registered: false }, - { client_type: 'vscode', installed: true, registered: false }, - { client_type: 'cursor', installed: true, registered: false }, - { client_type: 'claude-code', installed: true, registered: false }, - ], -} satisfies GetApiV1BetaDiscoveryClientsResponse +export const mockedGetApiV1BetaDiscoveryClients = + AutoAPIMock({ + clients: [ + { client_type: 'roo-code', installed: false, registered: false }, + { client_type: 'cline', installed: true, registered: false }, + { client_type: 'vscode-insider', installed: true, registered: false }, + { client_type: 'vscode', installed: true, registered: false }, + { client_type: 'cursor', installed: true, registered: false }, + { client_type: 'claude-code', installed: true, registered: false }, + ], + }) diff --git a/renderer/src/common/mocks/fixtures/groups/get.ts b/renderer/src/common/mocks/fixtures/groups/get.ts index 6960ecf6e..59ed97c92 100644 --- a/renderer/src/common/mocks/fixtures/groups/get.ts +++ b/renderer/src/common/mocks/fixtures/groups/get.ts @@ -1,10 +1,13 @@ import type { GetApiV1BetaGroupsResponse } from '@api/types.gen' +import { AutoAPIMock } from '@mocks' -export default { - groups: [ - { name: 'default', registered_clients: ['client-a'] }, - { name: 'research', registered_clients: ['client-b'] }, - { name: 'archive', registered_clients: [] }, - { name: 'my group', registered_clients: [] }, - ], -} satisfies GetApiV1BetaGroupsResponse +export const mockedGetApiV1BetaGroups = AutoAPIMock( + { + groups: [ + { name: 'default', registered_clients: ['client-a'] }, + { name: 'research', registered_clients: ['client-b'] }, + { name: 'archive', registered_clients: [] }, + { name: 'my group', registered_clients: [] }, + ], + } +) diff --git a/renderer/src/common/mocks/fixtures/groups/post.ts b/renderer/src/common/mocks/fixtures/groups/post.ts index a64651609..76f878dd2 100644 --- a/renderer/src/common/mocks/fixtures/groups/post.ts +++ b/renderer/src/common/mocks/fixtures/groups/post.ts @@ -1,5 +1,7 @@ import type { PostApiV1BetaGroupsResponse } from '@api/types.gen' +import { AutoAPIMock } from '@mocks' -export default { - name: 'fake-group-name', -} satisfies PostApiV1BetaGroupsResponse +export const mockedPostApiV1BetaGroups = + AutoAPIMock({ + name: 'fake-group-name', + }) diff --git a/renderer/src/common/mocks/fixtures/groups_name/delete.ts b/renderer/src/common/mocks/fixtures/groups_name/delete.ts index eb91d98a6..cd2ce4e42 100644 --- a/renderer/src/common/mocks/fixtures/groups_name/delete.ts +++ b/renderer/src/common/mocks/fixtures/groups_name/delete.ts @@ -1,3 +1,5 @@ import type { DeleteApiV1BetaGroupsByNameResponse } from '@api/types.gen' +import { AutoAPIMock } from '@mocks' -export default '' satisfies DeleteApiV1BetaGroupsByNameResponse +export const mockedDeleteApiV1BetaGroupsByName = + AutoAPIMock('') diff --git a/renderer/src/common/mocks/fixtures/groups_name/get.ts b/renderer/src/common/mocks/fixtures/groups_name/get.ts index 33023e25e..d9b3b3d44 100644 --- a/renderer/src/common/mocks/fixtures/groups_name/get.ts +++ b/renderer/src/common/mocks/fixtures/groups_name/get.ts @@ -1,6 +1,8 @@ import type { GetApiV1BetaGroupsByNameResponse } from '@api/types.gen' +import { AutoAPIMock } from '@mocks' -export default { - name: 'fake-group-name', - registered_clients: ['client1', 'client2'], -} satisfies GetApiV1BetaGroupsByNameResponse +export const mockedGetApiV1BetaGroupsByName = + AutoAPIMock({ + name: 'fake-group-name', + registered_clients: ['client1', 'client2'], + }) diff --git a/renderer/src/common/mocks/fixtures/workloads/post.ts b/renderer/src/common/mocks/fixtures/workloads/post.ts index 74d406b94..d78c16cbb 100644 --- a/renderer/src/common/mocks/fixtures/workloads/post.ts +++ b/renderer/src/common/mocks/fixtures/workloads/post.ts @@ -1,6 +1,8 @@ import type { PostApiV1BetaWorkloadsResponse } from '@api/types.gen' +import { AutoAPIMock } from '@mocks' -export default { - name: 'fake-workload-name', - port: 54454, -} satisfies PostApiV1BetaWorkloadsResponse +export const mockedPostApiV1BetaWorkloads = + AutoAPIMock({ + name: 'fake-workload-name', + port: 54454, + }) diff --git a/renderer/src/common/mocks/fixtures/workloads_name/get.ts b/renderer/src/common/mocks/fixtures/workloads_name/get.ts index baf0deb9e..1d03330ca 100644 --- a/renderer/src/common/mocks/fixtures/workloads_name/get.ts +++ b/renderer/src/common/mocks/fixtures/workloads_name/get.ts @@ -1,15 +1,17 @@ import type { GetApiV1BetaWorkloadsByNameResponse } from '@api/types.gen' +import { AutoAPIMock } from '@mocks' -export default { - name: 'postgres-db', - image: 'ghcr.io/postgres/postgres-mcp-server:latest', - transport: 'stdio', - host: '127.0.0.1', - target_port: 28135, - cmd_arguments: [], - env_vars: {}, - secrets: [], - volumes: [], - network_isolation: false, - group: 'default', -} satisfies GetApiV1BetaWorkloadsByNameResponse +export const mockedGetApiV1BetaWorkloadsByName = + AutoAPIMock({ + name: 'postgres-db', + image: 'ghcr.io/postgres/postgres-mcp-server:latest', + transport: 'stdio', + host: '127.0.0.1', + target_port: 28135, + cmd_arguments: [], + env_vars: {}, + secrets: [], + volumes: [], + network_isolation: false, + group: 'default', + }) diff --git a/renderer/src/common/mocks/fixtures/workloads_name_edit/post.ts b/renderer/src/common/mocks/fixtures/workloads_name_edit/post.ts index 88d976512..4a99325ad 100644 --- a/renderer/src/common/mocks/fixtures/workloads_name_edit/post.ts +++ b/renderer/src/common/mocks/fixtures/workloads_name_edit/post.ts @@ -1,6 +1,8 @@ import type { PostApiV1BetaWorkloadsByNameEditResponse } from '@api/types.gen' +import { AutoAPIMock } from '@mocks' -export default { - name: 'commodo', - port: 51877, -} satisfies PostApiV1BetaWorkloadsByNameEditResponse +export const mockedPostApiV1BetaWorkloadsByNameEdit = + AutoAPIMock({ + name: 'commodo', + port: 51877, + }) diff --git a/renderer/src/common/mocks/fixtures/workloads_restart/post.ts b/renderer/src/common/mocks/fixtures/workloads_restart/post.ts index da3ab98f3..f4f5d05f6 100644 --- a/renderer/src/common/mocks/fixtures/workloads_restart/post.ts +++ b/renderer/src/common/mocks/fixtures/workloads_restart/post.ts @@ -1,3 +1,5 @@ import type { PostApiV1BetaWorkloadsRestartResponse } from '@api/types.gen' +import { AutoAPIMock } from '@mocks' -export default 'Ut Excepteur sit in aute' satisfies PostApiV1BetaWorkloadsRestartResponse +export const mockedPostApiV1BetaWorkloadsRestart = + AutoAPIMock('Ut Excepteur sit in aute') diff --git a/renderer/src/common/mocks/index.ts b/renderer/src/common/mocks/index.ts new file mode 100644 index 000000000..7178d9bb2 --- /dev/null +++ b/renderer/src/common/mocks/index.ts @@ -0,0 +1,11 @@ +export type { + ActivateScenarioOptions, + AutoAPIMockInstance, +} from './autoAPIMock' +export { + AutoAPIMock, + activateMockScenario, + resetAllAutoAPIMocks, +} from './autoAPIMock' +export type { MockScenarioName } from './scenarioNames' +export { MockScenarios } from './scenarioNames' diff --git a/renderer/src/common/mocks/mockTemplate.ts b/renderer/src/common/mocks/mockTemplate.ts index 6d625e934..64ba242b2 100644 --- a/renderer/src/common/mocks/mockTemplate.ts +++ b/renderer/src/common/mocks/mockTemplate.ts @@ -1,17 +1,37 @@ +/** + * Derives a mock export name from a response type name. + * e.g., "GetApiV1BetaGroupsResponse" -> "mockedGetApiV1BetaGroups" + */ +export function deriveMockName(responseTypeName: string): string { + // Strip "Response" or "Responses" suffix and add "mocked" prefix + const baseName = responseTypeName + .replace(/Responses?$/, '') + .replace(/^Get/, 'get') + .replace(/^Post/, 'post') + .replace(/^Put/, 'put') + .replace(/^Patch/, 'patch') + .replace(/^Delete/, 'delete') + + return `mocked${baseName.charAt(0).toUpperCase()}${baseName.slice(1)}` +} + /** * Renders a TypeScript module for a generated mock fixture. - * If a response type name is provided, adds a type-only import from - * `@api/types.gen` and a `satisfies` clause on the default export - * to enforce the expected response type. + * When a response type name is provided, includes a type import + * from '@api/types.gen' and wraps the fixture in `AutoAPIMock` + * for type-safe test overrides. */ -export function buildMockModule( - payload: unknown, - options?: { opType?: string } -): string { - const opType = options?.opType?.trim() - const typeImport = opType - ? `import type { ${opType} } from '@api/types.gen'\n\n` - : '' - const typeSatisfies = opType ? ` satisfies ${opType}` : '' - return `${typeImport}export default ${JSON.stringify(payload, null, 2)}${typeSatisfies}\n` +export function buildMockModule(payload: unknown, opType?: string): string { + const typeName = opType?.trim() + const mockName = typeName ? deriveMockName(typeName) : 'mockedResponse' + + // Type imports first, then value imports (biome import order) + const imports = [ + ...(typeName ? [`import type { ${typeName} } from '@api/types.gen'`] : []), + `import { AutoAPIMock } from '@mocks'`, + ].join('\n') + + const typeParam = typeName ? `<${typeName}>` : '' + + return `${imports}\n\nexport const ${mockName} = AutoAPIMock${typeParam}(${JSON.stringify(payload, null, 2)})\n` } diff --git a/renderer/src/common/mocks/mocker.ts b/renderer/src/common/mocks/mocker.ts index cb4578701..330c2db0c 100644 --- a/renderer/src/common/mocks/mocker.ts +++ b/renderer/src/common/mocks/mocker.ts @@ -5,20 +5,13 @@ // @ts-nocheck import openapi from '../../../../api/openapi.json' -import Ajv from 'ajv' import fs from 'fs' import { JSONSchemaFaker as jsf } from 'json-schema-faker' import { http, HttpResponse } from 'msw' import path from 'path' import { fileURLToPath } from 'url' -import { buildMockModule } from './mockTemplate' - -const ajv = new Ajv({ strict: true }) -// Ignore vendor extensions present in the OpenAPI schema -// to prevent strict mode errors during validation -// e.g., x-enum-varnames is a common extension for enum labels - -;(ajv as any).addKeyword('x-enum-varnames') +import type { AutoAPIMockInstance } from './autoAPIMock' +import { buildMockModule, deriveMockName } from './mockTemplate' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -74,13 +67,20 @@ function opResponseTypeName(method: string, rawPath: string): string { return opResponsesTypeName(method, rawPath).replace(/Responses$/, 'Response') } +function getFixtureRelPath(safePath: string, method: string): string { + return `./fixtures/${safePath}/${method}.${FIXTURE_EXT}` +} + function autoGenerateHandlers() { const result = [] // Use a glob map so Vitest/Vite can track these files for watch/HMR. - // We'll still import per-request to get latest contents. + // Note: We don't use { import: 'default' } since fixtures now use named exports const fixtureImporters: Record Promise> = // @ts-ignore - vite-specific API available in vitest/vite runtime - import.meta.glob('./fixtures/**', { import: 'default' }) + typeof import.meta.glob === 'function' + ? import.meta.glob('./fixtures/**') + : {} + const specPaths = Object.entries( ((openapi as any).paths ?? {}) as Record ) @@ -95,7 +95,7 @@ function autoGenerateHandlers() { const mswPath = `*/${rawPath.replace(/^\//, '').replace(/\{([^}]+)\}/g, ':$1')}` result.push( - http[method](mswPath, async () => { + http[method](mswPath, async (info) => { const successStatus = pickSuccessStatus(operation.responses || {}) // Shorten noisy prefixes in fixture filenames. @@ -111,7 +111,6 @@ function autoGenerateHandlers() { const fileBase = `${safePath}/${method}.${FIXTURE_EXT}` const fixtureFileName = `${FIXTURES_PATH}/${fileBase}` - let data: any = undefined if (!fs.existsSync(path.dirname(fixtureFileName))) { fs.mkdirSync(path.dirname(fixtureFileName), { recursive: true }) } @@ -150,7 +149,7 @@ function autoGenerateHandlers() { const opType = successStatus ? opResponseTypeName(method, rawPath) : undefined - const tsModule = buildMockModule(payload, { opType }) + const tsModule = buildMockModule(payload, opType) fs.writeFileSync(fixtureFileName, tsModule) // After generating, rely on live import below so that // behavior matches pre-existing fixtures. @@ -160,41 +159,44 @@ function autoGenerateHandlers() { return new HttpResponse(null, { status: 204 }) } - if (data === undefined) { - const relPath = `./fixtures/${fileBase}` - try { - const importer = fixtureImporters[relPath] - if (importer) { - const mod: any = await importer() - data = mod?.default ?? mod - } else { - // Fall back to dynamic import for freshly generated files - const mod: any = await import(relPath) - data = mod?.default ?? mod - } - } catch (e) { - return new HttpResponse( - `Missing mock fixture: ${relPath}. ${e instanceof Error ? e.message : ''}`, - { status: 500 } - ) + const relPath = getFixtureRelPath(safePath, method) + const opType = successStatus + ? opResponseTypeName(method, rawPath) + : undefined + const mockName = opType ? deriveMockName(opType) : 'mockedResponse' + + let fixture: AutoAPIMockInstance | unknown + try { + const importer = fixtureImporters?.[relPath] + if (importer) { + const mod = (await importer()) as Record + // Try new format (named export) first, fall back to old format (default export) + fixture = mod[mockName] ?? mod.default ?? mod + } else { + // Fall back to dynamic import for freshly generated files + const mod = (await import(relPath)) as Record + fixture = mod[mockName] ?? mod.default ?? mod } + } catch (e) { + return new HttpResponse( + `[auto-mocker] Missing mock fixture: ${relPath}. ${e instanceof Error ? e.message : ''}`, + { status: 500 } + ) } - const schema = - operation.responses?.[successStatus ?? '200']?.content?.[ - 'application/json' - ]?.schema - if (schema) { - const resolved = derefSchema(schema) - const isValid = ajv.validate(resolved, data) - if (!isValid) { - console.error('Invalid mock response', { - fixtureFileName, - errors: ajv.errors, - }) - } + + // Check if fixture is an AutoAPIMock instance (new format) + if ( + fixture && + typeof (fixture as AutoAPIMockInstance) + .generatedHandler === 'function' + ) { + return (fixture as AutoAPIMockInstance).generatedHandler( + info + ) } - return HttpResponse.json(data, { + // Old format: plain data object (backward compatibility) + return HttpResponse.json(fixture, { status: successStatus ? Number(successStatus) : 200, }) }) diff --git a/renderer/src/common/mocks/scenarioNames.ts b/renderer/src/common/mocks/scenarioNames.ts new file mode 100644 index 000000000..412fa052e --- /dev/null +++ b/renderer/src/common/mocks/scenarioNames.ts @@ -0,0 +1,18 @@ +/** + * Global scenario names for API mocks. + * + * Use `MockScenarios.X` for autocomplete with documentation, + * or use the string literals directly. + */ +export const MockScenarios = { + /** Empty state - API returns no data */ + Empty: 'empty', + /** API returns 500 Internal Server Error */ + ServerError: 'server-error', +} as const + +/** + * Union of all available mock scenario names. + */ +export type MockScenarioName = + (typeof MockScenarios)[keyof typeof MockScenarios] diff --git a/renderer/src/features/registry-servers/components/__tests__/install-group-button.test.tsx b/renderer/src/features/registry-servers/components/__tests__/install-group-button.test.tsx index 77ff5e817..eeefd86ac 100644 --- a/renderer/src/features/registry-servers/components/__tests__/install-group-button.test.tsx +++ b/renderer/src/features/registry-servers/components/__tests__/install-group-button.test.tsx @@ -6,7 +6,7 @@ import { server } from '@/common/mocks/node' import { http, HttpResponse } from 'msw' import { createTestRouter } from '@/common/test/create-test-router' import { renderRoute } from '@/common/test/render-route' -import defaultGroups from '@/common/mocks/fixtures/groups/get' +import { mockedGetApiV1BetaGroups } from '@/common/mocks/fixtures/groups/get' const mockServer: RegistryImageMetadata = { name: 'atlassian', @@ -37,6 +37,8 @@ const mockGroup: RegistryGroup = { remote_servers: {}, } +const defaultGroups = mockedGetApiV1BetaGroups.defaultValue + describe('InstallGroupButton', () => { it('shows error when trying to install a group that already exists', async () => { server.use( @@ -44,7 +46,7 @@ describe('InstallGroupButton', () => { return HttpResponse.json({ ...defaultGroups, groups: [ - ...defaultGroups.groups, + ...(defaultGroups.groups ?? []), { name: 'dev-toolkit', registered_clients: [] }, ], }) diff --git a/tsconfig.app.json b/tsconfig.app.json index d215f150f..89ba8d878 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -23,7 +23,9 @@ "paths": { "@/*": ["./renderer/src/*"], "@api/*": ["./api/generated/*"], - "@utils/*": ["./utils/*"] + "@utils/*": ["./utils/*"], + "@mocks": ["./renderer/src/common/mocks"], + "@mocks/*": ["./renderer/src/common/mocks/*"] }, "types": ["vitest/globals", "@testing-library/jest-dom/vitest"] }, diff --git a/tsconfig.json b/tsconfig.json index 9fe2a705b..df328f30e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,9 @@ "paths": { "@/*": ["./renderer/src/*"], "@api/*": ["./api/generated/*"], - "@utils/*": ["./utils/*"] + "@utils/*": ["./utils/*"], + "@mocks": ["./renderer/src/common/mocks"], + "@mocks/*": ["./renderer/src/common/mocks/*"] } } } diff --git a/vitest.config.ts b/vitest.config.ts index 2973626bc..07f52f756 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ '@': path.resolve(__dirname, './renderer/src'), '@api': path.resolve(__dirname, './api/generated'), '@utils': path.resolve(__dirname, './utils'), + '@mocks': path.resolve(__dirname, './renderer/src/common/mocks'), }, }, test: { diff --git a/vitest.setup.ts b/vitest.setup.ts index 96a748d5f..a9a5fd465 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,14 +1,19 @@ import * as testingLibraryMatchers from '@testing-library/jest-dom/matchers' import '@testing-library/jest-dom/vitest' import { cleanup } from '@testing-library/react' -import { afterEach, expect, beforeAll, vi, afterAll } from 'vitest' +import { afterEach, expect, beforeAll, beforeEach, vi, afterAll } from 'vitest' import failOnConsole from 'vitest-fail-on-console' import { client } from './api/generated/client.gen' +import { resetAllAutoAPIMocks } from './renderer/src/common/mocks/autoAPIMock' import { server } from './renderer/src/common/mocks/node' import type { ElectronAPI } from './preload/src/preload' expect.extend(testingLibraryMatchers) +beforeEach(() => { + resetAllAutoAPIMocks() +}) + afterEach(() => { cleanup() })