@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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +879 -0
  3. package/dist/app.d.ts +4 -0
  4. package/dist/app.js +11 -0
  5. package/dist/config.d.ts +18 -0
  6. package/dist/config.js +34 -0
  7. package/dist/internal/components/ask-assistant-bridge.d.ts +10 -0
  8. package/dist/internal/components/ask-assistant-bridge.js +50 -0
  9. package/dist/internal/components/dashboard-grid.d.ts +45 -0
  10. package/dist/internal/components/dashboard-grid.js +84 -0
  11. package/dist/internal/components/dev-toolbar.d.ts +15 -0
  12. package/dist/internal/components/dev-toolbar.js +155 -0
  13. package/dist/internal/components/harness-providers.d.ts +16 -0
  14. package/dist/internal/components/harness-providers.js +83 -0
  15. package/dist/internal/components/harness-widget-provider.d.ts +60 -0
  16. package/dist/internal/components/harness-widget-provider.js +84 -0
  17. package/dist/internal/components/i18n-provider.d.ts +9 -0
  18. package/dist/internal/components/i18n-provider.js +9 -0
  19. package/dist/internal/components/row-groups.d.ts +23 -0
  20. package/dist/internal/components/row-groups.js +37 -0
  21. package/dist/internal/components/surface-nav.d.ts +4 -0
  22. package/dist/internal/components/surface-nav.js +70 -0
  23. package/dist/internal/components/viewport-frame.d.ts +14 -0
  24. package/dist/internal/components/viewport-frame.js +27 -0
  25. package/dist/internal/components/widget-boundary.d.ts +25 -0
  26. package/dist/internal/components/widget-boundary.js +44 -0
  27. package/dist/internal/components/widget-surface.d.ts +20 -0
  28. package/dist/internal/components/widget-surface.js +76 -0
  29. package/dist/internal/lib/fonts.d.ts +2 -0
  30. package/dist/internal/lib/fonts.js +22 -0
  31. package/dist/internal/lib/harness-fetch-interceptor.d.ts +89 -0
  32. package/dist/internal/lib/harness-fetch-interceptor.js +101 -0
  33. package/dist/internal/lib/harness-live-fetch.d.ts +66 -0
  34. package/dist/internal/lib/harness-live-fetch.js +121 -0
  35. package/dist/internal/lib/harness-query-client.d.ts +43 -0
  36. package/dist/internal/lib/harness-query-client.js +103 -0
  37. package/dist/internal/lib/http-fixtures.d.ts +145 -0
  38. package/dist/internal/lib/http-fixtures.js +256 -0
  39. package/dist/internal/lib/i18n.d.ts +2 -0
  40. package/dist/internal/lib/i18n.js +17 -0
  41. package/dist/internal/lib/redact.d.ts +33 -0
  42. package/dist/internal/lib/redact.js +43 -0
  43. package/dist/internal/lib/toolbar-context.d.ts +59 -0
  44. package/dist/internal/lib/toolbar-context.js +124 -0
  45. package/dist/internal/registry-context.d.ts +27 -0
  46. package/dist/internal/registry-context.js +51 -0
  47. package/dist/internal/routes/activation-page.d.ts +33 -0
  48. package/dist/internal/routes/activation-page.js +242 -0
  49. package/dist/internal/routes/index-page.d.ts +13 -0
  50. package/dist/internal/routes/index-page.js +62 -0
  51. package/dist/internal/routes/integration-layout.d.ts +9 -0
  52. package/dist/internal/routes/integration-layout.js +84 -0
  53. package/dist/internal/routes/root-layout.d.ts +34 -0
  54. package/dist/internal/routes/root-layout.js +39 -0
  55. package/dist/internal/routes/single-widget-page.d.ts +6 -0
  56. package/dist/internal/routes/single-widget-page.js +30 -0
  57. package/dist/internal/routes/tile-page.d.ts +1 -0
  58. package/dist/internal/routes/tile-page.js +88 -0
  59. package/dist/internal/routes/triggers-page.d.ts +1 -0
  60. package/dist/internal/routes/triggers-page.js +386 -0
  61. package/dist/internal/routes/widgets-page.d.ts +1 -0
  62. package/dist/internal/routes/widgets-page.js +19 -0
  63. package/dist/internal/surfaces.d.ts +25 -0
  64. package/dist/internal/surfaces.js +29 -0
  65. package/dist/mocks/team-account-workspace.d.ts +78 -0
  66. package/dist/mocks/team-account-workspace.js +86 -0
  67. package/dist/registry.d.ts +418 -0
  68. package/dist/registry.js +82 -0
  69. package/dist/routes.d.ts +24 -0
  70. package/dist/routes.js +15 -0
  71. package/dist/styles.css +236 -0
  72. 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
+ };
@@ -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
+ };
@@ -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
+ };