@specific.dev/spectest 0.26.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 -1819
- 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
package/dist/sql.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// `SQL` — a drop-in for `Bun.SQL` that records every query on the test event
|
|
2
|
+
// log and returns its rows inspect-wrapped, so `expect(rows[0]!.id)` links the
|
|
3
|
+
// assertion back to the query in the timeline (same provenance mechanism as
|
|
4
|
+
// `fetch`). Swap `new Bun.SQL(url)` → `new SQL(url)` and nothing else changes
|
|
5
|
+
// at the call site; query *results* come back `Wrapped<T[]>` instead of raw
|
|
6
|
+
// `T[]` (that wrapper is what carries provenance), so `.unwrap()` before feeding
|
|
7
|
+
// a row value to another real client or a `===` — exactly the rule that already
|
|
8
|
+
// holds for `ctx.svc.<postgres>.client`.
|
|
9
|
+
//
|
|
10
|
+
// This is the core primitive; the `postgres(...)` component is a thin user of
|
|
11
|
+
// it (its `helpers.client` is `new SQL(url, { label })`). Open one yourself
|
|
12
|
+
// whenever you stand up a database without the component — a Bun-native image
|
|
13
|
+
// like timescaledb/postgis, or a connection string a fake handed back after
|
|
14
|
+
// provisioning a DB at runtime.
|
|
15
|
+
import { recordDb, reserveEvent, safeSerialize } from "./recorder.js";
|
|
16
|
+
import { wrap } from "./inspect.js";
|
|
17
|
+
/**
|
|
18
|
+
* Open an instrumented SQL client against `url`. Usable with or without `new`
|
|
19
|
+
* (`new SQL(url)` mirrors `new Bun.SQL(url)`; a constructor that returns an
|
|
20
|
+
* object yields that object). Requires the Bun runtime — it runs inside the
|
|
21
|
+
* spectest daemon.
|
|
22
|
+
*/
|
|
23
|
+
export const SQL = function SQL(url, opts) {
|
|
24
|
+
const bun = globalThis.Bun;
|
|
25
|
+
if (!bun?.SQL) {
|
|
26
|
+
throw new Error("SQL(url) requires the Bun runtime (Bun >= 1.2) — it runs inside the spectest daemon.");
|
|
27
|
+
}
|
|
28
|
+
return instrumentSql(new bun.SQL(url), opts?.label ?? hostLabel(url));
|
|
29
|
+
};
|
|
30
|
+
/** Derive a default event label from a connection URL's host. */
|
|
31
|
+
function hostLabel(url) {
|
|
32
|
+
try {
|
|
33
|
+
return new URL(url).hostname || "db";
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return "db";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Proxy a `Bun.SQL` instance so each tagged-template call and each
|
|
41
|
+
* `unsafe(...)` call emits a `db` event into the active recorder when its
|
|
42
|
+
* promise settles, and resolves to a {@link Wrapped} result. Other `Bun.SQL`
|
|
43
|
+
* methods (`.transaction`, `.array`, `.file`, …) pass through unwrapped.
|
|
44
|
+
*
|
|
45
|
+
* Exported so a client built some other way (e.g. a pool you already hold) can
|
|
46
|
+
* opt into the same instrumentation without going through {@link SQL}.
|
|
47
|
+
*/
|
|
48
|
+
export function instrumentSql(raw, label) {
|
|
49
|
+
const wrapResult = (result, query, params) => {
|
|
50
|
+
const started = Date.now();
|
|
51
|
+
const resv = reserveEvent();
|
|
52
|
+
return Promise.resolve(result).then((value) => {
|
|
53
|
+
const { rows, rowsTruncated, columns } = captureRows(value);
|
|
54
|
+
const seq = recordDb({
|
|
55
|
+
service: label,
|
|
56
|
+
query,
|
|
57
|
+
params: params.length > 0 ? params.map(safeSerialize) : undefined,
|
|
58
|
+
rowCount: rowCountOf(value),
|
|
59
|
+
rowsAffected: isAffectedCount(query, value) || undefined,
|
|
60
|
+
rows,
|
|
61
|
+
rowsTruncated,
|
|
62
|
+
columns,
|
|
63
|
+
durationMs: Date.now() - started,
|
|
64
|
+
}, resv);
|
|
65
|
+
return wrap(value, seq);
|
|
66
|
+
}, (err) => {
|
|
67
|
+
const e = err;
|
|
68
|
+
recordDb({
|
|
69
|
+
service: label,
|
|
70
|
+
query,
|
|
71
|
+
params: params.length > 0 ? params.map(safeSerialize) : undefined,
|
|
72
|
+
durationMs: Date.now() - started,
|
|
73
|
+
error: e?.message ?? String(err),
|
|
74
|
+
}, resv);
|
|
75
|
+
throw err;
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
const handler = {
|
|
79
|
+
apply(target, thisArg, args) {
|
|
80
|
+
const [strings, ...values] = args;
|
|
81
|
+
const query = reconstructSqlTemplate(strings, values.length);
|
|
82
|
+
const result = Reflect.apply(target, thisArg, args);
|
|
83
|
+
return wrapResult(result, query, values);
|
|
84
|
+
},
|
|
85
|
+
get(target, prop, receiver) {
|
|
86
|
+
if (prop === "unsafe") {
|
|
87
|
+
return (text, params) => {
|
|
88
|
+
const result = target.unsafe.call(target, text, params);
|
|
89
|
+
return wrapResult(result, text, params ?? []);
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const v = Reflect.get(target, prop, receiver);
|
|
93
|
+
return typeof v === "function" ? v.bind(target) : v;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
return new Proxy(raw, handler);
|
|
97
|
+
}
|
|
98
|
+
/** Rebuild a parameterized SQL string from a tagged-template's pieces.
|
|
99
|
+
* Bun.SQL substitutes `${value}` for `$1`, `$2`, …; we do the same so
|
|
100
|
+
* the recorded query matches what the server sees. */
|
|
101
|
+
function reconstructSqlTemplate(strings, valueCount) {
|
|
102
|
+
let out = strings[0] ?? "";
|
|
103
|
+
for (let i = 0; i < valueCount; i++) {
|
|
104
|
+
out += `$${i + 1}` + (strings[i + 1] ?? "");
|
|
105
|
+
}
|
|
106
|
+
return out.trim();
|
|
107
|
+
}
|
|
108
|
+
function rowCountOf(value) {
|
|
109
|
+
if (value && typeof value === "object") {
|
|
110
|
+
const obj = value;
|
|
111
|
+
// Bun.SQL (postgres.js semantics): `.count` is rows RETURNED for
|
|
112
|
+
// SELECT/RETURNING queries and rows AFFECTED for other writes — where
|
|
113
|
+
// the result array itself is empty. Check it before `.length`, or a
|
|
114
|
+
// non-RETURNING `UPDATE` that touched 3 rows records a misleading 0.
|
|
115
|
+
if (typeof obj.count === "number")
|
|
116
|
+
return obj.count;
|
|
117
|
+
if (typeof obj.rowCount === "number")
|
|
118
|
+
return obj.rowCount;
|
|
119
|
+
}
|
|
120
|
+
if (Array.isArray(value))
|
|
121
|
+
return value.length;
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
/** True when the recorded count means "rows affected" rather than "rows
|
|
125
|
+
* returned" — a write with no result set. Lets the dashboard render
|
|
126
|
+
* `3 affected` instead of `3 rows` (and keeps `UPDATE … (0 rows)` from
|
|
127
|
+
* reading as "nothing updated"). */
|
|
128
|
+
function isAffectedCount(query, value) {
|
|
129
|
+
if (Array.isArray(value) && value.length > 0)
|
|
130
|
+
return false; // rows came back
|
|
131
|
+
const q = query.trimStart().slice(0, 8).toUpperCase();
|
|
132
|
+
return (q.startsWith("INSERT") ||
|
|
133
|
+
q.startsWith("UPDATE") ||
|
|
134
|
+
q.startsWith("DELETE") ||
|
|
135
|
+
q.startsWith("MERGE"));
|
|
136
|
+
}
|
|
137
|
+
const MAX_DB_ROWS = 50;
|
|
138
|
+
function captureRows(value) {
|
|
139
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
140
|
+
return {};
|
|
141
|
+
const slice = value.slice(0, MAX_DB_ROWS).map(safeSerialize);
|
|
142
|
+
const first = slice[0];
|
|
143
|
+
const columns = first && typeof first === "object" && !Array.isArray(first)
|
|
144
|
+
? Object.keys(first)
|
|
145
|
+
: undefined;
|
|
146
|
+
return {
|
|
147
|
+
rows: slice,
|
|
148
|
+
rowsTruncated: value.length > MAX_DB_ROWS ? true : undefined,
|
|
149
|
+
columns,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { Wrapped } from "./inspect.js";
|
|
2
|
+
export interface TerminalOpts {
|
|
3
|
+
/** PTY column count. Defaults to 80. */
|
|
4
|
+
cols?: number;
|
|
5
|
+
/** PTY row count. Defaults to 24. */
|
|
6
|
+
rows?: number;
|
|
7
|
+
/** Extra env vars; merged onto the container's. `TERM` defaults to
|
|
8
|
+
* `xterm-256color`. */
|
|
9
|
+
env?: Record<string, string>;
|
|
10
|
+
/**
|
|
11
|
+
* Command to run inside the container. When omitted, opens an
|
|
12
|
+
* interactive login shell (`sh -l`) — what you want for the typical
|
|
13
|
+
* "send commands, inspect output" interactive flow.
|
|
14
|
+
*
|
|
15
|
+
* When set, the command is the entrypoint and the session ends when
|
|
16
|
+
* it exits. This is what the one-shot `ctx.terminal(...)` wrapper
|
|
17
|
+
* passes through.
|
|
18
|
+
*/
|
|
19
|
+
command?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Hard timeout for the whole session in ms. When the session is
|
|
22
|
+
* interactive (no `command`), this defaults to undefined (no timeout)
|
|
23
|
+
* and the daemon's test-level timeout is what trips. For one-shot
|
|
24
|
+
* runs, the wrapper supplies the test's default timeout.
|
|
25
|
+
*/
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
}
|
|
28
|
+
/** Result of the one-shot `ctx.terminal(...)` convenience. */
|
|
29
|
+
export interface TerminalResult {
|
|
30
|
+
/** Full decoded PTY output (raw bytes, ANSI escapes included). Capped. */
|
|
31
|
+
output: string;
|
|
32
|
+
exitCode: number;
|
|
33
|
+
durationMs: number;
|
|
34
|
+
/** Opaque id linking to the persisted asciicast session. */
|
|
35
|
+
sessionId: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Interactive terminal handle. Operations are sequential per terminal —
|
|
39
|
+
* concurrent `send`/`waitFor` against the same handle has no well-defined
|
|
40
|
+
* meaning since both touch the same PTY/screen buffer. Open multiple
|
|
41
|
+
* terminals for parallel sessions.
|
|
42
|
+
*/
|
|
43
|
+
export interface Terminal {
|
|
44
|
+
/** PTY column count this terminal was opened with. */
|
|
45
|
+
readonly cols: number;
|
|
46
|
+
/** PTY row count this terminal was opened with. */
|
|
47
|
+
readonly rows: number;
|
|
48
|
+
/** Session id linking to the asciicast record. */
|
|
49
|
+
readonly sessionId: string;
|
|
50
|
+
/**
|
|
51
|
+
* Resolves when the underlying process ends — by `close()`, by the
|
|
52
|
+
* inner command exiting, or by timeout — to a `TerminalResult` with
|
|
53
|
+
* the full captured `output` and `exitCode`. Never rejects.
|
|
54
|
+
*
|
|
55
|
+
* The result is provenance-tagged against the `exit` step, so an
|
|
56
|
+
* `expect((await term.exited).output).toContain(...)` /
|
|
57
|
+
* `expect((await term.exited).exitCode).toBe(0)` nests under the
|
|
58
|
+
* terminal in the timeline. This is how you assert on a long-lived
|
|
59
|
+
* terminal's transcript — there is no separate `output()`/`screen()`
|
|
60
|
+
* getter (use `waitFor` to observe mid-session).
|
|
61
|
+
*/
|
|
62
|
+
readonly exited: Promise<Wrapped<TerminalResult>>;
|
|
63
|
+
/** Exit code if the process has exited, else `undefined`. Non-blocking
|
|
64
|
+
* peek; unlike `exited` it records no step and isn't tagged. */
|
|
65
|
+
readonly exitCode: number | undefined;
|
|
66
|
+
/** Write raw bytes to the PTY. No newline added. */
|
|
67
|
+
send(input: string): Promise<void>;
|
|
68
|
+
/** Convenience: `send(line + "\n")`. */
|
|
69
|
+
sendLine(line: string): Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* Press a named key. Supported names: `Enter`, `Tab`, `Backspace`,
|
|
72
|
+
* `Escape`, `Up`/`Down`/`Left`/`Right`, `Home`, `End`, `PageUp`,
|
|
73
|
+
* `PageDown`, `Delete`, and `Ctrl+<letter>` (case-insensitive).
|
|
74
|
+
* Anything else is sent as the literal string.
|
|
75
|
+
*/
|
|
76
|
+
press(key: string): Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* Poll until `matcher` resolves truthy against the rendered screen.
|
|
79
|
+
* This is the primitive for observing terminal state — race-free
|
|
80
|
+
* (it polls until the condition holds rather than reading a snapshot
|
|
81
|
+
* that may not have rendered yet) and recorded as a tied step.
|
|
82
|
+
* `matcher` can be:
|
|
83
|
+
* - a string → matches when the screen contains it; resolves to
|
|
84
|
+
* the string. The wait itself is the assertion (it throws on
|
|
85
|
+
* timeout), so no extra `expect` is needed for "contains X".
|
|
86
|
+
* - a regex → matches on `.test`; resolves to the match array.
|
|
87
|
+
* - a function → `fn(screen, output)` — any truthy return resolves
|
|
88
|
+
* and becomes the (provenance-tagged) result, so you can extract
|
|
89
|
+
* a value and `expect(...)` on it.
|
|
90
|
+
*
|
|
91
|
+
* `description` is a short human label that shows up in the event
|
|
92
|
+
* log so timelines don't fill with anonymous waits.
|
|
93
|
+
*
|
|
94
|
+
* Defaults: 5 s total timeout, 100 ms between polls. Throws on
|
|
95
|
+
* timeout.
|
|
96
|
+
*/
|
|
97
|
+
waitFor(description: string, matcher: string, opts?: {
|
|
98
|
+
timeoutMs?: number;
|
|
99
|
+
intervalMs?: number;
|
|
100
|
+
}): Promise<Wrapped<string>>;
|
|
101
|
+
waitFor(description: string, matcher: RegExp, opts?: {
|
|
102
|
+
timeoutMs?: number;
|
|
103
|
+
intervalMs?: number;
|
|
104
|
+
}): Promise<Wrapped<RegExpMatchArray>>;
|
|
105
|
+
waitFor<T = unknown>(description: string, matcher: (screen: string, output: string) => T, opts?: {
|
|
106
|
+
timeoutMs?: number;
|
|
107
|
+
intervalMs?: number;
|
|
108
|
+
}): Promise<Wrapped<NonNullable<Awaited<T>>>>;
|
|
109
|
+
/**
|
|
110
|
+
* Tear down. Writes Ctrl-D to stdin to nudge the shell out, then
|
|
111
|
+
* SIGKILLs after a grace period. Idempotent. Resolves to a
|
|
112
|
+
* `TerminalResult` (tagged against the `close` step) so you can assert
|
|
113
|
+
* on the transcript of a session that doesn't self-terminate. Optional
|
|
114
|
+
* — terminals are not auto-closed when a test ends.
|
|
115
|
+
*/
|
|
116
|
+
close(): Promise<Wrapped<TerminalResult>>;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The concrete handle `openTerminal` returns. Extends the public
|
|
120
|
+
* `Terminal` with raw accessors the daemon uses internally to build
|
|
121
|
+
* `TerminalResult`s and event previews. Deliberately NOT on the public
|
|
122
|
+
* `Terminal` surface: test code observes via `waitFor` (race-free, tied)
|
|
123
|
+
* and reads the transcript from `exited`/`close`, never from an
|
|
124
|
+
* out-of-band, untracked getter.
|
|
125
|
+
*/
|
|
126
|
+
export interface InternalTerminal extends Terminal {
|
|
127
|
+
/** Cumulative raw PTY bytes since the session opened, capped. ANSI included. */
|
|
128
|
+
rawOutput(): string;
|
|
129
|
+
/** Rendered screen — visible rows joined by newlines, trailing
|
|
130
|
+
* whitespace trimmed, cursor-position-aware (ANSI already applied). */
|
|
131
|
+
rawScreen(): string;
|
|
132
|
+
}
|
|
133
|
+
/** Sink for asciicast frames produced by this terminal. */
|
|
134
|
+
export interface TerminalFrameSink {
|
|
135
|
+
pushFrame(tSec: number, data: string): void;
|
|
136
|
+
markClosed(): void;
|
|
137
|
+
}
|
|
138
|
+
export interface OpenTerminalArgs {
|
|
139
|
+
service: string;
|
|
140
|
+
opts?: TerminalOpts;
|
|
141
|
+
/** Asciicast frames go here; the daemon owns the session record. */
|
|
142
|
+
sink: TerminalFrameSink;
|
|
143
|
+
/** Session id (precomputed so the inline TerminalEvent can reference
|
|
144
|
+
* it before the session record is finished). */
|
|
145
|
+
sessionId: string;
|
|
146
|
+
/**
|
|
147
|
+
* When set, the daemon emits per-op events into the recorder under
|
|
148
|
+
* this session id. Mirrors `BrowserSessionRecorder.sessionId`.
|
|
149
|
+
* `null` disables per-op event recording (e.g. eval, where there's
|
|
150
|
+
* no active recorder).
|
|
151
|
+
*/
|
|
152
|
+
recordEvents?: boolean;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Open a PTY-backed interactive terminal in a service container.
|
|
156
|
+
*
|
|
157
|
+
* The factory itself doesn't know about `TerminalSessionRecord`s — it
|
|
158
|
+
* just calls `sink.pushFrame` for every chunk it sees and `markClosed`
|
|
159
|
+
* when the session ends. The daemon owns the record and the sink.
|
|
160
|
+
*/
|
|
161
|
+
export declare function openTerminal(args: OpenTerminalArgs): Promise<InternalTerminal>;
|