> ## Documentation Index
> Fetch the complete documentation index at: https://sdk.ekanos.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Fixtures

> Record HTTP exchanges to mock vendor APIs without network requests.

Fixtures are recorded HTTP exchanges that answer every request your widgets make. In **fixtures mode** (the default), the harness patches `globalThis.fetch` to refuse the network and serve only what you recorded — however your widgets fetch (react-query, SWR, an effect, or a promise read with `use()`).

## Overview

Declare fixtures on the integration entry, keyed by variant:

```ts theme={null}
fixtures: {
  default: [
    { request: 'GET https://api.acme.com/v1/data', response: { items: [] } },
    { request: 'POST https://api.acme.com/v1/create', response: { id: 'new_1' } },
  ],
  empty: [
    { request: 'GET https://api.acme.com/v1/data', response: { items: [] } },
  ],
  error: [
    { request: 'GET https://api.acme.com/v1/data', status: 503 },
  ],
}
```

The same list answers widgets and, in fixtures mode, `ctx.fetch` inside webhook, schedule, and action handlers. In live mode, same-origin `/api/…` requests are still answered from fixtures, except the active integration's own `/api/integrations/<slug>/…` routes, which the harness's server serves (see [Live Mode](/harness/live-mode)).

## Fixture variants

The toolbar's **Fixtures** dropdown switches between three variants:

| Variant | Purpose | Behavior |
| - | - | - |
| `default` | Populated happy path | Declare this one |
| `empty` | Zero-rows state | Optional; omitted = every request refused |
| `error` | Failure state | Optional; omitted = every request refused |

Switching variants tears down and rebuilds the widget subtree, so any widget that re-fetches (via react-query or an effect) hits the new fixture. An omitted variant is not a fallback to `default`: every request under it is refused. A widget that caches its promise at MODULE scope will not; see [Promise caching](#promise-caching) below.

## HttpFixture

```ts theme={null}
interface HttpFixture {
  request: string; // '<METHOD> <url-or-path>'
  response?: unknown; // Body; JSON unless already a string
  status?: number; // Default 200 with response, 204 without
  headers?: Record<string, string>;
}
```

### Request line

The `request` field is a method and a target separated by a space:

```ts theme={null}
'GET https://api.acme.com/v1/payouts'; // Absolute URL
'GET /v1/payouts'; // Path-only (resolved against egress origin)
'GET /api/integrations/acme/summary'; // Host route
```

**Method** — case-insensitive; one of `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`.

**Target** — one of three forms:

| Form | Example | Resolved against |
| - | - | - |
| Absolute URL | `https://api.acme.com/v1/payouts` | itself (always unambiguous) |
| Path-only | `/v1/payouts` | your definition's `egress` origin (allowed only when exactly one is declared; otherwise the fixture throws while the surface renders) |
| Host route | `/api/integrations/acme/summary` | the harness's own origin |

**Path matching** (method and origin must be equal first):

* Path segments must agree in count
* `:param` matches exactly one path segment
* `*` matches the rest (including nothing)
* `/v1/payouts` does NOT match `/v1/payouts/42`, but `/v1/payouts/:id` and `/v1/payouts/*` both do

**Query matching:**

* If your fixture has no `?`, query is ignored entirely — `GET https://api.acme.com/v1/data` answers a request with any query string
* If your fixture has a `?`, every param in it must be present on the real request with the same value — a subset match, so you can pin two params and ignore nine others

**First match wins** — order matters. Write a specific fixture above a general one:

```ts theme={null}
{ request: 'GET /v1/payouts/:id/refunds' },  // Specific
{ request: 'GET /v1/payouts/*' },            // General
```

### Response

The `response` field is your vendor's body. If it's already a string it's sent as-is with `text/plain`; anything else is JSON-serialized with `application/json`.

```ts theme={null}
// No response, no status → 204 No Content
{ request: 'DELETE /api/items/:id' }

// Response, no status → 200 OK
{ request: 'GET /api/data', response: { items: [] } }

// String response → text/plain
{ request: 'GET /status', response: 'OK' }

// Custom status
{ request: 'GET /data', response: {}, status: 201 }
{ request: 'GET /data', status: 403 }  // Error with no body
```

### Headers

```ts theme={null}
{
  request: 'GET /api/data',
  response: { items: [] },
  headers: { 'X-Custom': 'value' },
}
```

## No fixture found

When a request doesn't match any fixture:

1. **Console line:**

   ```
   [harness:my-integration] refused GET https://api.acme.com/v1/data?…
   ```

2. **Promise rejects with `NoRecordedResponseError`:**

   ```
   No recorded response for GET https://api.acme.com/v1/data?….

   Fixtures mode answers every request from your registry entry and
   never leaves the machine, so this call was refused rather than sent.
   Record it:

     fixtures: {
       default: [{ request: 'GET https://api.acme.com/v1/data', response: /* … */ }],
     }

   To exercise the failure branch instead, give it a status:

     { request: 'GET https://api.acme.com/v1/data', status: 403 }

   The query string is hidden above because it can carry
   credentials. It is left off the line to paste on purpose: a
   request with no `?` matches whatever query the widget sends.
   Add `?key=value` only to pin params you want matched.

   If this URL looks unfamiliar, log it from the code that builds it —
   the path and any pinned query params must match exactly.
   ```

3. **Where you see the error** depends on your error handling:
   * react-query lands in the error state (harness sets `retry: false`)
   * A widget starting a promise in render and reading it with `use()` throws; the per-widget error boundary renders the full message

<Tip>
  If the URL looks unfamiliar, log it from the code that builds it. Check the
  path and any pinned query params for typos (trailing slash, extra segment,
  case mismatch).
</Tip>

## Host routes

Same-origin `/api/…` requests are the host-route namespace. The harness serves no backend for them, so a fixture is what answers them, and they are refused if none matches. When a refused request was a host route, the quoted `request:` line is the path form (`GET /api/integrations/acme/summary`), because the absolute URL would pin your fixture to the harness's port.

Exceptions: the active integration's own `/api/integrations/<slug>/…` routes are passed to the harness's server in live mode, and its `local-setup` and `actions/<name>` routes are passed through in both modes.

## Server-side rendering

A widget that starts its promise during render and reads it with `use()` also runs during the server render. Every fixture is compiled for that pass except the `/api/…` host-route shape.

**Absolute and path-only fixtures** work on the server and browser identically.

**Host-route fixtures** are not answered on the server, which has no origin to resolve them against. The request is refused with a message beginning `No recorded response for <METHOD> <url> — on the SERVER render.`; the client render answers it, so nothing needs to change. To answer it on the server too, make both the fixture and the widget's URL absolute (at the cost of hardcoding the dev server's origin).

