@ekanos/harness 0.1.1 → 0.1.3
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/README.md +97 -9
- package/dist/hooks.d.ts +12 -0
- package/dist/hooks.js +9 -0
- package/dist/internal/components/dev-toolbar.js +24 -9
- package/dist/internal/components/harness-providers.js +24 -2
- package/dist/internal/lib/definition-mock-context.d.ts +14 -0
- package/dist/internal/lib/definition-mock-context.js +43 -0
- package/dist/internal/lib/harness-fetch-interceptor.d.ts +24 -9
- package/dist/internal/lib/harness-fetch-interceptor.js +11 -1
- package/dist/internal/lib/harness-hooks.d.ts +30 -0
- package/dist/internal/lib/harness-hooks.js +28 -0
- package/dist/internal/lib/live-activation-store.d.ts +49 -0
- package/dist/internal/lib/live-activation-store.js +34 -0
- package/dist/internal/lib/run-activation-hook.d.ts +28 -0
- package/dist/internal/lib/run-activation-hook.js +20 -0
- package/dist/internal/lib/theme-toggle.d.ts +17 -0
- package/dist/internal/lib/theme-toggle.js +6 -0
- package/dist/internal/routes/activation-page.js +98 -21
- package/dist/internal/routes/integration-layout.js +3 -3
- package/dist/internal/routes/triggers-page.js +2 -37
- package/dist/registry.d.ts +66 -3
- package/dist/registry.js +6 -0
- package/dist/styles.css +37 -2
- package/package.json +9 -5
package/README.md
CHANGED
|
@@ -419,9 +419,13 @@ recognise:
|
|
|
419
419
|
[harness:tidepool] refused GET https://api.tidepool.example.com/v1/tides/current?…
|
|
420
420
|
```
|
|
421
421
|
|
|
422
|
-
Every third-party call is logged this way — `fixture`, `network` (
|
|
423
|
-
|
|
424
|
-
widget actually asked for versus what you recorded.
|
|
422
|
+
Every third-party call is logged this way — `fixture`, `network` (a vendor
|
|
423
|
+
request in live mode) or `refused` — so the console is the fastest place to
|
|
424
|
+
see what a widget actually asked for versus what you recorded. A same-origin
|
|
425
|
+
`/api/…` request logs `fixture` or `refused` in EITHER toolbar mode: it is
|
|
426
|
+
answered from your registry entry whether the switch says Fixtures or Live
|
|
427
|
+
API, because the harness serves no backend either way. Only genuinely
|
|
428
|
+
third-party traffic can log `network`, and only in live mode.
|
|
425
429
|
|
|
426
430
|
2. **The `fetch` call rejects with `NoRecordedResponseError`**, whose message
|
|
427
431
|
quotes the fixture to paste:
|
|
@@ -622,6 +626,81 @@ design draws: Fusion mocked, vendor real.
|
|
|
622
626
|
An integration with no `live` block is fixtures-only; the toolbar control is
|
|
623
627
|
disabled for it and says why.
|
|
624
628
|
|
|
629
|
+
### Knowing the mode, and the activation-data channel
|
|
630
|
+
|
|
631
|
+
Two things a live-mode widget routinely needs and could not get before:
|
|
632
|
+
**which mode is actually running**, and **a credential to authenticate with**.
|
|
633
|
+
|
|
634
|
+
`FetchProvider` — where you already have one — receives both as props, no
|
|
635
|
+
extra plumbing required:
|
|
636
|
+
|
|
637
|
+
```tsx
|
|
638
|
+
import type { ReactNode } from 'react';
|
|
639
|
+
|
|
640
|
+
import type { IntegrationFetch } from '@ekanos/sdk';
|
|
641
|
+
import type { DataMode } from '@ekanos/harness/registry';
|
|
642
|
+
|
|
643
|
+
export function AcmeFetchProvider({
|
|
644
|
+
fetch,
|
|
645
|
+
mode,
|
|
646
|
+
activationData,
|
|
647
|
+
children,
|
|
648
|
+
}: {
|
|
649
|
+
fetch: IntegrationFetch;
|
|
650
|
+
mode: DataMode;
|
|
651
|
+
activationData: unknown;
|
|
652
|
+
children: ReactNode;
|
|
653
|
+
}) {
|
|
654
|
+
return <AcmeFetchContext value={fetch}>{children}</AcmeFetchContext>;
|
|
655
|
+
}
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
`mode` is `'fixtures' | 'live'` — the EFFECTIVE mode, already accounting for an
|
|
659
|
+
integration with no `live` block or a `live.egress`/definition mismatch (both
|
|
660
|
+
force `'fixtures'`, whatever the toolbar says). `activationData` is whatever
|
|
661
|
+
was last submitted to this integration's activation form — `null` before any
|
|
662
|
+
submission or after Disconnect — held **in memory only**: never written to
|
|
663
|
+
`localStorage`, a fixture, or the registry, and cleared on reload and on
|
|
664
|
+
disconnect. It is a dev-only convenience standing in for `ctx.secrets`, which
|
|
665
|
+
is what production actually hands your server-side handlers.
|
|
666
|
+
|
|
667
|
+
Threading a submitted API token into a live request looks like this:
|
|
668
|
+
|
|
669
|
+
```tsx
|
|
670
|
+
import type { ReactNode } from 'react';
|
|
671
|
+
|
|
672
|
+
import type { IntegrationFetch } from '@ekanos/sdk';
|
|
673
|
+
import type { DataMode } from '@ekanos/harness/registry';
|
|
674
|
+
|
|
675
|
+
export function AcmeFetchProvider({ fetch, activationData, children }: {
|
|
676
|
+
fetch: IntegrationFetch;
|
|
677
|
+
mode: DataMode;
|
|
678
|
+
activationData: unknown;
|
|
679
|
+
children: ReactNode;
|
|
680
|
+
}) {
|
|
681
|
+
const token = (activationData as { apiToken?: string } | null)?.apiToken;
|
|
682
|
+
|
|
683
|
+
const authedFetch: IntegrationFetch = (input, init) =>
|
|
684
|
+
fetch(input, {
|
|
685
|
+
...init,
|
|
686
|
+
headers: {
|
|
687
|
+
...(init?.headers as Record<string, string> | undefined),
|
|
688
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
return <AcmeFetchContext value={authedFetch}>{children}</AcmeFetchContext>;
|
|
693
|
+
}
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
No `FetchProvider` mounted, or need the answer somewhere else entirely? Two
|
|
697
|
+
hooks answer the same two questions from `@ekanos/harness/hooks`:
|
|
698
|
+
`useHarnessDataMode()` and `useLiveActivationData()`. Prefer the props above
|
|
699
|
+
wherever you can — they need no import from this package, and behave
|
|
700
|
+
identically whether or not `@ekanos/harness` is even installed at the call
|
|
701
|
+
site. Reach for the hooks only from code that runs nowhere but inside the dev
|
|
702
|
+
harness.
|
|
703
|
+
|
|
625
704
|
### `FetchProvider` — the part you have to write yourself
|
|
626
705
|
|
|
627
706
|
> **Optional.** The harness patches `globalThis.fetch`, so it reaches your
|
|
@@ -693,12 +772,14 @@ That is the value of the pattern, beyond the harness: the *same* code path
|
|
|
693
772
|
serves `ctx.fetch` on the server and an allowlisted browser fetch on the
|
|
694
773
|
client.
|
|
695
774
|
|
|
696
|
-
The provider's props are fixed by the type — `{ fetch: IntegrationFetch;
|
|
697
|
-
children: ReactNode }` — because the harness
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
775
|
+
The provider's props are fixed by the type — `{ fetch: IntegrationFetch; mode:
|
|
776
|
+
DataMode; activationData: unknown; children: ReactNode }` — because the harness
|
|
777
|
+
mounts it (see the previous section for what `mode` and `activationData` are
|
|
778
|
+
for). Omit `FetchProvider` entirely if your widgets fetch only through your own
|
|
779
|
+
host routes: those are answered from fixtures (record them under `/api/…`) in
|
|
780
|
+
EVERY toolbar mode, live included — there is no Supabase and no `ctx` to
|
|
781
|
+
execute a route with, so there is nothing "live" to run for that traffic
|
|
782
|
+
either way.
|
|
702
783
|
|
|
703
784
|
This should be SDK surface, not partner surface. It is on the list.
|
|
704
785
|
|
|
@@ -738,6 +819,12 @@ Every invocation runs against **one** `createMockContext()` per visit, so state
|
|
|
738
819
|
accumulates across invocations the way it would in a real account. Leaving the
|
|
739
820
|
page resets it.
|
|
740
821
|
|
|
822
|
+
**`onActivate` runs on the Activation surface, not here.** Submitting the
|
|
823
|
+
activation form there runs your real `onActivate` hook (if declared) against
|
|
824
|
+
its own `createMockContext()`, seeded from the same `triggerMocks` below, and
|
|
825
|
+
shows the outcome — ran / skipped (not declared) / threw with the message —
|
|
826
|
+
right under the connect dialog.
|
|
827
|
+
|
|
741
828
|
### `triggerMocks`
|
|
742
829
|
|
|
743
830
|
The mock context is derived from the definition — slug, storage schemas, egress
|
|
@@ -821,6 +908,7 @@ API token predictably appears on screen.
|
|
|
821
908
|
| `@ekanos/harness/config` | `defineHarnessConfig()` — an identity function that exists for the inference. |
|
|
822
909
|
| `@ekanos/harness/app` | `RootLayout`, `IndexPage`, `harnessMetadata`. |
|
|
823
910
|
| `@ekanos/harness/routes` | `IntegrationLayout`, `WidgetsPage`, `SingleWidgetPage`, `TilePage`, `ActivationPage`, `TriggersPage`. |
|
|
911
|
+
| `@ekanos/harness/hooks` | `useHarnessDataMode()`, `useLiveActivationData()` — see "Knowing the mode, and the activation-data channel" above. Prefer `FetchProvider`'s own `mode`/`activationData` props where you can; reach for these only from code that runs nowhere but inside the dev harness. |
|
|
824
912
|
| `@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
913
|
| `@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
914
|
|
package/dist/hooks.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public hooks for widget code that only ever runs inside the harness.
|
|
3
|
+
*
|
|
4
|
+
* Most partner code should not need this entry at all: `HarnessLiveMode`'s
|
|
5
|
+
* `FetchProvider` (see `registry.ts`) already receives `mode` and
|
|
6
|
+
* `activationData` as props, which needs no import from this package and
|
|
7
|
+
* behaves the same whether or not `@ekanos/harness` is even installed at the
|
|
8
|
+
* call site. Reach for these two hooks only from code that is genuinely
|
|
9
|
+
* harness-only — a debug banner, a dev-mode-only branch — never from a
|
|
10
|
+
* widget's shipped data-fetching path.
|
|
11
|
+
*/
|
|
12
|
+
export { useHarnessDataMode, useLiveActivationData, } from './internal/lib/harness-hooks.js';
|
package/dist/hooks.js
ADDED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
resolveEgress,
|
|
22
22
|
supportsLiveMode
|
|
23
23
|
} from "../../registry.js";
|
|
24
|
+
import { getThemeToggleAccessibleLabel } from "../lib/theme-toggle.js";
|
|
24
25
|
import {
|
|
25
26
|
RENDER_STATES,
|
|
26
27
|
VIEWPORTS,
|
|
@@ -133,16 +134,30 @@ function DevToolbar() {
|
|
|
133
134
|
size: "sm",
|
|
134
135
|
variant: "outline",
|
|
135
136
|
onClick: () => setTheme(resolvedTheme === "dark" ? "light" : "dark"),
|
|
136
|
-
"aria-label":
|
|
137
|
+
"aria-label": getThemeToggleAccessibleLabel(resolvedTheme),
|
|
137
138
|
children: [
|
|
138
|
-
/* @__PURE__ */ jsxs(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
139
|
+
/* @__PURE__ */ jsxs(
|
|
140
|
+
"span",
|
|
141
|
+
{
|
|
142
|
+
"aria-hidden": "true",
|
|
143
|
+
className: cn("flex items-center dark:hidden"),
|
|
144
|
+
children: [
|
|
145
|
+
/* @__PURE__ */ jsx(Icon, { name: "fa-solid fa-moon", className: "mr-2 h-4 w-4" }),
|
|
146
|
+
"Dark"
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
),
|
|
150
|
+
/* @__PURE__ */ jsxs(
|
|
151
|
+
"span",
|
|
152
|
+
{
|
|
153
|
+
"aria-hidden": "true",
|
|
154
|
+
className: cn("hidden items-center dark:flex"),
|
|
155
|
+
children: [
|
|
156
|
+
/* @__PURE__ */ jsx(Icon, { name: "fa-solid fa-sun-bright", className: "mr-2 h-4 w-4" }),
|
|
157
|
+
"Light"
|
|
158
|
+
]
|
|
159
|
+
}
|
|
160
|
+
)
|
|
146
161
|
]
|
|
147
162
|
}
|
|
148
163
|
),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { Suspense, useMemo } from "react";
|
|
3
|
+
import { Suspense, useMemo, useSyncExternalStore } from "react";
|
|
4
4
|
import {
|
|
5
5
|
IntegrationActivationProvider
|
|
6
6
|
} from "@ekanos/sdk/hooks";
|
|
@@ -18,6 +18,13 @@ import {
|
|
|
18
18
|
logHarnessFetch
|
|
19
19
|
} from "../lib/harness-live-fetch.js";
|
|
20
20
|
import { createHarnessQueryClient } from "../lib/harness-query-client.js";
|
|
21
|
+
import {
|
|
22
|
+
clearLiveActivationData,
|
|
23
|
+
getLiveActivationServerSnapshot,
|
|
24
|
+
getLiveActivationSnapshot,
|
|
25
|
+
setLiveActivationData,
|
|
26
|
+
subscribeLiveActivationData
|
|
27
|
+
} from "../lib/live-activation-store.js";
|
|
21
28
|
import { redactSensitive } from "../lib/redact.js";
|
|
22
29
|
import { AskAssistantBridge } from "./ask-assistant-bridge.js";
|
|
23
30
|
installHarnessFetch();
|
|
@@ -27,6 +34,7 @@ const fixtureActivationActions = {
|
|
|
27
34
|
"[harness] activate (fixture, not persisted):",
|
|
28
35
|
redactSensitive(input)
|
|
29
36
|
);
|
|
37
|
+
setLiveActivationData(input.integrationSlug, input.activationData ?? null);
|
|
30
38
|
return { success: true };
|
|
31
39
|
},
|
|
32
40
|
deactivate: async (input) => {
|
|
@@ -34,6 +42,7 @@ const fixtureActivationActions = {
|
|
|
34
42
|
"[harness] deactivate (fixture, not persisted):",
|
|
35
43
|
redactSensitive(input)
|
|
36
44
|
);
|
|
45
|
+
clearLiveActivationData(input.integrationSlug);
|
|
37
46
|
return { success: true };
|
|
38
47
|
}
|
|
39
48
|
};
|
|
@@ -56,6 +65,11 @@ function HarnessProviders({
|
|
|
56
65
|
}
|
|
57
66
|
return createFixturesFetch();
|
|
58
67
|
}, [mode, egress, slug]);
|
|
68
|
+
const activationData = useSyncExternalStore(
|
|
69
|
+
subscribeLiveActivationData,
|
|
70
|
+
() => getLiveActivationSnapshot(slug),
|
|
71
|
+
getLiveActivationServerSnapshot
|
|
72
|
+
);
|
|
59
73
|
useMemo(() => {
|
|
60
74
|
installHarnessFetch();
|
|
61
75
|
publishHarnessRouting({
|
|
@@ -76,7 +90,15 @@ function HarnessProviders({
|
|
|
76
90
|
/* @__PURE__ */ jsx(AskAssistantBridge, {}),
|
|
77
91
|
/* @__PURE__ */ jsx(Suspense, { fallback: null, children })
|
|
78
92
|
] }) }) }) }) });
|
|
79
|
-
return FetchProvider ? /* @__PURE__ */ jsx(
|
|
93
|
+
return FetchProvider ? /* @__PURE__ */ jsx(
|
|
94
|
+
FetchProvider,
|
|
95
|
+
{
|
|
96
|
+
fetch: harnessFetch,
|
|
97
|
+
mode,
|
|
98
|
+
activationData,
|
|
99
|
+
children: tree
|
|
100
|
+
}
|
|
101
|
+
) : tree;
|
|
80
102
|
}
|
|
81
103
|
export {
|
|
82
104
|
HarnessProviders
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type MockIntegrationContext } from '@ekanos/sdk/testing';
|
|
2
|
+
import { type FixtureVariant, type HarnessIntegration } from '../../registry.js';
|
|
3
|
+
/**
|
|
4
|
+
* One `createMockContext()` derived from a harness integration's definition
|
|
5
|
+
* (slug, storage schemas, egress) plus its `triggerMocks` seeds — the exact
|
|
6
|
+
* construction the Triggers surface uses for webhooks/schedules, extracted
|
|
7
|
+
* here so the Activation surface can run `onActivate` against the SAME kind
|
|
8
|
+
* of context a partner's handlers see elsewhere in the harness.
|
|
9
|
+
*
|
|
10
|
+
* One context per mount: `useState`'s initializer keeps it stable across
|
|
11
|
+
* re-renders without an effect, and state (storage, secrets, logs)
|
|
12
|
+
* accumulates for the life of the visit, like a real account.
|
|
13
|
+
*/
|
|
14
|
+
export declare function useDefinitionMockContext(integration: HarnessIntegration, variant: FixtureVariant): MockIntegrationContext | null;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
createMockContext
|
|
5
|
+
} from "@ekanos/sdk/testing";
|
|
6
|
+
import {
|
|
7
|
+
HARNESS_ACCOUNT_ID,
|
|
8
|
+
resolveEgress
|
|
9
|
+
} from "../../registry.js";
|
|
10
|
+
import { compileFixtures, fixturesToMockHandlers } from "./http-fixtures.js";
|
|
11
|
+
function useDefinitionMockContext(integration, variant) {
|
|
12
|
+
const [ctx] = useState(() => {
|
|
13
|
+
const definition = integration.definition;
|
|
14
|
+
if (!definition) return null;
|
|
15
|
+
const mocks = integration.triggerMocks ?? {};
|
|
16
|
+
return createMockContext({
|
|
17
|
+
accountId: HARNESS_ACCOUNT_ID,
|
|
18
|
+
integration: { slug: definition.slug },
|
|
19
|
+
...definition.storage ? { storageSchemas: definition.storage } : {},
|
|
20
|
+
egress: definition.egress ?? [],
|
|
21
|
+
...mocks.storage ? {
|
|
22
|
+
storage: mocks.storage
|
|
23
|
+
} : {},
|
|
24
|
+
...mocks.secrets ? { secrets: mocks.secrets } : {},
|
|
25
|
+
fetchHandlers: [
|
|
26
|
+
...mocks.fetchHandlers ?? [],
|
|
27
|
+
...fixturesToMockHandlers(
|
|
28
|
+
// Client-only, like the layout: no origin on the server, and
|
|
29
|
+
// nothing fetches there.
|
|
30
|
+
globalThis.location === void 0 ? [] : compileFixtures(
|
|
31
|
+
integration.fixtures?.[variant] ?? [],
|
|
32
|
+
resolveEgress(integration),
|
|
33
|
+
globalThis.location.origin
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
]
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
return ctx;
|
|
40
|
+
}
|
|
41
|
+
export {
|
|
42
|
+
useDefinitionMockContext
|
|
43
|
+
};
|
|
@@ -25,19 +25,34 @@ import { type CompiledFixture, NoRecordedResponseError } from './http-fixtures.j
|
|
|
25
25
|
*
|
|
26
26
|
* ── What is deliberately NOT intercepted ─────────────────────────────────────
|
|
27
27
|
*
|
|
28
|
-
* Same-origin traffic passes through untouched —
|
|
28
|
+
* Same-origin traffic outside `/api/` passes through untouched — in EVERY
|
|
29
|
+
* mode, live included.
|
|
29
30
|
*
|
|
30
|
-
* Measured against a running harness,
|
|
31
|
-
*
|
|
31
|
+
* Measured against a running harness, that traffic is `/_next/static`, the
|
|
32
|
+
* document, and the `?_rsc=` payloads client navigation fetches: the
|
|
32
33
|
* framework's own plumbing, none of it the integration talking to an API.
|
|
33
34
|
* Intercepting it would break the app to no purpose.
|
|
34
35
|
*
|
|
35
|
-
* `/api/` is
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
36
|
+
* ── `/api/` is answered from fixtures in EVERY mode ──────────────────────────
|
|
37
|
+
*
|
|
38
|
+
* `/api/` is the one same-origin exception, because it is the integration's
|
|
39
|
+
* own namespace. A first-party integration's widgets call
|
|
40
|
+
* `/api/integrations/<slug>/…` rather than the vendor directly — the vendor
|
|
41
|
+
* call happens server-side, where the credential lives — and our own Acme
|
|
42
|
+
* example is shaped exactly that way.
|
|
43
|
+
*
|
|
44
|
+
* This is answered from the registry's `fixtures` REGARDLESS of the toolbar's
|
|
45
|
+
* fixtures/live switch. The harness serves no backend in either mode, so there
|
|
46
|
+
* is nothing "live" to run for this traffic even when live mode is on: passing
|
|
47
|
+
* it through would guarantee a 404 whichever mode asked for it, and the useful
|
|
48
|
+
* answer — a fixture, or a refusal that names the URL and says to add one — is
|
|
49
|
+
* the one fixtures mode already gives. A miss is `NoRecordedResponseError`,
|
|
50
|
+
* unconditionally, so the failure a partner sees does not change out from
|
|
51
|
+
* under them when they flip the toolbar.
|
|
52
|
+
*
|
|
53
|
+
* Only genuinely third-party traffic — a different origin — is what "live"
|
|
54
|
+
* actually means: reaching the real vendor under the egress allowlist below,
|
|
55
|
+
* instead of a recorded response.
|
|
41
56
|
*
|
|
42
57
|
* Scoped to `/api/` rather than "everything except `/_next/`" deliberately: a
|
|
43
58
|
* rule that has to enumerate the framework's internals is a rule that breaks
|
|
@@ -50,9 +50,19 @@ function installHarnessFetch() {
|
|
|
50
50
|
} catch {
|
|
51
51
|
throw new NoRecordedResponseError(method, target, true);
|
|
52
52
|
}
|
|
53
|
-
|
|
53
|
+
const isSameOrigin = globalThis.location !== void 0 && url.origin === globalThis.location.origin;
|
|
54
|
+
if (isSameOrigin && !url.pathname.startsWith("/api/")) {
|
|
54
55
|
return original(input, init);
|
|
55
56
|
}
|
|
57
|
+
if (isSameOrigin) {
|
|
58
|
+
const fixture2 = matchHttpFixture(method, url.toString(), active.fixtures);
|
|
59
|
+
if (!fixture2) {
|
|
60
|
+
notify(url, method, "refused");
|
|
61
|
+
throw new NoRecordedResponseError(method, redact(url), false);
|
|
62
|
+
}
|
|
63
|
+
notify(url, method, "fixture");
|
|
64
|
+
return fixtureResponse(fixture2);
|
|
65
|
+
}
|
|
56
66
|
if (active.mode === "live") {
|
|
57
67
|
if (!isEgressAllowed(url.toString(), active.egress)) {
|
|
58
68
|
notify(url, method, "refused");
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type DataMode } from '../../registry.js';
|
|
2
|
+
/**
|
|
3
|
+
* The two public hooks behind `@ekanos/harness/hooks` (see src/hooks.ts).
|
|
4
|
+
*
|
|
5
|
+
* Both exist so widget code can ask "what mode am I in / what did the user
|
|
6
|
+
* just activate with" WITHOUT reaching for `globalThis.fetch` or reimplementing
|
|
7
|
+
* the toolbar's own state. Prefer the props `HarnessLiveMode.FetchProvider`
|
|
8
|
+
* already receives (`mode`, `activationData`) wherever your `FetchProvider`
|
|
9
|
+
* is already in the tree — those need no import from this package, and behave
|
|
10
|
+
* identically whether or not `@ekanos/harness` is even installed at the call
|
|
11
|
+
* site. Reach for these hooks only from code that runs nowhere but inside the
|
|
12
|
+
* dev harness.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* `'fixtures' | 'live'` for the integration currently rendering — the
|
|
16
|
+
* EFFECTIVE mode, not the toolbar's raw preference. See
|
|
17
|
+
* `resolveEffectiveDataMode` in `registry.ts`: an integration with no `live`
|
|
18
|
+
* block, or one whose `live.egress` mismatches its definition, reports
|
|
19
|
+
* `'fixtures'` here even with the toolbar switched to Live API.
|
|
20
|
+
*/
|
|
21
|
+
export declare function useHarnessDataMode(): DataMode;
|
|
22
|
+
/**
|
|
23
|
+
* The `activationData` most recently submitted to THIS integration's
|
|
24
|
+
* activation form — `null` before any submission, after Disconnect, or while
|
|
25
|
+
* a different integration's activation is the one currently held.
|
|
26
|
+
*
|
|
27
|
+
* See `live-activation-store.ts` for the full contract: in memory only, never
|
|
28
|
+
* persisted, cleared on reload and on disconnect.
|
|
29
|
+
*/
|
|
30
|
+
export declare function useLiveActivationData(): unknown;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useSyncExternalStore } from "react";
|
|
3
|
+
import { resolveEffectiveDataMode } from "../../registry.js";
|
|
4
|
+
import { useHarnessIntegration } from "../registry-context.js";
|
|
5
|
+
import {
|
|
6
|
+
getLiveActivationServerSnapshot,
|
|
7
|
+
getLiveActivationSnapshot,
|
|
8
|
+
subscribeLiveActivationData
|
|
9
|
+
} from "./live-activation-store.js";
|
|
10
|
+
import { useToolbar } from "./toolbar-context.js";
|
|
11
|
+
function useHarnessDataMode() {
|
|
12
|
+
const integration = useHarnessIntegration();
|
|
13
|
+
const { state } = useToolbar();
|
|
14
|
+
return resolveEffectiveDataMode(integration, state.dataMode);
|
|
15
|
+
}
|
|
16
|
+
function useLiveActivationData() {
|
|
17
|
+
const integration = useHarnessIntegration();
|
|
18
|
+
const slug = integration?.slug ?? "";
|
|
19
|
+
return useSyncExternalStore(
|
|
20
|
+
subscribeLiveActivationData,
|
|
21
|
+
() => getLiveActivationSnapshot(slug),
|
|
22
|
+
getLiveActivationServerSnapshot
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
export {
|
|
26
|
+
useHarnessDataMode,
|
|
27
|
+
useLiveActivationData
|
|
28
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
* THE LIVE-MODE ACTIVATION-DATA CHANNEL — in memory, never persisted
|
|
4
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* A live-mode widget's own query function has nowhere to get a vendor
|
|
7
|
+
* credential from without this. `ctx.secrets` is server-only, and the
|
|
8
|
+
* activation form's payload used to die the moment its `onSuccess` callback
|
|
9
|
+
* returned. A real partner build found the same workaround every time: a
|
|
10
|
+
* bespoke module-level store, set from the activation form after
|
|
11
|
+
* `useActivateIntegration()` resolved, read by a context the widgets
|
|
12
|
+
* consulted directly (see the Priority Passport reference this generalises —
|
|
13
|
+
* `setLiveCredentials` / `useLiveCredentials`).
|
|
14
|
+
*
|
|
15
|
+
* `fixtureActivationActions.activate` in harness-providers.tsx already HOLDS
|
|
16
|
+
* the submitted `activationData` — it is the function's own argument — so
|
|
17
|
+
* capturing it here costs nothing new. What this module adds is the one thing
|
|
18
|
+
* a bespoke per-integration store cannot: ONE channel every activation flows
|
|
19
|
+
* through (the plain form, the OAuth form — anything built on
|
|
20
|
+
* `useActivateIntegration()`), keyed by `integrationSlug` so switching between
|
|
21
|
+
* two registered integrations without a reload cannot leak one's credential
|
|
22
|
+
* into the other's widgets.
|
|
23
|
+
*
|
|
24
|
+
* The invariants, stated plainly because this is exactly the kind of
|
|
25
|
+
* convenience that leaks into production if it is not:
|
|
26
|
+
*
|
|
27
|
+
* - IN MEMORY ONLY. A module-level variable, nothing else — never written to
|
|
28
|
+
* `localStorage`, a fixture, or the registry.
|
|
29
|
+
* - CLEARED ON RELOAD. There is nothing to clear: the module is
|
|
30
|
+
* re-evaluated and the variable starts back at `null`.
|
|
31
|
+
* - CLEARED ON DISCONNECT. `fixtureActivationActions.deactivate` and the
|
|
32
|
+
* Activation surface's own Disconnect / Reset flow controls all clear the
|
|
33
|
+
* entry for that slug, so a disconnected integration's widgets cannot keep
|
|
34
|
+
* using a credential the UI says is gone.
|
|
35
|
+
* - A DEV-ONLY CONVENIENCE. Production hands your handlers `ctx.secrets`
|
|
36
|
+
* instead — server-side, encrypted, never in the browser at all. This
|
|
37
|
+
* exists only because the harness has no server to hold a secret for the
|
|
38
|
+
* browser to borrow from.
|
|
39
|
+
*/
|
|
40
|
+
/** Recorded the moment an activation flow reports success. See above. */
|
|
41
|
+
export declare function setLiveActivationData(slug: string, activationData: unknown): void;
|
|
42
|
+
/** A no-op for any slug other than the one currently held. */
|
|
43
|
+
export declare function clearLiveActivationData(slug: string): void;
|
|
44
|
+
export declare function subscribeLiveActivationData(listener: () => void): () => void;
|
|
45
|
+
/** `null` when nothing has been submitted for `slug` yet, or it belongs to a different one. */
|
|
46
|
+
export declare function getLiveActivationSnapshot(slug: string): unknown;
|
|
47
|
+
export declare function getLiveActivationServerSnapshot(): null;
|
|
48
|
+
/** Test seam: forget everything, regardless of slug. */
|
|
49
|
+
export declare function resetLiveActivationData(): void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
let entry = null;
|
|
2
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
3
|
+
function setLiveActivationData(slug, activationData) {
|
|
4
|
+
entry = { slug, activationData: activationData ?? null };
|
|
5
|
+
listeners.forEach((listener) => listener());
|
|
6
|
+
}
|
|
7
|
+
function clearLiveActivationData(slug) {
|
|
8
|
+
if (entry?.slug !== slug) return;
|
|
9
|
+
entry = null;
|
|
10
|
+
listeners.forEach((listener) => listener());
|
|
11
|
+
}
|
|
12
|
+
function subscribeLiveActivationData(listener) {
|
|
13
|
+
listeners.add(listener);
|
|
14
|
+
return () => {
|
|
15
|
+
listeners.delete(listener);
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function getLiveActivationSnapshot(slug) {
|
|
19
|
+
return entry?.slug === slug ? entry.activationData : null;
|
|
20
|
+
}
|
|
21
|
+
function getLiveActivationServerSnapshot() {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
function resetLiveActivationData() {
|
|
25
|
+
entry = null;
|
|
26
|
+
}
|
|
27
|
+
export {
|
|
28
|
+
clearLiveActivationData,
|
|
29
|
+
getLiveActivationServerSnapshot,
|
|
30
|
+
getLiveActivationSnapshot,
|
|
31
|
+
resetLiveActivationData,
|
|
32
|
+
setLiveActivationData,
|
|
33
|
+
subscribeLiveActivationData
|
|
34
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { IntegrationDefinition } from '@ekanos/sdk/integration';
|
|
2
|
+
import { type MockIntegrationContext } from '@ekanos/sdk/testing';
|
|
3
|
+
/**
|
|
4
|
+
* The outcome of one local run of a definition's `onActivate` hook, for the
|
|
5
|
+
* Activation surface's readout:
|
|
6
|
+
*
|
|
7
|
+
* - `'skipped'` — the definition (or the harness entry) declares no
|
|
8
|
+
* `onActivate` at all. Not an error: most integrations have nothing to
|
|
9
|
+
* seed and this is a perfectly good answer.
|
|
10
|
+
* - `'ran'` — the hook completed without throwing.
|
|
11
|
+
* - `'threw'` — the hook threw. In production this is NON-FATAL (logged
|
|
12
|
+
* as a warning, activation stays active — see `OnActivateHandler`'s
|
|
13
|
+
* TSDoc); the harness surfaces the same "not fatal, but look at this"
|
|
14
|
+
* framing rather than treating it as a failed activation.
|
|
15
|
+
*
|
|
16
|
+
* Pulled out of the activation route component so it is testable without
|
|
17
|
+
* rendering anything — the harness deliberately carries no component/DOM
|
|
18
|
+
* tests (see vitest.config.ts), only the underlying logic.
|
|
19
|
+
*/
|
|
20
|
+
export type OnActivateOutcome = {
|
|
21
|
+
status: 'skipped';
|
|
22
|
+
} | {
|
|
23
|
+
status: 'ran';
|
|
24
|
+
} | {
|
|
25
|
+
status: 'threw';
|
|
26
|
+
message: string;
|
|
27
|
+
};
|
|
28
|
+
export declare function runOnActivateHook(definition: IntegrationDefinition | undefined, ctx: MockIntegrationContext | null): Promise<OnActivateOutcome>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {
|
|
2
|
+
invokeActivate
|
|
3
|
+
} from "@ekanos/sdk/testing";
|
|
4
|
+
function describeError(error) {
|
|
5
|
+
return error instanceof Error ? error.message : String(error);
|
|
6
|
+
}
|
|
7
|
+
async function runOnActivateHook(definition, ctx) {
|
|
8
|
+
if (!definition?.onActivate || !ctx) {
|
|
9
|
+
return { status: "skipped" };
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
await invokeActivate(definition, { context: ctx });
|
|
13
|
+
return { status: "ran" };
|
|
14
|
+
} catch (error) {
|
|
15
|
+
return { status: "threw", message: describeError(error) };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export {
|
|
19
|
+
runOnActivateHook
|
|
20
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure label logic for the dev toolbar's theme toggle button, split out of
|
|
3
|
+
* `DevToolbar` so it is testable without a DOM (this package's vitest config
|
|
4
|
+
* runs in `node` and deliberately has no component-rendering tests — see
|
|
5
|
+
* `vitest.config.ts`).
|
|
6
|
+
*
|
|
7
|
+
* The button renders BOTH "Dark"/"Light" label spans and lets CSS's `dark:`
|
|
8
|
+
* variant pick one for sighted users — necessary so the very first frame is
|
|
9
|
+
* already correct (see the comment in `dev-toolbar.tsx` on why `resolvedTheme`
|
|
10
|
+
* is not read during render for the visual label). Without an explicit
|
|
11
|
+
* `aria-label`, a screen reader concatenates BOTH spans' text into the
|
|
12
|
+
* accessible name ("DarkLight"), and the visible text names the CURRENT
|
|
13
|
+
* theme rather than the action the button performs. `getAccessibleLabel`
|
|
14
|
+
* supplies the correct accessible name instead: the ACTION the click
|
|
15
|
+
* performs, not the current state.
|
|
16
|
+
*/
|
|
17
|
+
export declare function getThemeToggleAccessibleLabel(resolvedTheme: string | undefined): string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { notFound } from "next/navigation";
|
|
5
5
|
import { Alert, AlertDescription, AlertTitle } from "@ekanos/ui/alert";
|
|
@@ -13,11 +13,34 @@ import {
|
|
|
13
13
|
DialogTitle
|
|
14
14
|
} from "@ekanos/ui/dialog";
|
|
15
15
|
import { HARNESS_ACCOUNT_ID } from "../../registry.js";
|
|
16
|
+
import { useDefinitionMockContext } from "../lib/definition-mock-context.js";
|
|
17
|
+
import {
|
|
18
|
+
useHarnessDataMode,
|
|
19
|
+
useLiveActivationData
|
|
20
|
+
} from "../lib/harness-hooks.js";
|
|
21
|
+
import { clearLiveActivationData } from "../lib/live-activation-store.js";
|
|
16
22
|
import { hasSensitiveValues, redactSensitive } from "../lib/redact.js";
|
|
23
|
+
import {
|
|
24
|
+
runOnActivateHook
|
|
25
|
+
} from "../lib/run-activation-hook.js";
|
|
26
|
+
import { useToolbar } from "../lib/toolbar-context.js";
|
|
17
27
|
import { useHarnessIntegration } from "../registry-context.js";
|
|
18
28
|
function ActivationPage() {
|
|
19
|
-
const [flow, setFlow] = useState({
|
|
29
|
+
const [flow, setFlow] = useState({
|
|
30
|
+
open: true,
|
|
31
|
+
connected: false,
|
|
32
|
+
result: null,
|
|
33
|
+
revealed: false,
|
|
34
|
+
activationHook: null
|
|
35
|
+
});
|
|
20
36
|
const integration = useHarnessIntegration();
|
|
37
|
+
const { state: toolbar } = useToolbar();
|
|
38
|
+
const mode = useHarnessDataMode();
|
|
39
|
+
const activationData = useLiveActivationData();
|
|
40
|
+
const ctx = useDefinitionMockContext(
|
|
41
|
+
integration ?? { slug: "", name: "", description: "", widgets: [] },
|
|
42
|
+
toolbar.variant
|
|
43
|
+
);
|
|
21
44
|
if (!integration) notFound();
|
|
22
45
|
const ActivationForm = integration.activationForm;
|
|
23
46
|
const definition = integration.definition;
|
|
@@ -63,12 +86,16 @@ function ActivationPage() {
|
|
|
63
86
|
{
|
|
64
87
|
variant: "ghost",
|
|
65
88
|
size: "sm",
|
|
66
|
-
onClick: () =>
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
89
|
+
onClick: () => {
|
|
90
|
+
clearLiveActivationData(integration.slug);
|
|
91
|
+
setFlow({
|
|
92
|
+
open: false,
|
|
93
|
+
connected: false,
|
|
94
|
+
result: null,
|
|
95
|
+
revealed: false,
|
|
96
|
+
activationHook: null
|
|
97
|
+
});
|
|
98
|
+
},
|
|
72
99
|
children: "Reset flow"
|
|
73
100
|
}
|
|
74
101
|
)
|
|
@@ -94,12 +121,18 @@ function ActivationPage() {
|
|
|
94
121
|
ConnectedState,
|
|
95
122
|
{
|
|
96
123
|
name: integration.name,
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
124
|
+
mode,
|
|
125
|
+
hasLiveActivationData: activationData !== null,
|
|
126
|
+
onDisconnect: () => {
|
|
127
|
+
clearLiveActivationData(integration.slug);
|
|
128
|
+
setFlow({
|
|
129
|
+
open: true,
|
|
130
|
+
connected: false,
|
|
131
|
+
result: null,
|
|
132
|
+
revealed: false,
|
|
133
|
+
activationHook: null
|
|
134
|
+
});
|
|
135
|
+
}
|
|
103
136
|
}
|
|
104
137
|
) : (
|
|
105
138
|
/*
|
|
@@ -113,12 +146,19 @@ function ActivationPage() {
|
|
|
113
146
|
accountId: HARNESS_ACCOUNT_ID,
|
|
114
147
|
productSlug: integration.slug,
|
|
115
148
|
inline: true,
|
|
116
|
-
onSuccess: (result) =>
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
149
|
+
onSuccess: async (result) => {
|
|
150
|
+
const activationHook = await runOnActivateHook(
|
|
151
|
+
definition,
|
|
152
|
+
ctx
|
|
153
|
+
);
|
|
154
|
+
setFlow({
|
|
155
|
+
open: false,
|
|
156
|
+
connected: true,
|
|
157
|
+
result: result ?? null,
|
|
158
|
+
revealed: false,
|
|
159
|
+
activationHook
|
|
160
|
+
});
|
|
161
|
+
},
|
|
122
162
|
onCancel: () => setFlow((prev) => ({ ...prev, open: false }))
|
|
123
163
|
}
|
|
124
164
|
)
|
|
@@ -174,7 +214,34 @@ function ActivationPage() {
|
|
|
174
214
|
null,
|
|
175
215
|
2
|
|
176
216
|
) })
|
|
177
|
-
] }) : null
|
|
217
|
+
] }) : null,
|
|
218
|
+
flow.activationHook ? /* @__PURE__ */ jsx(OnActivatePanel, { outcome: flow.activationHook }) : null
|
|
219
|
+
]
|
|
220
|
+
}
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
function OnActivatePanel({ outcome }) {
|
|
224
|
+
const badge = outcome.status === "ran" ? { variant: "default", label: "onActivate ran" } : outcome.status === "skipped" ? {
|
|
225
|
+
variant: "secondary",
|
|
226
|
+
label: "onActivate skipped \u2014 not declared"
|
|
227
|
+
} : { variant: "destructive", label: "onActivate threw" };
|
|
228
|
+
return /* @__PURE__ */ jsxs(
|
|
229
|
+
"div",
|
|
230
|
+
{
|
|
231
|
+
className: "bg-card flex flex-col gap-2 rounded-lg border p-6",
|
|
232
|
+
"data-test": "activation-onactivate-outcome",
|
|
233
|
+
children: [
|
|
234
|
+
/* @__PURE__ */ jsx("div", { className: "flex items-center gap-2", children: /* @__PURE__ */ jsx(Badge, { variant: badge.variant, children: badge.label }) }),
|
|
235
|
+
outcome.status === "skipped" ? /* @__PURE__ */ jsxs("p", { className: "text-muted-foreground text-sm", children: [
|
|
236
|
+
"This integration declares no ",
|
|
237
|
+
/* @__PURE__ */ jsx("code", { children: "onActivate" }),
|
|
238
|
+
" hook \u2014 a perfectly good answer for one with nothing to seed at connect time. Add one to ",
|
|
239
|
+
/* @__PURE__ */ jsx("code", { children: "defineIntegration()" }),
|
|
240
|
+
" to warm a cache or sanity-check a credential the moment a user connects."
|
|
241
|
+
] }) : outcome.status === "threw" ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
242
|
+
/* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-sm", children: "In production this is NON-FATAL: the throw is logged and shown to the user as a warning, and the activation stays connected \u2014 this hook is for cache seeding and eager validation, not a connect gate." }),
|
|
243
|
+
/* @__PURE__ */ jsx("pre", { className: "bg-muted overflow-x-auto rounded-md p-3 text-xs", children: outcome.message })
|
|
244
|
+
] }) : /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-sm", children: "Ran against the same kind of capability context (storage, secrets, egress) your schedules and webhooks run against." })
|
|
178
245
|
]
|
|
179
246
|
}
|
|
180
247
|
);
|
|
@@ -218,6 +285,8 @@ function PermissionList({
|
|
|
218
285
|
}
|
|
219
286
|
function ConnectedState({
|
|
220
287
|
name,
|
|
288
|
+
mode,
|
|
289
|
+
hasLiveActivationData,
|
|
221
290
|
onDisconnect
|
|
222
291
|
}) {
|
|
223
292
|
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-5", children: [
|
|
@@ -225,6 +294,14 @@ function ConnectedState({
|
|
|
225
294
|
name,
|
|
226
295
|
" is connected to your workspace."
|
|
227
296
|
] }),
|
|
297
|
+
/* @__PURE__ */ jsx(
|
|
298
|
+
"p",
|
|
299
|
+
{
|
|
300
|
+
className: "text-muted-foreground text-sm",
|
|
301
|
+
"data-test": "activation-mode-notice",
|
|
302
|
+
children: mode === "live" ? hasLiveActivationData ? "The toolbar is on Live API \u2014 this activation data is available to live-mode widgets on this visit." : "The toolbar is on Live API, but this activation submitted no data for live-mode widgets to authenticate with." : "This surface is Fusion-mocked; widgets read fixtures \u2014 switch the toolbar to Live API to exercise real requests."
|
|
303
|
+
}
|
|
304
|
+
),
|
|
228
305
|
/* @__PURE__ */ jsx(
|
|
229
306
|
Button,
|
|
230
307
|
{
|
|
@@ -5,9 +5,9 @@ import { notFound } from "next/navigation";
|
|
|
5
5
|
import { Alert, AlertDescription, AlertTitle } from "@ekanos/ui/alert";
|
|
6
6
|
import {
|
|
7
7
|
findEgressMismatch,
|
|
8
|
+
resolveEffectiveDataMode,
|
|
8
9
|
resolveEgress,
|
|
9
|
-
seedsForMode
|
|
10
|
-
supportsLiveMode
|
|
10
|
+
seedsForMode
|
|
11
11
|
} from "../../registry.js";
|
|
12
12
|
import { HarnessProviders } from "../components/harness-providers.js";
|
|
13
13
|
import { SurfaceNav } from "../components/surface-nav.js";
|
|
@@ -21,7 +21,7 @@ function IntegrationLayout({ children }) {
|
|
|
21
21
|
() => integration ? findEgressMismatch(integration) : null,
|
|
22
22
|
[integration]
|
|
23
23
|
);
|
|
24
|
-
const mode =
|
|
24
|
+
const mode = resolveEffectiveDataMode(integration, state.dataMode);
|
|
25
25
|
const seeds = useMemo(
|
|
26
26
|
() => integration ? seedsForMode(integration, state.variant, mode) : [],
|
|
27
27
|
[integration, state.variant, mode]
|
|
@@ -4,7 +4,6 @@ import { useState } from "react";
|
|
|
4
4
|
import { notFound } from "next/navigation";
|
|
5
5
|
import { isEgressAllowed } from "@ekanos/sdk/context";
|
|
6
6
|
import {
|
|
7
|
-
createMockContext,
|
|
8
7
|
invokeSchedule,
|
|
9
8
|
invokeWebhook
|
|
10
9
|
} from "@ekanos/sdk/testing";
|
|
@@ -12,43 +11,9 @@ import { Alert, AlertDescription, AlertTitle } from "@ekanos/ui/alert";
|
|
|
12
11
|
import { Badge } from "@ekanos/ui/badge";
|
|
13
12
|
import { Button } from "@ekanos/ui/button";
|
|
14
13
|
import { Textarea } from "@ekanos/ui/textarea";
|
|
15
|
-
import {
|
|
16
|
-
HARNESS_ACCOUNT_ID,
|
|
17
|
-
resolveEgress
|
|
18
|
-
} from "../../registry.js";
|
|
19
|
-
import { compileFixtures, fixturesToMockHandlers } from "../lib/http-fixtures.js";
|
|
14
|
+
import { useDefinitionMockContext } from "../lib/definition-mock-context.js";
|
|
20
15
|
import { useToolbar } from "../lib/toolbar-context.js";
|
|
21
16
|
import { useHarnessIntegration } from "../registry-context.js";
|
|
22
|
-
function useTriggerContext(integration, variant) {
|
|
23
|
-
const [ctx] = useState(() => {
|
|
24
|
-
const definition = integration.definition;
|
|
25
|
-
if (!definition) return null;
|
|
26
|
-
const mocks = integration.triggerMocks ?? {};
|
|
27
|
-
return createMockContext({
|
|
28
|
-
accountId: HARNESS_ACCOUNT_ID,
|
|
29
|
-
integration: { slug: definition.slug },
|
|
30
|
-
...definition.storage ? { storageSchemas: definition.storage } : {},
|
|
31
|
-
egress: definition.egress ?? [],
|
|
32
|
-
...mocks.storage ? {
|
|
33
|
-
storage: mocks.storage
|
|
34
|
-
} : {},
|
|
35
|
-
...mocks.secrets ? { secrets: mocks.secrets } : {},
|
|
36
|
-
fetchHandlers: [
|
|
37
|
-
...mocks.fetchHandlers ?? [],
|
|
38
|
-
...fixturesToMockHandlers(
|
|
39
|
-
// Client-only, like the layout: no origin on the server, and
|
|
40
|
-
// nothing fetches there.
|
|
41
|
-
globalThis.location === void 0 ? [] : compileFixtures(
|
|
42
|
-
integration.fixtures?.[variant] ?? [],
|
|
43
|
-
resolveEgress(integration),
|
|
44
|
-
globalThis.location.origin
|
|
45
|
-
)
|
|
46
|
-
)
|
|
47
|
-
]
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
return ctx;
|
|
51
|
-
}
|
|
52
17
|
function OutcomePanel({ outcome }) {
|
|
53
18
|
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3", "data-test": "trigger-outcome", children: [
|
|
54
19
|
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
@@ -320,7 +285,7 @@ function OAuthCard({ definition }) {
|
|
|
320
285
|
function TriggersPage() {
|
|
321
286
|
const integration = useHarnessIntegration();
|
|
322
287
|
const { state } = useToolbar();
|
|
323
|
-
const ctx =
|
|
288
|
+
const ctx = useDefinitionMockContext(
|
|
324
289
|
integration ?? { slug: "", name: "", description: "", widgets: [] },
|
|
325
290
|
state.variant
|
|
326
291
|
);
|
package/dist/registry.d.ts
CHANGED
|
@@ -193,12 +193,60 @@ export interface HarnessLiveMode {
|
|
|
193
193
|
* the full worked example.
|
|
194
194
|
*
|
|
195
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
|
|
197
|
-
* whose request is under `/api/` is answered
|
|
198
|
-
* the
|
|
196
|
+
* routes, because the harness could not serve those. It can, in EITHER
|
|
197
|
+
* mode: a fixture whose request is under `/api/` is answered from your
|
|
198
|
+
* registry entry whether the toolbar says Fixtures or Live API, which is
|
|
199
|
+
* how the Acme example's widgets work. There is nothing "live" to run for
|
|
200
|
+
* that traffic — the harness serves no backend either way.)
|
|
199
201
|
*/
|
|
200
202
|
FetchProvider?: ComponentType<{
|
|
201
203
|
fetch: IntegrationFetch;
|
|
204
|
+
/**
|
|
205
|
+
* `'fixtures' | 'live'` — which mode is actually running right now, so a
|
|
206
|
+
* partner's own provider can expose it to widgets (or just log it) with
|
|
207
|
+
* no context of its own and no import from this package. The
|
|
208
|
+
* `useHarnessDataMode()` hook in `@ekanos/harness/hooks` answers the same
|
|
209
|
+
* question for code that runs outside a mounted `FetchProvider`.
|
|
210
|
+
*/
|
|
211
|
+
mode: DataMode;
|
|
212
|
+
/**
|
|
213
|
+
* The `activationData` most recently submitted to THIS integration's
|
|
214
|
+
* activation form — `null` before any submission, after Disconnect, or
|
|
215
|
+
* while a different integration's activation is the one currently held.
|
|
216
|
+
*
|
|
217
|
+
* The harness's sanctioned live-credential channel: every activation flow
|
|
218
|
+
* (the plain form, the OAuth form — anything built on
|
|
219
|
+
* `useActivateIntegration()`) already receives this as its own argument,
|
|
220
|
+
* and the harness threads it here instead of making every partner
|
|
221
|
+
* reinvent the module-level store that motivated this field. IN MEMORY
|
|
222
|
+
* ONLY — never written to `localStorage`, a fixture, or the registry —
|
|
223
|
+
* and cleared on reload and on Disconnect. See
|
|
224
|
+
* `internal/lib/live-activation-store.ts` for the full contract.
|
|
225
|
+
*
|
|
226
|
+
* A DEV-ONLY CONVENIENCE: production hands your handlers `ctx.secrets`
|
|
227
|
+
* instead, server-side and encrypted. Nothing reaching this prop should
|
|
228
|
+
* be mistaken for that.
|
|
229
|
+
*
|
|
230
|
+
* Worked example — an activation field landing in a live request header:
|
|
231
|
+
*
|
|
232
|
+
* ```tsx
|
|
233
|
+
* export function AcmeFetchProvider({ fetch, activationData, children }: {
|
|
234
|
+
* fetch: IntegrationFetch;
|
|
235
|
+
* mode: DataMode;
|
|
236
|
+
* activationData: unknown;
|
|
237
|
+
* children: ReactNode;
|
|
238
|
+
* }) {
|
|
239
|
+
* const token = (activationData as { apiToken?: string } | null)?.apiToken;
|
|
240
|
+
* const authedFetch: IntegrationFetch = (input, init) =>
|
|
241
|
+
* fetch(input, {
|
|
242
|
+
* ...init,
|
|
243
|
+
* headers: { ...init?.headers, ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
244
|
+
* });
|
|
245
|
+
* return <AcmeFetchContext value={authedFetch}>{children}</AcmeFetchContext>;
|
|
246
|
+
* }
|
|
247
|
+
* ```
|
|
248
|
+
*/
|
|
249
|
+
activationData: unknown;
|
|
202
250
|
children: ReactNode;
|
|
203
251
|
}>;
|
|
204
252
|
/**
|
|
@@ -416,3 +464,18 @@ export declare function resolveEgress(integration: HarnessIntegration): readonly
|
|
|
416
464
|
export declare function supportsLiveMode(integration: HarnessIntegration | null): integration is HarnessIntegration & {
|
|
417
465
|
live: HarnessLiveMode;
|
|
418
466
|
};
|
|
467
|
+
/**
|
|
468
|
+
* The EFFECTIVE data mode for one integration — not the toolbar's raw
|
|
469
|
+
* preference.
|
|
470
|
+
*
|
|
471
|
+
* Live mode has to be forced back to `'fixtures'` whenever the toolbar switch
|
|
472
|
+
* would not mean anything: an integration with no `live` block has nothing to
|
|
473
|
+
* switch, and a `live.egress`/definition mismatch (`findEgressMismatch`) would
|
|
474
|
+
* run the switch against an allowlist production never validated — the false
|
|
475
|
+
* green live mode exists to prevent.
|
|
476
|
+
*
|
|
477
|
+
* `IntegrationLayout` and `useHarnessDataMode()` both call this rather than
|
|
478
|
+
* each recomputing it, so there is exactly one place that decides and the
|
|
479
|
+
* hook can never report a mode other than the one that actually rendered.
|
|
480
|
+
*/
|
|
481
|
+
export declare function resolveEffectiveDataMode(integration: HarnessIntegration | null, toolbarMode: DataMode): DataMode;
|
package/dist/registry.js
CHANGED
|
@@ -64,6 +64,11 @@ function resolveEgress(integration) {
|
|
|
64
64
|
function supportsLiveMode(integration) {
|
|
65
65
|
return integration?.live !== void 0;
|
|
66
66
|
}
|
|
67
|
+
function resolveEffectiveDataMode(integration, toolbarMode) {
|
|
68
|
+
if (!supportsLiveMode(integration)) return "fixtures";
|
|
69
|
+
if (findEgressMismatch(integration)) return "fixtures";
|
|
70
|
+
return toolbarMode;
|
|
71
|
+
}
|
|
67
72
|
export {
|
|
68
73
|
DATA_MODES,
|
|
69
74
|
FIXTURE_VARIANTS,
|
|
@@ -76,6 +81,7 @@ export {
|
|
|
76
81
|
findIntegration,
|
|
77
82
|
findWidget,
|
|
78
83
|
harnessWidgetsFromDefinition,
|
|
84
|
+
resolveEffectiveDataMode,
|
|
79
85
|
resolveEgress,
|
|
80
86
|
seedsForMode,
|
|
81
87
|
supportsLiveMode
|
package/dist/styles.css
CHANGED
|
@@ -161,8 +161,12 @@ i:is(.fa-light, .fa-thin, .fa-duotone, .fa-sharp) {
|
|
|
161
161
|
* below and the authored Pro glyph renders exactly as designed. Nothing here
|
|
162
162
|
* changes what a correctly licensed host shows.
|
|
163
163
|
*
|
|
164
|
-
* Keep this list to names our own bundled examples actually reference
|
|
165
|
-
*
|
|
164
|
+
* Keep this list to names our own bundled examples actually reference, or a
|
|
165
|
+
* real Pro-only name a partner build hit and reported (the block below it) —
|
|
166
|
+
* it is a courtesy for demo/partner surfaces, not a general-purpose
|
|
167
|
+
* Pro→Free shim covering every Pro glyph. `src/_impl/__tests__/
|
|
168
|
+
* fa-free-icon-names.test.tsx` in `@ekanos/ui` asserts every name here is
|
|
169
|
+
* genuinely Free-missing and every codepoint names a real Free glyph. */
|
|
166
170
|
i.fa-sun-bright {
|
|
167
171
|
--fa: '\f185'; /* fa-sun */
|
|
168
172
|
}
|
|
@@ -194,6 +198,37 @@ i.fa-sparkles {
|
|
|
194
198
|
--fa: '\e2ca'; /* fa-wand-magic-sparkles */
|
|
195
199
|
}
|
|
196
200
|
|
|
201
|
+
/* Reported by a partner build (2026-09-08): all seven are real Font Awesome
|
|
202
|
+
* PRO names (verified against packages/ui/fontawesome) that Free does not
|
|
203
|
+
* ship, so every one of them was rendering the bare circle-question fallback
|
|
204
|
+
* with no indication why. `fa-circle-question` itself was also reported, but
|
|
205
|
+
* it is NOT Free-missing — Free ships it natively at the same codepoint
|
|
206
|
+
* (`\f059`) the fallback rule above uses, so a probe that treats "content
|
|
207
|
+
* resolved to \f059" as "broken" cannot tell a genuine circle-question icon
|
|
208
|
+
* apart from the fallback. That is a probe-methodology caveat, not a shim
|
|
209
|
+
* this file can fix. */
|
|
210
|
+
i.fa-cloud-snow {
|
|
211
|
+
--fa: '\f2dc'; /* fa-snowflake */
|
|
212
|
+
}
|
|
213
|
+
i.fa-cloud-hail {
|
|
214
|
+
--fa: '\f73b'; /* fa-cloud-meatball */
|
|
215
|
+
}
|
|
216
|
+
i.fa-cloud-hail-mixed {
|
|
217
|
+
--fa: '\f73b'; /* fa-cloud-meatball */
|
|
218
|
+
}
|
|
219
|
+
i.fa-fog {
|
|
220
|
+
--fa: '\f75f'; /* fa-smog */
|
|
221
|
+
}
|
|
222
|
+
i.fa-sun-cloud {
|
|
223
|
+
--fa: '\f6c4'; /* fa-cloud-sun */
|
|
224
|
+
}
|
|
225
|
+
i.fa-moon-cloud {
|
|
226
|
+
--fa: '\f6c3'; /* fa-cloud-moon */
|
|
227
|
+
}
|
|
228
|
+
i.fa-cloud-bolt-sun {
|
|
229
|
+
--fa: '\f76c'; /* fa-cloud-bolt */
|
|
230
|
+
}
|
|
231
|
+
|
|
197
232
|
@layer base {
|
|
198
233
|
body {
|
|
199
234
|
@apply bg-background text-foreground;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ekanos/harness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The Ekanos integration dev harness — every surface of an integration rendered in real Fusion chrome, from fixtures, with no Supabase, auth or network.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,6 +36,10 @@
|
|
|
36
36
|
"types": "./dist/routes.d.ts",
|
|
37
37
|
"default": "./dist/routes.js"
|
|
38
38
|
},
|
|
39
|
+
"./hooks": {
|
|
40
|
+
"types": "./dist/hooks.d.ts",
|
|
41
|
+
"default": "./dist/hooks.js"
|
|
42
|
+
},
|
|
39
43
|
"./styles.css": "./dist/styles.css",
|
|
40
44
|
"./mocks/team-account-workspace": {
|
|
41
45
|
"types": "./dist/mocks/team-account-workspace.d.ts",
|
|
@@ -53,8 +57,8 @@
|
|
|
53
57
|
"tw-animate-css": "1.4.0"
|
|
54
58
|
},
|
|
55
59
|
"peerDependencies": {
|
|
56
|
-
"@ekanos/sdk": "^0.1.
|
|
57
|
-
"@ekanos/ui": "^0.1.
|
|
60
|
+
"@ekanos/sdk": "^0.1.4",
|
|
61
|
+
"@ekanos/ui": "^0.1.4",
|
|
58
62
|
"@tanstack/react-query": "^5.101.4",
|
|
59
63
|
"next": "^16.0.0",
|
|
60
64
|
"react": "^19.2.8",
|
|
@@ -62,8 +66,8 @@
|
|
|
62
66
|
"tailwindcss": "^4.0.0"
|
|
63
67
|
},
|
|
64
68
|
"devDependencies": {
|
|
65
|
-
"@ekanos/sdk": "0.1.
|
|
66
|
-
"@ekanos/ui": "0.1.
|
|
69
|
+
"@ekanos/sdk": "0.1.5",
|
|
70
|
+
"@ekanos/ui": "0.1.5",
|
|
67
71
|
"@kit/eslint-config": "0.2.0",
|
|
68
72
|
"@kit/prettier-config": "0.1.0",
|
|
69
73
|
"@kit/tsconfig": "0.1.0",
|