@specific.dev/spectest 0.24.0 → 0.27.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/dist/aws-sigv4.d.ts +42 -0
- package/dist/aws-sigv4.js +166 -0
- package/dist/browser.d.ts +314 -0
- package/dist/browser.js +1320 -0
- package/dist/components/email.d.ts +135 -0
- package/dist/components/email.js +271 -0
- package/dist/components/expo.d.ts +69 -0
- package/dist/components/expo.js +125 -0
- package/dist/components/index.d.ts +8 -0
- package/dist/components/index.js +18 -0
- package/dist/components/k3s.d.ts +143 -0
- package/dist/components/k3s.js +1067 -0
- package/dist/components/postgres.d.ts +93 -0
- package/dist/components/postgres.js +58 -0
- package/dist/components/replayFake.d.ts +169 -0
- package/dist/components/replayFake.js +738 -0
- package/dist/components/s3.d.ts +99 -0
- package/dist/components/s3.js +81 -0
- package/dist/components/supabase.d.ts +197 -0
- package/dist/components/supabase.js +1003 -0
- package/dist/daemon.d.ts +1 -0
- package/dist/daemon.js +4223 -0
- package/dist/ids.d.ts +2 -0
- package/{src/ids.ts → dist/ids.js} +46 -50
- package/dist/index.d.ts +1183 -0
- package/dist/index.js +769 -0
- package/dist/ingress.d.ts +114 -0
- package/dist/ingress.js +210 -0
- package/dist/inspect.d.ts +228 -0
- package/dist/inspect.js +429 -0
- package/dist/locator.d.ts +260 -0
- package/dist/locator.js +293 -0
- package/dist/mobile.d.ts +71 -0
- package/dist/mobile.js +65 -0
- package/dist/record-secrets.d.ts +9 -0
- package/{src/record-secrets.ts → dist/record-secrets.js} +13 -15
- package/dist/recorder.d.ts +516 -0
- package/dist/recorder.js +219 -0
- package/dist/redis.d.ts +54 -0
- package/dist/redis.js +126 -0
- package/dist/replay-bundle.d.ts +38 -0
- package/{src/replay-bundle.ts → dist/replay-bundle.js} +29 -47
- package/dist/resolver.d.ts +1 -0
- package/dist/resolver.js +309 -0
- package/dist/s3.d.ts +89 -0
- package/dist/s3.js +198 -0
- package/dist/sql.d.ts +74 -0
- package/dist/sql.js +151 -0
- package/dist/terminal.d.ts +161 -0
- package/dist/terminal.js +538 -0
- package/package.json +24 -9
- package/src/browser.ts +0 -1807
- package/src/components/email.ts +0 -398
- package/src/components/expo.ts +0 -167
- package/src/components/index.ts +0 -63
- package/src/components/k3s.ts +0 -1312
- package/src/components/postgres.ts +0 -105
- package/src/components/replayFake.ts +0 -848
- package/src/components/s3.ts +0 -132
- package/src/components/supabase.ts +0 -1299
- package/src/daemon.ts +0 -4969
- package/src/index.ts +0 -2350
- package/src/ingress.ts +0 -288
- package/src/inspect.ts +0 -673
- package/src/locator.ts +0 -594
- package/src/mobile.ts +0 -133
- package/src/recorder.ts +0 -817
- package/src/redis.ts +0 -202
- package/src/resolver.ts +0 -351
- package/src/s3.ts +0 -333
- package/src/sql.ts +0 -243
- package/src/terminal.ts +0 -740
- package/src/vendor/rrweb-plugin-console-record.umd.js +0 -521
- package/src/vendor/rrweb-record.min.js +0 -5061
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { Project, ServiceConfig } from "./index.js";
|
|
2
|
+
/** Where a DNS name points. `ingress` = the spectest-daemon listener on the
|
|
3
|
+
* bridge gateway (fakes, TLS-terminated proxies); `service` = a container's
|
|
4
|
+
* live IP on spectest-net (a plain peer alias, no daemon hop). */
|
|
5
|
+
export type DnsTarget = {
|
|
6
|
+
ingress: true;
|
|
7
|
+
} | {
|
|
8
|
+
service: string;
|
|
9
|
+
};
|
|
10
|
+
export interface CertificateDecl {
|
|
11
|
+
kind: "certificate";
|
|
12
|
+
/** Hostnames the leaf cert's SANs cover. One cert is minted per decl. */
|
|
13
|
+
hostnames: string[];
|
|
14
|
+
}
|
|
15
|
+
export interface DnsDecl {
|
|
16
|
+
kind: "dns";
|
|
17
|
+
hostname: string;
|
|
18
|
+
target: DnsTarget;
|
|
19
|
+
}
|
|
20
|
+
export interface ProxyDecl {
|
|
21
|
+
kind: "proxy";
|
|
22
|
+
hostname: string;
|
|
23
|
+
upstream: {
|
|
24
|
+
service: string;
|
|
25
|
+
port: number;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export type IngressDecl = CertificateDecl | DnsDecl | ProxyDecl;
|
|
29
|
+
/** Token a component can use in a decl where it can't know its own
|
|
30
|
+
* services-map key yet — resolved to that key during `lowerIngress`.
|
|
31
|
+
* Mirrors the `{{SPECTEST_SERVICE}}` token honoured in `files`. */
|
|
32
|
+
export declare const SELF_SERVICE_TOKEN = "{{SPECTEST_SERVICE}}";
|
|
33
|
+
/** A wildcard is `*.` + a normal multi-label hostname (e.g.
|
|
34
|
+
* `*.example.com`). It matches any name ending in that suffix. */
|
|
35
|
+
export declare function isWildcard(hostname: string): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Request a leaf certificate (signed by the in-VM root CA) covering
|
|
38
|
+
* `hostnames`. The daemon binds it on the HTTPS ingress (:443, SNI per
|
|
39
|
+
* hostname). Pairs with a `proxy(...)` (TLS-terminated reverse proxy) or a
|
|
40
|
+
* fake handler; on its own it just makes those hostnames serve HTTPS.
|
|
41
|
+
*/
|
|
42
|
+
export declare function certificate(hostnames: string[]): CertificateDecl;
|
|
43
|
+
/**
|
|
44
|
+
* Register `hostname` in spectest's DNS. `{ ingress: true }` points it at
|
|
45
|
+
* the daemon (for fakes / TLS proxies — resolved to the bridge gateway and
|
|
46
|
+
* injected as `--add-host` into every container); `{ service }` makes it an
|
|
47
|
+
* extra peer alias for that container (resolved live to its IP). The
|
|
48
|
+
* `SELF_SERVICE_TOKEN` may be used for `service` when a component can't yet
|
|
49
|
+
* know its own key.
|
|
50
|
+
*/
|
|
51
|
+
export declare function dnsName(hostname: string, target: DnsTarget): DnsDecl;
|
|
52
|
+
/**
|
|
53
|
+
* Reverse-proxy `hostname` to `http://<service>:<port>` on spectest-net.
|
|
54
|
+
* Implies the hostname resolves to the daemon ingress, so containers reach
|
|
55
|
+
* it without any extra `dnsName(...)`. Add a `certificate([hostname])` to
|
|
56
|
+
* serve it over HTTPS as well as HTTP.
|
|
57
|
+
*/
|
|
58
|
+
export declare function proxy(hostname: string, upstream: {
|
|
59
|
+
service: string;
|
|
60
|
+
port: number;
|
|
61
|
+
}): ProxyDecl;
|
|
62
|
+
/**
|
|
63
|
+
* Attach low-level ingress decls to a service definition a component
|
|
64
|
+
* returns. The decls are read by `lowerIngress` at load time and never
|
|
65
|
+
* serialised into the wire config.
|
|
66
|
+
*
|
|
67
|
+
* ```ts
|
|
68
|
+
* return provides({ image, command }, [
|
|
69
|
+
* certificate(["app.test"]),
|
|
70
|
+
* dnsName("app.test", { ingress: true }),
|
|
71
|
+
* proxy("app.test", { service: SELF_SERVICE_TOKEN, port: 3000 }),
|
|
72
|
+
* ]);
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export declare function provides<T extends ServiceConfig>(service: T, decls: IngressDecl[]): T;
|
|
76
|
+
/** Everything the daemon needs to stand up ingress, derived once per
|
|
77
|
+
* /load. The daemon executes this generically — it reads neither
|
|
78
|
+
* `svc.tls` nor `svc.hostnames` directly. */
|
|
79
|
+
export interface LoweredIngress {
|
|
80
|
+
/** One leaf cert per group; SANs = the group's hostnames. */
|
|
81
|
+
certificates: {
|
|
82
|
+
hostnames: string[];
|
|
83
|
+
}[];
|
|
84
|
+
/** Reverse-proxy routes: hostname → upstream service:port. */
|
|
85
|
+
proxies: {
|
|
86
|
+
hostname: string;
|
|
87
|
+
service: string;
|
|
88
|
+
port: number;
|
|
89
|
+
}[];
|
|
90
|
+
/** Hostnames that resolve to the daemon ingress gateway (DNS registry
|
|
91
|
+
* + `--add-host` on every container). Covers fakes, TLS proxies, and
|
|
92
|
+
* any `dnsName(h, { ingress: true })`. */
|
|
93
|
+
ingressHosts: string[];
|
|
94
|
+
/** service key → extra `--network-alias`es (from `hostnames` /
|
|
95
|
+
* `dnsName(h, { service })`). */
|
|
96
|
+
aliasesByService: Record<string, string[]>;
|
|
97
|
+
/** Wildcard suffixes (`*.example.com` → `.example.com`) and where they
|
|
98
|
+
* point. Resolved to a concrete IP in the daemon (service → that
|
|
99
|
+
* container's IP, ingress → the bridge gateway) and written to the
|
|
100
|
+
* resolver's wildcard table. Only the resolver answers these — they
|
|
101
|
+
* can't be expressed as `--network-alias`/`--add-host`. */
|
|
102
|
+
wildcards: {
|
|
103
|
+
pattern: string;
|
|
104
|
+
target: DnsTarget;
|
|
105
|
+
}[];
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Lower a project's friendly surface (`services.*.tls`,
|
|
109
|
+
* `services.*.hostnames`, component `provides(...)`, and `fakes`) into the
|
|
110
|
+
* generic `LoweredIngress` the daemon executes. This is the framework
|
|
111
|
+
* abstraction the user asked for: the special-casing lives here, in one
|
|
112
|
+
* pure function, instead of being scattered through the daemon.
|
|
113
|
+
*/
|
|
114
|
+
export declare function lowerIngress(project: Project): LoweredIngress;
|
package/dist/ingress.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Low-level ingress primitives — the first-class building blocks the
|
|
2
|
+
// daemon's networking is expressed in terms of. The friendly
|
|
3
|
+
// `services.<name>.tls` / `services.<name>.hostnames` fields and
|
|
4
|
+
// `defineFake(...)` are all *sugar* that lower onto these three:
|
|
5
|
+
//
|
|
6
|
+
// certificate(hostnames) — mint a leaf cert from the in-VM root CA
|
|
7
|
+
// dnsName(hostname, target) — register a name → a container or the daemon
|
|
8
|
+
// proxy(hostname, upstream) — reverse-proxy a hostname to a service:port
|
|
9
|
+
//
|
|
10
|
+
// The daemon consumes only the lowered result (see `lowerIngress`), so it
|
|
11
|
+
// never special-cases tls/hostnames/fakes — it just executes a generic set
|
|
12
|
+
// of certs, proxy routes, DNS registrations, and aliases. A user-authored
|
|
13
|
+
// component emits the same primitives by attaching them with `provides(...)`,
|
|
14
|
+
// so it can do anything the built-ins do without any daemon change.
|
|
15
|
+
//
|
|
16
|
+
// None of this is part of the wire `EnvironmentConfig` the control plane
|
|
17
|
+
// deserialises: the decls live on the `Project` (daemon-side only) and,
|
|
18
|
+
// when attached to a service, ride on a Symbol key that `JSON.stringify`
|
|
19
|
+
// drops — so Rust's `deny_unknown_fields` never sees them.
|
|
20
|
+
/** Token a component can use in a decl where it can't know its own
|
|
21
|
+
* services-map key yet — resolved to that key during `lowerIngress`.
|
|
22
|
+
* Mirrors the `{{SPECTEST_SERVICE}}` token honoured in `files`. */
|
|
23
|
+
export const SELF_SERVICE_TOKEN = "{{SPECTEST_SERVICE}}";
|
|
24
|
+
// Multi-label hostname: at least one dot, each label 1–63 chars of
|
|
25
|
+
// [a-z0-9-], no leading/trailing hyphen. Kept in lockstep with index.ts.
|
|
26
|
+
const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
|
27
|
+
function assertHostname(h, ctx) {
|
|
28
|
+
const lower = h.toLowerCase();
|
|
29
|
+
if (!HOSTNAME_RE.test(lower)) {
|
|
30
|
+
throw new Error(`${ctx}: invalid hostname ${JSON.stringify(h)} — must be a multi-label DNS name (e.g. "api.stripe.com")`);
|
|
31
|
+
}
|
|
32
|
+
if (lower === "internal" || lower.endsWith(".internal")) {
|
|
33
|
+
throw new Error(`${ctx}: hostname ${JSON.stringify(h)} ends in the reserved ".internal" TLD`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** A wildcard is `*.` + a normal multi-label hostname (e.g.
|
|
37
|
+
* `*.example.com`). It matches any name ending in that suffix. */
|
|
38
|
+
export function isWildcard(hostname) {
|
|
39
|
+
return hostname.startsWith("*.");
|
|
40
|
+
}
|
|
41
|
+
/** Validate an exact hostname OR a `*.suffix` wildcard. */
|
|
42
|
+
function assertNameOrWildcard(h, ctx) {
|
|
43
|
+
if (isWildcard(h)) {
|
|
44
|
+
// The bit after `*.` must itself be a valid multi-label hostname, so
|
|
45
|
+
// `*.example.com` is fine but `*.com` (too broad) and `*` are not.
|
|
46
|
+
assertHostname(h.slice(2), `${ctx} wildcard`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
assertHostname(h, ctx);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Request a leaf certificate (signed by the in-VM root CA) covering
|
|
53
|
+
* `hostnames`. The daemon binds it on the HTTPS ingress (:443, SNI per
|
|
54
|
+
* hostname). Pairs with a `proxy(...)` (TLS-terminated reverse proxy) or a
|
|
55
|
+
* fake handler; on its own it just makes those hostnames serve HTTPS.
|
|
56
|
+
*/
|
|
57
|
+
export function certificate(hostnames) {
|
|
58
|
+
if (!Array.isArray(hostnames) || hostnames.length === 0) {
|
|
59
|
+
throw new Error("certificate(): at least one hostname is required");
|
|
60
|
+
}
|
|
61
|
+
for (const h of hostnames)
|
|
62
|
+
assertHostname(h, "certificate()");
|
|
63
|
+
return { kind: "certificate", hostnames: hostnames.map((h) => h.toLowerCase()) };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Register `hostname` in spectest's DNS. `{ ingress: true }` points it at
|
|
67
|
+
* the daemon (for fakes / TLS proxies — resolved to the bridge gateway and
|
|
68
|
+
* injected as `--add-host` into every container); `{ service }` makes it an
|
|
69
|
+
* extra peer alias for that container (resolved live to its IP). The
|
|
70
|
+
* `SELF_SERVICE_TOKEN` may be used for `service` when a component can't yet
|
|
71
|
+
* know its own key.
|
|
72
|
+
*/
|
|
73
|
+
export function dnsName(hostname, target) {
|
|
74
|
+
assertNameOrWildcard(hostname, "dnsName()");
|
|
75
|
+
if (!("ingress" in target) && !("service" in target)) {
|
|
76
|
+
throw new Error(`dnsName(${JSON.stringify(hostname)}): target must be { ingress: true } or { service }`);
|
|
77
|
+
}
|
|
78
|
+
return { kind: "dns", hostname: hostname.toLowerCase(), target };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Reverse-proxy `hostname` to `http://<service>:<port>` on spectest-net.
|
|
82
|
+
* Implies the hostname resolves to the daemon ingress, so containers reach
|
|
83
|
+
* it without any extra `dnsName(...)`. Add a `certificate([hostname])` to
|
|
84
|
+
* serve it over HTTPS as well as HTTP.
|
|
85
|
+
*/
|
|
86
|
+
export function proxy(hostname, upstream) {
|
|
87
|
+
assertHostname(hostname, "proxy()");
|
|
88
|
+
if (!upstream || typeof upstream.service !== "string" || typeof upstream.port !== "number") {
|
|
89
|
+
throw new Error(`proxy(${JSON.stringify(hostname)}): upstream must be { service: string, port: number }`);
|
|
90
|
+
}
|
|
91
|
+
return { kind: "proxy", hostname: hostname.toLowerCase(), upstream };
|
|
92
|
+
}
|
|
93
|
+
/** Symbol key under which `provides(...)` stashes a component's ingress
|
|
94
|
+
* decls on its service object. A Symbol so `JSON.stringify(environment)`
|
|
95
|
+
* (the wire config shipped to the Rust control plane) drops it — symbol
|
|
96
|
+
* keys are never serialised, regardless of enumerability. Kept
|
|
97
|
+
* *enumerable* so it survives the common `{ ...component() }` spread (object
|
|
98
|
+
* spread copies enumerable symbol props but skips non-enumerable ones). */
|
|
99
|
+
const INGRESS_DECLS = Symbol.for("spectest.ingress.decls");
|
|
100
|
+
/**
|
|
101
|
+
* Attach low-level ingress decls to a service definition a component
|
|
102
|
+
* returns. The decls are read by `lowerIngress` at load time and never
|
|
103
|
+
* serialised into the wire config.
|
|
104
|
+
*
|
|
105
|
+
* ```ts
|
|
106
|
+
* return provides({ image, command }, [
|
|
107
|
+
* certificate(["app.test"]),
|
|
108
|
+
* dnsName("app.test", { ingress: true }),
|
|
109
|
+
* proxy("app.test", { service: SELF_SERVICE_TOKEN, port: 3000 }),
|
|
110
|
+
* ]);
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
export function provides(service, decls) {
|
|
114
|
+
// Merge with any decls already attached (e.g. a component built via
|
|
115
|
+
// provides() that the caller then wraps again) so neither layer is lost.
|
|
116
|
+
const prior = readProvided(service);
|
|
117
|
+
Object.defineProperty(service, INGRESS_DECLS, {
|
|
118
|
+
value: [...prior, ...decls],
|
|
119
|
+
enumerable: true,
|
|
120
|
+
configurable: true,
|
|
121
|
+
writable: true,
|
|
122
|
+
});
|
|
123
|
+
return service;
|
|
124
|
+
}
|
|
125
|
+
function readProvided(service) {
|
|
126
|
+
const decls = service[INGRESS_DECLS];
|
|
127
|
+
return Array.isArray(decls) ? decls : [];
|
|
128
|
+
}
|
|
129
|
+
function resolveSelf(service, selfKey) {
|
|
130
|
+
return service === SELF_SERVICE_TOKEN ? selfKey : service;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Lower a project's friendly surface (`services.*.tls`,
|
|
134
|
+
* `services.*.hostnames`, component `provides(...)`, and `fakes`) into the
|
|
135
|
+
* generic `LoweredIngress` the daemon executes. This is the framework
|
|
136
|
+
* abstraction the user asked for: the special-casing lives here, in one
|
|
137
|
+
* pure function, instead of being scattered through the daemon.
|
|
138
|
+
*/
|
|
139
|
+
export function lowerIngress(project) {
|
|
140
|
+
const certificates = [];
|
|
141
|
+
const proxies = [];
|
|
142
|
+
const ingressSet = new Set();
|
|
143
|
+
const aliasesByService = {};
|
|
144
|
+
const wildcards = [];
|
|
145
|
+
const addAlias = (service, host) => {
|
|
146
|
+
(aliasesByService[service] ??= []).push(host.toLowerCase());
|
|
147
|
+
};
|
|
148
|
+
const applyDecl = (decl, selfKey) => {
|
|
149
|
+
switch (decl.kind) {
|
|
150
|
+
case "certificate":
|
|
151
|
+
certificates.push({ hostnames: decl.hostnames });
|
|
152
|
+
break;
|
|
153
|
+
case "proxy": {
|
|
154
|
+
const service = resolveSelf(decl.upstream.service, selfKey);
|
|
155
|
+
proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port });
|
|
156
|
+
// A proxied hostname must route to the daemon.
|
|
157
|
+
ingressSet.add(decl.hostname);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case "dns": {
|
|
161
|
+
// A wildcard can only be answered by the resolver (no
|
|
162
|
+
// --network-alias / --add-host equivalent), so route either kind
|
|
163
|
+
// of target through the wildcard table; the daemon resolves the IP.
|
|
164
|
+
if (isWildcard(decl.hostname)) {
|
|
165
|
+
const target = "service" in decl.target
|
|
166
|
+
? { service: resolveSelf(decl.target.service, selfKey) }
|
|
167
|
+
: decl.target;
|
|
168
|
+
wildcards.push({ pattern: decl.hostname, target });
|
|
169
|
+
}
|
|
170
|
+
else if ("ingress" in decl.target) {
|
|
171
|
+
ingressSet.add(decl.hostname);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
addAlias(resolveSelf(decl.target.service, selfKey), decl.hostname);
|
|
175
|
+
}
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
for (const [name, svc] of Object.entries(project.environment.services)) {
|
|
181
|
+
// Component-provided explicit decls.
|
|
182
|
+
for (const decl of readProvided(svc))
|
|
183
|
+
applyDecl(decl, name);
|
|
184
|
+
// `tls: [{ hostname, port }]` → cert + TLS-terminated reverse proxy.
|
|
185
|
+
for (const entry of svc.tls ?? []) {
|
|
186
|
+
applyDecl(certificate([entry.hostname]), name);
|
|
187
|
+
applyDecl(proxy(entry.hostname, { service: name, port: entry.port }), name);
|
|
188
|
+
}
|
|
189
|
+
// `hostnames: [h]` → plain peer alias (no daemon hop).
|
|
190
|
+
for (const h of svc.hostnames ?? []) {
|
|
191
|
+
applyDecl(dnsName(h, { service: name }), name);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Fakes: in-daemon handlers. The handler routing stays keyed by hostname
|
|
195
|
+
// in the daemon's FAKES map; here we only contribute their networking
|
|
196
|
+
// (a leaf cert for HTTPS + ingress DNS).
|
|
197
|
+
for (const fake of Object.values(project.fakes ?? {})) {
|
|
198
|
+
const hostnames = fake.hostnames.map((h) => h.toLowerCase());
|
|
199
|
+
certificates.push({ hostnames });
|
|
200
|
+
for (const h of hostnames)
|
|
201
|
+
ingressSet.add(h);
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
certificates,
|
|
205
|
+
proxies,
|
|
206
|
+
ingressHosts: [...ingressSet],
|
|
207
|
+
aliasesByService,
|
|
208
|
+
wildcards,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
export declare const OP_TAG: unique symbol;
|
|
2
|
+
export declare const UNWRAP: unique symbol;
|
|
3
|
+
export interface OpTag {
|
|
4
|
+
/** Seq of the timeline event this value came from, or `undefined` when the
|
|
5
|
+
* value was wrapped without a recorded event (no recorder, paused, or the
|
|
6
|
+
* event was truncated). `expect()` only nests under a defined `sourceSeq`. */
|
|
7
|
+
sourceSeq: number | undefined;
|
|
8
|
+
path: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
/** Read the tag if present; returns undefined for raw values. */
|
|
11
|
+
export declare function readTag(x: unknown): OpTag | undefined;
|
|
12
|
+
/** Forget any pending nullish-leaf note. Called by the recorder whenever a new
|
|
13
|
+
* op is recorded, so a note can't outlive the read that produced it. */
|
|
14
|
+
export declare function clearPendingNullish(): void;
|
|
15
|
+
/**
|
|
16
|
+
* If `value` is an untagged `null`/`undefined` that matches the most recent
|
|
17
|
+
* nullish-leaf read, mint a tagged {@link makeCarrier} holder for it (the same
|
|
18
|
+
* stand-in `field`/`retag` use) and clear the note; otherwise return `value`
|
|
19
|
+
* unchanged. The holder is safe because it goes straight to `expect()`, which
|
|
20
|
+
* `readRaw`s it before matching — it never escapes into control flow.
|
|
21
|
+
*/
|
|
22
|
+
export declare function adoptNullishTag<T>(value: T): T;
|
|
23
|
+
/** If x is wrapped, return the raw value; otherwise return x. */
|
|
24
|
+
export declare function readRaw<T>(x: T): T;
|
|
25
|
+
/** The raw type behind a wrapper: a `Carrier`/`WrappedObject`/`WrappedArray`/
|
|
26
|
+
* `WrappedResponse` resolves to its `unwrap()` return type; anything else is
|
|
27
|
+
* already raw and passes through unchanged. */
|
|
28
|
+
export type Unwrap<T> = T extends {
|
|
29
|
+
unwrap(): infer V;
|
|
30
|
+
} ? V : T;
|
|
31
|
+
/**
|
|
32
|
+
* Wrap a value so reads through it carry an `OpTag`. Recursion is lazy:
|
|
33
|
+
* a property read on an object Proxy wraps its child on demand.
|
|
34
|
+
*/
|
|
35
|
+
export declare function wrap<T>(raw: T, sourceSeq: number | undefined, path?: readonly string[]): T;
|
|
36
|
+
/**
|
|
37
|
+
* Provenance-preserving, null-safe field selector — for asserting on a leaf
|
|
38
|
+
* that is `null`/`undefined`. Reading such a leaf off a wrapped object hands
|
|
39
|
+
* back a raw `null`/`undefined` (a symbol tag can't ride on those, and minting
|
|
40
|
+
* a stand-in object would break every `=== null` / `if (!x)` in real code), so
|
|
41
|
+
* the raw value alone loses its link to the originating op. `field` reads the
|
|
42
|
+
* tag from the *container* and navigates the raw value, returning a tagged
|
|
43
|
+
* handle even when the leaf is nullish — safe because the handle only ever
|
|
44
|
+
* feeds `expect()`. Pass it straight to `expect(...)`; works with every matcher.
|
|
45
|
+
*
|
|
46
|
+
* Mostly redundant now: the plain `expect(dep.status.readyReplicas).toBeFalsy()`
|
|
47
|
+
* form recovers provenance on its own — the proxy notes each nullish leaf read
|
|
48
|
+
* and `expect` adopts the note (see {@link adoptNullishTag}). Reach for `field`
|
|
49
|
+
* when the nullish read and the `expect` are separated by another recorded op
|
|
50
|
+
* (which clears the note), or to make the navigation explicit.
|
|
51
|
+
*
|
|
52
|
+
* ```ts
|
|
53
|
+
* expect(field(created, "branched_from_environment_id")).toBe(null);
|
|
54
|
+
* expect(field(dep, "status", "readyReplicas")).toBeFalsy();
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export declare function field(value: unknown, ...path: Array<string | number>): unknown;
|
|
58
|
+
/**
|
|
59
|
+
* A provenance-carrying handle to a value produced by a tracked op
|
|
60
|
+
* (`ctx.fetch`, a db query, `browser.evaluate`). Pass it straight to
|
|
61
|
+
* `expect(...)` — the matcher reads the provenance and nests the
|
|
62
|
+
* assertion under the originating op in the timeline.
|
|
63
|
+
*
|
|
64
|
+
* At runtime, coercion sinks recover the raw value (`` `${carrier}` ``,
|
|
65
|
+
* `JSON.stringify(carrier)` behave as if raw). But the *type* is honestly an
|
|
66
|
+
* object, not `T`, so arithmetic (`carrier + 1`), `==`/`===`, and handing it
|
|
67
|
+
* to a typed API are all compile errors — `carrier.unwrap()` (or `expect(...)`,
|
|
68
|
+
* which unwraps for you) first. This is deliberate: a wrapped value is a
|
|
69
|
+
* handle, and the type says so rather than masquerading as its raw type.
|
|
70
|
+
*/
|
|
71
|
+
export interface Carrier<T> {
|
|
72
|
+
/** Recover the raw underlying value. */
|
|
73
|
+
unwrap(): T;
|
|
74
|
+
/**
|
|
75
|
+
* Run the raw value through `fn` and get back a provenance-carrying handle to
|
|
76
|
+
* the result — for asserting on a *decoded* value (base64, JSON, JWT, …) while
|
|
77
|
+
* keeping its link to the op that produced it. The op path is extended by a
|
|
78
|
+
* `<label>` marker, so a later `expect(...)` on the result still nests under
|
|
79
|
+
* the source op. `label` is a plain word (`"base64"`, `"json"`); the UI adds
|
|
80
|
+
* the `<…>` brackets, so don't include them yourself. `fn` receives the raw
|
|
81
|
+
* value (typed `T`), so no cast is needed. `R` types the result. A throw in
|
|
82
|
+
* `fn` is rethrown prefixed with `<label>:`, failing the test at the value.
|
|
83
|
+
*
|
|
84
|
+
* ```ts
|
|
85
|
+
* expect(secret.data.url.transform("base64", (s) => Buffer.from(s, "base64").toString()))
|
|
86
|
+
* .toContain("redis://");
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
transform<R = unknown>(label: string, fn: (raw: T) => R): Wrapped<R>;
|
|
90
|
+
/** Coerces to the raw value (arithmetic, `==`). */
|
|
91
|
+
valueOf(): T;
|
|
92
|
+
/** Renders the raw value (template interpolation). */
|
|
93
|
+
toString(): string;
|
|
94
|
+
/** Serializes as the raw value under `JSON.stringify`. */
|
|
95
|
+
toJSON(): T;
|
|
96
|
+
/** Coerces to the raw value. */
|
|
97
|
+
[Symbol.toPrimitive](hint?: string): T;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The values `expect()` accepts: anything carrying provenance from a recorded
|
|
101
|
+
* op (fetch / db / exec / browser / fakes / k8s) — every member of the
|
|
102
|
+
* {@link wrap} family ({@link Carrier}, {@link WrappedObject},
|
|
103
|
+
* {@link WrappedArray}, {@link WrappedResponse}) exposes `.unwrap()`, so this
|
|
104
|
+
* structural shape admits them all and rejects a raw primitive / object.
|
|
105
|
+
*
|
|
106
|
+
* `null`/`undefined` are also admitted: a nullish leaf can't carry the symbol
|
|
107
|
+
* tag, but `adoptNullishTag` recovers its provenance at runtime, so
|
|
108
|
+
* `expect(rows[0]?.text)` and `expect(dep.status.readyReplicas)` stay on
|
|
109
|
+
* `expect`. To assert on a value with no provenance (a computed number, a raw
|
|
110
|
+
* WebSocket frame), use `expectRaw(value, message)` instead.
|
|
111
|
+
*/
|
|
112
|
+
export type Provenanced = {
|
|
113
|
+
unwrap(): unknown;
|
|
114
|
+
} | null | undefined;
|
|
115
|
+
/**
|
|
116
|
+
* What a tracked op's value looks like once wrapped — applied *deeply*, so the
|
|
117
|
+
* type matches the runtime at every level (the proxy lazily wraps each leaf you
|
|
118
|
+
* read). A primitive becomes a {@link Carrier}; an object keeps its keys but
|
|
119
|
+
* each property is itself `Wrapped`, plus an `.unwrap()`; an array keeps a raw
|
|
120
|
+
* `.length` and indexes to `Wrapped` elements (see {@link WrappedArray}).
|
|
121
|
+
*
|
|
122
|
+
* Because leaves are `Carrier`s rather than their raw type, using one as raw
|
|
123
|
+
* data — arithmetic, string methods, a typed client — is a compile error;
|
|
124
|
+
* `x.unwrap()` (or `expect(x)`, which unwraps) recovers the raw value. That's
|
|
125
|
+
* the whole point: the type tells you it's a handle instead of pretending to be
|
|
126
|
+
* the underlying value and blowing up at runtime.
|
|
127
|
+
*
|
|
128
|
+
* **Distributes over unions** so the nullish members of an optional property
|
|
129
|
+
* survive as `null`/`undefined` rather than collapsing into a `Carrier<undefined>`.
|
|
130
|
+
* That's what lets `?.` narrow a wrapped optional leaf: `Wrapped<V1PodStatus |
|
|
131
|
+
* undefined>` is `WrappedObject<V1PodStatus> | undefined` (so `pod.status?.phase`
|
|
132
|
+
* type-checks), not `Carrier<undefined> | WrappedObject<…>` (where `?.` can't see
|
|
133
|
+
* the `Carrier` as nullish). A nullish leaf still reaches `expect` untagged and is
|
|
134
|
+
* recovered at runtime by {@link adoptNullishTag}; `Provenanced` admits it because
|
|
135
|
+
* it includes `null | undefined`.
|
|
136
|
+
*/
|
|
137
|
+
export type Wrapped<T> = T extends null | undefined ? T : T extends readonly (infer U)[] ? WrappedArray<U> : T extends (...args: never[]) => unknown ? T : T extends object ? WrappedObject<T> : Carrier<T>;
|
|
138
|
+
/** A wrapped object: every own property is itself {@link Wrapped}, plus an
|
|
139
|
+
* `.unwrap()` that recovers the fully-raw value (all nested leaves raw). */
|
|
140
|
+
export type WrappedObject<T> = {
|
|
141
|
+
readonly [K in keyof T]: Wrapped<T[K]>;
|
|
142
|
+
} & {
|
|
143
|
+
/** Recover the fully raw value (nested leaves unwrapped too). */
|
|
144
|
+
unwrap(): T;
|
|
145
|
+
/** Run the raw object through `fn`, keeping provenance (op path + `<label>`),
|
|
146
|
+
* and get back a navigable handle to the result. See {@link Carrier.transform}. */
|
|
147
|
+
transform<R = unknown>(label: string, fn: (raw: T) => R): Wrapped<R>;
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* A wrapped array. Indexing and the content-deriving methods (`find`, `map`,
|
|
151
|
+
* `filter`, `slice`, …) return {@link Wrapped} values so assertions on them
|
|
152
|
+
* still fold under the originating op; their *predicates* receive raw elements
|
|
153
|
+
* (the method runs on the raw target), so `=== ` comparisons inside a predicate
|
|
154
|
+
* keep working. `.length` stays a real `number` — provenance belongs on the
|
|
155
|
+
* data, not the container's size, so `rows.length === 1` must be a plain
|
|
156
|
+
* comparison. Iteration (`for…of`, spread) yields raw elements.
|
|
157
|
+
*/
|
|
158
|
+
export interface WrappedArray<U> {
|
|
159
|
+
readonly length: number;
|
|
160
|
+
readonly [index: number]: Wrapped<U>;
|
|
161
|
+
/** Recover the raw array (elements unwrapped). */
|
|
162
|
+
unwrap(): U[];
|
|
163
|
+
/** Run the raw array through `fn`, keeping provenance (op path + `<label>`),
|
|
164
|
+
* and get back a navigable handle to the result. See {@link Carrier.transform}. */
|
|
165
|
+
transform<R = unknown>(label: string, fn: (raw: U[]) => R): Wrapped<R>;
|
|
166
|
+
at(index: number): Wrapped<U> | undefined;
|
|
167
|
+
find(predicate: (value: U, index: number, obj: U[]) => unknown): Wrapped<U> | undefined;
|
|
168
|
+
findLast(predicate: (value: U, index: number, obj: U[]) => unknown): Wrapped<U> | undefined;
|
|
169
|
+
findIndex(predicate: (value: U, index: number, obj: U[]) => unknown): number;
|
|
170
|
+
findLastIndex(predicate: (value: U, index: number, obj: U[]) => unknown): number;
|
|
171
|
+
filter(predicate: (value: U, index: number, array: U[]) => unknown): WrappedArray<U>;
|
|
172
|
+
map<R>(callback: (value: U, index: number, array: U[]) => R): WrappedArray<R>;
|
|
173
|
+
slice(start?: number, end?: number): WrappedArray<U>;
|
|
174
|
+
concat(...items: U[][]): WrappedArray<U>;
|
|
175
|
+
flat(): WrappedArray<unknown>;
|
|
176
|
+
flatMap<R>(callback: (value: U, index: number, array: U[]) => R): WrappedArray<R>;
|
|
177
|
+
/** Note: returns a `Carrier<boolean>` at runtime (re-wrapped for provenance),
|
|
178
|
+
* so use `arr.includes(x).unwrap()` or `expect(arr.includes(x))` rather than
|
|
179
|
+
* a bare `if`. */
|
|
180
|
+
includes(value: U, fromIndex?: number): Carrier<boolean>;
|
|
181
|
+
indexOf(value: U, fromIndex?: number): Carrier<number>;
|
|
182
|
+
lastIndexOf(value: U, fromIndex?: number): Carrier<number>;
|
|
183
|
+
some(predicate: (value: U, index: number, array: U[]) => unknown): Carrier<boolean>;
|
|
184
|
+
every(predicate: (value: U, index: number, array: U[]) => unknown): Carrier<boolean>;
|
|
185
|
+
/** Iteration yields *raw* elements (the runtime forwards the raw iterator). */
|
|
186
|
+
[Symbol.iterator](): IterableIterator<U>;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The view `ctx.fetch` resolves to in every context (a spectest op wraps
|
|
190
|
+
* unconditionally): a {@link Response} whose status-line accessors are
|
|
191
|
+
* {@link Carrier}s — so a raw `res.status === 200` is a *type error*
|
|
192
|
+
* (status is a `Carrier<number>`, not a number), the exact mistake that
|
|
193
|
+
* used to silently always be false. Compare `res.status.unwrap() === 200`
|
|
194
|
+
* or `res.unwrap().status === 200`, or assert with `expect(res.status)`.
|
|
195
|
+
*
|
|
196
|
+
* `json<T>()` / `text()` return {@link Wrapped} body values; `.unwrap()`
|
|
197
|
+
* (or {@link unwrap the whole response}) recovers the plain `Response`.
|
|
198
|
+
*/
|
|
199
|
+
export interface WrappedResponse {
|
|
200
|
+
readonly status: Carrier<number>;
|
|
201
|
+
readonly ok: Carrier<boolean>;
|
|
202
|
+
readonly statusText: Carrier<string>;
|
|
203
|
+
readonly url: Carrier<string>;
|
|
204
|
+
readonly redirected: Carrier<boolean>;
|
|
205
|
+
readonly type: Carrier<string>;
|
|
206
|
+
readonly headers: Headers;
|
|
207
|
+
readonly bodyUsed: boolean;
|
|
208
|
+
json<T = unknown>(): Promise<Wrapped<T>>;
|
|
209
|
+
text(): Promise<Carrier<string>>;
|
|
210
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
211
|
+
blob(): Promise<Blob>;
|
|
212
|
+
formData(): Promise<FormData>;
|
|
213
|
+
clone(): WrappedResponse;
|
|
214
|
+
/** Recover the underlying raw {@link Response} (a real `number` status,
|
|
215
|
+
* an unwrapped body, etc.). */
|
|
216
|
+
unwrap(): Response;
|
|
217
|
+
}
|
|
218
|
+
/** The signature of `ctx.fetch`: a `fetch` that resolves to a
|
|
219
|
+
* {@link WrappedResponse} so reads carry provenance into assertions. */
|
|
220
|
+
export type SpectestFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<WrappedResponse>;
|
|
221
|
+
/**
|
|
222
|
+
* Bespoke wrapper for `fetch` responses. Reads on `status` / `ok` /
|
|
223
|
+
* `statusText` / `url` / `redirected` / `type` return carriers tagged
|
|
224
|
+
* to `sourceSeq`. The body-reading methods (`json`, `text`) return the
|
|
225
|
+
* resolved value wrapped under `path: ["body"]`. Everything else passes
|
|
226
|
+
* through bound to the real Response.
|
|
227
|
+
*/
|
|
228
|
+
export declare function wrapResponse(res: Response, sourceSeq: number | undefined): WrappedResponse;
|