**Same-origin traffic outside `/api/`** (`/_next/static`, the document, `?_rsc=` payloads) is passed straight through in the browser and never intercepted. On the server render it has nowhere to go and is refused with the same `— on the SERVER render.` message; give it an absolute URL if it was meant to reach a vendor.

## Promise caching

A widget that caches its promise at **MODULE scope** will not re-fetch when the fixture variant changes:

```tsx theme={null}
// ❌ This caches at MODULE scope — won't re-fetch on variant change
const cachedPromise = fetch('https://api.example.com/data');

function Widget() {
  const data = use(cachedPromise); // Always reads first variant
  return <div>{data}</div>;
}
```

The module outlives the fixture variant switch, so it keeps serving the first variant's answer while the toolbar says `error`.

**Solution: create the promise in the non-suspending parent:**

```tsx theme={null}
// ✅ Create in parent, pass across Suspense boundary
function Widget() {
  const [request] = useState(() => fetch('https://api.example.com/data'));

  return (
    <Suspense fallback={<Loading />}>
      <WidgetBody request={request} />
    </Suspense>
  );
}

function WidgetBody({ request }: { request: Promise<Response> }) {
  const data = use(request);
  return <div>{data}</div>;
}
```

The parent commits so its state survives; the child suspends and retries against the same promise.

<Note>
  This is a widget-side pattern, not a fixtures problem — the fixture swap
  worked correctly. Creating the promise with `useState` inside the component
  that suspends is worse: a suspended render is discarded before it commits, so
  the initializer runs again on every retry.
</Note>

## FixtureSeed (react-query fast path)

If your data layer is `@tanstack/react-query`, you can seed the cache directly for zero network traffic:

```ts theme={null}
widgets: [
  {
    id: 'my-widget',
    title: 'My Widget',
    component: MyWidget,
    seeds: {
      default: [{ queryKey: myKey, data: myData }],
      error: [{ queryKey: myKey, error: new Error('503') }],
    },
  },
],
```

A `FixtureSeed` is `{ queryKey, data }` or `{ queryKey, error }`. If both are set, `error` wins.

**Rules:**

* Derive the query key from the same factory your hook calls (import it, don't retype)
* A seeded key never fetches, so an HTTP fixture behind it is unreachable — use one or the other
* A query with neither a seed nor a `queryFn` of its own throws `MissingFixtureError`; there is no request to record, so seed the key

**Why HTTP fixtures instead:** both solve real problems, but HTTP fixtures solve two at once—you write vendor data once in one shape, serving widgets and triggers alike—and they don't require react-query.

## Credentials in fixtures

Credentials are redacted from console logs and error messages:

* Userinfo in URLs is stripped
* The entire query string is replaced with `?…` (because an API key in a query param is exactly what ends up in screenshots)

The quoted `request:` line in an error therefore has no query. This is correct as written: a fixture with no `?` matches whatever query your widget sends. Add `?key=value` back only to pin a param you specifically want matched.

## Request logging

Intercepted requests log to the console (URL redacted), which is the fastest way to see what a widget actually asked for vs. what you recorded:

```
[harness:my-integration] fixture GET https://api.acme.com/v1/data
[harness:my-integration] network GET https://api.acme.com/v1/live-data
[harness:my-integration] refused GET https://api.acme.com/v1/unknown
```

* `fixture` — answered from your registry
* `network` — sent for real (a vendor request in live mode, or a request passed to the harness's own routes)
* `refused` — not answered: no matching fixture, or an origin outside your egress in live mode

Same-origin `/api/…` requests answered from fixtures log `fixture` or `refused` in either mode.

## Troubleshooting

### No recorded response error

Check the URL in the error message (with query redacted). Log it from the code that builds it to verify the path, params, and any hardcoded segments.

### Variant switch doesn't apply

The widget didn't re-fetch. See [Promise caching](#promise-caching) to fix it.

### Widget shows old data under new variant

Same issue — the promise was created at MODULE scope. Move it into the parent component's state.

### Server render returns different data than browser

Host-route (`/api/…`) fixtures are not answered during the server render; the client render answers them after hydration. Make the fixture and the widget URL absolute if you need server-side data.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.