@specific.dev/spectest 0.4.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 +38 -0
- package/src/browser.ts +824 -0
- package/src/components/index.ts +32 -0
- package/src/components/k3s.ts +1324 -0
- package/src/components/postgres.ts +281 -0
- package/src/components/replayFake.ts +515 -0
- package/src/daemon.ts +3910 -0
- package/src/index.ts +1601 -0
- package/src/ingress.ts +288 -0
- package/src/inspect.ts +604 -0
- package/src/record-secrets.ts +41 -0
- package/src/recorder.ts +659 -0
- package/src/resolver.ts +351 -0
- package/src/terminal.ts +740 -0
- package/src/vendor/rrweb-plugin-console-record.umd.js +520 -0
- package/src/vendor/rrweb-record.min.js +5 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import type { ServiceDefinition } from "../index.js";
|
|
2
|
+
import { recordDb, reserveEvent, safeSerialize } from "../recorder.js";
|
|
3
|
+
import { wrap } from "../inspect.js";
|
|
4
|
+
import type { Wrapped } from "../inspect.js";
|
|
5
|
+
|
|
6
|
+
export interface PostgresOptions {
|
|
7
|
+
/** Image tag for the official `postgres` image. Default `"18-alpine"`. */
|
|
8
|
+
version?: string;
|
|
9
|
+
/** Database created on first boot. */
|
|
10
|
+
database: string;
|
|
11
|
+
/** Superuser name created on first boot. */
|
|
12
|
+
user: string;
|
|
13
|
+
/** Superuser password. */
|
|
14
|
+
password: string;
|
|
15
|
+
/**
|
|
16
|
+
* Whether postgres data persists across container restarts via a bind
|
|
17
|
+
* mount under `.spectest/volumes/<name>/`. Default `true`.
|
|
18
|
+
* Snapshots/forks always preserve state regardless of this flag — the
|
|
19
|
+
* volume just means a plain `docker rm`/`docker run` cycle keeps data.
|
|
20
|
+
*/
|
|
21
|
+
persistent?: boolean;
|
|
22
|
+
/** TCP port the container listens on. Default `5432`. */
|
|
23
|
+
port?: number;
|
|
24
|
+
/** Extra environment variables forwarded to the container. */
|
|
25
|
+
env?: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
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
|
+
/** Helpers a `postgres(...)` service exposes on `ctx.svc.<name>`. */
|
|
107
|
+
export interface PostgresHelpers {
|
|
108
|
+
/** A Bun SQL pool, wrapped so each query lands on the test event
|
|
109
|
+
* log alongside http/exec/assertion events — its rows come back
|
|
110
|
+
* inspect-wrapped, so `expect(...)` on them links to the query. */
|
|
111
|
+
client: RecordingSqlClient;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A ready-to-use Postgres service. Drop into `environment.services`:
|
|
116
|
+
*
|
|
117
|
+
* ```ts
|
|
118
|
+
* services: {
|
|
119
|
+
* db: postgres({ database: "todos", user: "todos", password: "todos" }),
|
|
120
|
+
* ...
|
|
121
|
+
* }
|
|
122
|
+
* ```
|
|
123
|
+
*
|
|
124
|
+
* Peer services reach it at `<key>:<port>` (e.g. `db:5432` for the
|
|
125
|
+
* example above). Tests get a wired-up Bun SQL client at
|
|
126
|
+
* `ctx.svc.<key>.client` — `await ctx.svc.db.client\`SELECT 1\`` — with
|
|
127
|
+
* every query recorded on the test event log.
|
|
128
|
+
*/
|
|
129
|
+
export function postgres(opts: PostgresOptions) {
|
|
130
|
+
const version = opts.version ?? "18-alpine";
|
|
131
|
+
const port = opts.port ?? 5432;
|
|
132
|
+
const persistent = opts.persistent ?? true;
|
|
133
|
+
// `satisfies` (instead of a return-type annotation) preserves the
|
|
134
|
+
// literal type — in particular, the `helpers` factory's return shape.
|
|
135
|
+
// The mapped type in `ServiceHandlesFor<S>` reads that to type
|
|
136
|
+
// `ctx.svc.<name>` as `{ client: SqlClient }`.
|
|
137
|
+
return {
|
|
138
|
+
image: { type: "registry", reference: `postgres:${version}` },
|
|
139
|
+
env: {
|
|
140
|
+
POSTGRES_DB: opts.database,
|
|
141
|
+
POSTGRES_USER: opts.user,
|
|
142
|
+
POSTGRES_PASSWORD: opts.password,
|
|
143
|
+
// PGDATA lives in a subdir so postgres can initialise inside a bind
|
|
144
|
+
// mount that may not be empty on first boot.
|
|
145
|
+
PGDATA: "/var/lib/postgresql/data/pgdata",
|
|
146
|
+
...(opts.env ?? {}),
|
|
147
|
+
},
|
|
148
|
+
volumes: persistent ? [{ target: "/var/lib/postgresql/data" }] : [],
|
|
149
|
+
ports: [port],
|
|
150
|
+
readyCheck: { type: "tcp" as const, port, timeoutSecs: 60 },
|
|
151
|
+
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
|
+
const url =
|
|
160
|
+
`postgres://${encodeURIComponent(opts.user)}` +
|
|
161
|
+
`:${encodeURIComponent(opts.password)}` +
|
|
162
|
+
`@${name}:${port}/${encodeURIComponent(opts.database)}`;
|
|
163
|
+
return { client: wrapSqlForRecording(new bun.SQL(url), name) };
|
|
164
|
+
},
|
|
165
|
+
} satisfies ServiceDefinition<PostgresHelpers>;
|
|
166
|
+
}
|
|
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
|
+
}
|