@ekanos/harness 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +879 -0
- package/dist/app.d.ts +4 -0
- package/dist/app.js +11 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +34 -0
- package/dist/internal/components/ask-assistant-bridge.d.ts +10 -0
- package/dist/internal/components/ask-assistant-bridge.js +50 -0
- package/dist/internal/components/dashboard-grid.d.ts +45 -0
- package/dist/internal/components/dashboard-grid.js +84 -0
- package/dist/internal/components/dev-toolbar.d.ts +15 -0
- package/dist/internal/components/dev-toolbar.js +155 -0
- package/dist/internal/components/harness-providers.d.ts +16 -0
- package/dist/internal/components/harness-providers.js +83 -0
- package/dist/internal/components/harness-widget-provider.d.ts +60 -0
- package/dist/internal/components/harness-widget-provider.js +84 -0
- package/dist/internal/components/i18n-provider.d.ts +9 -0
- package/dist/internal/components/i18n-provider.js +9 -0
- package/dist/internal/components/row-groups.d.ts +23 -0
- package/dist/internal/components/row-groups.js +37 -0
- package/dist/internal/components/surface-nav.d.ts +4 -0
- package/dist/internal/components/surface-nav.js +70 -0
- package/dist/internal/components/viewport-frame.d.ts +14 -0
- package/dist/internal/components/viewport-frame.js +27 -0
- package/dist/internal/components/widget-boundary.d.ts +25 -0
- package/dist/internal/components/widget-boundary.js +44 -0
- package/dist/internal/components/widget-surface.d.ts +20 -0
- package/dist/internal/components/widget-surface.js +76 -0
- package/dist/internal/lib/fonts.d.ts +2 -0
- package/dist/internal/lib/fonts.js +22 -0
- package/dist/internal/lib/harness-fetch-interceptor.d.ts +89 -0
- package/dist/internal/lib/harness-fetch-interceptor.js +101 -0
- package/dist/internal/lib/harness-live-fetch.d.ts +66 -0
- package/dist/internal/lib/harness-live-fetch.js +121 -0
- package/dist/internal/lib/harness-query-client.d.ts +43 -0
- package/dist/internal/lib/harness-query-client.js +103 -0
- package/dist/internal/lib/http-fixtures.d.ts +145 -0
- package/dist/internal/lib/http-fixtures.js +256 -0
- package/dist/internal/lib/i18n.d.ts +2 -0
- package/dist/internal/lib/i18n.js +17 -0
- package/dist/internal/lib/redact.d.ts +33 -0
- package/dist/internal/lib/redact.js +43 -0
- package/dist/internal/lib/toolbar-context.d.ts +59 -0
- package/dist/internal/lib/toolbar-context.js +124 -0
- package/dist/internal/registry-context.d.ts +27 -0
- package/dist/internal/registry-context.js +51 -0
- package/dist/internal/routes/activation-page.d.ts +33 -0
- package/dist/internal/routes/activation-page.js +242 -0
- package/dist/internal/routes/index-page.d.ts +13 -0
- package/dist/internal/routes/index-page.js +62 -0
- package/dist/internal/routes/integration-layout.d.ts +9 -0
- package/dist/internal/routes/integration-layout.js +84 -0
- package/dist/internal/routes/root-layout.d.ts +34 -0
- package/dist/internal/routes/root-layout.js +39 -0
- package/dist/internal/routes/single-widget-page.d.ts +6 -0
- package/dist/internal/routes/single-widget-page.js +30 -0
- package/dist/internal/routes/tile-page.d.ts +1 -0
- package/dist/internal/routes/tile-page.js +88 -0
- package/dist/internal/routes/triggers-page.d.ts +1 -0
- package/dist/internal/routes/triggers-page.js +386 -0
- package/dist/internal/routes/widgets-page.d.ts +1 -0
- package/dist/internal/routes/widgets-page.js +19 -0
- package/dist/internal/surfaces.d.ts +25 -0
- package/dist/internal/surfaces.js +29 -0
- package/dist/mocks/team-account-workspace.d.ts +78 -0
- package/dist/mocks/team-account-workspace.js +86 -0
- package/dist/registry.d.ts +418 -0
- package/dist/registry.js +82 -0
- package/dist/routes.d.ts +24 -0
- package/dist/routes.js +15 -0
- package/dist/styles.css +236 -0
- package/package.json +101 -0
|
@@ -0,0 +1,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[];
|