@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,66 @@
1
+ import type { IntegrationFetch } from '@ekanos/sdk';
2
+ /**
3
+ * ─────────────────────────────────────────────────────────────────────────────
4
+ * THE HARNESS'S `ctx.fetch`
5
+ * ─────────────────────────────────────────────────────────────────────────────
6
+ *
7
+ * The harness mocks Fusion. It does NOT mock your vendor's API — and in live
8
+ * mode it stops standing between you and it. What it does instead is enforce
9
+ * the one rule that matters: an integration may only reach the origins it
10
+ * DECLARED in `defineIntegration({ egress })`.
11
+ *
12
+ * The check is not a harness-local imitation. `isEgressAllowed` is imported
13
+ * from `@ekanos/sdk/context` — the same pure matcher used by
14
+ * `createMockContext()` in your unit tests and by the production `ctx.fetch`
15
+ * wrapper. Three enforcement points, one implementation, so a call that works
16
+ * here works after promotion, and a call that is denied here would have been
17
+ * denied in production too.
18
+ *
19
+ * That is the entire point of live mode: **it proves the egress declaration is
20
+ * correct before the integration is ever submitted.** An origin you forgot to
21
+ * declare fails on your laptop, with the same `EgressDeniedError` and the same
22
+ * remediation message, rather than in a reviewer's queue or a customer's tenant.
23
+ */
24
+ export interface HarnessFetchCall {
25
+ url: string;
26
+ method: string;
27
+ denied: boolean;
28
+ at: string;
29
+ }
30
+ export interface AllowlistedFetchOptions {
31
+ /**
32
+ * Notified for every call, allowed or denied — the harness console logger
33
+ * hangs off this. Never throws into the caller.
34
+ */
35
+ onCall?: (call: HarnessFetchCall) => void;
36
+ }
37
+ /**
38
+ * Builds an `IntegrationFetch` bound to one integration's declared egress list.
39
+ *
40
+ * Denial is SYNCHRONOUS, before any I/O, and throws `EgressDeniedError` rather
41
+ * than resolving a synthetic 403 — matching the production wrapper exactly. A
42
+ * fake response would get retried, parsed, and blamed on the remote API.
43
+ */
44
+ export declare function createAllowlistedFetch(egress: readonly string[], options?: AllowlistedFetchOptions): IntegrationFetch;
45
+ /**
46
+ * The default observer: one console line per live request, prefixed so it is
47
+ * obvious in a busy devtools console which calls left the building.
48
+ *
49
+ * `call.url` arrives already redacted — see `redactUrl`.
50
+ */
51
+ export declare function logHarnessFetch(slug: string): (call: HarnessFetchCall) => void;
52
+ /**
53
+ * The fetch fixtures mode installs — it refuses everything.
54
+ *
55
+ * The third of the three mechanisms closing fixtures mode, and the only one
56
+ * that survives a widget doing something the harness did not anticipate. The
57
+ * partner's own fetch context defaults to the browser's real `fetch`, so a
58
+ * widget whose `queryFn` runs would reach the internet through that default.
59
+ * That is not hypothetical: it is exactly how the `error` fixture variant came
60
+ * to fire four live requests at api.github.com, because the query-client guard
61
+ * that was supposed to prevent the `queryFn` running had never worked.
62
+ *
63
+ * Throws synchronously, like the live guard, so nothing reaches the microtask
64
+ * queue first.
65
+ */
66
+ export declare function createFixturesFetch(): IntegrationFetch;
@@ -0,0 +1,121 @@
1
+ import { EgressDeniedError, isEgressAllowed } from "@ekanos/sdk/context";
2
+ const BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set(["localhost"]);
3
+ function isNonPublicIpv4(hostname) {
4
+ const octets = hostname.split(".");
5
+ if (octets.length !== 4) return false;
6
+ const parts = octets.map((octet) => Number(octet));
7
+ if (parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
8
+ return false;
9
+ }
10
+ const [a, b] = parts;
11
+ return a === 0 || // "this network"
12
+ a === 127 || // loopback
13
+ a === 10 || // RFC1918
14
+ a === 172 && b >= 16 && b <= 31 || // RFC1918
15
+ a === 192 && b === 168 || // RFC1918
16
+ a === 169 && b === 254 || // link-local, incl. cloud metadata
17
+ a === 100 && b >= 64 && b <= 127 || // CGNAT
18
+ a >= 224;
19
+ }
20
+ function isNonPublicIpv6(hostname) {
21
+ const address = hostname.slice(1, -1).toLowerCase();
22
+ const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(address);
23
+ if (mapped) {
24
+ const high = Number.parseInt(mapped[1], 16);
25
+ const low = Number.parseInt(mapped[2], 16);
26
+ return isNonPublicIpv4(
27
+ [high >> 8, high & 255, low >> 8, low & 255].join(".")
28
+ );
29
+ }
30
+ return address === "::1" || // loopback
31
+ address === "::" || // unspecified
32
+ /^fe[89ab]/.test(address) || // fe80::/10 link-local
33
+ /^f[cd]/.test(address) || // fc00::/7 unique local
34
+ /^ff/.test(address);
35
+ }
36
+ function assertPublicDestination(url) {
37
+ const hostname = url.hostname.toLowerCase();
38
+ const nonPublic = BLOCKED_HOSTNAMES.has(hostname) || hostname.endsWith(".localhost") || (hostname.startsWith("[") ? isNonPublicIpv6(hostname) : isNonPublicIpv4(hostname));
39
+ if (nonPublic) {
40
+ throw new EgressDeniedError(url.origin);
41
+ }
42
+ }
43
+ function redactUrl(url) {
44
+ const redacted = new URL(url.toString());
45
+ redacted.username = "";
46
+ redacted.password = "";
47
+ const hadQuery = redacted.search !== "";
48
+ redacted.search = "";
49
+ return `${redacted.toString()}${hadQuery ? "?\u2026" : ""}`;
50
+ }
51
+ function createAllowlistedFetch(egress, options = {}) {
52
+ const record = (url, method, denied) => {
53
+ try {
54
+ options.onCall?.({
55
+ url,
56
+ method,
57
+ denied,
58
+ at: (/* @__PURE__ */ new Date()).toISOString()
59
+ });
60
+ } catch {
61
+ }
62
+ };
63
+ return (input, init) => {
64
+ const parsed = new URL(input);
65
+ const url = parsed.toString();
66
+ const method = init?.method ?? "GET";
67
+ const allowed = isEgressAllowed(url, egress);
68
+ record(redactUrl(parsed), method, !allowed);
69
+ if (!allowed) {
70
+ throw new EgressDeniedError(parsed.origin);
71
+ }
72
+ assertPublicDestination(parsed);
73
+ return globalThis.fetch(url, {
74
+ ...init,
75
+ /**
76
+ * The allowlist is a promise about ONE url. Following a redirect would
77
+ * silently turn it into a promise about wherever that url points next —
78
+ * an attacker origin, a LAN service, link-local metadata — and a 307/308
79
+ * carries the method and body along for the ride.
80
+ *
81
+ * Production re-vets every hop and rejects cross-origin redirects
82
+ * outright. A browser cannot do that: `redirect: 'manual'` yields an
83
+ * opaque response whose `Location` header is unreadable, so there is no
84
+ * way to check the next hop and continue. `'error'` is therefore the
85
+ * only sound choice here — the request fails loudly instead of following
86
+ * an unchecked redirect.
87
+ *
88
+ * A partner whose API legitimately redirects should call the final URL
89
+ * directly, which is the same advice production's hop-limit error gives.
90
+ */
91
+ redirect: "error"
92
+ });
93
+ };
94
+ }
95
+ function logHarnessFetch(slug) {
96
+ return (call) => {
97
+ if (call.denied) {
98
+ console.error(
99
+ `[harness:live:${slug}] DENIED ${call.method} ${call.url} \u2014 origin not in the integration's declared egress list.`
100
+ );
101
+ return;
102
+ }
103
+ console.info(`[harness:live:${slug}] ${call.method} ${call.url}`);
104
+ };
105
+ }
106
+ function createFixturesFetch() {
107
+ return (input) => {
108
+ throw new Error(
109
+ `[harness] fixtures mode refused a request to ${redactUrl(new URL(input))}.
110
+
111
+ A widget called the network directly instead of reading a seeded query. Fixtures mode answers every query from your registry entry and never leaves the machine, so this call was refused.
112
+
113
+ Seed the query key this widget uses, or switch the toolbar to Live API if the integration declares an \`egress\` list.`
114
+ );
115
+ };
116
+ }
117
+ export {
118
+ createAllowlistedFetch,
119
+ createFixturesFetch,
120
+ logHarnessFetch
121
+ };
@@ -0,0 +1,43 @@
1
+ import { QueryClient } from '@tanstack/react-query';
2
+ import type { DataMode, FixtureSeed } from '../../registry.js';
3
+ /**
4
+ * Thrown when a widget fires a query the harness has no fixture for.
5
+ *
6
+ * This is the harness's single most important error message: it is what a
7
+ * partner sees the first time they wire up a widget, and it has to tell them
8
+ * exactly what to paste into their registry entry.
9
+ */
10
+ export declare class MissingFixtureError extends Error {
11
+ readonly queryKey: readonly unknown[];
12
+ constructor(queryKey: readonly unknown[]);
13
+ }
14
+ /**
15
+ * Build a QueryClient for one mode.
16
+ *
17
+ * FIXTURES mode is closed by two independent mechanisms, and it needs both:
18
+ *
19
+ * 1. Every declared key is programmed into the cache up front — `data` seeds
20
+ * as data, `error` seeds as an error state — so widgets paint populated (or
21
+ * paint their error branch) on the very first render with no loading flash
22
+ * and no fetch.
23
+ * 2. `defaultQueryOptions` puts the seed table AHEAD of whatever the widget
24
+ * supplied, so a seeded key never fetches. An unseeded key now falls
25
+ * through to the widget's own fetcher rather than throwing — safe because
26
+ * `globalThis.fetch` is patched, and necessary because otherwise a
27
+ * recorded HTTP response could never be reached by a react-query widget.
28
+ *
29
+ * The layout closes it a third time, outside this file, by handing fixtures
30
+ * mode a fetch seam that throws (`harness-providers.tsx`). Belt, braces, and a
31
+ * second pair of braces, because "the harness makes no network requests" is a
32
+ * promise a partner relies on when they point it at a production API key.
33
+ *
34
+ * LIVE mode installs no override at all, so react-query falls through to each
35
+ * widget's own `queryFn` — the real one, reaching the real vendor API through
36
+ * the allowlisted fetch. Fusion-side keys are still pre-seeded, so a widget
37
+ * that needs an account setting before it can call anything still gets one.
38
+ *
39
+ * `retry: false` matters as much as the rest: the default three retries with
40
+ * backoff would turn one missing fixture into several seconds of confusing
41
+ * pending state before the error surfaced.
42
+ */
43
+ export declare function createHarnessQueryClient(seeds: FixtureSeed[], mode?: DataMode): QueryClient;
@@ -0,0 +1,103 @@
1
+ import { QueryClient, hashKey } from "@tanstack/react-query";
2
+ class MissingFixtureError extends Error {
3
+ constructor(queryKey) {
4
+ const serialized = JSON.stringify(queryKey);
5
+ super(
6
+ [
7
+ `No fixture seeded for query key ${serialized}.`,
8
+ "",
9
+ "This query has no seed AND no `queryFn` of its own, so nothing can",
10
+ "answer it \u2014 not even an HTTP fixture, because no request is made.",
11
+ "Seed the key on the widget in your harness registry entry:",
12
+ "",
13
+ " seeds: {",
14
+ ` default: [{ queryKey: ${serialized}, data: /* your fixture */ }],`,
15
+ " }",
16
+ "",
17
+ "To exercise the error branch instead, seed `error` rather than `data`.",
18
+ "If this key looks unfamiliar, log it from the widget's own hook \u2014 the",
19
+ "key must match exactly, including every argument the hook interpolates."
20
+ ].join("\n")
21
+ );
22
+ this.queryKey = queryKey;
23
+ this.name = "MissingFixtureError";
24
+ }
25
+ }
26
+ function isErrorSeed(seed) {
27
+ return "error" in seed && seed.error !== void 0;
28
+ }
29
+ function forceFixturesQueryFn(client, seedsByKey) {
30
+ const base = client.defaultQueryOptions.bind(client);
31
+ client.defaultQueryOptions = ((options) => {
32
+ const merged = base(options);
33
+ const own = merged.queryFn;
34
+ return {
35
+ ...merged,
36
+ queryFn: (context) => {
37
+ const seed = seedsByKey.get(hashKey(context.queryKey));
38
+ if (seed) {
39
+ if (isErrorSeed(seed)) throw seed.error;
40
+ return seed.data;
41
+ }
42
+ if (typeof own === "function") {
43
+ return own(context);
44
+ }
45
+ throw new MissingFixtureError(context.queryKey);
46
+ }
47
+ };
48
+ });
49
+ }
50
+ function createHarnessQueryClient(seeds, mode = "fixtures") {
51
+ const seedsByKey = /* @__PURE__ */ new Map();
52
+ for (const seed of seeds) {
53
+ seedsByKey.set(hashKey(seed.queryKey), seed);
54
+ }
55
+ const live = mode === "live";
56
+ const defaultOptions = {
57
+ queries: {
58
+ retry: false,
59
+ refetchOnWindowFocus: false,
60
+ refetchOnReconnect: false,
61
+ // Fixtures never go stale — nothing can refresh them. In live mode the
62
+ // widget's own `staleTime` is the one that should apply, so the default
63
+ // gets out of the way.
64
+ ...live ? {} : {
65
+ staleTime: Infinity,
66
+ gcTime: Infinity,
67
+ // An error state has no `dataUpdatedAt`, so `staleTime` cannot
68
+ // hold it and react-query would refetch it on mount. Harmless now
69
+ // that the queryFn is forced, but it would turn every seeded error
70
+ // into a pointless extra round through the seed table.
71
+ refetchOnMount: false
72
+ }
73
+ },
74
+ mutations: {
75
+ retry: false
76
+ }
77
+ };
78
+ const client = new QueryClient({ defaultOptions });
79
+ if (!live) {
80
+ forceFixturesQueryFn(client, seedsByKey);
81
+ }
82
+ for (const seed of seeds) {
83
+ if (isErrorSeed(seed)) {
84
+ if (!live) {
85
+ const query = client.getQueryCache().build(client, { queryKey: seed.queryKey });
86
+ query.setState({
87
+ status: "error",
88
+ error: seed.error,
89
+ errorUpdateCount: 1,
90
+ errorUpdatedAt: Date.now(),
91
+ fetchStatus: "idle"
92
+ });
93
+ }
94
+ continue;
95
+ }
96
+ client.setQueryData(seed.queryKey, seed.data);
97
+ }
98
+ return client;
99
+ }
100
+ export {
101
+ MissingFixtureError,
102
+ createHarnessQueryClient
103
+ };
@@ -0,0 +1,145 @@
1
+ import type { MockFetchHandler } from '@ekanos/sdk/testing';
2
+ import type { HttpFixture } from '../../registry.js';
3
+ /**
4
+ * ─────────────────────────────────────────────────────────────────────────────
5
+ * MATCHING A REQUEST TO A RECORDED RESPONSE
6
+ * ─────────────────────────────────────────────────────────────────────────────
7
+ *
8
+ * Pure, and deliberately so: `(request, fixtures) -> fixture | null` with no
9
+ * React, no network and no clock. That keeps it exhaustively testable, and it
10
+ * is the seam record/replay will need later — a captured exchange has to be the
11
+ * same object a hand-written one is, so nothing here may depend on how the
12
+ * fixture was produced.
13
+ *
14
+ * The rules, all of which exist because a real API forced them:
15
+ *
16
+ * ORIGIN `'GET https://api.acme.com/v1/payouts'` is unambiguous. The
17
+ * path-only `'GET /v1/payouts'` resolves against the integration's
18
+ * declared `egress` — reusing the declaration instead of adding a
19
+ * second `baseUrl` to keep in step — and is allowed ONLY when
20
+ * exactly one origin is declared. With two, there is no defensible
21
+ * guess, so it throws and names them.
22
+ *
23
+ * EXCEPT under `/api/`, which resolves against the harness's OWN
24
+ * origin. That is the host-route namespace: a first-party
25
+ * integration's widgets call `/api/integrations/<slug>/…` rather
26
+ * than the vendor directly, and the vendor call happens server-side
27
+ * where the credential lives. Our own Acme example is shaped that
28
+ * way, so without this the format could not express the request the
29
+ * most realistic widget in the repo actually makes.
30
+ *
31
+ * PATH `:param` matches one segment, `*` matches the rest. Segment
32
+ * counts must otherwise agree, so `/v1/payouts` does not match
33
+ * `/v1/payouts/42`.
34
+ *
35
+ * QUERY If the fixture's request has a `?`, every param in it must be
36
+ * present on the request with the same value — a SUBSET match, so
37
+ * a fixture can pin the two params that matter and ignore the
38
+ * other nine. If it has no `?`, query is ignored entirely.
39
+ * Open-Meteo settles this: its whole request is query params, and
40
+ * requiring an exact match would mean restating a 200-character
41
+ * string that changes whenever a widget adds a field.
42
+ *
43
+ * First declaration wins, so a specific fixture can shadow a general one by
44
+ * being written above it.
45
+ */
46
+ /** A fixture with its request line parsed once, ready to match against. */
47
+ export interface CompiledFixture {
48
+ method: string;
49
+ origin: string;
50
+ segments: readonly string[];
51
+ /** Params that must be present with these values, or null to ignore query. */
52
+ query: readonly (readonly [string, string])[] | null;
53
+ fixture: HttpFixture;
54
+ }
55
+ export declare class FixtureRequestError extends Error {
56
+ constructor(message: string);
57
+ }
58
+ /**
59
+ * Parse one `'<METHOD> <url-or-path>'` request line.
60
+ *
61
+ * Throws rather than skipping: a fixture the harness cannot understand is a
62
+ * fixture the partner believes is live, and silently ignoring it produces the
63
+ * "no recorded response" error for a request they can see they recorded.
64
+ */
65
+ export declare function compileFixture(fixture: HttpFixture, egress: readonly string[], selfOrigin?: string): CompiledFixture;
66
+ /**
67
+ * Does compiling this fixture require the harness's OWN origin?
68
+ *
69
+ * Only the host-route shape does. An absolute request line carries its origin;
70
+ * a path-only one resolves against the declared `egress`. `/api/…` is the one
71
+ * branch above that has nothing to resolve against but `selfOrigin`.
72
+ *
73
+ * This exists so a caller with no origin — a server render — can compile the
74
+ * fixtures that do not need one instead of compiling nothing. Duplicating the
75
+ * request-line parse at that call site is how the two would drift, which is
76
+ * the failure this whole module is written to avoid, so the question is
77
+ * answered here next to the code that answers it for real.
78
+ *
79
+ * A malformed request line is reported as NOT needing an origin, deliberately:
80
+ * `compileFixture` already has a precise error for every way a line can be
81
+ * wrong, and filtering it out early would replace that error with silence.
82
+ */
83
+ export declare function needsSelfOrigin(fixture: HttpFixture): boolean;
84
+ export declare function compileFixtures(fixtures: readonly HttpFixture[], egress: readonly string[], selfOrigin?: string): CompiledFixture[];
85
+ /**
86
+ * The first fixture that matches, or null.
87
+ *
88
+ * Null is not an error here — the caller decides what an unmatched request
89
+ * means, because it differs by mode: refuse in fixtures, go to the network in
90
+ * live.
91
+ */
92
+ export declare function matchHttpFixture(method: string, requestUrl: string, compiled: readonly CompiledFixture[]): HttpFixture | null;
93
+ /**
94
+ * Build the `Response` a matched fixture stands for.
95
+ *
96
+ * `status` defaults to 200 when a body is present and 204 when it is not, so
97
+ * `{ request: 'DELETE /v1/thing/:id' }` is a complete fixture. A string body is
98
+ * sent as-is; anything else is JSON, which is what makes `{ response: {...} }`
99
+ * the common case rather than a special one.
100
+ */
101
+ export declare function fixtureResponse(fixture: HttpFixture): Response;
102
+ export declare class NoRecordedResponseError extends Error {
103
+ readonly method: string;
104
+ readonly url: string;
105
+ /**
106
+ * Set when this refusal is a host-route request during a server render —
107
+ * the one shape not compiled for that pass.
108
+ */
109
+ readonly deferredToClientRender: boolean;
110
+ constructor(method: string, url: string,
111
+ /**
112
+ * Set when this refusal is a host-route request during a server render —
113
+ * the one shape not compiled for that pass.
114
+ */
115
+ deferredToClientRender?: boolean);
116
+ }
117
+ /**
118
+ * ─────────────────────────────────────────────────────────────────────────────
119
+ * THE SAME FIXTURES, ANSWERING THE TRIGGERS SURFACE
120
+ * ─────────────────────────────────────────────────────────────────────────────
121
+ *
122
+ * This is the half that kills the double-authoring. A partner used to write
123
+ * their vendor's payloads twice — once as query-key seeds for widgets, once as
124
+ * wire-shaped `fetchHandlers` for webhook and schedule handlers — in two
125
+ * different shapes, with nothing relating them. Our own Acme example
126
+ * demonstrates it. Now both seams read one list.
127
+ *
128
+ * Returned as a single CATCH-ALL handler rather than one per fixture, and that
129
+ * matters twice over:
130
+ *
131
+ * - matching stays in `matchHttpFixture`, so a request resolves identically
132
+ * whether a widget or a schedule made it. Two matchers would drift, and the
133
+ * drift would be invisible until a handler and a widget disagreed about the
134
+ * same URL.
135
+ * - `createMockContext()`'s own fallback, when no handler matches, is
136
+ * `new Response('{}', { status: 200 })` — a plausible empty success. That is
137
+ * the trigger-side twin of the silent fixture miss: a handler calling an
138
+ * endpoint nobody recorded gets a 200 and carries on. Taking every request
139
+ * replaces it with the same named refusal a widget gets.
140
+ *
141
+ * Explicit `triggerMocks.fetchHandlers` still win, because they are placed
142
+ * ahead of this one — a partner needing a dynamic or stateful response should
143
+ * not have to abandon fixtures for the rest.
144
+ */
145
+ export declare function fixturesToMockHandlers(compiled: readonly CompiledFixture[]): MockFetchHandler[];