@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.
Files changed (74) hide show
  1. package/dist/aws-sigv4.d.ts +42 -0
  2. package/dist/aws-sigv4.js +166 -0
  3. package/dist/browser.d.ts +314 -0
  4. package/dist/browser.js +1320 -0
  5. package/dist/components/email.d.ts +135 -0
  6. package/dist/components/email.js +271 -0
  7. package/dist/components/expo.d.ts +69 -0
  8. package/dist/components/expo.js +125 -0
  9. package/dist/components/index.d.ts +8 -0
  10. package/dist/components/index.js +18 -0
  11. package/dist/components/k3s.d.ts +143 -0
  12. package/dist/components/k3s.js +1067 -0
  13. package/dist/components/postgres.d.ts +93 -0
  14. package/dist/components/postgres.js +58 -0
  15. package/dist/components/replayFake.d.ts +169 -0
  16. package/dist/components/replayFake.js +738 -0
  17. package/dist/components/s3.d.ts +99 -0
  18. package/dist/components/s3.js +81 -0
  19. package/dist/components/supabase.d.ts +197 -0
  20. package/dist/components/supabase.js +1003 -0
  21. package/dist/daemon.d.ts +1 -0
  22. package/dist/daemon.js +4223 -0
  23. package/dist/ids.d.ts +2 -0
  24. package/{src/ids.ts → dist/ids.js} +46 -50
  25. package/dist/index.d.ts +1183 -0
  26. package/dist/index.js +769 -0
  27. package/dist/ingress.d.ts +114 -0
  28. package/dist/ingress.js +210 -0
  29. package/dist/inspect.d.ts +228 -0
  30. package/dist/inspect.js +429 -0
  31. package/dist/locator.d.ts +260 -0
  32. package/dist/locator.js +293 -0
  33. package/dist/mobile.d.ts +71 -0
  34. package/dist/mobile.js +65 -0
  35. package/dist/record-secrets.d.ts +9 -0
  36. package/{src/record-secrets.ts → dist/record-secrets.js} +13 -15
  37. package/dist/recorder.d.ts +516 -0
  38. package/dist/recorder.js +219 -0
  39. package/dist/redis.d.ts +54 -0
  40. package/dist/redis.js +126 -0
  41. package/dist/replay-bundle.d.ts +38 -0
  42. package/{src/replay-bundle.ts → dist/replay-bundle.js} +29 -47
  43. package/dist/resolver.d.ts +1 -0
  44. package/dist/resolver.js +309 -0
  45. package/dist/s3.d.ts +89 -0
  46. package/dist/s3.js +198 -0
  47. package/dist/sql.d.ts +74 -0
  48. package/dist/sql.js +151 -0
  49. package/dist/terminal.d.ts +161 -0
  50. package/dist/terminal.js +538 -0
  51. package/package.json +24 -9
  52. package/src/browser.ts +0 -1807
  53. package/src/components/email.ts +0 -398
  54. package/src/components/expo.ts +0 -167
  55. package/src/components/index.ts +0 -63
  56. package/src/components/k3s.ts +0 -1312
  57. package/src/components/postgres.ts +0 -105
  58. package/src/components/replayFake.ts +0 -848
  59. package/src/components/s3.ts +0 -132
  60. package/src/components/supabase.ts +0 -1299
  61. package/src/daemon.ts +0 -4969
  62. package/src/index.ts +0 -2350
  63. package/src/ingress.ts +0 -288
  64. package/src/inspect.ts +0 -673
  65. package/src/locator.ts +0 -594
  66. package/src/mobile.ts +0 -133
  67. package/src/recorder.ts +0 -817
  68. package/src/redis.ts +0 -202
  69. package/src/resolver.ts +0 -351
  70. package/src/s3.ts +0 -333
  71. package/src/sql.ts +0 -243
  72. package/src/terminal.ts +0 -740
  73. package/src/vendor/rrweb-plugin-console-record.umd.js +0 -521
  74. 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>;