@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/recorder.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// Per-test event recorder. The daemon installs a fresh recorder before
|
|
2
|
+
// running a test case and drains it afterwards. While a recorder is
|
|
3
|
+
// active, instrumented sites (the SDK's `expect`, the daemon's wrapped
|
|
4
|
+
// `ctx.exec`, and the test-scoped `fetch` wrapper) push structured
|
|
5
|
+
// events into it.
|
|
6
|
+
//
|
|
7
|
+
// When no test is running (production code paths, eval, etc.) the
|
|
8
|
+
// current recorder is `null` and the `record*` helpers are no-ops, so
|
|
9
|
+
// callers can invoke them unconditionally.
|
|
10
|
+
import { clearPendingNullish } from "./inspect.js";
|
|
11
|
+
const OUTPUT_SNIPPET_BYTES = 256 * 1024;
|
|
12
|
+
class Recorder {
|
|
13
|
+
events = [];
|
|
14
|
+
seq = 0;
|
|
15
|
+
start = Date.now();
|
|
16
|
+
/** Claim a seq + start offset now; pass the result to `push` at finish. */
|
|
17
|
+
reserve() {
|
|
18
|
+
return { seq: this.seq++, tOffsetMs: Date.now() - this.start };
|
|
19
|
+
}
|
|
20
|
+
push(ev, reservation) {
|
|
21
|
+
// A new op invalidates any pending nullish-leaf note: a note must not
|
|
22
|
+
// survive past the op it belongs to and get adopted by a later, unrelated
|
|
23
|
+
// `expect`. The assertion event itself is exempt — its `expect()` already
|
|
24
|
+
// adopted (and consumed) the note before recording.
|
|
25
|
+
if (ev.kind !== "assertion")
|
|
26
|
+
clearPendingNullish();
|
|
27
|
+
const seq = reservation?.seq ?? this.seq++;
|
|
28
|
+
this.events.push({
|
|
29
|
+
...ev,
|
|
30
|
+
seq,
|
|
31
|
+
tOffsetMs: reservation?.tOffsetMs ?? Date.now() - this.start,
|
|
32
|
+
});
|
|
33
|
+
return seq;
|
|
34
|
+
}
|
|
35
|
+
drain() {
|
|
36
|
+
return this.events;
|
|
37
|
+
}
|
|
38
|
+
/** Index of the next event that would be pushed. Use with `truncate`
|
|
39
|
+
* to drop everything pushed since this index. The seq counter does
|
|
40
|
+
* not roll back — discarded seqs leave gaps. */
|
|
41
|
+
eventCount() {
|
|
42
|
+
return this.events.length;
|
|
43
|
+
}
|
|
44
|
+
/** Drop events with index >= `toLen`. The seq counter is unaffected
|
|
45
|
+
* (so seqs already handed out remain unique even though the events
|
|
46
|
+
* they named are gone). */
|
|
47
|
+
truncate(toLen) {
|
|
48
|
+
if (toLen < this.events.length) {
|
|
49
|
+
this.events.length = toLen;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Stamp `parentSeq` onto every event at index >= `startIdx`. Used
|
|
53
|
+
* by `ctx.poll` to group the kept iteration's events under the
|
|
54
|
+
* resulting wait event. */
|
|
55
|
+
markChildren(startIdx, parentSeq) {
|
|
56
|
+
for (let i = startIdx; i < this.events.length; i++) {
|
|
57
|
+
const ev = this.events[i];
|
|
58
|
+
if (ev.seq === parentSeq)
|
|
59
|
+
continue;
|
|
60
|
+
ev.parentSeq = parentSeq;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Shallow-merge `patch` into the already-recorded event with this
|
|
64
|
+
* `seq`. Lets an instrumentation site enrich or reclassify an event
|
|
65
|
+
* after the fact — e.g. the k3s component upgrades the generic `http`
|
|
66
|
+
* event its API call produced into a Kubernetes-specific `kube` event
|
|
67
|
+
* once it has parsed the request. Searches from the end since the
|
|
68
|
+
* target is almost always the most recent event. No-op when no event
|
|
69
|
+
* carries that seq. */
|
|
70
|
+
annotate(seq, patch) {
|
|
71
|
+
for (let i = this.events.length - 1; i >= 0; i--) {
|
|
72
|
+
const ev = this.events[i];
|
|
73
|
+
if (ev.seq === seq) {
|
|
74
|
+
Object.assign(ev, patch);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Drop the single event carrying this `seq`. Used to retract an event
|
|
80
|
+
* that an instrumentation site recorded but then decided is noise — e.g.
|
|
81
|
+
* the dynamic kube client's internal discovery GET. Caller must ensure
|
|
82
|
+
* nothing references the seq (no child events/assertions hang off it).
|
|
83
|
+
* The seq counter does not roll back, so the seq stays retired. Searches
|
|
84
|
+
* from the end since the target is almost always the most recent event. */
|
|
85
|
+
remove(seq) {
|
|
86
|
+
for (let i = this.events.length - 1; i >= 0; i--) {
|
|
87
|
+
if (this.events[i].seq === seq) {
|
|
88
|
+
this.events.splice(i, 1);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
let current = null;
|
|
95
|
+
// Counter-based pause: `pauseRecording`/`resumeRecording` nest safely
|
|
96
|
+
// (a nested `ctx.poll` inside another `ctx.poll`'s predicate stays
|
|
97
|
+
// suppressed until its own resume balances out). Active record* sites
|
|
98
|
+
// no-op while `paused > 0`, so http/exec/db/etc. inside a paused
|
|
99
|
+
// region produce no events and the returned seq is `undefined` —
|
|
100
|
+
// which means the daemon's fetch wrapper also skips installing the
|
|
101
|
+
// inspector proxy on the Response.
|
|
102
|
+
let paused = 0;
|
|
103
|
+
export function pauseRecording() {
|
|
104
|
+
paused += 1;
|
|
105
|
+
}
|
|
106
|
+
export function resumeRecording() {
|
|
107
|
+
paused = Math.max(0, paused - 1);
|
|
108
|
+
}
|
|
109
|
+
function active() {
|
|
110
|
+
return current !== null && paused === 0;
|
|
111
|
+
}
|
|
112
|
+
export function startRecording() {
|
|
113
|
+
current = new Recorder();
|
|
114
|
+
paused = 0;
|
|
115
|
+
}
|
|
116
|
+
export function stopRecording() {
|
|
117
|
+
if (!current)
|
|
118
|
+
return [];
|
|
119
|
+
const evs = current.drain();
|
|
120
|
+
current = null;
|
|
121
|
+
paused = 0;
|
|
122
|
+
return evs;
|
|
123
|
+
}
|
|
124
|
+
export function isRecording() {
|
|
125
|
+
return current !== null;
|
|
126
|
+
}
|
|
127
|
+
/** Reserve an ordering slot at the start of an op so nested events it triggers
|
|
128
|
+
* (which finish — and record — first) still sort after it. Hand the returned
|
|
129
|
+
* reservation to the matching `record*` call at op finish. Returns `undefined`
|
|
130
|
+
* when nothing is recording, in which case `record*` falls back to allocating
|
|
131
|
+
* the seq at record time. */
|
|
132
|
+
export function reserveEvent() {
|
|
133
|
+
return active() ? current.reserve() : undefined;
|
|
134
|
+
}
|
|
135
|
+
export function recordExec(ev, reservation) {
|
|
136
|
+
return active() ? current.push({ kind: "exec", ...ev }, reservation) : undefined;
|
|
137
|
+
}
|
|
138
|
+
export function recordAssertion(ev) {
|
|
139
|
+
return active() ? current.push({ kind: "assertion", ...ev }) : undefined;
|
|
140
|
+
}
|
|
141
|
+
export function recordHttp(ev, reservation) {
|
|
142
|
+
return active() ? current.push({ kind: "http", ...ev }, reservation) : undefined;
|
|
143
|
+
}
|
|
144
|
+
export function recordBrowser(ev, reservation) {
|
|
145
|
+
return active() ? current.push({ kind: "browser", ...ev }, reservation) : undefined;
|
|
146
|
+
}
|
|
147
|
+
export function recordDb(ev, reservation) {
|
|
148
|
+
return active() ? current.push({ kind: "db", ...ev }, reservation) : undefined;
|
|
149
|
+
}
|
|
150
|
+
export function recordRedis(ev, reservation) {
|
|
151
|
+
return active() ? current.push({ kind: "redis", ...ev }, reservation) : undefined;
|
|
152
|
+
}
|
|
153
|
+
export function recordS3(ev, reservation) {
|
|
154
|
+
return active() ? current.push({ kind: "s3", ...ev }, reservation) : undefined;
|
|
155
|
+
}
|
|
156
|
+
export function recordTerminal(ev, reservation) {
|
|
157
|
+
return active() ? current.push({ kind: "terminal", ...ev }, reservation) : undefined;
|
|
158
|
+
}
|
|
159
|
+
export function recordTerminalStep(ev, reservation) {
|
|
160
|
+
return active() ? current.push({ kind: "terminal-step", ...ev }, reservation) : undefined;
|
|
161
|
+
}
|
|
162
|
+
export function recordFake(ev, reservation) {
|
|
163
|
+
return active() ? current.push({ kind: "fake", ...ev }, reservation) : undefined;
|
|
164
|
+
}
|
|
165
|
+
export function recordEnv(ev, reservation) {
|
|
166
|
+
return active() ? current.push({ kind: "env", ...ev }, reservation) : undefined;
|
|
167
|
+
}
|
|
168
|
+
export function recordEmail(ev, reservation) {
|
|
169
|
+
return active() ? current.push({ kind: "email", ...ev }, reservation) : undefined;
|
|
170
|
+
}
|
|
171
|
+
export function recordWait(ev, reservation) {
|
|
172
|
+
// Use `current` directly (not `active()`) so a `recordWait` at the
|
|
173
|
+
// end of a `ctx.poll` block lands even though the surrounding code
|
|
174
|
+
// just resumed from `paused`. Pushing this event is the whole point
|
|
175
|
+
// of the poll primitive.
|
|
176
|
+
return current?.push({ kind: "wait", ...ev }, reservation);
|
|
177
|
+
}
|
|
178
|
+
/** Number of events the active recorder has accumulated. Returns 0
|
|
179
|
+
* when no recorder is active. */
|
|
180
|
+
export function recorderEventCount() {
|
|
181
|
+
return current?.eventCount() ?? 0;
|
|
182
|
+
}
|
|
183
|
+
/** Drop events with index >= `toLen` from the active recorder. */
|
|
184
|
+
export function recorderTruncate(toLen) {
|
|
185
|
+
current?.truncate(toLen);
|
|
186
|
+
}
|
|
187
|
+
/** Stamp `parentSeq` onto every event from `startIdx` onward, so the
|
|
188
|
+
* UI groups them under the parent in the timeline. */
|
|
189
|
+
export function recorderMarkChildren(startIdx, parentSeq) {
|
|
190
|
+
current?.markChildren(startIdx, parentSeq);
|
|
191
|
+
}
|
|
192
|
+
/** Shallow-merge `patch` into the active recorder's event with this
|
|
193
|
+
* `seq` (enrich/reclassify after the fact). No-op when nothing is
|
|
194
|
+
* recording or no event carries that seq. */
|
|
195
|
+
export function recorderAnnotate(seq, patch) {
|
|
196
|
+
current?.annotate(seq, patch);
|
|
197
|
+
}
|
|
198
|
+
/** Retract the active recorder's event with this `seq` (an instrumentation
|
|
199
|
+
* site recorded it, then decided it's noise). No-op when nothing is
|
|
200
|
+
* recording or no event carries that seq. */
|
|
201
|
+
export function recorderRemove(seq) {
|
|
202
|
+
current?.remove(seq);
|
|
203
|
+
}
|
|
204
|
+
/** Best-effort JSON-safe deep clone; falls back to `String(v)`. */
|
|
205
|
+
export function safeSerialize(v) {
|
|
206
|
+
if (v === undefined)
|
|
207
|
+
return undefined;
|
|
208
|
+
try {
|
|
209
|
+
return JSON.parse(JSON.stringify(v));
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return String(v);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
export function truncateUtf8(s) {
|
|
216
|
+
if (s.length <= OUTPUT_SNIPPET_BYTES)
|
|
217
|
+
return { value: s, truncated: false };
|
|
218
|
+
return { value: s.slice(0, OUTPUT_SNIPPET_BYTES), truncated: true };
|
|
219
|
+
}
|
package/dist/redis.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Wrapped } from "./inspect.js";
|
|
2
|
+
/**
|
|
3
|
+
* Structural type for the parts of `Bun.RedisClient` we use, declared locally
|
|
4
|
+
* so user projects don't need `@types/bun`. The real implementation comes from
|
|
5
|
+
* Bun at runtime via `globalThis.Bun.RedisClient`. Commands resolve to their
|
|
6
|
+
* reply; lifecycle methods (`connect`/`close`/`subscribe`/…) are passed through
|
|
7
|
+
* un-instrumented.
|
|
8
|
+
*/
|
|
9
|
+
interface RawRedisClient {
|
|
10
|
+
send(command: string, args: string[]): Promise<unknown>;
|
|
11
|
+
close?(): void;
|
|
12
|
+
[method: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
/** The instrumented Redis surface — same shape as `Bun.RedisClient`, but every
|
|
15
|
+
* command resolves to a {@link Wrapped} reply recorded as a `redis` event.
|
|
16
|
+
* Indexable so every Bun command method is reachable without re-declaring the
|
|
17
|
+
* whole (large) surface; the common ones are typed for ergonomics. */
|
|
18
|
+
export interface RedisClientLike {
|
|
19
|
+
get(key: string): Promise<Wrapped<string | null>>;
|
|
20
|
+
set(key: string, value: string): Promise<Wrapped<string>>;
|
|
21
|
+
del(...keys: string[]): Promise<Wrapped<number>>;
|
|
22
|
+
exists(...keys: string[]): Promise<Wrapped<number>>;
|
|
23
|
+
incr(key: string): Promise<Wrapped<number>>;
|
|
24
|
+
hgetall(key: string): Promise<Wrapped<Record<string, string>>>;
|
|
25
|
+
/** Run an arbitrary command — `client.send("SET", ["k", "v"])`. */
|
|
26
|
+
send(command: string, args: string[]): Promise<Wrapped<unknown>>;
|
|
27
|
+
close(): void;
|
|
28
|
+
[method: string]: (...args: any[]) => any;
|
|
29
|
+
}
|
|
30
|
+
/** Options accepted by the {@link RedisClient} constructor beyond Bun's own. */
|
|
31
|
+
export interface RedisOptions {
|
|
32
|
+
/** Label shown on each recorded `redis` event (the timeline step's tag).
|
|
33
|
+
* Defaults to the connection URL's host (usually the service name). */
|
|
34
|
+
label?: string;
|
|
35
|
+
}
|
|
36
|
+
interface RedisConstructor {
|
|
37
|
+
new (url?: string, opts?: RedisOptions): RedisClientLike;
|
|
38
|
+
(url?: string, opts?: RedisOptions): RedisClientLike;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Open an instrumented Redis client against `url` (default: Bun's own
|
|
42
|
+
* `REDIS_URL`/`VALKEY_URL`). Usable with or without `new`. Requires the Bun
|
|
43
|
+
* runtime — it runs inside the spectest daemon.
|
|
44
|
+
*/
|
|
45
|
+
export declare const RedisClient: RedisConstructor;
|
|
46
|
+
/**
|
|
47
|
+
* Proxy a `Bun.RedisClient` so each command method (and `send(...)`) emits a
|
|
48
|
+
* `redis` event when its promise settles and resolves to a {@link Wrapped}
|
|
49
|
+
* reply. Lifecycle methods and non-function properties pass through.
|
|
50
|
+
*
|
|
51
|
+
* Exported so a client built another way can opt into the same instrumentation.
|
|
52
|
+
*/
|
|
53
|
+
export declare function instrumentRedis(raw: RawRedisClient, label: string): RedisClientLike;
|
|
54
|
+
export {};
|
package/dist/redis.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// `RedisClient` — a drop-in for `Bun.RedisClient` (`Bun.redis`) that records
|
|
2
|
+
// every command on the test event log and returns its reply inspect-wrapped,
|
|
3
|
+
// so `expect(await client.get("k"))` links the assertion back to the command in
|
|
4
|
+
// the timeline (same provenance mechanism as the wrapped `fetch` / `SQL`). Swap
|
|
5
|
+
// `new Bun.RedisClient(url)` → `new RedisClient(url)` and nothing else changes
|
|
6
|
+
// at the call site; replies come back `Wrapped<T>` (that wrapper is what carries
|
|
7
|
+
// provenance), so `.unwrap()` before a `===` or before feeding a value onward.
|
|
8
|
+
//
|
|
9
|
+
// Reach for it in service `helpers` when you stand up a redis/valkey yourself,
|
|
10
|
+
// or directly in a test against a connection string a fake handed back.
|
|
11
|
+
import { recordRedis, reserveEvent, safeSerialize } from "./recorder.js";
|
|
12
|
+
import { wrap } from "./inspect.js";
|
|
13
|
+
// Long-lived / connection-management methods that don't map to a single
|
|
14
|
+
// request→reply step — passed straight through, never recorded.
|
|
15
|
+
const REDIS_PASSTHROUGH = new Set([
|
|
16
|
+
"connect",
|
|
17
|
+
"close",
|
|
18
|
+
"disconnect",
|
|
19
|
+
"duplicate",
|
|
20
|
+
"subscribe",
|
|
21
|
+
"unsubscribe",
|
|
22
|
+
"psubscribe",
|
|
23
|
+
"punsubscribe",
|
|
24
|
+
"ssubscribe",
|
|
25
|
+
"sunsubscribe",
|
|
26
|
+
"onclose",
|
|
27
|
+
"ref",
|
|
28
|
+
"unref",
|
|
29
|
+
]);
|
|
30
|
+
const MAX_RESULT_BYTES = 2048;
|
|
31
|
+
/**
|
|
32
|
+
* Open an instrumented Redis client against `url` (default: Bun's own
|
|
33
|
+
* `REDIS_URL`/`VALKEY_URL`). Usable with or without `new`. Requires the Bun
|
|
34
|
+
* runtime — it runs inside the spectest daemon.
|
|
35
|
+
*/
|
|
36
|
+
export const RedisClient = function RedisClient(url, opts) {
|
|
37
|
+
const bun = globalThis.Bun;
|
|
38
|
+
if (!bun?.RedisClient) {
|
|
39
|
+
throw new Error("RedisClient requires the Bun runtime (Bun >= 1.2.9) — it runs inside the spectest daemon.");
|
|
40
|
+
}
|
|
41
|
+
const raw = url !== undefined ? new bun.RedisClient(url, opts) : new bun.RedisClient();
|
|
42
|
+
return instrumentRedis(raw, opts?.label ?? hostLabel(url));
|
|
43
|
+
};
|
|
44
|
+
function hostLabel(url) {
|
|
45
|
+
if (!url)
|
|
46
|
+
return "redis";
|
|
47
|
+
try {
|
|
48
|
+
return new URL(url).hostname || "redis";
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return "redis";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Proxy a `Bun.RedisClient` so each command method (and `send(...)`) emits a
|
|
56
|
+
* `redis` event when its promise settles and resolves to a {@link Wrapped}
|
|
57
|
+
* reply. Lifecycle methods and non-function properties pass through.
|
|
58
|
+
*
|
|
59
|
+
* Exported so a client built another way can opt into the same instrumentation.
|
|
60
|
+
*/
|
|
61
|
+
export function instrumentRedis(raw, label) {
|
|
62
|
+
const run = (command, key, extra, invoke) => {
|
|
63
|
+
const started = Date.now();
|
|
64
|
+
const resv = reserveEvent();
|
|
65
|
+
return Promise.resolve(invoke()).then((value) => {
|
|
66
|
+
const { preview, truncated } = capValue(value);
|
|
67
|
+
const seq = recordRedis({
|
|
68
|
+
service: label,
|
|
69
|
+
command,
|
|
70
|
+
key,
|
|
71
|
+
args: extra.length > 0 ? extra.map(safeSerialize) : undefined,
|
|
72
|
+
result: preview,
|
|
73
|
+
resultTruncated: truncated,
|
|
74
|
+
durationMs: Date.now() - started,
|
|
75
|
+
}, resv);
|
|
76
|
+
return wrap(value, seq);
|
|
77
|
+
}, (err) => {
|
|
78
|
+
const e = err;
|
|
79
|
+
recordRedis({
|
|
80
|
+
service: label,
|
|
81
|
+
command,
|
|
82
|
+
key,
|
|
83
|
+
args: extra.length > 0 ? extra.map(safeSerialize) : undefined,
|
|
84
|
+
durationMs: Date.now() - started,
|
|
85
|
+
error: e?.message ?? String(err),
|
|
86
|
+
}, resv);
|
|
87
|
+
throw err;
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
const handler = {
|
|
91
|
+
get(target, prop, receiver) {
|
|
92
|
+
if (typeof prop !== "string")
|
|
93
|
+
return Reflect.get(target, prop, receiver);
|
|
94
|
+
const v = Reflect.get(target, prop, receiver);
|
|
95
|
+
if (typeof v !== "function")
|
|
96
|
+
return v;
|
|
97
|
+
const fn = v;
|
|
98
|
+
if (REDIS_PASSTHROUGH.has(prop))
|
|
99
|
+
return fn.bind(target);
|
|
100
|
+
// `send(command, args)` carries the real command in its first arg.
|
|
101
|
+
if (prop === "send") {
|
|
102
|
+
return (command, args = []) => run(String(command).toUpperCase(), args[0], args.slice(1), () => fn.call(target, command, args));
|
|
103
|
+
}
|
|
104
|
+
return (...args) => run(prop.toUpperCase(), typeof args[0] === "string" ? args[0] : undefined, args.slice(1), () => fn.apply(target, args));
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
return new Proxy(raw, handler);
|
|
108
|
+
}
|
|
109
|
+
/** Best-effort JSON-safe preview of a reply, capped at {@link MAX_RESULT_BYTES}. */
|
|
110
|
+
function capValue(value) {
|
|
111
|
+
if (value === null || value === undefined)
|
|
112
|
+
return {};
|
|
113
|
+
const safe = safeSerialize(value);
|
|
114
|
+
const json = (() => {
|
|
115
|
+
try {
|
|
116
|
+
return JSON.stringify(safe);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
})();
|
|
122
|
+
if (json !== undefined && json.length > MAX_RESULT_BYTES) {
|
|
123
|
+
return { preview: `${json.slice(0, MAX_RESULT_BYTES)}…`, truncated: true };
|
|
124
|
+
}
|
|
125
|
+
return { preview: safe };
|
|
126
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Token an extracted asset is replaced with. Still a syntactically valid
|
|
2
|
+
* data: URI so anything that merely carries it along stays well-formed;
|
|
3
|
+
* it never renders (the viewer rehydrates before the events reach rrweb). */
|
|
4
|
+
export declare function assetToken(hash: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* Serialize `sessions` and pull every large base64 data: URI out into a
|
|
7
|
+
* content-addressed asset table. Returns the deduplicated sessions as a
|
|
8
|
+
* JSON *string* (already serialized — splice it into the bundle document
|
|
9
|
+
* verbatim) plus the asset table.
|
|
10
|
+
*/
|
|
11
|
+
export declare function extractReplayAssets(sessions: unknown): {
|
|
12
|
+
sessionsJson: string;
|
|
13
|
+
assets: Record<string, string>;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Encode a case's sessions as the gzipped replay-bundle document the
|
|
17
|
+
* control plane archives to S3 verbatim:
|
|
18
|
+
* `{ caseId, sessions: [...], assets: { <hash>: <dataUri> } }` — the
|
|
19
|
+
* shape of `storage.rs::CaseReplayBundle`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function encodeReplayBundle(caseId: string, sessions: unknown): Buffer;
|
|
22
|
+
/** Per-chunk raw-byte ceiling for `/replay-chunk` replies. Base64 inflates
|
|
23
|
+
* 4/3× and the JSON reply must clear the vm-agent's 16 MB response cap
|
|
24
|
+
* with headroom. The control plane's `fetch_replay_bundle` requests
|
|
25
|
+
* exactly this much per round trip. */
|
|
26
|
+
export declare const REPLAY_CHUNK_MAX_BYTES: number;
|
|
27
|
+
/**
|
|
28
|
+
* One `/replay-chunk` reply: base64 of `gz[offset, offset+limit)`, with
|
|
29
|
+
* offset/limit clamped to sane values (never more than
|
|
30
|
+
* `REPLAY_CHUNK_MAX_BYTES` raw bytes). An out-of-range offset yields an
|
|
31
|
+
* empty `b64`, which the control plane treats as an error — it only ever
|
|
32
|
+
* asks for offsets below the advertised total.
|
|
33
|
+
*/
|
|
34
|
+
export declare function replayChunk(gz: Buffer, offsetIn: unknown, limitIn: unknown): {
|
|
35
|
+
total: number;
|
|
36
|
+
offset: number;
|
|
37
|
+
b64: string;
|
|
38
|
+
};
|
|
@@ -19,72 +19,58 @@
|
|
|
19
19
|
// `web/case_viewer.rs::rehydrateAssets`). Only base64 URIs are extracted:
|
|
20
20
|
// their charset needs no JSON escaping, so plain text substitution in
|
|
21
21
|
// either direction cannot corrupt the surrounding JSON document.
|
|
22
|
-
|
|
23
22
|
import { createHash } from "node:crypto";
|
|
24
23
|
import { gzipSync } from "node:zlib";
|
|
25
|
-
|
|
26
24
|
/** Minimum data: URI payload length (chars) worth extracting. Below this
|
|
27
25
|
* the token + asset-table overhead rivals the URI itself. */
|
|
28
26
|
const ASSET_MIN_BASE64_CHARS = 1024;
|
|
29
|
-
|
|
30
27
|
/** Base64 `data:` URIs, ≥1 KiB of payload. Mediatype + parameters are
|
|
31
28
|
* restricted to charsets that never need JSON escaping — a match taken
|
|
32
29
|
* from serialized JSON text is therefore byte-identical to the logical
|
|
33
30
|
* string value it sits inside. */
|
|
34
|
-
const ASSET_RX = new RegExp(
|
|
35
|
-
|
|
36
|
-
`[A-Za-z0-9+/=]{${ASSET_MIN_BASE64_CHARS},}`,
|
|
37
|
-
"g",
|
|
38
|
-
);
|
|
39
|
-
|
|
31
|
+
const ASSET_RX = new RegExp("data:[A-Za-z0-9.+-]+/[A-Za-z0-9.+-]+(?:;[A-Za-z0-9.=+-]+)*;base64," +
|
|
32
|
+
`[A-Za-z0-9+/=]{${ASSET_MIN_BASE64_CHARS},}`, "g");
|
|
40
33
|
/** Token an extracted asset is replaced with. Still a syntactically valid
|
|
41
34
|
* data: URI so anything that merely carries it along stays well-formed;
|
|
42
35
|
* it never renders (the viewer rehydrates before the events reach rrweb). */
|
|
43
|
-
export function assetToken(hash
|
|
44
|
-
|
|
36
|
+
export function assetToken(hash) {
|
|
37
|
+
return `data:x-spectest-asset/${hash}`;
|
|
45
38
|
}
|
|
46
|
-
|
|
47
39
|
/**
|
|
48
40
|
* Serialize `sessions` and pull every large base64 data: URI out into a
|
|
49
41
|
* content-addressed asset table. Returns the deduplicated sessions as a
|
|
50
42
|
* JSON *string* (already serialized — splice it into the bundle document
|
|
51
43
|
* verbatim) plus the asset table.
|
|
52
44
|
*/
|
|
53
|
-
export function extractReplayAssets(sessions
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
assets
|
|
65
|
-
return assetToken(hash);
|
|
66
|
-
});
|
|
67
|
-
return { sessionsJson, assets };
|
|
45
|
+
export function extractReplayAssets(sessions) {
|
|
46
|
+
const assets = {};
|
|
47
|
+
const raw = JSON.stringify(sessions);
|
|
48
|
+
const sessionsJson = raw.replace(ASSET_RX, (uri) => {
|
|
49
|
+
// 16 hex chars (64 bits) of SHA-256: collision-safe at replay-asset
|
|
50
|
+
// scale (dozens of assets per case), and short enough that a token is
|
|
51
|
+
// negligible next to the URI it replaces.
|
|
52
|
+
const hash = createHash("sha256").update(uri).digest("hex").slice(0, 16);
|
|
53
|
+
assets[hash] = uri;
|
|
54
|
+
return assetToken(hash);
|
|
55
|
+
});
|
|
56
|
+
return { sessionsJson, assets };
|
|
68
57
|
}
|
|
69
|
-
|
|
70
58
|
/**
|
|
71
59
|
* Encode a case's sessions as the gzipped replay-bundle document the
|
|
72
60
|
* control plane archives to S3 verbatim:
|
|
73
61
|
* `{ caseId, sessions: [...], assets: { <hash>: <dataUri> } }` — the
|
|
74
62
|
* shape of `storage.rs::CaseReplayBundle`.
|
|
75
63
|
*/
|
|
76
|
-
export function encodeReplayBundle(caseId
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
64
|
+
export function encodeReplayBundle(caseId, sessions) {
|
|
65
|
+
const { sessionsJson, assets } = extractReplayAssets(sessions);
|
|
66
|
+
const doc = `{"caseId":${JSON.stringify(caseId)},"sessions":${sessionsJson},"assets":${JSON.stringify(assets)}}`;
|
|
67
|
+
return gzipSync(Buffer.from(doc));
|
|
80
68
|
}
|
|
81
|
-
|
|
82
69
|
/** Per-chunk raw-byte ceiling for `/replay-chunk` replies. Base64 inflates
|
|
83
70
|
* 4/3× and the JSON reply must clear the vm-agent's 16 MB response cap
|
|
84
71
|
* with headroom. The control plane's `fetch_replay_bundle` requests
|
|
85
72
|
* exactly this much per round trip. */
|
|
86
73
|
export const REPLAY_CHUNK_MAX_BYTES = 6 * 1024 * 1024;
|
|
87
|
-
|
|
88
74
|
/**
|
|
89
75
|
* One `/replay-chunk` reply: base64 of `gz[offset, offset+limit)`, with
|
|
90
76
|
* offset/limit clamped to sane values (never more than
|
|
@@ -92,17 +78,13 @@ export const REPLAY_CHUNK_MAX_BYTES = 6 * 1024 * 1024;
|
|
|
92
78
|
* empty `b64`, which the control plane treats as an error — it only ever
|
|
93
79
|
* asks for offsets below the advertised total.
|
|
94
80
|
*/
|
|
95
|
-
export function replayChunk(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
total: gz.length,
|
|
105
|
-
offset,
|
|
106
|
-
b64: gz.subarray(offset, offset + limit).toString("base64"),
|
|
107
|
-
};
|
|
81
|
+
export function replayChunk(gz, offsetIn, limitIn) {
|
|
82
|
+
const offset = Math.max(0, Math.floor(Number(offsetIn) || 0));
|
|
83
|
+
const wanted = Math.floor(Number(limitIn) || REPLAY_CHUNK_MAX_BYTES);
|
|
84
|
+
const limit = Math.min(Math.max(1, wanted), REPLAY_CHUNK_MAX_BYTES);
|
|
85
|
+
return {
|
|
86
|
+
total: gz.length,
|
|
87
|
+
offset,
|
|
88
|
+
b64: gz.subarray(offset, offset + limit).toString("base64"),
|
|
89
|
+
};
|
|
108
90
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|