@specific.dev/spectest 0.9.0 → 0.11.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/package.json +1 -1
- package/src/components/index.ts +5 -2
- package/src/components/postgres.ts +34 -210
- package/src/daemon.ts +22 -0
- package/src/index.ts +14 -92
- package/src/inspect.ts +61 -4
- package/src/recorder.ts +77 -0
- package/src/redis.ts +202 -0
- package/src/s3.ts +333 -0
- package/src/sql.ts +223 -0
package/package.json
CHANGED
package/src/components/index.ts
CHANGED
|
@@ -8,10 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
export {
|
|
10
10
|
postgres,
|
|
11
|
-
sqlClient,
|
|
12
11
|
type PostgresOptions,
|
|
13
|
-
type
|
|
12
|
+
type PostgresHelpers,
|
|
14
13
|
} from "./postgres.js";
|
|
14
|
+
// `SQL` / `SqlClient` now live in the core SDK (`@specific.dev/spectest`),
|
|
15
|
+
// next to `expect` and `fetch` — re-exported here so existing
|
|
16
|
+
// `from ".../components"` imports keep resolving.
|
|
17
|
+
export { SQL, type SqlClient, type SqlOptions } from "../sql.js";
|
|
15
18
|
export {
|
|
16
19
|
k3s,
|
|
17
20
|
type K3sOptions,
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import type { ServiceDefinition } from "../index.js";
|
|
2
|
-
import {
|
|
3
|
-
import { wrap } from "../inspect.js";
|
|
4
|
-
import type { Wrapped } from "../inspect.js";
|
|
2
|
+
import { SQL, type SqlClient } from "../sql.js";
|
|
5
3
|
|
|
6
4
|
export interface PostgresOptions {
|
|
7
|
-
/** Image tag for the official `postgres` image. Default `"18-alpine"`.
|
|
5
|
+
/** Image tag for the official `postgres` image. Default `"18-alpine"`.
|
|
6
|
+
* Ignored when {@link image} is set. */
|
|
8
7
|
version?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Full image reference, overriding the default `postgres:<version>`. For a
|
|
10
|
+
* Bun-SQL-/wire-compatible image (timescaledb, postgis, pgvector, …) you
|
|
11
|
+
* still get the instrumented client on `ctx.svc.<name>.client`.
|
|
12
|
+
*/
|
|
13
|
+
image?: string;
|
|
9
14
|
/** Database created on first boot. */
|
|
10
15
|
database: string;
|
|
11
16
|
/** Superuser name created on first boot. */
|
|
@@ -21,94 +26,23 @@ export interface PostgresOptions {
|
|
|
21
26
|
persistent?: boolean;
|
|
22
27
|
/** TCP port the container listens on. Default `5432`. */
|
|
23
28
|
port?: number;
|
|
29
|
+
/**
|
|
30
|
+
* CMD args appended after the image entrypoint (keeps the entrypoint, unlike
|
|
31
|
+
* `command`). For extension tuning, e.g.
|
|
32
|
+
* `["postgres", "-c", "timescaledb.max_background_workers=0"]`.
|
|
33
|
+
*/
|
|
34
|
+
args?: string[];
|
|
24
35
|
/** Extra environment variables forwarded to the container. */
|
|
25
36
|
env?: Record<string, string>;
|
|
26
37
|
}
|
|
27
38
|
|
|
28
|
-
/**
|
|
29
|
-
* Minimal structural type for the parts of `Bun.SQL` we use. Declared
|
|
30
|
-
* locally so user projects don't need `@types/bun` for type-checking to
|
|
31
|
-
* follow the SDK through to the daemon. The real type comes from Bun at
|
|
32
|
-
* runtime via `globalThis.Bun.SQL`.
|
|
33
|
-
*
|
|
34
|
-
* `SqlClient` is callable as a tagged template (`sql\`...\``) and has an
|
|
35
|
-
* `unsafe(text, params?)` escape hatch. Both return a thenable that
|
|
36
|
-
* executes lazily; we wrap them so each settle is recorded as a DbEvent.
|
|
37
|
-
*
|
|
38
|
-
* Both forms are generic in the row type and resolve to an array of rows,
|
|
39
|
-
* so callers name the shape once at the call site instead of casting the
|
|
40
|
-
* result: `` await ctx.svc.db.client<TodoRow>`SELECT * FROM todos` `` is
|
|
41
|
-
* `Promise<TodoRow[]>`. `T` defaults to `unknown` (the row shape is the
|
|
42
|
-
* caller's to assert) — pass it to drop the cast.
|
|
43
|
-
*/
|
|
44
|
-
interface SqlClientBase {
|
|
45
|
-
close?(opts?: { timeout?: number }): Promise<void>;
|
|
46
|
-
/** Close the connection / drain the pool (Bun's `SQL.end`). */
|
|
47
|
-
end(opts?: { timeout?: number }): Promise<void>;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface SqlClient extends SqlClientBase {
|
|
51
|
-
// Callable as a tagged template; resolves to the result rows.
|
|
52
|
-
<T = unknown>(
|
|
53
|
-
strings: TemplateStringsArray,
|
|
54
|
-
...values: unknown[]
|
|
55
|
-
): Promise<T[]>;
|
|
56
|
-
unsafe<T = unknown>(text: string, params?: unknown[]): Promise<T[]>;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* The recorder-instrumented client wired onto `ctx.svc.<name>.client`. Same
|
|
61
|
-
* surface as {@link SqlClient}, but each query resolves to a **wrapped** row
|
|
62
|
-
* array ({@link Wrapped}) rather than the raw rows: every settle is recorded
|
|
63
|
-
* as a DbEvent, and the wrapper carries provenance back to it. That's why
|
|
64
|
-
* `expect(rows)` / `expect(rows[0]!.id)` link under the query in the timeline
|
|
65
|
-
* with no cast — and why you `.unwrap()` before feeding a row
|
|
66
|
-
* value to a real client or a `===`. The raw escape-hatch {@link sqlClient}
|
|
67
|
-
* stays un-instrumented (plain {@link SqlClient}), so its results are genuinely
|
|
68
|
-
* raw and belong with `expectRaw`.
|
|
69
|
-
*/
|
|
70
|
-
export interface RecordingSqlClient extends SqlClientBase {
|
|
71
|
-
<T = unknown>(
|
|
72
|
-
strings: TemplateStringsArray,
|
|
73
|
-
...values: unknown[]
|
|
74
|
-
): Promise<Wrapped<T[]>>;
|
|
75
|
-
unsafe<T = unknown>(text: string, params?: unknown[]): Promise<Wrapped<T[]>>;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
interface BunGlobal {
|
|
79
|
-
SQL: new (url: string) => SqlClient;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Open a raw Bun SQL client against `url`, typed as {@link SqlClient}.
|
|
84
|
-
*
|
|
85
|
-
* Unlike the pool `postgres(...)` wires onto `ctx.svc.<name>.client`, this
|
|
86
|
-
* is **not** recorder-instrumented — reach for it when you only learn a
|
|
87
|
-
* connection string at runtime (e.g. one a fake handed back after
|
|
88
|
-
* provisioning a database via `ctx.startService(...)`), where the boot-time
|
|
89
|
-
* component isn't an option. Queries are still generic in the row type:
|
|
90
|
-
* `` await sqlClient(url)<{ name: string }>`SELECT name FROM t` ``.
|
|
91
|
-
*
|
|
92
|
-
* Requires the Bun runtime (it runs inside the spectest daemon), so it
|
|
93
|
-
* replaces the `(globalThis as any).Bun` reach-around with a typed entry
|
|
94
|
-
* point.
|
|
95
|
-
*/
|
|
96
|
-
export function sqlClient(url: string): SqlClient {
|
|
97
|
-
const bun = (globalThis as { Bun?: BunGlobal }).Bun;
|
|
98
|
-
if (!bun?.SQL) {
|
|
99
|
-
throw new Error(
|
|
100
|
-
"sqlClient(url) requires Bun.SQL (Bun >= 1.2) — it runs inside the spectest daemon.",
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
return new bun.SQL(url);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
39
|
/** Helpers a `postgres(...)` service exposes on `ctx.svc.<name>`. */
|
|
107
40
|
export interface PostgresHelpers {
|
|
108
|
-
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
|
|
41
|
+
/** An instrumented Bun SQL client — each query lands on the test event log
|
|
42
|
+
* alongside http/exec/assertion events, and its rows come back
|
|
43
|
+
* inspect-wrapped so `expect(...)` on them links to the query. See
|
|
44
|
+
* {@link SQL}. */
|
|
45
|
+
client: SqlClient;
|
|
112
46
|
}
|
|
113
47
|
|
|
114
48
|
/**
|
|
@@ -125,9 +59,20 @@ export interface PostgresHelpers {
|
|
|
125
59
|
* example above). Tests get a wired-up Bun SQL client at
|
|
126
60
|
* `ctx.svc.<key>.client` — `await ctx.svc.db.client\`SELECT 1\`` — with
|
|
127
61
|
* every query recorded on the test event log.
|
|
62
|
+
*
|
|
63
|
+
* Point it at a wire-compatible image with `image`/`args`:
|
|
64
|
+
*
|
|
65
|
+
* ```ts
|
|
66
|
+
* db: postgres({
|
|
67
|
+
* image: "timescale/timescaledb:latest-pg17",
|
|
68
|
+
* args: ["postgres", "-c", "timescaledb.max_background_workers=0"],
|
|
69
|
+
* database: "app", user: "app", password: "app",
|
|
70
|
+
* }),
|
|
71
|
+
* ```
|
|
128
72
|
*/
|
|
129
73
|
export function postgres(opts: PostgresOptions) {
|
|
130
74
|
const version = opts.version ?? "18-alpine";
|
|
75
|
+
const reference = opts.image ?? `postgres:${version}`;
|
|
131
76
|
const port = opts.port ?? 5432;
|
|
132
77
|
const persistent = opts.persistent ?? true;
|
|
133
78
|
// `satisfies` (instead of a return-type annotation) preserves the
|
|
@@ -135,7 +80,8 @@ export function postgres(opts: PostgresOptions) {
|
|
|
135
80
|
// The mapped type in `ServiceHandlesFor<S>` reads that to type
|
|
136
81
|
// `ctx.svc.<name>` as `{ client: SqlClient }`.
|
|
137
82
|
return {
|
|
138
|
-
image: { type: "registry", reference
|
|
83
|
+
image: { type: "registry", reference },
|
|
84
|
+
...(opts.args ? { args: opts.args } : {}),
|
|
139
85
|
env: {
|
|
140
86
|
POSTGRES_DB: opts.database,
|
|
141
87
|
POSTGRES_USER: opts.user,
|
|
@@ -149,133 +95,11 @@ export function postgres(opts: PostgresOptions) {
|
|
|
149
95
|
ports: [port],
|
|
150
96
|
readyCheck: { type: "tcp" as const, port, timeoutSecs: 60 },
|
|
151
97
|
helpers: ({ name }: { name: string }): PostgresHelpers => {
|
|
152
|
-
const bun = (globalThis as { Bun?: BunGlobal }).Bun;
|
|
153
|
-
if (!bun?.SQL) {
|
|
154
|
-
throw new Error(
|
|
155
|
-
"postgres component requires Bun.SQL (Bun >= 1.2). " +
|
|
156
|
-
"This helpers factory is meant to run inside the spectest daemon.",
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
98
|
const url =
|
|
160
99
|
`postgres://${encodeURIComponent(opts.user)}` +
|
|
161
100
|
`:${encodeURIComponent(opts.password)}` +
|
|
162
101
|
`@${name}:${port}/${encodeURIComponent(opts.database)}`;
|
|
163
|
-
return { client:
|
|
102
|
+
return { client: new SQL(url, { label: name }) };
|
|
164
103
|
},
|
|
165
104
|
} satisfies ServiceDefinition<PostgresHelpers>;
|
|
166
105
|
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Proxy a Bun.SQL instance so each tagged-template call and each
|
|
170
|
-
* `unsafe(...)` call emits a DbEvent into the active recorder when its
|
|
171
|
-
* promise settles. Other Bun.SQL methods (`.transaction`, `.array`,
|
|
172
|
-
* `.file`, …) pass through unwrapped.
|
|
173
|
-
*/
|
|
174
|
-
function wrapSqlForRecording(raw: SqlClient, service: string): RecordingSqlClient {
|
|
175
|
-
const wrapResult = <T>(
|
|
176
|
-
result: PromiseLike<T>,
|
|
177
|
-
query: string,
|
|
178
|
-
params: unknown[],
|
|
179
|
-
): Promise<T> => {
|
|
180
|
-
const started = Date.now();
|
|
181
|
-
const resv = reserveEvent();
|
|
182
|
-
return Promise.resolve(result).then(
|
|
183
|
-
(value) => {
|
|
184
|
-
const { rows, rowsTruncated, columns } = captureRows(value);
|
|
185
|
-
const seq = recordDb({
|
|
186
|
-
service,
|
|
187
|
-
query,
|
|
188
|
-
params: params.length > 0 ? params.map(safeSerialize) : undefined,
|
|
189
|
-
rowCount: rowCountOf(value),
|
|
190
|
-
rows,
|
|
191
|
-
rowsTruncated,
|
|
192
|
-
columns,
|
|
193
|
-
durationMs: Date.now() - started,
|
|
194
|
-
}, resv);
|
|
195
|
-
return wrap(value, seq) as T;
|
|
196
|
-
},
|
|
197
|
-
(err) => {
|
|
198
|
-
const e = err as Error;
|
|
199
|
-
recordDb({
|
|
200
|
-
service,
|
|
201
|
-
query,
|
|
202
|
-
params: params.length > 0 ? params.map(safeSerialize) : undefined,
|
|
203
|
-
durationMs: Date.now() - started,
|
|
204
|
-
error: e?.message ?? String(err),
|
|
205
|
-
}, resv);
|
|
206
|
-
throw err;
|
|
207
|
-
},
|
|
208
|
-
);
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
const handler: ProxyHandler<SqlClient> = {
|
|
212
|
-
apply(target, thisArg, args) {
|
|
213
|
-
const [strings, ...values] = args as [TemplateStringsArray, ...unknown[]];
|
|
214
|
-
const query = reconstructSqlTemplate(strings, values.length);
|
|
215
|
-
const result = Reflect.apply(
|
|
216
|
-
target as unknown as (...a: unknown[]) => PromiseLike<unknown>,
|
|
217
|
-
thisArg,
|
|
218
|
-
args,
|
|
219
|
-
);
|
|
220
|
-
return wrapResult(result, query, values);
|
|
221
|
-
},
|
|
222
|
-
get(target, prop, receiver) {
|
|
223
|
-
if (prop === "unsafe") {
|
|
224
|
-
return (text: string, params?: unknown[]) => {
|
|
225
|
-
const result = (
|
|
226
|
-
target.unsafe as (t: string, p?: unknown[]) => PromiseLike<unknown>
|
|
227
|
-
).call(target, text, params);
|
|
228
|
-
return wrapResult(result, text, params ?? []);
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
const v = Reflect.get(target, prop, receiver);
|
|
232
|
-
return typeof v === "function" ? v.bind(target) : v;
|
|
233
|
-
},
|
|
234
|
-
};
|
|
235
|
-
return new Proxy(raw, handler) as unknown as RecordingSqlClient;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/** Rebuild a parameterized SQL string from a tagged-template's pieces.
|
|
239
|
-
* Bun.SQL substitutes `${value}` for `$1`, `$2`, …; we do the same so
|
|
240
|
-
* the recorded query matches what the server sees. */
|
|
241
|
-
function reconstructSqlTemplate(
|
|
242
|
-
strings: TemplateStringsArray,
|
|
243
|
-
valueCount: number,
|
|
244
|
-
): string {
|
|
245
|
-
let out = strings[0] ?? "";
|
|
246
|
-
for (let i = 0; i < valueCount; i++) {
|
|
247
|
-
out += `$${i + 1}` + (strings[i + 1] ?? "");
|
|
248
|
-
}
|
|
249
|
-
return out.trim();
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function rowCountOf(value: unknown): number | undefined {
|
|
253
|
-
if (Array.isArray(value)) return value.length;
|
|
254
|
-
if (value && typeof value === "object") {
|
|
255
|
-
const obj = value as { count?: unknown; rowCount?: unknown };
|
|
256
|
-
if (typeof obj.count === "number") return obj.count;
|
|
257
|
-
if (typeof obj.rowCount === "number") return obj.rowCount;
|
|
258
|
-
}
|
|
259
|
-
return undefined;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const MAX_DB_ROWS = 50;
|
|
263
|
-
|
|
264
|
-
function captureRows(value: unknown): {
|
|
265
|
-
rows?: unknown[];
|
|
266
|
-
rowsTruncated?: boolean;
|
|
267
|
-
columns?: string[];
|
|
268
|
-
} {
|
|
269
|
-
if (!Array.isArray(value) || value.length === 0) return {};
|
|
270
|
-
const slice = value.slice(0, MAX_DB_ROWS).map(safeSerialize);
|
|
271
|
-
const first = slice[0];
|
|
272
|
-
const columns =
|
|
273
|
-
first && typeof first === "object" && !Array.isArray(first)
|
|
274
|
-
? Object.keys(first as Record<string, unknown>)
|
|
275
|
-
: undefined;
|
|
276
|
-
return {
|
|
277
|
-
rows: slice,
|
|
278
|
-
rowsTruncated: value.length > MAX_DB_ROWS ? true : undefined,
|
|
279
|
-
columns,
|
|
280
|
-
};
|
|
281
|
-
}
|
package/src/daemon.ts
CHANGED
|
@@ -166,6 +166,25 @@ function casesMetadata(suite: TestSuite | undefined): CaseMeta[] {
|
|
|
166
166
|
}));
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
interface FakeMeta {
|
|
170
|
+
name: string;
|
|
171
|
+
hostnames: string[];
|
|
172
|
+
port: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Display-only summary of the project's fakes for the control plane (folded
|
|
176
|
+
// into the env config's `fakes`, surfaced on the run page). Fakes aren't part
|
|
177
|
+
// of `project.environment`, so they ride alongside it as a separate field of
|
|
178
|
+
// the /load response — just the routing surface, no handler/state/helpers.
|
|
179
|
+
function fakesSummary(project: Project | undefined): FakeMeta[] {
|
|
180
|
+
if (!project?.fakes) return [];
|
|
181
|
+
return Object.entries(project.fakes).map(([name, def]) => ({
|
|
182
|
+
name,
|
|
183
|
+
hostnames: def.hostnames.map((h) => h.toLowerCase()),
|
|
184
|
+
port: def.port ?? DEFAULT_FAKE_PORT,
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
169
188
|
function resolveEntry(): string {
|
|
170
189
|
const explicit = process.env.SPECTEST_PROJECT_ENTRY;
|
|
171
190
|
if (explicit && existsSync(explicit)) return explicit;
|
|
@@ -3935,6 +3954,7 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse, state
|
|
|
3935
3954
|
jsonResponse(res, 200, {
|
|
3936
3955
|
environment: proj.environment,
|
|
3937
3956
|
cases: casesMetadata(proj.tests),
|
|
3957
|
+
fakes: fakesSummary(proj),
|
|
3938
3958
|
});
|
|
3939
3959
|
return;
|
|
3940
3960
|
}
|
|
@@ -3948,6 +3968,7 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse, state
|
|
|
3948
3968
|
jsonResponse(res, 200, {
|
|
3949
3969
|
environment: l.project.environment,
|
|
3950
3970
|
cases: casesMetadata(l.project.tests),
|
|
3971
|
+
fakes: fakesSummary(l.project),
|
|
3951
3972
|
});
|
|
3952
3973
|
return;
|
|
3953
3974
|
}
|
|
@@ -3966,6 +3987,7 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse, state
|
|
|
3966
3987
|
jsonResponse(res, 200, {
|
|
3967
3988
|
environment: l.project.environment,
|
|
3968
3989
|
cases: casesMetadata(l.project.tests),
|
|
3990
|
+
fakes: fakesSummary(l.project),
|
|
3969
3991
|
});
|
|
3970
3992
|
return;
|
|
3971
3993
|
}
|
package/src/index.ts
CHANGED
|
@@ -26,13 +26,24 @@ import type { OpTag, Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
|
|
|
26
26
|
// `field` — a provenance-preserving, null-safe selector: a `null`/`undefined`
|
|
27
27
|
// leaf read off a wrapped op result is raw and untagged, so an `expect(...)` on
|
|
28
28
|
// it renders detached from its source op; `field` tags from the container so the
|
|
29
|
-
// assertion still nests. (
|
|
30
|
-
//
|
|
29
|
+
// assertion still nests. (A decoded value loses provenance the same way — that
|
|
30
|
+
// case is the `.transform(label, fn)` method every wrapped value carries, which
|
|
31
|
+
// runs the still-tagged value through `fn` and re-wraps the result.)
|
|
31
32
|
// To recover a raw value, call `.unwrap()` on it — spectest op results are
|
|
32
33
|
// always wrapped (in every context), so the method is always there; there is no
|
|
33
34
|
// `unwrap(x)` free function. See the `.unwrap()` discipline section of `spectest docs`.
|
|
34
35
|
export { field } from "./inspect.js";
|
|
35
36
|
|
|
37
|
+
// Instrumented client primitives — drop-in replacements for Bun's native
|
|
38
|
+
// clients that record each operation on the test event log and return their
|
|
39
|
+
// results inspect-wrapped, so `expect(...)` on a result links back to the op
|
|
40
|
+
// in the timeline (same provenance mechanism as the wrapped `fetch`). Reach
|
|
41
|
+
// for these over the raw `Bun.*` clients in service `helpers` and tests so
|
|
42
|
+
// assertions stay tracked. See `sql.ts` / `redis.ts` / `s3.ts`.
|
|
43
|
+
export { SQL, type SqlClient, type SqlOptions } from "./sql.js";
|
|
44
|
+
export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js";
|
|
45
|
+
export { S3Client, type S3ClientLike, type S3File, type S3Options } from "./s3.js";
|
|
46
|
+
|
|
36
47
|
export type {
|
|
37
48
|
Browser,
|
|
38
49
|
BrowserOptions,
|
|
@@ -1344,52 +1355,7 @@ interface Matchers {
|
|
|
1344
1355
|
toHaveLength(n: number): void;
|
|
1345
1356
|
}
|
|
1346
1357
|
|
|
1347
|
-
|
|
1348
|
-
* Provenance-preserving transforms. Each returns a fresh {@link Expectation}
|
|
1349
|
-
* bound to the *decoded* value but still carrying the originating op's tag (its
|
|
1350
|
-
* path extended by a marker like `<base64>`), so the eventual assertion nests
|
|
1351
|
-
* under the source read in the timeline. This is what lets a value that must be
|
|
1352
|
-
* decoded through a plain function — `Buffer.from(x,"base64").toString()`,
|
|
1353
|
-
* `decodeURIComponent(x)` — be asserted on with the *full* matcher vocabulary
|
|
1354
|
-
* (`toHaveLength`, `toContain`, `toMatch`, …) without dropping to `expectRaw`,
|
|
1355
|
-
* which severs the link. Transforms chain (`.base64Decoded().urlDecoded()`),
|
|
1356
|
-
* and the matcher you finish with may be negated (`.base64Decoded().not.…`).
|
|
1357
|
-
*
|
|
1358
|
-
* They supersede the old `toBeBase64Of` matcher, which was equality-only:
|
|
1359
|
-
* `expect(secret.data?.X).base64Decoded().toBe("plain")` is the direct
|
|
1360
|
-
* replacement, and `.toHaveLength(32)` / `.toContain("redis://")` / `.toMatch(re)`
|
|
1361
|
-
* are the cases it couldn't express.
|
|
1362
|
-
*/
|
|
1363
|
-
interface Transforms {
|
|
1364
|
-
/** Interpret the value as a base64 string and decode it (UTF-8), mirroring
|
|
1365
|
-
* `Buffer.from(x, "base64").toString()`. Throws-as-failed-assertion if the
|
|
1366
|
-
* value isn't a string. */
|
|
1367
|
-
base64Decoded(): Expectation;
|
|
1368
|
-
/** URL-decode the value, mirroring `decodeURIComponent(x)`. Chains after
|
|
1369
|
-
* `base64Decoded()` for base64-then-URL-encoded values. */
|
|
1370
|
-
urlDecoded(): Expectation;
|
|
1371
|
-
/** Parse the value as JSON (`JSON.parse(x)`) and assert on the result with the
|
|
1372
|
-
* full matcher vocabulary — `toEqual` against the decoded object/array,
|
|
1373
|
-
* `toContain`/`toHaveLength` against a decoded array. Throws-as-failed-assertion
|
|
1374
|
-
* if the value isn't a string or isn't valid JSON. Chains after other
|
|
1375
|
-
* transforms (e.g. `base64Decoded().jsonDecoded()` for a base64-wrapped JSON
|
|
1376
|
-
* payload). The optional type parameter `T` annotates the decoded shape at the
|
|
1377
|
-
* call site (`jsonDecoded<User>()`) — it casts the parsed value, but, like the
|
|
1378
|
-
* matchers, is not enforced at runtime. */
|
|
1379
|
-
jsonDecoded<T = unknown>(): Expectation;
|
|
1380
|
-
/**
|
|
1381
|
-
* Generic escape hatch: apply an arbitrary `fn` to the raw value and assert on
|
|
1382
|
-
* the result, keeping provenance. `label` is a plain word (`"json"`,
|
|
1383
|
-
* `"decompressed"`) appended to the op path so the timeline shows what was
|
|
1384
|
-
* derived; the UI brackets it (`<json>`) to mark it as a derived step, so do
|
|
1385
|
-
* **not** include the brackets yourself. Sugar like {@link base64Decoded} is
|
|
1386
|
-
* built on this. If `fn` throws, the next assertion records as a failure
|
|
1387
|
-
* describing the transform error.
|
|
1388
|
-
*/
|
|
1389
|
-
transform(label: string, fn: (value: unknown) => unknown): Expectation;
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
export interface Expectation extends Matchers, Transforms {
|
|
1358
|
+
export interface Expectation extends Matchers {
|
|
1393
1359
|
not: Matchers;
|
|
1394
1360
|
}
|
|
1395
1361
|
|
|
@@ -1495,31 +1461,6 @@ function buildCore(
|
|
|
1495
1461
|
});
|
|
1496
1462
|
if (!passed) throw new ExpectationError(msg);
|
|
1497
1463
|
};
|
|
1498
|
-
// Apply `fn` to the raw value and rebuild against the result, extending the
|
|
1499
|
-
// op path by a derived-step marker so the decoded value still nests under the
|
|
1500
|
-
// source read. `label` is a plain word ("base64", "json"); the `<…>` marker
|
|
1501
|
-
// syntax that distinguishes a transform from a real property read in the
|
|
1502
|
-
// timeline is added here, so it lives in one place and never leaks into the
|
|
1503
|
-
// API surface — the same convention `inspect.ts` uses for array methods
|
|
1504
|
-
// (`<find>`, `<map>`). A throw becomes a `pendingError` on the returned
|
|
1505
|
-
// Expectation rather than escaping — a malformed decode renders as a failed
|
|
1506
|
-
// assertion in the timeline, not an unhandled exception. Propagates an
|
|
1507
|
-
// existing `pendingError` untouched.
|
|
1508
|
-
const applyTransform = (label: string, fn: (value: unknown) => unknown): Expectation => {
|
|
1509
|
-
const marker = `<${label}>`;
|
|
1510
|
-
const nextTag: OpTag | undefined = tag
|
|
1511
|
-
? { sourceSeq: tag.sourceSeq, path: [...tag.path, marker] }
|
|
1512
|
-
: undefined;
|
|
1513
|
-
if (pendingError !== undefined) {
|
|
1514
|
-
return buildCore(undefined, nextTag, false, message, pendingError);
|
|
1515
|
-
}
|
|
1516
|
-
try {
|
|
1517
|
-
return buildCore(fn(actual), nextTag, false, message);
|
|
1518
|
-
} catch (err) {
|
|
1519
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
1520
|
-
return buildCore(undefined, nextTag, false, message, `${marker}: ${detail}`);
|
|
1521
|
-
}
|
|
1522
|
-
};
|
|
1523
1464
|
return {
|
|
1524
1465
|
toBe(expected) {
|
|
1525
1466
|
const exp = readRaw(expected);
|
|
@@ -1628,25 +1569,6 @@ function buildCore(
|
|
|
1628
1569
|
{ actual: len, pathSuffix: "length" },
|
|
1629
1570
|
);
|
|
1630
1571
|
},
|
|
1631
|
-
transform(label, fn) {
|
|
1632
|
-
return applyTransform(label, fn);
|
|
1633
|
-
},
|
|
1634
|
-
base64Decoded() {
|
|
1635
|
-
return applyTransform("base64", (v) => {
|
|
1636
|
-
if (typeof v !== "string") {
|
|
1637
|
-
throw new Error(`base64Decoded expects a string, got ${typeof v}`);
|
|
1638
|
-
}
|
|
1639
|
-
return Buffer.from(v, "base64").toString();
|
|
1640
|
-
});
|
|
1641
|
-
},
|
|
1642
|
-
urlDecoded() {
|
|
1643
|
-
return applyTransform("urldecode", (v) => {
|
|
1644
|
-
if (typeof v !== "string") {
|
|
1645
|
-
throw new Error(`urlDecoded expects a string, got ${typeof v}`);
|
|
1646
|
-
}
|
|
1647
|
-
return decodeURIComponent(v);
|
|
1648
|
-
});
|
|
1649
|
-
},
|
|
1650
1572
|
get not(): Matchers {
|
|
1651
1573
|
// Re-pass the already-unwrapped value, tag and message so the tag and raw
|
|
1652
1574
|
// label are preserved for the negated branch's AssertionEvent.
|
package/src/inspect.ts
CHANGED
|
@@ -248,6 +248,12 @@ function wrapObject<T extends object>(
|
|
|
248
248
|
return () => readRaw(target);
|
|
249
249
|
}
|
|
250
250
|
}
|
|
251
|
+
// `.transform(label, fn)` — same shape/guard as `unwrap`: only intercept
|
|
252
|
+
// when the underlying object has no own `transform`, so a real data field
|
|
253
|
+
// named `transform` is never shadowed.
|
|
254
|
+
if (prop === "transform" && !(prop in (target as object))) {
|
|
255
|
+
return makeTransform(readRaw(target), sourceSeq, path);
|
|
256
|
+
}
|
|
251
257
|
// Hide thenable-ness from `await`. We must never accidentally
|
|
252
258
|
// implement `then`, or `await fetch(...)` would resolve to the
|
|
253
259
|
// wrong thing if we ever wrapped a Promise (we don't, but be safe).
|
|
@@ -298,6 +304,34 @@ function wrapObject<T extends object>(
|
|
|
298
304
|
return new Proxy(raw, handler);
|
|
299
305
|
}
|
|
300
306
|
|
|
307
|
+
// The `.transform(label, fn)` method every wrapper (carrier + object/array
|
|
308
|
+
// proxy) carries — the value-level analogue of `.unwrap()`. It runs the raw
|
|
309
|
+
// value through `fn` and re-`wrap`s the result with `path` extended by a
|
|
310
|
+
// `<label>` derived-step marker, so the decoded value keeps the source op's
|
|
311
|
+
// provenance (`sourceSeq`) and stays navigable: a later `expect(...)` on it, or
|
|
312
|
+
// on a subfield, still nests under the originating op (`secret.data.config.<json>.tier`).
|
|
313
|
+
// `label` is a plain word; the `<…>` brackets are added here so they never leak
|
|
314
|
+
// into the call site (the same convention array methods use, `<find>`/`<map>`).
|
|
315
|
+
// A throw in `fn` (a non-string value, malformed base64/JSON) is rethrown
|
|
316
|
+
// prefixed with `<label>:`, failing the test at the bad value.
|
|
317
|
+
function makeTransform(
|
|
318
|
+
raw: unknown,
|
|
319
|
+
sourceSeq: number | undefined,
|
|
320
|
+
path: readonly string[],
|
|
321
|
+
): (label: string, fn: (raw: never) => unknown) => unknown {
|
|
322
|
+
return (label, fn) => {
|
|
323
|
+
const nextPath = [...path, `<${label}>`];
|
|
324
|
+
let result: unknown;
|
|
325
|
+
try {
|
|
326
|
+
result = (fn as (r: unknown) => unknown)(raw);
|
|
327
|
+
} catch (err) {
|
|
328
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
329
|
+
throw new Error(`<${label}>: ${detail}`);
|
|
330
|
+
}
|
|
331
|
+
return wrap(result, sourceSeq, nextPath);
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
301
335
|
// Runtime shape of a primitive carrier: the public `Carrier<T>` surface
|
|
302
336
|
// (`unwrap()`) plus the internal provenance symbols. Kept private so the
|
|
303
337
|
// exported `Carrier<T>` stays clean.
|
|
@@ -316,6 +350,7 @@ function makeCarrier<T>(
|
|
|
316
350
|
[OP_TAG]: tag,
|
|
317
351
|
[UNWRAP]: raw,
|
|
318
352
|
unwrap: () => raw,
|
|
353
|
+
transform: makeTransform(raw, sourceSeq, path) as unknown as Carrier<T>["transform"],
|
|
319
354
|
// Coercion sinks recover the raw primitive instead of inheriting
|
|
320
355
|
// Object.prototype's defaults ("[object Object]" / NaN / {}). A
|
|
321
356
|
// carrier interpolated into a string, fed to arithmetic, `==`, or
|
|
@@ -338,10 +373,10 @@ function makeCarrier<T>(
|
|
|
338
373
|
// passes them through raw. So `expect(row.deleted_at).toBe(null)` reaches
|
|
339
374
|
// `expect()` as a bare `null` with no tag and renders as a disconnected
|
|
340
375
|
// top-level row. `field` recovers the link by tagging from the *container*.
|
|
341
|
-
// (
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
376
|
+
// (A decoded value loses its tag the same way, through the decode; the
|
|
377
|
+
// `.transform(label, fn)` method every wrapper carries (see `makeTransform`)
|
|
378
|
+
// recovers it — it runs the still-tagged value through `fn` and re-`wrap`s the
|
|
379
|
+
// result with the source path extended by a `<label>` marker.)
|
|
345
380
|
// ───────────────────────────────────────────────────────────────────────────
|
|
346
381
|
|
|
347
382
|
/**
|
|
@@ -410,6 +445,22 @@ export function field(value: unknown, ...path: Array<string | number>): unknown
|
|
|
410
445
|
export interface Carrier<T> {
|
|
411
446
|
/** Recover the raw underlying value. */
|
|
412
447
|
unwrap(): T;
|
|
448
|
+
/**
|
|
449
|
+
* Run the raw value through `fn` and get back a provenance-carrying handle to
|
|
450
|
+
* the result — for asserting on a *decoded* value (base64, JSON, JWT, …) while
|
|
451
|
+
* keeping its link to the op that produced it. The op path is extended by a
|
|
452
|
+
* `<label>` marker, so a later `expect(...)` on the result still nests under
|
|
453
|
+
* the source op. `label` is a plain word (`"base64"`, `"json"`); the UI adds
|
|
454
|
+
* the `<…>` brackets, so don't include them yourself. `fn` receives the raw
|
|
455
|
+
* value (typed `T`), so no cast is needed. `R` types the result. A throw in
|
|
456
|
+
* `fn` is rethrown prefixed with `<label>:`, failing the test at the value.
|
|
457
|
+
*
|
|
458
|
+
* ```ts
|
|
459
|
+
* expect(secret.data.url.transform("base64", (s) => Buffer.from(s, "base64").toString()))
|
|
460
|
+
* .toContain("redis://");
|
|
461
|
+
* ```
|
|
462
|
+
*/
|
|
463
|
+
transform<R = unknown>(label: string, fn: (raw: T) => R): Wrapped<R>;
|
|
413
464
|
/** Coerces to the raw value (arithmetic, `==`). */
|
|
414
465
|
valueOf(): T;
|
|
415
466
|
/** Renders the raw value (template interpolation). */
|
|
@@ -475,6 +526,9 @@ export type WrappedObject<T> = {
|
|
|
475
526
|
} & {
|
|
476
527
|
/** Recover the fully raw value (nested leaves unwrapped too). */
|
|
477
528
|
unwrap(): T;
|
|
529
|
+
/** Run the raw object through `fn`, keeping provenance (op path + `<label>`),
|
|
530
|
+
* and get back a navigable handle to the result. See {@link Carrier.transform}. */
|
|
531
|
+
transform<R = unknown>(label: string, fn: (raw: T) => R): Wrapped<R>;
|
|
478
532
|
};
|
|
479
533
|
|
|
480
534
|
/**
|
|
@@ -491,6 +545,9 @@ export interface WrappedArray<U> {
|
|
|
491
545
|
readonly [index: number]: Wrapped<U>;
|
|
492
546
|
/** Recover the raw array (elements unwrapped). */
|
|
493
547
|
unwrap(): U[];
|
|
548
|
+
/** Run the raw array through `fn`, keeping provenance (op path + `<label>`),
|
|
549
|
+
* and get back a navigable handle to the result. See {@link Carrier.transform}. */
|
|
550
|
+
transform<R = unknown>(label: string, fn: (raw: U[]) => R): Wrapped<R>;
|
|
494
551
|
at(index: number): Wrapped<U> | undefined;
|
|
495
552
|
find(
|
|
496
553
|
predicate: (value: U, index: number, obj: U[]) => unknown,
|