@ekanos/harness 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +879 -0
- package/dist/app.d.ts +4 -0
- package/dist/app.js +11 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +34 -0
- package/dist/internal/components/ask-assistant-bridge.d.ts +10 -0
- package/dist/internal/components/ask-assistant-bridge.js +50 -0
- package/dist/internal/components/dashboard-grid.d.ts +45 -0
- package/dist/internal/components/dashboard-grid.js +84 -0
- package/dist/internal/components/dev-toolbar.d.ts +15 -0
- package/dist/internal/components/dev-toolbar.js +155 -0
- package/dist/internal/components/harness-providers.d.ts +16 -0
- package/dist/internal/components/harness-providers.js +83 -0
- package/dist/internal/components/harness-widget-provider.d.ts +60 -0
- package/dist/internal/components/harness-widget-provider.js +84 -0
- package/dist/internal/components/i18n-provider.d.ts +9 -0
- package/dist/internal/components/i18n-provider.js +9 -0
- package/dist/internal/components/row-groups.d.ts +23 -0
- package/dist/internal/components/row-groups.js +37 -0
- package/dist/internal/components/surface-nav.d.ts +4 -0
- package/dist/internal/components/surface-nav.js +70 -0
- package/dist/internal/components/viewport-frame.d.ts +14 -0
- package/dist/internal/components/viewport-frame.js +27 -0
- package/dist/internal/components/widget-boundary.d.ts +25 -0
- package/dist/internal/components/widget-boundary.js +44 -0
- package/dist/internal/components/widget-surface.d.ts +20 -0
- package/dist/internal/components/widget-surface.js +76 -0
- package/dist/internal/lib/fonts.d.ts +2 -0
- package/dist/internal/lib/fonts.js +22 -0
- package/dist/internal/lib/harness-fetch-interceptor.d.ts +89 -0
- package/dist/internal/lib/harness-fetch-interceptor.js +101 -0
- package/dist/internal/lib/harness-live-fetch.d.ts +66 -0
- package/dist/internal/lib/harness-live-fetch.js +121 -0
- package/dist/internal/lib/harness-query-client.d.ts +43 -0
- package/dist/internal/lib/harness-query-client.js +103 -0
- package/dist/internal/lib/http-fixtures.d.ts +145 -0
- package/dist/internal/lib/http-fixtures.js +256 -0
- package/dist/internal/lib/i18n.d.ts +2 -0
- package/dist/internal/lib/i18n.js +17 -0
- package/dist/internal/lib/redact.d.ts +33 -0
- package/dist/internal/lib/redact.js +43 -0
- package/dist/internal/lib/toolbar-context.d.ts +59 -0
- package/dist/internal/lib/toolbar-context.js +124 -0
- package/dist/internal/registry-context.d.ts +27 -0
- package/dist/internal/registry-context.js +51 -0
- package/dist/internal/routes/activation-page.d.ts +33 -0
- package/dist/internal/routes/activation-page.js +242 -0
- package/dist/internal/routes/index-page.d.ts +13 -0
- package/dist/internal/routes/index-page.js +62 -0
- package/dist/internal/routes/integration-layout.d.ts +9 -0
- package/dist/internal/routes/integration-layout.js +84 -0
- package/dist/internal/routes/root-layout.d.ts +34 -0
- package/dist/internal/routes/root-layout.js +39 -0
- package/dist/internal/routes/single-widget-page.d.ts +6 -0
- package/dist/internal/routes/single-widget-page.js +30 -0
- package/dist/internal/routes/tile-page.d.ts +1 -0
- package/dist/internal/routes/tile-page.js +88 -0
- package/dist/internal/routes/triggers-page.d.ts +1 -0
- package/dist/internal/routes/triggers-page.js +386 -0
- package/dist/internal/routes/widgets-page.d.ts +1 -0
- package/dist/internal/routes/widgets-page.js +19 -0
- package/dist/internal/surfaces.d.ts +25 -0
- package/dist/internal/surfaces.js +29 -0
- package/dist/mocks/team-account-workspace.d.ts +78 -0
- package/dist/mocks/team-account-workspace.js +86 -0
- package/dist/registry.d.ts +418 -0
- package/dist/registry.js +82 -0
- package/dist/routes.d.ts +24 -0
- package/dist/routes.js +15 -0
- package/dist/styles.css +236 -0
- package/package.json +101 -0
package/README.md
ADDED
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
# @ekanos/harness
|
|
2
|
+
|
|
3
|
+
The Ekanos integration dev harness: every surface of an integration rendered in
|
|
4
|
+
real Fusion chrome — the actual dashboard grid, the actual `Widget.*` compound
|
|
5
|
+
components, the actual design tokens — from fixtures, with no Supabase, no auth
|
|
6
|
+
and no network.
|
|
7
|
+
|
|
8
|
+
**New here? Start with the `@ekanos/sdk` README** — it is the authoring
|
|
9
|
+
reference for everything you declare (widgets, tools, webhooks, schedules,
|
|
10
|
+
OAuth, storage, egress). This document covers only the local dev harness that
|
|
11
|
+
renders those surfaces.
|
|
12
|
+
|
|
13
|
+
> **Not published yet.** `@ekanos/harness` and `@ekanos/cli` are not on npm, so
|
|
14
|
+
> the commands below do not resolve today. `@ekanos/sdk`, `@ekanos/ui` and
|
|
15
|
+
> `@ekanos/integration-schema` are (`0.1.2`), and everything in the SDK README
|
|
16
|
+
> works without this package — `defineIntegration()` validates at import and
|
|
17
|
+
> the testing helpers need only vitest. What you cannot do until this ships is
|
|
18
|
+
> *see* your surfaces rendered. This document describes what will ship.
|
|
19
|
+
|
|
20
|
+
You do not normally install this by hand. `ekanos dev` scaffolds a shell around
|
|
21
|
+
it and adds it to your `package.json`.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pnpm --filter <your project> exec ekanos dev
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- [Why it is a package](#why-it-is-a-package)
|
|
28
|
+
- [The registry is injected, never imported](#the-registry-is-injected-never-imported)
|
|
29
|
+
- [The registry — `harness.config.ts`](#the-registry--harnessconfigts)
|
|
30
|
+
- [Fixtures](#fixtures)
|
|
31
|
+
- [`HttpFixture` — the request line](#httpfixture--the-request-line)
|
|
32
|
+
- [When there is no fixture for a request](#when-there-is-no-fixture-for-a-request)
|
|
33
|
+
- [`seeds` — the react-query fast path](#seeds--the-react-query-fast-path)
|
|
34
|
+
- [Live mode — real requests to your own API](#live-mode--real-requests-to-your-own-api)
|
|
35
|
+
- [`FetchProvider` — the part you have to write yourself](#fetchprovider--the-part-you-have-to-write-yourself)
|
|
36
|
+
- [Triggers — running webhooks, schedules and OAuth locally](#triggers--running-webhooks-schedules-and-oauth-locally)
|
|
37
|
+
- [`triggerMocks`](#triggermocks)
|
|
38
|
+
- [What ends up in the browser bundle](#what-ends-up-in-the-browser-bundle)
|
|
39
|
+
- [Exports](#exports)
|
|
40
|
+
- [The shell contract](#the-shell-contract)
|
|
41
|
+
|
|
42
|
+
The surfaces this renders — widgets, the tile, the activation form, and the
|
|
43
|
+
webhook / schedule / OAuth declarations behind Triggers — are documented in the
|
|
44
|
+
`@ekanos/sdk` README. This one covers the harness's own contract.
|
|
45
|
+
|
|
46
|
+
## Why it is a package
|
|
47
|
+
|
|
48
|
+
So that a harness bugfix reaches you as a version bump you pick up with
|
|
49
|
+
`pnpm up`, with zero edits to any file you own. The harness is a dependency,
|
|
50
|
+
not code you copied.
|
|
51
|
+
|
|
52
|
+
| Path | Who owns it |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `harness.config.ts` | **you** — the registry, and the only file you have to edit |
|
|
55
|
+
| your integration source | **you** |
|
|
56
|
+
| the generated `app/**`, `styles/globals.css`, `next.config.mjs` | the CLI; regenerated on upgrade |
|
|
57
|
+
| `@ekanos/harness` | us |
|
|
58
|
+
|
|
59
|
+
## The registry is injected, never imported
|
|
60
|
+
|
|
61
|
+
A package inside `node_modules` cannot reach a file in your project, so nothing
|
|
62
|
+
here imports your config. The shell passes your array down instead:
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
// app/harness-shell.tsx — generated
|
|
66
|
+
'use client';
|
|
67
|
+
|
|
68
|
+
import { RootLayout } from '@ekanos/harness/app';
|
|
69
|
+
|
|
70
|
+
import { harnessIntegrations } from '~/harness.config';
|
|
71
|
+
|
|
72
|
+
export default function HarnessShell(props: { children: React.ReactNode }) {
|
|
73
|
+
return <RootLayout integrations={harnessIntegrations} {...props} />;
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`RootLayout` mounts a context provider; every route reads the registry back out
|
|
78
|
+
of it and takes its own `[slug]` from `useParams()`. That is why the generated
|
|
79
|
+
route files are one line each:
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
export { WidgetsPage as default } from '@ekanos/harness/routes';
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`RootLayout` is the client boundary of the whole harness, and has to be: a
|
|
86
|
+
`HarnessIntegration` holds live React component references and MCP `run`
|
|
87
|
+
functions, which cannot be serialized across a server/client edge.
|
|
88
|
+
|
|
89
|
+
## The registry — `harness.config.ts`
|
|
90
|
+
|
|
91
|
+
This is the only file you edit. It exports an array of `HarnessIntegration`,
|
|
92
|
+
one entry per integration, and each entry lights up four tabbed surfaces at
|
|
93
|
+
`/<slug>` — `widgets`, `tile`, `activation` and `triggers` — plus a
|
|
94
|
+
single-widget page at `/<slug>/widgets/<widgetId>`, which the widget grid links
|
|
95
|
+
into for isolating one widget.
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { defineHarnessConfig } from '@ekanos/harness/config';
|
|
99
|
+
|
|
100
|
+
import { acmePayments } from './harness/acme-payments';
|
|
101
|
+
|
|
102
|
+
export const harnessIntegrations = defineHarnessConfig([acmePayments]);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`defineHarnessConfig()` is an identity function. It exists for the inference —
|
|
106
|
+
nothing else.
|
|
107
|
+
|
|
108
|
+
### `HarnessIntegration`
|
|
109
|
+
|
|
110
|
+
| Field | Required | What it does |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| `slug` | yes | URL segment, and the id you would ship as your product slug |
|
|
113
|
+
| `name`, `description` | yes | Header chrome and the index page |
|
|
114
|
+
| `widgets` | yes | `HarnessWidget[]` — may be empty |
|
|
115
|
+
| `fixtures` | no | `Partial<Record<FixtureVariant, HttpFixture[]>>` — the recorded HTTP exchanges, per variant. **This is how data gets in** |
|
|
116
|
+
| `tile` | no | `ComponentType<MarketplaceTileProps>` — the marketplace card |
|
|
117
|
+
| `activationForm` | no | `ComponentType<ActivationFormProps>`, rendered with `inline` so it does not open a dialog |
|
|
118
|
+
| `definition` | no | Your validated `defineIntegration()` output. Powers Triggers |
|
|
119
|
+
| `triggerMocks` | no | Seeds for the Triggers surface's mock context |
|
|
120
|
+
| `live` | no | Opt in to real third-party requests |
|
|
121
|
+
|
|
122
|
+
### `HarnessWidget`
|
|
123
|
+
|
|
124
|
+
| Field | Required | What it does |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| `id` | yes | Stable id; the URL segment on `/<slug>/widgets/<widgetId>` |
|
|
127
|
+
| `title` | yes | Rendered in the widget header chrome |
|
|
128
|
+
| `component` | yes | `ComponentType<IntegrationComponentProps>` |
|
|
129
|
+
| `width` | no | `'half'` (default) or `'full'`. Runs of `half` balance across two columns |
|
|
130
|
+
| `isCollapsible` | no | Renders the collapse button. Off unless set |
|
|
131
|
+
| `isPinnable` | no | Reaches `WidgetContext`; nothing draws a pin — see below |
|
|
132
|
+
| `aiFooterEnabled` | no | Renders the "Ask about this" AI footer bar. Off unless set |
|
|
133
|
+
| `seeds` | no | `Partial<Record<FixtureVariant, FixtureSeed[]>>` — react-query cache seeds for this widget, per variant. The fast path, not the default; see [Fixtures](#fixtures) |
|
|
134
|
+
|
|
135
|
+
All three chrome flags are opt-in and behave as the type reads: omit one and
|
|
136
|
+
you get nothing. The harness applies the host's own default (`?? false`) in its
|
|
137
|
+
copy of the host's grid, so what renders here is what renders in production.
|
|
138
|
+
|
|
139
|
+
One of them, though, renders nothing even when you do set it:
|
|
140
|
+
|
|
141
|
+
**`isPinnable` is plumbed end to end with no consumer.** It reaches
|
|
142
|
+
`WidgetContext` as `meta.isPinnable`, the current value is `state.pinned`, and
|
|
143
|
+
`actions.togglePinned()` works — but no shipped `Widget.*` component draws a
|
|
144
|
+
pin control.
|
|
145
|
+
|
|
146
|
+
**This is faithful, not a harness gap.** The real dashboard does not render a
|
|
147
|
+
pin in widget chrome either; it drives pinning from its own loader and
|
|
148
|
+
customize panel, outside the widget. So if you want a pin *inside* your widget,
|
|
149
|
+
it is yours to build, in the harness and in production alike:
|
|
150
|
+
|
|
151
|
+
```tsx
|
|
152
|
+
const ctx = use(WidgetContext);
|
|
153
|
+
{ctx?.meta.isPinnable && (
|
|
154
|
+
<button onClick={ctx.actions.togglePinned}>
|
|
155
|
+
{ctx.state.pinned ? 'Unpin' : 'Pin'}
|
|
156
|
+
</button>
|
|
157
|
+
)}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Derive the widget list — don't write it twice
|
|
161
|
+
|
|
162
|
+
`HarnessWidget` repeats `id`, `component`, `isCollapsible`, `isPinnable` and
|
|
163
|
+
`aiFooterEnabled` from `components.widgets[]` in your `defineIntegration()`
|
|
164
|
+
output, renaming `name` to `title`. Writing both by hand is two declarations of
|
|
165
|
+
the same thing with nothing detecting drift, so don't:
|
|
166
|
+
|
|
167
|
+
```ts HarnessIntegration
|
|
168
|
+
import { harnessWidgetsFromDefinition } from '@ekanos/harness/registry';
|
|
169
|
+
|
|
170
|
+
widgets: harnessWidgetsFromDefinition(acmeDefinition, {
|
|
171
|
+
'acme-payments-summary': { width: 'full' },
|
|
172
|
+
}),
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The definition supplies id, title, component and the chrome flags. The
|
|
176
|
+
overrides map supplies what is genuinely harness-only — `width`, and per-widget
|
|
177
|
+
`seeds` if you use them — and is **keyed by widget id**, so `id` is not a field
|
|
178
|
+
inside the override value. An override wins over the definition where both
|
|
179
|
+
speak.
|
|
180
|
+
|
|
181
|
+
Your HTTP fixtures are not in here. They sit on the integration
|
|
182
|
+
(`HarnessIntegration.fixtures`), one level up, because a URL is not owned by
|
|
183
|
+
one widget.
|
|
184
|
+
|
|
185
|
+
Pass your definition straight in, generic and all — unlike the `definition:`
|
|
186
|
+
field on the same entry, this does **not** need `asHarnessDefinition()` (the
|
|
187
|
+
generic-erasing wrapper the Triggers section below explains). That
|
|
188
|
+
asymmetry is real and easy to trip on: the field stores the definition in a
|
|
189
|
+
heterogeneous array and so needs the generic erased, while the helper only
|
|
190
|
+
reads it.
|
|
191
|
+
|
|
192
|
+
Two properties worth knowing, both deliberate:
|
|
193
|
+
|
|
194
|
+
- **An override key that matches no declared widget throws**, naming the key
|
|
195
|
+
and listing the ids that do exist. A typo'd id would otherwise produce a
|
|
196
|
+
widget rendering with none of its overrides and no complaint — the same
|
|
197
|
+
silent-drift class the helper exists to kill.
|
|
198
|
+
- **A chrome flag your definition does not declare is omitted, not defaulted**,
|
|
199
|
+
so it still lands on the host's default rather than one the helper invented.
|
|
200
|
+
|
|
201
|
+
**Call this unless you have a reason not to.** The manual form is fully
|
|
202
|
+
supported and `widgets` is still an ordinary `HarnessWidget[]` — the helper
|
|
203
|
+
just returns one, so you can spread it and add entries. That is the reason to
|
|
204
|
+
reach for the manual form: a scratch widget or a variant you are trying out
|
|
205
|
+
that is deliberately *not* in your definition yet. The harness is exactly where
|
|
206
|
+
you should be able to try something before declaring it.
|
|
207
|
+
|
|
208
|
+
```ts HarnessIntegration
|
|
209
|
+
widgets: [
|
|
210
|
+
...harnessWidgetsFromDefinition(acmeDefinition, { /* … */ }),
|
|
211
|
+
{ id: 'scratch-experiment', title: 'Scratch', component: Experiment },
|
|
212
|
+
],
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**Why any of this is needed: the harness never reads
|
|
216
|
+
`definition.components.widgets`.** It touches your definition in four places —
|
|
217
|
+
the egress cross-check, twice in the Triggers surface, and once on the
|
|
218
|
+
activation page for `capabilities`/`permissions`. Widgets are not among them,
|
|
219
|
+
so on this surface the registry is the *only* declaration and the definition's
|
|
220
|
+
widget flags are inert. The helper is how you make the definition the source
|
|
221
|
+
anyway.
|
|
222
|
+
|
|
223
|
+
One thing the helper does **not** unify, because the two are genuinely
|
|
224
|
+
different: **widget sizing is two unrelated systems.**
|
|
225
|
+
`components.widgets[].layouts` (`lg`/`md`/`sm` grid rectangles, in the
|
|
226
|
+
definition) is what the real dashboard reads. `HarnessWidget.width`
|
|
227
|
+
(`'half' | 'full'`, in the registry) is what the harness reads. Setting
|
|
228
|
+
`layouts` changes nothing here and setting `width` changes nothing in
|
|
229
|
+
production, so set both and expect neither to validate the other.
|
|
230
|
+
|
|
231
|
+
Your widgets are rendered with `HARNESS_ACCOUNT_ID`, `HARNESS_SOURCE_ID` and
|
|
232
|
+
`HARNESS_ACCOUNT_SLUG`, all exported from `@ekanos/harness/registry`. They are
|
|
233
|
+
plausible UUIDs rather than sentinels like `'preview-mode'`, so a widget that
|
|
234
|
+
validates the shape of its `accountId` is happy. Nothing reads them — there is
|
|
235
|
+
no database.
|
|
236
|
+
|
|
237
|
+
## Fixtures
|
|
238
|
+
|
|
239
|
+
**In fixtures mode the harness answers every request from recorded HTTP
|
|
240
|
+
exchanges and refuses the network.** It patches `globalThis.fetch` itself, so
|
|
241
|
+
this holds however your widgets fetch — react-query, SWR, a bare `useEffect`, a
|
|
242
|
+
promise started in render and read with `use()`. There is no data layer to opt
|
|
243
|
+
into and nothing to wire.
|
|
244
|
+
|
|
245
|
+
You declare those exchanges on the **integration**, keyed by variant:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
import type { HarnessIntegration } from '@ekanos/harness/registry';
|
|
249
|
+
|
|
250
|
+
import { TidepoolForecast, TidepoolNow, TidepoolNowEmpty } from './fixtures';
|
|
251
|
+
import { ForecastWidget, NowWidget } from './widgets';
|
|
252
|
+
|
|
253
|
+
const API = 'https://api.tidepool.example.com';
|
|
254
|
+
|
|
255
|
+
export const tidepool: HarnessIntegration = {
|
|
256
|
+
slug: 'tidepool',
|
|
257
|
+
name: 'Tidepool',
|
|
258
|
+
description: 'Tide readings and forecast.',
|
|
259
|
+
|
|
260
|
+
fixtures: {
|
|
261
|
+
default: [
|
|
262
|
+
{ request: `GET ${API}/v1/tides/current`, response: TidepoolNow },
|
|
263
|
+
{ request: `GET ${API}/v1/tides/forecast`, response: TidepoolForecast },
|
|
264
|
+
],
|
|
265
|
+
empty: [
|
|
266
|
+
{ request: `GET ${API}/v1/tides/current`, response: TidepoolNowEmpty },
|
|
267
|
+
{
|
|
268
|
+
request: `GET ${API}/v1/tides/forecast`,
|
|
269
|
+
response: { station: '9414290', entries: [] },
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
error: [
|
|
273
|
+
{ request: `GET ${API}/v1/tides/current`, status: 503 },
|
|
274
|
+
{ request: `GET ${API}/v1/tides/forecast`, status: 503 },
|
|
275
|
+
],
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
widgets: [
|
|
279
|
+
{ id: 'tidepool-now', title: 'Tide Now', component: NowWidget },
|
|
280
|
+
{ id: 'tidepool-forecast', title: 'Tide Forecast', component: ForecastWidget },
|
|
281
|
+
],
|
|
282
|
+
};
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
**They sit on the integration, not the widget, because a URL is not owned by
|
|
286
|
+
one.** The same `GET /v1/payouts` may answer one widget, a second widget, and a
|
|
287
|
+
webhook handler on the Triggers surface — and it should be written once. The
|
|
288
|
+
Triggers surface reads this same list, so `ctx.fetch` inside a webhook or
|
|
289
|
+
schedule handler resolves against the fixtures your widgets already use.
|
|
290
|
+
|
|
291
|
+
There are exactly three variants, switched from the dev toolbar:
|
|
292
|
+
|
|
293
|
+
| Variant | For |
|
|
294
|
+
|---|---|
|
|
295
|
+
| `default` | the populated happy path — declare this one |
|
|
296
|
+
| `empty` | the zero-rows state |
|
|
297
|
+
| `error` | the failure state, as ordinary responses with a failure `status` |
|
|
298
|
+
|
|
299
|
+
`empty` and `error` are optional, and an omitted variant is **not** a fallback
|
|
300
|
+
to `default`: the list for that variant is empty, so every request is refused
|
|
301
|
+
and each widget shows the refusal. That is deliberate — a variant that silently
|
|
302
|
+
served the happy path would look like your empty state working.
|
|
303
|
+
|
|
304
|
+
**Switching the variant only reaches a widget that re-fetches.** The harness
|
|
305
|
+
tears down and rebuilds the whole subtree on a switch, so a widget fetching in
|
|
306
|
+
an effect or through react-query asks again and gets the new recording. A widget
|
|
307
|
+
that starts its promise during render and caches it at MODULE scope — the
|
|
308
|
+
obvious way to stop `use()` re-firing on every render — does not: the module
|
|
309
|
+
outlives the remount, so it keeps serving the first variant's answer while the
|
|
310
|
+
toolbar says `error`. Measured on our own tidepool example, whose forecast
|
|
311
|
+
widget is exactly that shape: three switches, one `forecast` request, happy-path
|
|
312
|
+
data showing under every variant.
|
|
313
|
+
|
|
314
|
+
**The fix is where the promise is created, not how.** Moving the cache into the
|
|
315
|
+
component that calls `use()` — `const [p] = useState(() => fetch(…))` right above
|
|
316
|
+
the `use(p)` — looks right and is worse: a render that suspends is discarded
|
|
317
|
+
before it commits, so the state it created is thrown away and the initializer
|
|
318
|
+
runs again on every retry. Measured on the same three switches, that turns one
|
|
319
|
+
request into twenty-six, and the widget still displays correctly the whole time.
|
|
320
|
+
|
|
321
|
+
Create the promise in the non-suspending **parent** and pass it across the
|
|
322
|
+
Suspense boundary:
|
|
323
|
+
|
|
324
|
+
```tsx
|
|
325
|
+
function ForecastWidget() {
|
|
326
|
+
const [request] = useState(() => fetchForecast(FORECAST_URL));
|
|
327
|
+
|
|
328
|
+
return (
|
|
329
|
+
<Suspense fallback={<Loading />}>
|
|
330
|
+
<ForecastBody request={request} />
|
|
331
|
+
</Suspense>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
The parent commits, so its state survives; the child suspends and retries
|
|
337
|
+
against the same promise. It still starts during render, so nothing about the
|
|
338
|
+
archetype changes.
|
|
339
|
+
|
|
340
|
+
This is a widget-side trap rather than a fixtures one — the recording was
|
|
341
|
+
swapped correctly; nothing asked for it — but it looks exactly like a fixture
|
|
342
|
+
that did not take, so it is worth recognising. Note what does NOT distinguish
|
|
343
|
+
the three states: the rendered output. Stuck, storming and correct all look
|
|
344
|
+
identical on screen, and only a request count tells them apart.
|
|
345
|
+
|
|
346
|
+
### `HttpFixture` — the request line
|
|
347
|
+
|
|
348
|
+
```ts-mirror HttpFixture
|
|
349
|
+
interface HttpFixture {
|
|
350
|
+
request: string; // '<METHOD> <url-or-path>'
|
|
351
|
+
response?: unknown; // body; JSON unless already a string
|
|
352
|
+
status?: number; // default 200 with a body, 204 without
|
|
353
|
+
headers?: Record<string, string>;
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
`request` is a method and a target separated by a space. The method is
|
|
358
|
+
case-insensitive and must be one of `GET`, `HEAD`, `POST`, `PUT`, `PATCH`,
|
|
359
|
+
`DELETE`, `OPTIONS`. The target is one of three forms:
|
|
360
|
+
|
|
361
|
+
| Form | Example | Resolves against |
|
|
362
|
+
|---|---|---|
|
|
363
|
+
| Absolute URL | `GET https://api.acme.com/v1/payouts` | itself — always unambiguous |
|
|
364
|
+
| Path only | `GET /v1/payouts` | your `defineIntegration({ egress })` origin |
|
|
365
|
+
| Host route | `GET /api/integrations/acme/summary` | the harness's own origin |
|
|
366
|
+
|
|
367
|
+
The path-only form exists so you do not maintain a second `baseUrl` beside your
|
|
368
|
+
egress declaration, and it is allowed **only when your definition declares
|
|
369
|
+
exactly one origin**. With none or with two, the harness cannot make a
|
|
370
|
+
defensible guess, so `compileFixture` throws while the surface renders — naming
|
|
371
|
+
your fixture and printing the absolute form to write instead. It never guesses
|
|
372
|
+
and it never skips a fixture it cannot parse.
|
|
373
|
+
|
|
374
|
+
`/api/…` is the exception to both rules: it is the host-route namespace. A
|
|
375
|
+
first-party integration's widgets call `/api/integrations/<slug>/…` rather than
|
|
376
|
+
the vendor directly, because that is where the credential lives. The harness
|
|
377
|
+
serves no backend, so those would 404; a fixture is the only thing that can
|
|
378
|
+
answer one, and it is answered like any other.
|
|
379
|
+
|
|
380
|
+
Matching, in the order the rules apply:
|
|
381
|
+
|
|
382
|
+
- **Method and origin must be equal.**
|
|
383
|
+
- **Path segments must agree in count.** `:param` matches exactly one segment,
|
|
384
|
+
`*` matches the rest (including nothing). So `/v1/payouts` does *not* match
|
|
385
|
+
`/v1/payouts/42`, but `/v1/payouts/:id` and `/v1/payouts/*` both do.
|
|
386
|
+
- **Query is a subset match, or ignored entirely.** If your request line has a
|
|
387
|
+
`?`, every param in it must be present on the real request with the same
|
|
388
|
+
value — so you can pin the two that matter and ignore the other nine. If it
|
|
389
|
+
has no `?`, query is not consulted at all. That is what makes
|
|
390
|
+
`GET https://api.acme.com/v1/payouts` answer a request carrying a page
|
|
391
|
+
cursor, a locale and an API key.
|
|
392
|
+
- **First declaration wins.** Write the specific fixture above the general one.
|
|
393
|
+
|
|
394
|
+
The response side has defaults chosen so the common cases are one line.
|
|
395
|
+
`{ request: 'DELETE /v1/thing/:id' }` is a complete fixture: no body, so 204. A
|
|
396
|
+
`response` that is a string is sent as-is with `text/plain`; anything else is
|
|
397
|
+
JSON-serialized with `application/json`. `status` overrides the default, which
|
|
398
|
+
is how an error variant is written — `{ request: 'GET /v1/payouts', status: 503 }`
|
|
399
|
+
is an ordinary recorded response rather than a special code path.
|
|
400
|
+
|
|
401
|
+
One constraint comes from the shell rather than from fixtures:
|
|
402
|
+
`harness.config.ts` is imported from both a server component and a client
|
|
403
|
+
component, so **everything reachable from it must be importable in both
|
|
404
|
+
graphs.** A fixture module carrying a `'use client'` directive compiles and then
|
|
405
|
+
fails at request time with `Attempted to call … from the server`. Keep fixture
|
|
406
|
+
data and URL constants in a plain module with no directive; put hooks and
|
|
407
|
+
components in the client ones.
|
|
408
|
+
|
|
409
|
+
### When there is no fixture for a request
|
|
410
|
+
|
|
411
|
+
The request is **refused, not sent** — that is the promise fixtures mode makes,
|
|
412
|
+
and it is unconditional. Three things happen, and they are worth being able to
|
|
413
|
+
recognise:
|
|
414
|
+
|
|
415
|
+
1. **The console gets a line naming the URL**, from the harness rather than
|
|
416
|
+
from your code:
|
|
417
|
+
|
|
418
|
+
```
|
|
419
|
+
[harness:tidepool] refused GET https://api.tidepool.example.com/v1/tides/current?…
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Every third-party call is logged this way — `fixture`, `network` (live mode
|
|
423
|
+
only) or `refused` — so the console is the fastest place to see what a
|
|
424
|
+
widget actually asked for versus what you recorded.
|
|
425
|
+
|
|
426
|
+
2. **The `fetch` call rejects with `NoRecordedResponseError`**, whose message
|
|
427
|
+
quotes the fixture to paste:
|
|
428
|
+
|
|
429
|
+
```
|
|
430
|
+
No recorded response for GET https://api.tidepool.example.com/v1/tides/current?….
|
|
431
|
+
|
|
432
|
+
Fixtures mode answers every request from your registry entry and
|
|
433
|
+
never leaves the machine, so this call was refused rather than sent.
|
|
434
|
+
Record it:
|
|
435
|
+
|
|
436
|
+
fixtures: {
|
|
437
|
+
default: [{ request: 'GET https://api.tidepool.example.com/v1/tides/current', response: /* … */ }],
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
To exercise the failure branch instead, give it a status:
|
|
441
|
+
|
|
442
|
+
{ request: 'GET https://api.tidepool.example.com/v1/tides/current', status: 403 }
|
|
443
|
+
|
|
444
|
+
The query string is hidden above because it can carry
|
|
445
|
+
credentials. It is left off the line to paste on purpose: a
|
|
446
|
+
request with no `?` matches whatever query the widget sends.
|
|
447
|
+
Add `?key=value` only to pin params you want matched.
|
|
448
|
+
|
|
449
|
+
If this URL looks unfamiliar, log it from the code that builds it —
|
|
450
|
+
the path and any pinned query params must match exactly.
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
3. **Where you see it depends on your own error handling**, because it is an
|
|
454
|
+
ordinary rejected `fetch`. A react-query widget lands in its error state
|
|
455
|
+
(the harness sets `retry: false`, so immediately rather than after three
|
|
456
|
+
backoffs). A widget that starts a promise in render and reads it with
|
|
457
|
+
`use()` throws, and if it does not catch it itself the harness's per-widget
|
|
458
|
+
error boundary renders the whole message in the card — one broken widget,
|
|
459
|
+
not a broken page.
|
|
460
|
+
|
|
461
|
+
**Credentials are redacted from what is printed.** Any userinfo in the URL is
|
|
462
|
+
stripped and the entire query string is replaced with `?…`, in the console line
|
|
463
|
+
and in the error alike, because an API key in a query param is exactly the thing
|
|
464
|
+
that ends up in a screenshot. The quoted `request:` line therefore has no query
|
|
465
|
+
on it — which is correct as written, since a fixture with no `?` matches
|
|
466
|
+
whatever query your widget sends. Add `?key=value` back only to pin a param you
|
|
467
|
+
want matched.
|
|
468
|
+
|
|
469
|
+
When the refused request was a **host route**, the quoted line is the path form
|
|
470
|
+
(`GET /api/integrations/acme/summary`) even though the URL above it is absolute.
|
|
471
|
+
That is deliberate, not an inconsistency: `/api/…` resolves against whatever port
|
|
472
|
+
this harness happens to run on, and quoting that back would pin your fixture to
|
|
473
|
+
a port that is ours and not yours. Paste it as printed.
|
|
474
|
+
|
|
475
|
+
**A URL you do not recognise is usually the real answer.** Log it from the code
|
|
476
|
+
that builds it rather than guessing: the path and any pinned query params have
|
|
477
|
+
to match exactly, and a trailing slash or an extra segment is enough to miss.
|
|
478
|
+
|
|
479
|
+
One thing is deliberately **not** intercepted: same-origin traffic outside
|
|
480
|
+
`/api/`. That is `/_next/static`, the document itself and the `?_rsc=` payloads
|
|
481
|
+
client navigation fetches — the framework's own plumbing, none of it your
|
|
482
|
+
integration talking to an API.
|
|
483
|
+
|
|
484
|
+
**The server render is a partial pass, not an exempt one.** It matters because a
|
|
485
|
+
widget that starts its fetch during render and reads it with `use()` runs there
|
|
486
|
+
too. Every fixture is compiled for that pass **except the `/api/` host-route
|
|
487
|
+
shape**, which is the only one that resolves against the harness's own origin —
|
|
488
|
+
and a server render has none. Absolute and path-only fixtures need no origin, so
|
|
489
|
+
they answer on the server exactly as they do in the browser.
|
|
490
|
+
|
|
491
|
+
A host-route request during a server render is therefore refused, with a message
|
|
492
|
+
that says so and tells you there is nothing to change in your registry entry —
|
|
493
|
+
the client render compiles that fixture and answers it, so what you see after
|
|
494
|
+
hydration is the real result. If you need it answered on the server too, write
|
|
495
|
+
the fixture and the widget's URL both absolute, at the cost of hardcoding the
|
|
496
|
+
dev server's origin.
|
|
497
|
+
|
|
498
|
+
Same-origin traffic **outside** `/api/` gets a third message, and it is the one
|
|
499
|
+
worth reading carefully: there is no fixture to write for it. The client passes
|
|
500
|
+
that traffic straight through as framework plumbing, so it is never refused in
|
|
501
|
+
the browser — only the server pass has nowhere to send it. If the request was
|
|
502
|
+
meant to reach a vendor, give it an absolute URL and it becomes an ordinary
|
|
503
|
+
fixture in both passes.
|
|
504
|
+
|
|
505
|
+
### `seeds` — the react-query fast path
|
|
506
|
+
|
|
507
|
+
`HarnessWidget.seeds` writes straight into the react-query cache, so a widget
|
|
508
|
+
paints populated with **no request at all**:
|
|
509
|
+
|
|
510
|
+
```ts HarnessIntegration
|
|
511
|
+
widgets: [
|
|
512
|
+
{
|
|
513
|
+
id: 'acme-payments-summary',
|
|
514
|
+
title: 'Payments Summary',
|
|
515
|
+
component: AcmeSummaryWidget,
|
|
516
|
+
seeds: {
|
|
517
|
+
default: [{ queryKey: summaryKey, data: acmeSummaryFixture }],
|
|
518
|
+
empty: [{ queryKey: summaryKey, data: acmeSummaryEmptyFixture }],
|
|
519
|
+
error: [{ queryKey: summaryKey, error: new Error('Acme returned 503') }],
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
],
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
A `FixtureSeed` is `{ queryKey, data }` or `{ queryKey, error }`. Set both and
|
|
526
|
+
`error` wins.
|
|
527
|
+
|
|
528
|
+
**This is the fastest seam and the narrowest.** It only works if your data layer
|
|
529
|
+
is `@tanstack/react-query`, and it addresses data by query key rather than by
|
|
530
|
+
what your vendor returns — so the same payload has to be written again, in a
|
|
531
|
+
different shape, for anything that is not a widget. Reach for it when you want a
|
|
532
|
+
specific widget to skip the request entirely; otherwise record the exchange.
|
|
533
|
+
|
|
534
|
+
Two rules if you do use it:
|
|
535
|
+
|
|
536
|
+
- **Derive the key from the same factory your hook calls.** Retyping a key array
|
|
537
|
+
gives you a widget that silently renders empty when someone renames a key;
|
|
538
|
+
importing the factory makes it a compile error.
|
|
539
|
+
- **A seeded key never fetches, so an HTTP fixture behind it is unreachable.**
|
|
540
|
+
Seeding short-circuits the request. Use one or the other for a given key, not
|
|
541
|
+
both.
|
|
542
|
+
|
|
543
|
+
There is one error a widget can hit that no HTTP fixture can answer:
|
|
544
|
+
`MissingFixtureError`, thrown when a query has neither a seed nor a `queryFn` of
|
|
545
|
+
its own. Nothing makes a request in that case, so there is nothing to record —
|
|
546
|
+
seed the key.
|
|
547
|
+
|
|
548
|
+
Finally, `HarnessWidget.seeds` was called `fixtures` before HTTP fixtures
|
|
549
|
+
existed. It was renamed rather than overloaded: two fields called `fixtures` one
|
|
550
|
+
level apart, meaning different things, is a trap worth spending a rename to
|
|
551
|
+
avoid.
|
|
552
|
+
|
|
553
|
+
### Why the format is HTTP and not query keys
|
|
554
|
+
|
|
555
|
+
Both of the problems it fixes are ones you would otherwise hit on your first
|
|
556
|
+
integration:
|
|
557
|
+
|
|
558
|
+
- **You authored the same vendor data twice, in two shapes, with nothing
|
|
559
|
+
relating them.** A seed takes `data` in the shape your hook *returns* —
|
|
560
|
+
domain-shaped, post-parse. A `triggerMocks.fetchHandlers` entry returns a
|
|
561
|
+
`Response`, so it is wire-shaped. The shipped Acme example used to carry both:
|
|
562
|
+
one export feeding the handler's JSON envelope, separate exports feeding the
|
|
563
|
+
widget seeds. Change one and the other went stale silently, and then the
|
|
564
|
+
widget and the webhook handler disagreed about the same account.
|
|
565
|
+
- **Only react-query users could express a fixture at all.** `queryKey` was the
|
|
566
|
+
only way to address one, so a partner fetching with SWR, in a server component
|
|
567
|
+
with plain `fetch`, or from a `useEffect` had an inert fixtures surface — the
|
|
568
|
+
harness mocking a cache they did not have rather than the network they did.
|
|
569
|
+
That was also the population whose requests reached the real internet, because
|
|
570
|
+
the interception was mounted on the same seams the fixtures were.
|
|
571
|
+
|
|
572
|
+
Recording the HTTP boundary fixes both at once: one declaration in one shape,
|
|
573
|
+
serving the widget and the handlers alike, with no opinion about how your
|
|
574
|
+
components fetch. `@tanstack/react-query` is still a hard peer of `@ekanos/sdk`
|
|
575
|
+
— `useActivateIntegration` and `useOAuthConnectionStatus` are built on it, and
|
|
576
|
+
the harness mounts `QueryClientProvider` unconditionally so the activation
|
|
577
|
+
surface works — so you install it either way. You just no longer have to build
|
|
578
|
+
on it to write a fixture.
|
|
579
|
+
|
|
580
|
+
## Live mode — real requests to your own API
|
|
581
|
+
|
|
582
|
+
The harness mocks Fusion, not your vendor. Declare a `live` block and the
|
|
583
|
+
toolbar grows a fixtures ⇄ live switch: in live mode the harness stops seeding
|
|
584
|
+
your data keys, your widgets' own query functions run, and their requests reach
|
|
585
|
+
the real API. Fusion stays mocked either way — still no Supabase, no auth, no
|
|
586
|
+
server actions.
|
|
587
|
+
|
|
588
|
+
```ts HarnessIntegration
|
|
589
|
+
live: {
|
|
590
|
+
// No `egress` here: the harness reads it off `definition`.
|
|
591
|
+
FetchProvider: AcmeFetchProvider,
|
|
592
|
+
seeds: [{ queryKey: settingsKey, data: acmeSettingsFixture }],
|
|
593
|
+
},
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
**The guarantee: live mode enforces the egress your definition declares — not
|
|
597
|
+
a second list you maintain beside it.** A request to an origin your definition
|
|
598
|
+
did not declare throws `EgressDeniedError` on your laptop, carrying the message
|
|
599
|
+
a reviewer would have seen at promotion. Flipping the toolbar to live is
|
|
600
|
+
therefore a real test of the declaration under review, not of a convenience
|
|
601
|
+
copy that happens to sit next to it.
|
|
602
|
+
|
|
603
|
+
**So when you set `definition`, do not set `live.egress`.** Supplying both is a
|
|
604
|
+
hard error naming your slug, not a silent preference — a silent preference is
|
|
605
|
+
exactly how the two lists became able to disagree. The harness resolves the
|
|
606
|
+
allowlist from `definition.egress` centrally (`resolveEgress`), so there is only
|
|
607
|
+
one list and it is the one a reviewer reads.
|
|
608
|
+
|
|
609
|
+
`live.egress` survives for the one case with nothing to read: an entry with no
|
|
610
|
+
`definition` linked. There it is the only source, and `findEgressMismatch` —
|
|
611
|
+
which reports a `live.egress` wider than the definition's — is what still has
|
|
612
|
+
work to do. A definition declaring more than live mode exercises is fine; the
|
|
613
|
+
reverse is the fault, and while it stands the harness forces fixtures mode and
|
|
614
|
+
says so on the surface.
|
|
615
|
+
|
|
616
|
+
**`seeds` are the keys that stay mocked in live mode.** Fusion-side state your
|
|
617
|
+
widgets read before they can call anything: a saved location, an account
|
|
618
|
+
preference, whatever your own host route would have returned. Everything not
|
|
619
|
+
listed runs its real query function. That one line is the whole boundary the
|
|
620
|
+
design draws: Fusion mocked, vendor real.
|
|
621
|
+
|
|
622
|
+
An integration with no `live` block is fixtures-only; the toolbar control is
|
|
623
|
+
disabled for it and says why.
|
|
624
|
+
|
|
625
|
+
### `FetchProvider` — the part you have to write yourself
|
|
626
|
+
|
|
627
|
+
> **Optional.** The harness patches `globalThis.fetch`, so it reaches your
|
|
628
|
+
> requests whether or not you supply this — and if you do supply one, your
|
|
629
|
+
> fetch still gets wrapped, because the patch sits underneath it. It is not an
|
|
630
|
+
> escape hatch in either direction.
|
|
631
|
+
>
|
|
632
|
+
> Write it anyway if the shape fits your code: it is how you keep one path
|
|
633
|
+
> serving `ctx.fetch` on the server and a browser fetch on the client, which
|
|
634
|
+
> is useful well outside the harness.
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
This is the awkward bit, and there is no way around it today. `ctx.fetch` — the
|
|
638
|
+
egress-allowlisted fetch — is handed to server-side handlers only. **The SDK
|
|
639
|
+
has no client-side equivalent.** So a widget that talks to a vendor API straight
|
|
640
|
+
from the browser has nowhere to get one, and needs a seam of its own before
|
|
641
|
+
live mode can hand it an allowlisted fetch.
|
|
642
|
+
|
|
643
|
+
The seam is a React context whose default is the browser's own `fetch`, so
|
|
644
|
+
nothing changes for a consumer that never mounts the provider. It is about
|
|
645
|
+
twenty lines, and this is all of them:
|
|
646
|
+
|
|
647
|
+
```tsx
|
|
648
|
+
'use client';
|
|
649
|
+
|
|
650
|
+
import { type ReactNode, createContext, use } from 'react';
|
|
651
|
+
|
|
652
|
+
import type { IntegrationFetch } from '@ekanos/sdk';
|
|
653
|
+
|
|
654
|
+
const browserFetch: IntegrationFetch = (input, init) =>
|
|
655
|
+
globalThis.fetch(input as RequestInfo, init);
|
|
656
|
+
|
|
657
|
+
const AcmeFetchContext = createContext<IntegrationFetch>(browserFetch);
|
|
658
|
+
|
|
659
|
+
export function AcmeFetchProvider({
|
|
660
|
+
fetch,
|
|
661
|
+
children,
|
|
662
|
+
}: {
|
|
663
|
+
fetch: IntegrationFetch;
|
|
664
|
+
children: ReactNode;
|
|
665
|
+
}) {
|
|
666
|
+
return <AcmeFetchContext value={fetch}>{children}</AcmeFetchContext>;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** The fetch this integration's query functions should use. */
|
|
670
|
+
export function useAcmeFetch(): IntegrationFetch {
|
|
671
|
+
return use(AcmeFetchContext);
|
|
672
|
+
}
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
Then have your query functions **take an `IntegrationFetch` as an argument**
|
|
676
|
+
rather than reaching for a global:
|
|
677
|
+
|
|
678
|
+
```ts
|
|
679
|
+
import type { IntegrationFetch } from '@ekanos/sdk';
|
|
680
|
+
|
|
681
|
+
export async function fetchAcmeSummary(fetch: IntegrationFetch, accountId: string) {
|
|
682
|
+
const response = await fetch(`https://api.acme.example/v1/summary?a=${accountId}`);
|
|
683
|
+
if (!response.ok) throw new Error(`Acme returned ${response.status}.`);
|
|
684
|
+
return response.json();
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// in the hook
|
|
688
|
+
const fetch = useAcmeFetch();
|
|
689
|
+
useQuery({ queryKey: acmeKeys.summary(accountId), queryFn: () => fetchAcmeSummary(fetch, accountId) });
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
That is the value of the pattern, beyond the harness: the *same* code path
|
|
693
|
+
serves `ctx.fetch` on the server and an allowlisted browser fetch on the
|
|
694
|
+
client.
|
|
695
|
+
|
|
696
|
+
The provider's props are fixed by the type — `{ fetch: IntegrationFetch;
|
|
697
|
+
children: ReactNode }` — because the harness mounts it. Omit `FetchProvider`
|
|
698
|
+
entirely if your widgets fetch only through your own host routes: fixtures mode
|
|
699
|
+
answers those (record them under `/api/…`), but live mode cannot run them —
|
|
700
|
+
there is no Supabase and no `ctx` to execute a route with — so live mode has
|
|
701
|
+
nothing to offer you yet.
|
|
702
|
+
|
|
703
|
+
This should be SDK surface, not partner surface. It is on the list.
|
|
704
|
+
|
|
705
|
+
## Triggers — running webhooks, schedules and OAuth locally
|
|
706
|
+
|
|
707
|
+
Set `definition` on the registry entry and the Triggers surface reads your
|
|
708
|
+
declared webhooks, schedules and OAuth straight off it. Omit it and the surface
|
|
709
|
+
explains what to add.
|
|
710
|
+
|
|
711
|
+
```ts HarnessIntegration
|
|
712
|
+
import { asHarnessDefinition } from '@ekanos/harness/registry';
|
|
713
|
+
|
|
714
|
+
import { integration as acmeDefinition } from '@you/acme-payments/integration';
|
|
715
|
+
|
|
716
|
+
definition: asHarnessDefinition(acmeDefinition),
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
`asHarnessDefinition()` erases the storage generic so the definition can sit in
|
|
720
|
+
the heterogeneous registry array — the same erasure the host performs when it
|
|
721
|
+
registers you. Nothing rests on the generic surviving: the mock context is built
|
|
722
|
+
FROM the definition's own `storage` schemas and validates every read and write
|
|
723
|
+
against them at runtime.
|
|
724
|
+
|
|
725
|
+
What the surface then does:
|
|
726
|
+
|
|
727
|
+
- **Webhooks** get a payload editor, seeded from `examplePayload`. Delivering
|
|
728
|
+
runs your REAL handler. The payload is validated against `payloadSchema`
|
|
729
|
+
first; an invalid one never reaches the handler. Signature verification is
|
|
730
|
+
logged as skipped — it is the transport's job in every environment.
|
|
731
|
+
- **Schedules** get a "Run now" button, which invokes the handler with
|
|
732
|
+
`trigger: 'manual'`.
|
|
733
|
+
- **OAuth** gets a read-only declaration readout plus an egress-coverage check
|
|
734
|
+
on both endpoint origins. There is no local authorize→callback loop yet; unit
|
|
735
|
+
test `onTokens` directly with `createMockContext()` until there is.
|
|
736
|
+
|
|
737
|
+
Every invocation runs against **one** `createMockContext()` per visit, so state
|
|
738
|
+
accumulates across invocations the way it would in a real account. Leaving the
|
|
739
|
+
page resets it.
|
|
740
|
+
|
|
741
|
+
### `triggerMocks`
|
|
742
|
+
|
|
743
|
+
The mock context is derived from the definition — slug, storage schemas, egress
|
|
744
|
+
— and from your `fixtures`, which answer `ctx.fetch` here exactly as they answer
|
|
745
|
+
a widget. `triggerMocks` supplies the rest: whatever Fusion-side state your
|
|
746
|
+
handlers need to run a happy path.
|
|
747
|
+
|
|
748
|
+
**So do not re-author your vendor payloads as `fetchHandlers`.** That was the
|
|
749
|
+
double-authoring HTTP fixtures exist to kill — the same data in a domain shape
|
|
750
|
+
for widgets and a wire shape for handlers, with nothing relating them. Reach for
|
|
751
|
+
`fetchHandlers` only when a response has to be dynamic or stateful; an explicit
|
|
752
|
+
handler is tried first and wins for the URLs it matches, so you keep fixtures
|
|
753
|
+
for everything else.
|
|
754
|
+
|
|
755
|
+
One behaviour worth knowing: a handler calling an endpoint nothing answers gets
|
|
756
|
+
the same named `NoRecordedResponseError` a widget gets. `createMockContext()`'s
|
|
757
|
+
own fallback is a plausible empty `200` — the trigger-side twin of a silent
|
|
758
|
+
fixture miss — and the harness replaces it.
|
|
759
|
+
|
|
760
|
+
```ts HarnessIntegration
|
|
761
|
+
triggerMocks: {
|
|
762
|
+
// Storage rows per scope, validated against your declared schemas.
|
|
763
|
+
storage: {
|
|
764
|
+
account: { config: { merchantId: 'mrc_4820193', environment: 'sandbox' } },
|
|
765
|
+
user: {},
|
|
766
|
+
},
|
|
767
|
+
// Secrets by tier. `account` is writable via ctx.secrets.set(); `admin`
|
|
768
|
+
// stands in for admin-issued credentials and rejects writes.
|
|
769
|
+
secrets: {
|
|
770
|
+
account: { acme_api_key: 'acme_test_9f2c41ab' },
|
|
771
|
+
},
|
|
772
|
+
// ONLY for a response that has to be dynamic or stateful — your `fixtures`
|
|
773
|
+
// already answer ctx.fetch. A string `match` matches URLs that start with
|
|
774
|
+
// it; a RegExp is tested against the full URL. Tried before the fixtures.
|
|
775
|
+
fetchHandlers: [
|
|
776
|
+
{
|
|
777
|
+
match: 'https://api.acme.example',
|
|
778
|
+
respond: () => Response.json({ payments: [], totalCount: 0 }),
|
|
779
|
+
},
|
|
780
|
+
],
|
|
781
|
+
},
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
There is no Supabase and no network in here either way. Keep the values fake —
|
|
785
|
+
see the bundle note below.
|
|
786
|
+
|
|
787
|
+
## What ends up in the browser bundle
|
|
788
|
+
|
|
789
|
+
**Everything in your registry does, including `triggerMocks.secrets`.**
|
|
790
|
+
|
|
791
|
+
The shell's injection module is a client module — it has to be, because the
|
|
792
|
+
registry holds live React component references — so the whole of
|
|
793
|
+
`harness.config.ts` is compiled into the browser bundle and written into the
|
|
794
|
+
`.next` build output on disk. There is no boundary that can prevent this while
|
|
795
|
+
the registry still reaches the routes, so the harness warns at startup rather
|
|
796
|
+
than pretending otherwise:
|
|
797
|
+
|
|
798
|
+
```
|
|
799
|
+
[harness] acme-widgets seed triggerMocks.secrets, and those literals are
|
|
800
|
+
compiled into the browser bundle and written into the .next build output …
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
In practice this is your own mock values on your own machine, which is why it
|
|
804
|
+
is a warning and not a refusal. Two things follow from it:
|
|
805
|
+
|
|
806
|
+
- **Keep trigger mocks fake.** They are seeds for a local mock context, not
|
|
807
|
+
credentials — nothing in the harness ever authenticates.
|
|
808
|
+
- **Do not commit or deploy the generated build directory.** `.ekanos/` is
|
|
809
|
+
gitignored for you; the `.next` output inside it is a build artifact.
|
|
810
|
+
|
|
811
|
+
Related, and deliberate: the activation surface hides credential-shaped fields
|
|
812
|
+
in its payload readout behind a **Reveal values** toggle, and the fixture
|
|
813
|
+
activation actions log a redacted copy. That readout is the one place a real
|
|
814
|
+
API token predictably appears on screen.
|
|
815
|
+
|
|
816
|
+
## Exports
|
|
817
|
+
|
|
818
|
+
| Subpath | Contents |
|
|
819
|
+
|---|---|
|
|
820
|
+
| `@ekanos/harness/registry` | `HarnessIntegration`, `HarnessWidget`, `HttpFixture`, `FixtureSeed`, `FixtureVariant`, `HarnessLiveMode`, `HarnessTriggerMocks`, the `HARNESS_*` ids and the pure helpers. Server-safe; this is the type surface you author against. |
|
|
821
|
+
| `@ekanos/harness/config` | `defineHarnessConfig()` — an identity function that exists for the inference. |
|
|
822
|
+
| `@ekanos/harness/app` | `RootLayout`, `IndexPage`, `harnessMetadata`. |
|
|
823
|
+
| `@ekanos/harness/routes` | `IntegrationLayout`, `WidgetsPage`, `SingleWidgetPage`, `TilePage`, `ActivationPage`, `TriggersPage`. |
|
|
824
|
+
| `@ekanos/harness/styles.css` | The harness's Tailwind layer: the Tailwind entry, the `@ekanos/ui` token preset, the Font Awesome repairs, and the `@source` globs covering everything the chrome renders. |
|
|
825
|
+
| `@ekanos/harness/mocks/team-account-workspace` | The stand-in for the host's `useTeamAccountWorkspace()`. The generated `next.config.mjs` aliases `@kit/team-accounts/hooks/use-team-account-workspace` onto it, so unmodified widget source runs unchanged. |
|
|
826
|
+
|
|
827
|
+
## The shell contract
|
|
828
|
+
|
|
829
|
+
`package.json` declares `ekanos.shellContract`. `@ekanos/cli` reads it before
|
|
830
|
+
scaffolding and refuses if its templates generate a different one, naming which
|
|
831
|
+
side to upgrade.
|
|
832
|
+
|
|
833
|
+
Bump it when the SHELL this package requires changes — an export renamed or
|
|
834
|
+
moved between `/app` and `/routes`, the props of `RootLayout` or `IndexPage`,
|
|
835
|
+
the server/client split, the alias specifier, the stylesheet's ownership of the
|
|
836
|
+
Tailwind import, or the set of files the shell must contain. A release that only
|
|
837
|
+
fixes a bug inside the package leaves it alone.
|
|
838
|
+
|
|
839
|
+
**A shell change also needs a `@ekanos/cli` release.** The templates live there;
|
|
840
|
+
only the code they import lives here. `ekanos dev` rewrites a generated file
|
|
841
|
+
only when its bytes change, so bumping the harness alone regenerates nothing —
|
|
842
|
+
the old CLI's templates are exactly what the old CLI intends. The contract
|
|
843
|
+
integer is what turns that silent mismatch into a refusal.
|
|
844
|
+
|
|
845
|
+
**What it does NOT cover is your `harness.config.ts`.** The contract is checked
|
|
846
|
+
before the CLI writes the generated shell, so it guards `.ekanos/harness/**` —
|
|
847
|
+
our files. Yours is bootstrapped once and never touched again, deliberately,
|
|
848
|
+
because your fixtures live in it. So if we rename something in the registry
|
|
849
|
+
types you write against, it reaches you as a TypeScript error in a file no tool
|
|
850
|
+
will migrate for you. That is the right trade — we are not editing your file —
|
|
851
|
+
but it means **the release notes are the migration path**, and you should read
|
|
852
|
+
them on a minor bump rather than only on a major. The `HarnessWidget.fixtures`
|
|
853
|
+
→ `seeds` rename — made when `HarnessIntegration.fixtures` took the name — is
|
|
854
|
+
exactly this shape.
|
|
855
|
+
|
|
856
|
+
## Building it
|
|
857
|
+
|
|
858
|
+
```bash
|
|
859
|
+
pnpm --filter @ekanos/harness build
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
`@ekanos/ui` must be built first — `styles.css` imports its token preset.
|
|
863
|
+
|
|
864
|
+
The build compiles **per file** rather than bundling (`bundle: false` in
|
|
865
|
+
`tsup.config.ts`), and that is load-bearing in three separate ways: the
|
|
866
|
+
server/client boundary runs *through* the `/app` entry rather than around it,
|
|
867
|
+
`next/font/google` requires its call to survive as a `const` (esbuild's bundler
|
|
868
|
+
rewrites top-level `const` to `var`, which Next rejects outright), and there is
|
|
869
|
+
nothing to inline in the first place. Each of those is a bug we have already
|
|
870
|
+
shipped once; the build config in the source tree carries the long version.
|
|
871
|
+
|
|
872
|
+
`pnpm --filter @ekanos/harness pack:test` re-checks all of that on the actual
|
|
873
|
+
published tarball, in a clean room outside the workspace.
|
|
874
|
+
|
|
875
|
+
## Licence
|
|
876
|
+
|
|
877
|
+
MIT. Font Awesome is the consuming app's responsibility: this package ships no
|
|
878
|
+
icon-font data, and the generated shell loads Font Awesome **Free**, because
|
|
879
|
+
Fusion's own Pro webfonts are commercially licensed and cannot be redistributed.
|