@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
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from 'react';
|
|
2
|
+
import type { ActivationFormProps, IntegrationComponentProps, IntegrationFetch, MarketplaceTileProps } from '@ekanos/sdk';
|
|
3
|
+
import type { StorageSchemas } from '@ekanos/sdk';
|
|
4
|
+
import type { IntegrationDefinition } from '@ekanos/sdk/integration';
|
|
5
|
+
import type { MockFetchHandler } from '@ekanos/sdk/testing';
|
|
6
|
+
/**
|
|
7
|
+
* The fixture variants the dev toolbar can switch between. Every widget should
|
|
8
|
+
* supply at least `default`; the others are optional and fall back to
|
|
9
|
+
* "nothing seeded", which surfaces as the widget's own error state.
|
|
10
|
+
*/
|
|
11
|
+
export declare const FIXTURE_VARIANTS: readonly ["default", "empty", "error"];
|
|
12
|
+
export type FixtureVariant = (typeof FIXTURE_VARIANTS)[number];
|
|
13
|
+
/**
|
|
14
|
+
* One programmed outcome for one react-query key.
|
|
15
|
+
*
|
|
16
|
+
* `data` seeds the cache so the widget renders populated on first paint with
|
|
17
|
+
* zero network traffic. `error` programs a rejection instead, so you can
|
|
18
|
+
* exercise a widget's error branch without unplugging anything.
|
|
19
|
+
*
|
|
20
|
+
* Exactly one of `data` / `error` should be set. If both are, `error` wins.
|
|
21
|
+
*/
|
|
22
|
+
export interface FixtureSeed {
|
|
23
|
+
queryKey: readonly unknown[];
|
|
24
|
+
data?: unknown;
|
|
25
|
+
error?: unknown;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
* ONE RECORDED HTTP EXCHANGE — the default way to give the harness data
|
|
30
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
*
|
|
32
|
+
* Fixtures describe what your VENDOR returns, not what your widget's cache
|
|
33
|
+
* holds. That one change is why this format exists:
|
|
34
|
+
*
|
|
35
|
+
* - it is authored ONCE and feeds both seams. The same fixture answers a
|
|
36
|
+
* widget's request and a webhook handler's `ctx.fetch`, where before the
|
|
37
|
+
* same vendor data had to be written twice in two different shapes with
|
|
38
|
+
* nothing relating them;
|
|
39
|
+
* - it does not care how you fetch. Query-key seeding could only be expressed
|
|
40
|
+
* through `@tanstack/react-query`, so a partner using SWR, RSC `fetch` or a
|
|
41
|
+
* plain `useEffect` had no working fixture surface at all;
|
|
42
|
+
* - the `error` variant stops being a special case. `status: 403` is an
|
|
43
|
+
* ordinary recorded response, where before "seed an error" meant "skip
|
|
44
|
+
* seeding and hope something throws", which is precisely the path that let
|
|
45
|
+
* fixtures mode reach the real internet.
|
|
46
|
+
*
|
|
47
|
+
* Nothing here is computed at author time, so a captured exchange is the same
|
|
48
|
+
* object as a hand-written one — which is what leaves record/replay reachable.
|
|
49
|
+
*/
|
|
50
|
+
export interface HttpFixture {
|
|
51
|
+
/**
|
|
52
|
+
* `'<METHOD> <url-or-path>'`.
|
|
53
|
+
*
|
|
54
|
+
* Absolute — `'GET https://api.acme.com/v1/payouts'` — or path-only —
|
|
55
|
+
* `'GET /v1/payouts'` — which resolves against the integration's declared
|
|
56
|
+
* `egress` and is allowed only when exactly one origin is declared.
|
|
57
|
+
*
|
|
58
|
+
* `:param` matches one path segment, `*` matches the rest. A `?query` means
|
|
59
|
+
* those params must all be present with those values (a subset match, so you
|
|
60
|
+
* can pin the two that matter); no `?` ignores query entirely.
|
|
61
|
+
*
|
|
62
|
+
* First declaration wins, so write a specific fixture above a general one.
|
|
63
|
+
*/
|
|
64
|
+
request: string;
|
|
65
|
+
/** Body. Sent as JSON unless it is already a string. */
|
|
66
|
+
response?: unknown;
|
|
67
|
+
/** Defaults to 200 when `response` is set, 204 when it is not. */
|
|
68
|
+
status?: number;
|
|
69
|
+
headers?: Record<string, string>;
|
|
70
|
+
}
|
|
71
|
+
export interface HarnessWidget {
|
|
72
|
+
/** Stable id. Also the URL segment on /[slug]/widgets/[widgetId]. */
|
|
73
|
+
id: string;
|
|
74
|
+
/** Title rendered in the widget header chrome. */
|
|
75
|
+
title: string;
|
|
76
|
+
component: ComponentType<IntegrationComponentProps>;
|
|
77
|
+
/**
|
|
78
|
+
* Column span in the dashboard grid. `full` gets its own row; runs of
|
|
79
|
+
* `half` widgets are balanced across two columns. Defaults to `half`.
|
|
80
|
+
*/
|
|
81
|
+
width?: 'half' | 'full';
|
|
82
|
+
isCollapsible?: boolean;
|
|
83
|
+
isPinnable?: boolean;
|
|
84
|
+
/** Renders the "Ask about this" AI footer bar. */
|
|
85
|
+
aiFooterEnabled?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Query-key seeds per variant — the zero-flicker FAST PATH, not the default.
|
|
88
|
+
*
|
|
89
|
+
* Writes straight into the react-query cache, so a widget paints populated
|
|
90
|
+
* with no request at all. That makes it the fastest thing available and the
|
|
91
|
+
* narrowest: it only works if your data layer is `@tanstack/react-query`,
|
|
92
|
+
* and it addresses data by query key rather than by what the vendor returns.
|
|
93
|
+
*
|
|
94
|
+
* Prefer `HarnessIntegration.fixtures`. Reach for this when you want a
|
|
95
|
+
* specific widget to skip the request entirely.
|
|
96
|
+
*
|
|
97
|
+
* (Named `fixtures` before HTTP fixtures existed. Renamed so the two are not
|
|
98
|
+
* the same word one level apart.)
|
|
99
|
+
*/
|
|
100
|
+
seeds?: Partial<Record<FixtureVariant, FixtureSeed[]>>;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
* LIVE MODE — opting your integration into real third-party requests
|
|
105
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
106
|
+
*
|
|
107
|
+
* The harness mocks Fusion, not your vendor. Declare this and the toolbar grows
|
|
108
|
+
* a fixtures ⇄ live switch for your integration: in live mode the harness stops
|
|
109
|
+
* seeding your data keys, your widgets' own query functions run, and the
|
|
110
|
+
* requests they make reach the real API.
|
|
111
|
+
*
|
|
112
|
+
* Fusion stays mocked either way. There is still no Supabase, no auth and no
|
|
113
|
+
* server action — which is what `seeds` below is for.
|
|
114
|
+
*
|
|
115
|
+
* What you write is three fields:
|
|
116
|
+
*
|
|
117
|
+
* ```ts
|
|
118
|
+
* live: {
|
|
119
|
+
* egress: MY_INTEGRATION_EGRESS, // the same array you pass to defineIntegration
|
|
120
|
+
* FetchProvider: MyIntegrationFetchProvider,
|
|
121
|
+
* seeds: [{ queryKey: settingsKey, data: MY_SETTINGS }],
|
|
122
|
+
* }
|
|
123
|
+
* ```
|
|
124
|
+
*
|
|
125
|
+
* An integration with no `live` block behaves exactly as before; the toolbar
|
|
126
|
+
* control is disabled for it and says why.
|
|
127
|
+
*/
|
|
128
|
+
export interface HarnessLiveMode {
|
|
129
|
+
/**
|
|
130
|
+
* The integration's egress allowlist.
|
|
131
|
+
*
|
|
132
|
+
* OPTIONAL, and you almost certainly should NOT set it. When the harness
|
|
133
|
+
* entry carries a `definition`, the harness reads egress off the validated
|
|
134
|
+
* definition (`resolveEgress`) — and setting it in both places is a hard
|
|
135
|
+
* error, deliberately, because two copies are how they drift apart and the
|
|
136
|
+
* definition is the one a reviewer reads and production enforces.
|
|
137
|
+
*
|
|
138
|
+
* This field exists only for an entry with NO `definition`, where there is
|
|
139
|
+
* nothing to read it from. Requiring it here used to contradict the runtime
|
|
140
|
+
* outright: the type demanded a field `resolveEgress` refused to accept.
|
|
141
|
+
*
|
|
142
|
+
* When it is the source, the harness builds its allowlisted fetch from this
|
|
143
|
+
* array using the SDK's own `isEgressAllowed`, so a request to an origin
|
|
144
|
+
* missing from it throws `EgressDeniedError` locally exactly as it would in
|
|
145
|
+
* production.
|
|
146
|
+
*/
|
|
147
|
+
egress?: readonly string[];
|
|
148
|
+
/**
|
|
149
|
+
* OPTIONAL, and no longer needed for the harness to work.
|
|
150
|
+
*
|
|
151
|
+
* The harness patches `globalThis.fetch`, so it reaches your requests
|
|
152
|
+
* whether or not you supply this — which is the point: an interception a
|
|
153
|
+
* partner can decline to wire is not really an interception. It used to be
|
|
154
|
+
* the only seam through which live mode could hand you an allowlisted fetch,
|
|
155
|
+
* and omitting it was an unintentional escape hatch.
|
|
156
|
+
*
|
|
157
|
+
* It is still worth having in YOUR code, for a reason that has nothing to do
|
|
158
|
+
* with the harness: it lets one code path serve `ctx.fetch` on the server and
|
|
159
|
+
* a browser fetch on the client, instead of a query function reaching for a
|
|
160
|
+
* global and behaving differently in each. If you supply it, the harness
|
|
161
|
+
* hands it the same fetch it would have used anyway.
|
|
162
|
+
*
|
|
163
|
+
* The pattern is a ~20-line React context whose default is the browser's
|
|
164
|
+
* `fetch`:
|
|
165
|
+
*
|
|
166
|
+
* ```tsx
|
|
167
|
+
* 'use client';
|
|
168
|
+
*
|
|
169
|
+
* import { type ReactNode, createContext, use } from 'react';
|
|
170
|
+
*
|
|
171
|
+
* import type { IntegrationFetch } from '@ekanos/sdk';
|
|
172
|
+
*
|
|
173
|
+
* const browserFetch: IntegrationFetch = (input, init) =>
|
|
174
|
+
* globalThis.fetch(input as RequestInfo, init);
|
|
175
|
+
*
|
|
176
|
+
* const AcmeFetchContext = createContext<IntegrationFetch>(browserFetch);
|
|
177
|
+
*
|
|
178
|
+
* export function AcmeFetchProvider({
|
|
179
|
+
* fetch,
|
|
180
|
+
* children,
|
|
181
|
+
* }: {
|
|
182
|
+
* fetch: IntegrationFetch;
|
|
183
|
+
* children: ReactNode;
|
|
184
|
+
* }) {
|
|
185
|
+
* return <AcmeFetchContext value={fetch}>{children}</AcmeFetchContext>;
|
|
186
|
+
* }
|
|
187
|
+
*
|
|
188
|
+
* export const useAcmeFetch = () => use(AcmeFetchContext);
|
|
189
|
+
* ```
|
|
190
|
+
*
|
|
191
|
+
* Then have your query functions take an `IntegrationFetch` as an argument
|
|
192
|
+
* rather than reaching for a global. The README's "Live mode" section has
|
|
193
|
+
* the full worked example.
|
|
194
|
+
*
|
|
195
|
+
* (This used to say to omit it if your widgets only call your own host
|
|
196
|
+
* routes, because the harness could not serve those. It can: a fixture
|
|
197
|
+
* whose request is under `/api/` is answered like any other, which is how
|
|
198
|
+
* the Acme example's widgets work.)
|
|
199
|
+
*/
|
|
200
|
+
FetchProvider?: ComponentType<{
|
|
201
|
+
fetch: IntegrationFetch;
|
|
202
|
+
children: ReactNode;
|
|
203
|
+
}>;
|
|
204
|
+
/**
|
|
205
|
+
* Keys that stay MOCKED in live mode: Fusion-side state your widgets read
|
|
206
|
+
* before they can call anything — a saved location, an account preference,
|
|
207
|
+
* whatever your own host route would have returned. Seeded exactly like a
|
|
208
|
+
* fixture, and for exactly the same reason: the harness has no Supabase.
|
|
209
|
+
*
|
|
210
|
+
* Everything NOT listed here runs its real query function.
|
|
211
|
+
*/
|
|
212
|
+
seeds?: FixtureSeed[];
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
216
|
+
* TRIGGERS — the local transport's seeds for the mock context
|
|
217
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
218
|
+
*
|
|
219
|
+
* The Triggers surface executes the definition's declared webhooks and
|
|
220
|
+
* schedules against ONE `createMockContext()` per visit (state accumulates
|
|
221
|
+
* across invocations, like a real account). The context is derived from the
|
|
222
|
+
* definition itself — slug, storage schemas, egress — and these seeds are the
|
|
223
|
+
* only extra input: whatever Fusion-side state your handlers need to run a
|
|
224
|
+
* happy path (a stored config row, a seeded API key, a programmed response
|
|
225
|
+
* for `ctx.fetch`). There is no Supabase and no network in here either way.
|
|
226
|
+
*/
|
|
227
|
+
export interface HarnessTriggerMocks {
|
|
228
|
+
/** Secrets seeded into the mock context, by tier. */
|
|
229
|
+
secrets?: {
|
|
230
|
+
account?: Record<string, string>;
|
|
231
|
+
admin?: Record<string, string>;
|
|
232
|
+
};
|
|
233
|
+
/** Storage rows seeded per scope — validated against the declared schemas. */
|
|
234
|
+
storage?: {
|
|
235
|
+
account?: Record<string, unknown>;
|
|
236
|
+
user?: Record<string, unknown>;
|
|
237
|
+
};
|
|
238
|
+
/** Programmed responses for the handlers' `ctx.fetch` calls. */
|
|
239
|
+
fetchHandlers?: MockFetchHandler[];
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* The harness-only half of a widget entry — everything
|
|
243
|
+
* `harnessWidgetsFromDefinition` cannot derive, plus any chrome flag you want
|
|
244
|
+
* to override for the harness alone.
|
|
245
|
+
*/
|
|
246
|
+
export type HarnessWidgetOverride = Partial<Omit<HarnessWidget, 'id'>>;
|
|
247
|
+
export interface HarnessIntegration {
|
|
248
|
+
/** URL segment and the id a partner would ship as their product slug. */
|
|
249
|
+
slug: string;
|
|
250
|
+
name: string;
|
|
251
|
+
description: string;
|
|
252
|
+
widgets: HarnessWidget[];
|
|
253
|
+
/**
|
|
254
|
+
* Recorded HTTP exchanges per variant — the documented default.
|
|
255
|
+
*
|
|
256
|
+
* Declared on the INTEGRATION rather than a widget because a URL is not
|
|
257
|
+
* owned by one: the same `GET /v1/payouts` may answer a widget, a second
|
|
258
|
+
* widget, and a webhook handler, and it should be written once.
|
|
259
|
+
*/
|
|
260
|
+
fixtures?: Partial<Record<FixtureVariant, HttpFixture[]>>;
|
|
261
|
+
/** The marketplace tile. Optional — most real integrations don't ship one. */
|
|
262
|
+
tile?: ComponentType<MarketplaceTileProps>;
|
|
263
|
+
/** The activation form. Rendered with `inline` so it doesn't open a dialog. */
|
|
264
|
+
activationForm?: ComponentType<ActivationFormProps>;
|
|
265
|
+
/** Opt in to real third-party requests. Omit for fixtures-only. */
|
|
266
|
+
live?: HarnessLiveMode;
|
|
267
|
+
/**
|
|
268
|
+
* The validated `defineIntegration()` output — import it from your package
|
|
269
|
+
* (`import { integration } from '@you/your-integration/integration'`).
|
|
270
|
+
* Powers the Triggers surface: declared webhooks (payload editor seeded
|
|
271
|
+
* from `examplePayload`), schedules ("Run now"), and the OAuth declaration
|
|
272
|
+
* readout. Omit it and the Triggers surface explains what to add.
|
|
273
|
+
*/
|
|
274
|
+
definition?: IntegrationDefinition;
|
|
275
|
+
/** Seeds for the Triggers surface's mock context. */
|
|
276
|
+
triggerMocks?: HarnessTriggerMocks;
|
|
277
|
+
}
|
|
278
|
+
/** The two data sources a surface can render from. */
|
|
279
|
+
export declare const DATA_MODES: readonly ["fixtures", "live"];
|
|
280
|
+
export type DataMode = (typeof DATA_MODES)[number];
|
|
281
|
+
/**
|
|
282
|
+
* The account/product identifiers every surface is rendered with. These are
|
|
283
|
+
* plausible UUIDs rather than sentinels like `'preview-mode'`, so any widget
|
|
284
|
+
* that validates the shape of its `accountId` is happy. Nothing reads them —
|
|
285
|
+
* there is no database.
|
|
286
|
+
*/
|
|
287
|
+
export declare const HARNESS_ACCOUNT_ID = "00000000-0000-4000-8000-000000000001";
|
|
288
|
+
export declare const HARNESS_SOURCE_ID = "00000000-0000-4000-8000-000000000002";
|
|
289
|
+
export declare const HARNESS_ACCOUNT_SLUG = "harness-team";
|
|
290
|
+
/**
|
|
291
|
+
* Erases a definition's storage generic so it can sit in the heterogeneous
|
|
292
|
+
* registry array — the same erasure the host performs inside
|
|
293
|
+
* `registerPartnerIntegration()`. Nothing rests on the generic surviving:
|
|
294
|
+
* the Triggers surface builds its mock context FROM the definition's own
|
|
295
|
+
* `storage` schemas, and every read/write is validated against them at
|
|
296
|
+
* runtime regardless of the static parameter.
|
|
297
|
+
*/
|
|
298
|
+
export declare function asHarnessDefinition<Schemas extends StorageSchemas>(definition: IntegrationDefinition<Schemas>): IntegrationDefinition;
|
|
299
|
+
/**
|
|
300
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
301
|
+
* DERIVE THE WIDGET LIST FROM THE DEFINITION YOU ALREADY WROTE
|
|
302
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
303
|
+
*
|
|
304
|
+
* `HarnessWidget` restates `id`, `title`, `component`, `isCollapsible`,
|
|
305
|
+
* `isPinnable` and `aiFooterEnabled` — every one of which is already in
|
|
306
|
+
* `defineIntegration({ components: { widgets } })`. Writing both by hand means
|
|
307
|
+
* two declarations of the same thing with nothing detecting drift, and it is
|
|
308
|
+
* how `isCollapsible` came to be declared in one place and read from the
|
|
309
|
+
* other.
|
|
310
|
+
*
|
|
311
|
+
* This derives the harness entry from the definition, so the definition stays
|
|
312
|
+
* the single source and the harness entry adds only what is genuinely
|
|
313
|
+
* harness-only: `width`, and per-widget `seeds`.
|
|
314
|
+
*
|
|
315
|
+
* widgets: harnessWidgetsFromDefinition(integration, {
|
|
316
|
+
* 'acme-payouts': { width: 'full' },
|
|
317
|
+
* }),
|
|
318
|
+
*
|
|
319
|
+
* HTTP fixtures are NOT in here — they live on the integration
|
|
320
|
+
* (`HarnessIntegration.fixtures`), because a URL is not owned by one widget.
|
|
321
|
+
*
|
|
322
|
+
* ── The cast, and why it lives here rather than in your config ──
|
|
323
|
+
*
|
|
324
|
+
* `defineIntegration` is dependency-pure — zod only, no React — so it types a
|
|
325
|
+
* widget's `component` structurally as `ComponentReference`
|
|
326
|
+
* (`(...args: never[]) => unknown`, or anything with a `$$typeof`). That is
|
|
327
|
+
* deliberate and correct for validation, but it is not assignable to
|
|
328
|
+
* `ComponentType<IntegrationComponentProps>`, so SOMETHING has to assert the
|
|
329
|
+
* type back.
|
|
330
|
+
*
|
|
331
|
+
* Without this helper that assertion happens in every partner's config file,
|
|
332
|
+
* once per widget, unreviewed. Here it happens once, in code we own and test.
|
|
333
|
+
* The runtime risk is unchanged either way: the definition was already
|
|
334
|
+
* validated by `defineIntegration`, which checked that each `component` is a
|
|
335
|
+
* component reference — this only recovers the parameter type that validation
|
|
336
|
+
* could not carry.
|
|
337
|
+
*
|
|
338
|
+
* If the SDK ever preserves component types through validation, delete the
|
|
339
|
+
* cast and nothing else changes.
|
|
340
|
+
*/
|
|
341
|
+
export declare function harnessWidgetsFromDefinition(definition: IntegrationDefinition, overrides?: Record<string, HarnessWidgetOverride>): HarnessWidget[];
|
|
342
|
+
export declare function findIntegration(integrations: HarnessIntegration[], slug: string): HarnessIntegration | null;
|
|
343
|
+
export declare function findWidget(integration: HarnessIntegration, widgetId: string): HarnessWidget | null;
|
|
344
|
+
/** Every seed a given integration declares for the active fixture variant. */
|
|
345
|
+
export declare function collectSeeds(integration: HarnessIntegration, variant: FixtureVariant): FixtureSeed[];
|
|
346
|
+
/**
|
|
347
|
+
* The seeds for one integration in one mode.
|
|
348
|
+
*
|
|
349
|
+
* Fixtures mode seeds everything the widgets declare. Live mode seeds ONLY the
|
|
350
|
+
* Fusion-side keys, so every other query function runs for real — that one
|
|
351
|
+
* difference is the whole fixtures ⇄ live switch.
|
|
352
|
+
*/
|
|
353
|
+
export declare function seedsForMode(integration: HarnessIntegration, variant: FixtureVariant, mode: DataMode): FixtureSeed[];
|
|
354
|
+
/**
|
|
355
|
+
* A `live.egress` that does not match the integration's own
|
|
356
|
+
* `defineIntegration({ egress })` declaration.
|
|
357
|
+
*/
|
|
358
|
+
export interface EgressMismatch {
|
|
359
|
+
/** Origins the validated definition declares. */
|
|
360
|
+
declared: readonly string[];
|
|
361
|
+
/** Origins live mode would actually allow. */
|
|
362
|
+
live: readonly string[];
|
|
363
|
+
/** In `live.egress` but NOT declared — the ones that matter. */
|
|
364
|
+
undeclared: readonly string[];
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
368
|
+
* THE TWO EGRESS LISTS MUST AGREE
|
|
369
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
370
|
+
*
|
|
371
|
+
* `HarnessLiveMode.egress` is documented as the definition's array passed
|
|
372
|
+
* through VERBATIM, and until now nothing enforced that. A partner could
|
|
373
|
+
* declare a modest `egress` in `defineIntegration` — the list a reviewer
|
|
374
|
+
* actually reads — and independently set `live.egress` to something wider, or
|
|
375
|
+
* to `http://127.0.0.1:8080`. The harness would happily use the second list,
|
|
376
|
+
* so a green local run would prove nothing about the declaration we review.
|
|
377
|
+
* That defeats the entire purpose of live mode.
|
|
378
|
+
*
|
|
379
|
+
* Only origins present in `live` but absent from the definition are a fault. A
|
|
380
|
+
* definition may legitimately declare more than live mode exercises; the
|
|
381
|
+
* reverse is the harness permitting what production would refuse.
|
|
382
|
+
*
|
|
383
|
+
* Returns null when there is nothing to compare — no `live` block, or no
|
|
384
|
+
* `definition` linked. An unlinked definition is already surfaced by the
|
|
385
|
+
* Triggers surface, and inventing a second complaint here would not help.
|
|
386
|
+
*/
|
|
387
|
+
export declare function findEgressMismatch(integration: HarnessIntegration): EgressMismatch | null;
|
|
388
|
+
/**
|
|
389
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
390
|
+
* ONE EGRESS LIST, AND IT IS THE ONE A REVIEWER READS
|
|
391
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
392
|
+
*
|
|
393
|
+
* `live.egress` used to be the harness's own copy of the allowlist, documented
|
|
394
|
+
* as "the definition's array, verbatim" and enforced by nothing. A partner
|
|
395
|
+
* could declare a modest list in `defineIntegration` — the list a reviewer
|
|
396
|
+
* actually reads — and quietly widen the harness's. `findEgressMismatch`
|
|
397
|
+
* detected that; reading the definition directly makes it impossible, which is
|
|
398
|
+
* better than detecting it.
|
|
399
|
+
*
|
|
400
|
+
* So when a definition is linked, its `egress` is THE list. Supplying
|
|
401
|
+
* `live.egress` as well is a hard error rather than a silent preference,
|
|
402
|
+
* because a silent preference is how the divergence became possible in the
|
|
403
|
+
* first place.
|
|
404
|
+
*
|
|
405
|
+
* Without a definition there is nothing to read, so `live.egress` remains the
|
|
406
|
+
* only source — which is the one case `findEgressMismatch` still has work to
|
|
407
|
+
* do in. That is why it looks narrower than the story around it; it is not a
|
|
408
|
+
* leftover to be generalised back.
|
|
409
|
+
*/
|
|
410
|
+
export declare function resolveEgress(integration: HarnessIntegration): readonly string[];
|
|
411
|
+
/**
|
|
412
|
+
* Whether live mode is actually available here. The toolbar reads this to
|
|
413
|
+
* disable the control (and say why) rather than offering a switch that would
|
|
414
|
+
* silently render empty widgets.
|
|
415
|
+
*/
|
|
416
|
+
export declare function supportsLiveMode(integration: HarnessIntegration | null): integration is HarnessIntegration & {
|
|
417
|
+
live: HarnessLiveMode;
|
|
418
|
+
};
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const FIXTURE_VARIANTS = ["default", "empty", "error"];
|
|
2
|
+
const DATA_MODES = ["fixtures", "live"];
|
|
3
|
+
const HARNESS_ACCOUNT_ID = "00000000-0000-4000-8000-000000000001";
|
|
4
|
+
const HARNESS_SOURCE_ID = "00000000-0000-4000-8000-000000000002";
|
|
5
|
+
const HARNESS_ACCOUNT_SLUG = "harness-team";
|
|
6
|
+
function asHarnessDefinition(definition) {
|
|
7
|
+
return definition;
|
|
8
|
+
}
|
|
9
|
+
function harnessWidgetsFromDefinition(definition, overrides = {}) {
|
|
10
|
+
const declared = definition.components?.widgets ?? [];
|
|
11
|
+
for (const id of Object.keys(overrides)) {
|
|
12
|
+
if (!declared.some((widget) => widget.id === id)) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`harnessWidgetsFromDefinition(): "${id}" is not a widget in "${definition.slug}". Declared: ${declared.map((w) => w.id).join(", ") || "(none)"}. An override keyed to a widget that does not exist is silently ignored otherwise, which reads as the override not working.`
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return declared.map((widget) => {
|
|
19
|
+
const override = overrides[widget.id] ?? {};
|
|
20
|
+
return {
|
|
21
|
+
id: widget.id,
|
|
22
|
+
title: widget.name,
|
|
23
|
+
component: widget.component,
|
|
24
|
+
// Chrome flags come from the definition and are passed through ONLY when
|
|
25
|
+
// it declares them, so an undeclared flag still lands on the host's
|
|
26
|
+
// default rather than one invented here.
|
|
27
|
+
...widget.isCollapsible === void 0 ? {} : { isCollapsible: widget.isCollapsible },
|
|
28
|
+
...widget.isPinnable === void 0 ? {} : { isPinnable: widget.isPinnable },
|
|
29
|
+
...widget.aiFooterEnabled === void 0 ? {} : { aiFooterEnabled: widget.aiFooterEnabled },
|
|
30
|
+
...override
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function findIntegration(integrations, slug) {
|
|
35
|
+
return integrations.find((integration) => integration.slug === slug) ?? null;
|
|
36
|
+
}
|
|
37
|
+
function findWidget(integration, widgetId) {
|
|
38
|
+
return integration.widgets.find((widget) => widget.id === widgetId) ?? null;
|
|
39
|
+
}
|
|
40
|
+
function collectSeeds(integration, variant) {
|
|
41
|
+
return integration.widgets.flatMap((widget) => widget.seeds?.[variant] ?? []);
|
|
42
|
+
}
|
|
43
|
+
function seedsForMode(integration, variant, mode) {
|
|
44
|
+
return mode === "live" ? integration.live?.seeds ?? [] : collectSeeds(integration, variant);
|
|
45
|
+
}
|
|
46
|
+
function findEgressMismatch(integration) {
|
|
47
|
+
const live = integration.live?.egress;
|
|
48
|
+
const declared = integration.definition?.egress;
|
|
49
|
+
if (!live || !declared) return null;
|
|
50
|
+
const declaredSet = new Set(declared);
|
|
51
|
+
const undeclared = live.filter((origin) => !declaredSet.has(origin));
|
|
52
|
+
return undeclared.length > 0 ? { declared, live, undeclared } : null;
|
|
53
|
+
}
|
|
54
|
+
function resolveEgress(integration) {
|
|
55
|
+
const declared = integration.definition?.egress;
|
|
56
|
+
const live = integration.live?.egress;
|
|
57
|
+
if (declared && live) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`"${integration.slug}" declares egress in BOTH defineIntegration() and its harness entry's \`live.egress\`. The harness reads it from your definition now, so remove \`live.egress\` \u2014 keeping two copies is how they drift apart, and the definition is the one a reviewer reads and production enforces.`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return declared ?? live ?? [];
|
|
63
|
+
}
|
|
64
|
+
function supportsLiveMode(integration) {
|
|
65
|
+
return integration?.live !== void 0;
|
|
66
|
+
}
|
|
67
|
+
export {
|
|
68
|
+
DATA_MODES,
|
|
69
|
+
FIXTURE_VARIANTS,
|
|
70
|
+
HARNESS_ACCOUNT_ID,
|
|
71
|
+
HARNESS_ACCOUNT_SLUG,
|
|
72
|
+
HARNESS_SOURCE_ID,
|
|
73
|
+
asHarnessDefinition,
|
|
74
|
+
collectSeeds,
|
|
75
|
+
findEgressMismatch,
|
|
76
|
+
findIntegration,
|
|
77
|
+
findWidget,
|
|
78
|
+
harnessWidgetsFromDefinition,
|
|
79
|
+
resolveEgress,
|
|
80
|
+
seedsForMode,
|
|
81
|
+
supportsLiveMode
|
|
82
|
+
};
|
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The five `[slug]` surfaces plus their shared layout.
|
|
3
|
+
*
|
|
4
|
+
* Every one is a `'use client'` module: the registry holds live React component
|
|
5
|
+
* references, which cannot cross the server/client boundary as props, so each
|
|
6
|
+
* route reads the injected registry out of context instead. That makes this
|
|
7
|
+
* whole entry a client entry — see the directive note in tsup.config.ts.
|
|
8
|
+
*
|
|
9
|
+
* The shell's route files are one-line re-exports of these:
|
|
10
|
+
*
|
|
11
|
+
* export { WidgetsPage as default } from '@ekanos/harness/routes';
|
|
12
|
+
*
|
|
13
|
+
* The directive sits on the BARREL as well as on each module it re-exports.
|
|
14
|
+
* Every export here is a client component, so it is simply true — and it makes
|
|
15
|
+
* the boundary legible at the one file a reader is likely to open. It does cost
|
|
16
|
+
* the six route modules being bundled together rather than per route; for a
|
|
17
|
+
* local dev tool that is not a real cost.
|
|
18
|
+
*/
|
|
19
|
+
export { IntegrationLayout } from './internal/routes/integration-layout.js';
|
|
20
|
+
export { WidgetsPage } from './internal/routes/widgets-page.js';
|
|
21
|
+
export { SingleWidgetPage } from './internal/routes/single-widget-page.js';
|
|
22
|
+
export { TilePage } from './internal/routes/tile-page.js';
|
|
23
|
+
export { ActivationPage } from './internal/routes/activation-page.js';
|
|
24
|
+
export { TriggersPage } from './internal/routes/triggers-page.js';
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { IntegrationLayout } from "./internal/routes/integration-layout.js";
|
|
3
|
+
import { WidgetsPage } from "./internal/routes/widgets-page.js";
|
|
4
|
+
import { SingleWidgetPage } from "./internal/routes/single-widget-page.js";
|
|
5
|
+
import { TilePage } from "./internal/routes/tile-page.js";
|
|
6
|
+
import { ActivationPage } from "./internal/routes/activation-page.js";
|
|
7
|
+
import { TriggersPage } from "./internal/routes/triggers-page.js";
|
|
8
|
+
export {
|
|
9
|
+
ActivationPage,
|
|
10
|
+
IntegrationLayout,
|
|
11
|
+
SingleWidgetPage,
|
|
12
|
+
TilePage,
|
|
13
|
+
TriggersPage,
|
|
14
|
+
WidgetsPage
|
|
15
|
+
};
|