@specific.dev/spectest 0.19.1 → 0.20.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/browser.ts CHANGED
@@ -33,6 +33,7 @@ import { readFileSync } from "node:fs";
33
33
  import path from "node:path";
34
34
  import { fileURLToPath } from "node:url";
35
35
 
36
+ import { generateId } from "./ids.js";
36
37
  import { recordBrowser, reserveEvent, truncateUtf8 } from "./recorder.js";
37
38
  import { wrap } from "./inspect.js";
38
39
  import type { Wrapped } from "./inspect.js";
@@ -76,13 +77,12 @@ export interface BrowserOptions {
76
77
  recorder?: BrowserSessionRecorder | null;
77
78
  }
78
79
 
79
- export type ScreenshotFormat = "png" | "jpeg" | "webp";
80
-
81
- export interface ScreenshotOptions {
82
- /** Image format. WebP requires Chrome. Default PNG. */
83
- format?: ScreenshotFormat;
84
- /** JPEG/WebP quality 0–100. Ignored for PNG. */
85
- quality?: number;
80
+ /** Decoded byte count of a base64 string, without decoding it. */
81
+ function base64ByteLength(b64: string): number {
82
+ let padding = 0;
83
+ if (b64.endsWith("==")) padding = 2;
84
+ else if (b64.endsWith("=")) padding = 1;
85
+ return (b64.length * 3) / 4 - padding;
86
86
  }
87
87
 
88
88
  /** One Browser-action's worth of rrweb events, in the order rrweb emitted them. */
@@ -114,6 +114,20 @@ export interface BrowserSessionRecorder {
114
114
  recordStep(step: BrowserSessionStep): void;
115
115
  /** Optional: called whenever `Browser.navigate(url)` is invoked. */
116
116
  noteNavigation?(url: string): void;
117
+ /**
118
+ * Optional: register a captured artifact (screenshot bytes) for upload.
119
+ * Present only in eval context — the daemon wires it to the per-eval
120
+ * collector, and its absence is what makes `screenshot()` throw during
121
+ * test runs (artifacts are eval-only for now). Throws when the eval's
122
+ * artifact byte budget is exhausted.
123
+ */
124
+ registerArtifact?(artifact: {
125
+ id: string;
126
+ kind: "screenshot";
127
+ contentType: string;
128
+ sizeBytes: number;
129
+ bytesBase64: string;
130
+ }): void;
117
131
  }
118
132
 
119
133
  /**
@@ -200,8 +214,14 @@ export interface Browser {
200
214
  forward(): Promise<void>;
201
215
  /** Reload the current page. */
202
216
  reload(): Promise<void>;
203
- /** Capture a PNG/JPEG/WebP screenshot of the viewport. Returns raw bytes. */
204
- screenshot(options?: ScreenshotOptions): Promise<Uint8Array>;
217
+ /**
218
+ * Capture a PNG screenshot of the viewport and upload it as a
219
+ * downloadable **artifact**. Resolves to the artifact's `art_…` id —
220
+ * fetch the image locally with `spectest artifact download <id>`.
221
+ * Only available inside an eval (`spectest_eval` / `spectest env eval`);
222
+ * throws with a clear message during test runs.
223
+ */
224
+ screenshot(): Promise<string>;
205
225
  /**
206
226
  * Destroy the underlying view. Idempotent; drains any pending rrweb
207
227
  * events first. For the persistent session behind `ctx.browser()` /
@@ -1521,16 +1541,39 @@ function buildBackend(
1521
1541
  await holder.page.reload();
1522
1542
  });
1523
1543
  },
1524
- async screenshot(options) {
1525
- const format = options?.format ?? "png";
1526
- return instrumented("screenshot", { format }, async () => {
1527
- // Raw CDP rather than page.screenshot(): CDP supports webp too, and
1528
- // captures the viewport exactly like the pre-Playwright backend.
1544
+ async screenshot() {
1545
+ const fields: Partial<RecordableFields> = { format: "png" };
1546
+ return instrumented("screenshot", fields, async () => {
1547
+ // Artifacts are eval-only for now: the daemon wires a
1548
+ // registerArtifact sink onto eval sessions' recorders and nothing
1549
+ // else's, so its absence means "test run / recorder-less browser".
1550
+ const register = recorder?.registerArtifact;
1551
+ if (!register) {
1552
+ throw new Error(
1553
+ "screenshot() uploads the capture as a downloadable artifact and is " +
1554
+ "currently only available inside an eval (spectest_eval / `spectest env eval`) — " +
1555
+ "it cannot be used in test runs.",
1556
+ );
1557
+ }
1558
+ // Raw CDP rather than page.screenshot(): captures the viewport
1559
+ // exactly like the pre-Playwright backend.
1529
1560
  const res = (await holder.cdp.send("Page.captureScreenshot", {
1530
- format,
1531
- ...(format === "png" ? {} : { quality: options?.quality ?? 90 }),
1561
+ format: "png",
1532
1562
  } as never)) as { data: string };
1533
- return new Uint8Array(Buffer.from(res.data, "base64"));
1563
+ const id = generateId("art");
1564
+ register({
1565
+ id,
1566
+ kind: "screenshot",
1567
+ contentType: "image/png",
1568
+ // CDP already hands us base64 — pass it through un-recoded and
1569
+ // derive the raw byte count from the encoding.
1570
+ sizeBytes: base64ByteLength(res.data),
1571
+ bytesBase64: res.data,
1572
+ });
1573
+ // Stamped onto the browser event before instrumented() records it —
1574
+ // same mutate-fields-inside-fn() precedent as waitFor's `attempts`.
1575
+ fields.artifactId = id;
1576
+ return id;
1534
1577
  });
1535
1578
  },
1536
1579
  async close() {
@@ -1627,6 +1670,7 @@ interface RecordableFields {
1627
1670
  dy: number;
1628
1671
  x: number;
1629
1672
  y: number;
1630
- format: ScreenshotFormat;
1673
+ format: string;
1631
1674
  attempts: number;
1675
+ artifactId: string;
1632
1676
  }
package/src/daemon.ts CHANGED
@@ -2994,15 +2994,71 @@ function newSessionId(idScope: string): string {
2994
2994
  return idScope ? `${idScope}:${randomUUID()}` : randomUUID();
2995
2995
  }
2996
2996
 
2997
+ /**
2998
+ * One artifact captured during an eval (screenshot bytes), shipped inline
2999
+ * (base64) on the `/eval` reply's `artifacts` array — parallel to
3000
+ * `browserSessions`. The control plane uploads each to S3, records the
3001
+ * metadata row, and strips `bytesBase64` before the reply reaches the
3002
+ * caller. The id is minted in-VM by `ids.ts` (see its fork-frozen-RNG
3003
+ * caveat) in the control plane's `art_0…` format.
3004
+ */
3005
+ interface CapturedArtifact {
3006
+ id: string;
3007
+ kind: "screenshot";
3008
+ contentType: string;
3009
+ sizeBytes: number;
3010
+ bytesBase64: string;
3011
+ }
3012
+
3013
+ /**
3014
+ * Cumulative raw-byte cap on one eval's artifacts. The bytes ride the
3015
+ * `/eval` JSON reply base64'd (~1.33x), and the vm-agent hard-errors on
3016
+ * proxied daemon responses over 16 MB (the same cap that pushed /run's
3017
+ * replay bundles out-of-band — see REPLAY_BUNDLES). 8 MiB raw ≈ 10.7 MB
3018
+ * encoded leaves headroom for the reply's sessions/log; screenshots are
3019
+ * typically well under 2 MiB each. If artifacts ever need to grow past
3020
+ * this, move them to the parked-chunk side channel instead of raising it.
3021
+ */
3022
+ const MAX_EVAL_ARTIFACT_BYTES = 8 * 1024 * 1024;
3023
+
3024
+ /**
3025
+ * Per-eval artifact sink. `register` throws (failing the screenshot() call,
3026
+ * never the eval) once the byte budget is exhausted.
3027
+ */
3028
+ function newArtifactCollector(): {
3029
+ artifacts: CapturedArtifact[];
3030
+ register(artifact: CapturedArtifact): void;
3031
+ } {
3032
+ const artifacts: CapturedArtifact[] = [];
3033
+ let total = 0;
3034
+ return {
3035
+ artifacts,
3036
+ register(artifact) {
3037
+ total += artifact.sizeBytes;
3038
+ if (total > MAX_EVAL_ARTIFACT_BYTES) {
3039
+ throw new Error(
3040
+ `artifact byte budget for this eval exceeded (${MAX_EVAL_ARTIFACT_BYTES / (1024 * 1024)} MiB) — ` +
3041
+ "capture fewer screenshots per eval",
3042
+ );
3043
+ }
3044
+ artifacts.push(artifact);
3045
+ },
3046
+ };
3047
+ }
3048
+
2997
3049
  /**
2998
3050
  * Build the recorder sink + bookkeeping for a single Browser session.
2999
3051
  * The returned `recorder` is what `openBrowser` writes into; the
3000
3052
  * returned `record` is the in-flight session object the daemon owns.
3053
+ * `artifacts` (eval-only) wires `screenshot()`'s artifact registration —
3054
+ * test-run sessions don't pass it, which is exactly what makes
3055
+ * `screenshot()` throw outside eval.
3001
3056
  */
3002
3057
  function newBrowserSession(
3003
3058
  testStart: number,
3004
3059
  idScope: string,
3005
3060
  frame: "browser" | "mobile" = "browser",
3061
+ artifacts?: ReturnType<typeof newArtifactCollector>,
3006
3062
  ): {
3007
3063
  recorder: BrowserSessionRecorder;
3008
3064
  record: BrowserSessionRecord;
@@ -3027,6 +3083,9 @@ function newBrowserSession(
3027
3083
  if (closed) return;
3028
3084
  if (record.initialUrl === undefined) record.initialUrl = url;
3029
3085
  },
3086
+ ...(artifacts
3087
+ ? { registerArtifact: (a: CapturedArtifact) => artifacts.register(a) }
3088
+ : {}),
3030
3089
  },
3031
3090
  markClosed() {
3032
3091
  if (closed) return;
@@ -4001,6 +4060,9 @@ interface EvalResult {
4001
4060
  browserSessions: BrowserSessionRecord[];
4002
4061
  /** asciicast sessions for any ctx.terminal() call during the eval. */
4003
4062
  terminalSessions: TerminalSessionRecord[];
4063
+ /** Artifacts captured during the eval (screenshot bytes, base64 inline —
4064
+ * stripped by the control plane before the reply reaches the caller). */
4065
+ artifacts: CapturedArtifact[];
4004
4066
  error?: { message: string; stack?: string };
4005
4067
  }
4006
4068
 
@@ -4125,11 +4187,14 @@ async function evalCode(
4125
4187
  // live session — and a snapshot taken afterwards carries it.
4126
4188
  const browserDetaches: Array<() => Promise<void>> = [];
4127
4189
  const sessions: Array<ReturnType<typeof newBrowserSession>> = [];
4190
+ // Eval-only artifact sink — wiring it here (and nowhere in runOne) is
4191
+ // what gates screenshot() to eval context.
4192
+ const artifactCollector = newArtifactCollector();
4128
4193
  let sharedBrowser: Browser | null = null;
4129
4194
  const sharedMobiles = new Map<string, Mobile>();
4130
4195
  const trackedOpenBrowser = async (opts?: BrowserOptions): Promise<Browser> => {
4131
4196
  if (sharedBrowser) return sharedBrowser;
4132
- const session = newBrowserSession(start, "eval");
4197
+ const session = newBrowserSession(start, "eval", "browser", artifactCollector);
4133
4198
  sessions.push(session);
4134
4199
  const { browser, attached, detach } = await acquirePersistentBrowser({
4135
4200
  ...(opts ?? {}),
@@ -4161,7 +4226,7 @@ async function evalCode(
4161
4226
  }
4162
4227
  const existing = sharedMobiles.get(app.url);
4163
4228
  if (existing) return existing;
4164
- const session = newBrowserSession(start, "eval", "mobile");
4229
+ const session = newBrowserSession(start, "eval", "mobile", artifactCollector);
4165
4230
  sessions.push(session);
4166
4231
  const { mobile, attached, detach, safeAreaInsets } = await openPersistentMobile({
4167
4232
  url: app.url,
@@ -4310,6 +4375,8 @@ async function evalCode(
4310
4375
  });
4311
4376
  }
4312
4377
  }
4378
+ // `artifacts` ships on the error branch too — a screenshot captured
4379
+ // before a snippet crash is exactly the evidence the caller wants.
4313
4380
  return outcome.ok
4314
4381
  ? {
4315
4382
  ok: true,
@@ -4319,6 +4386,7 @@ async function evalCode(
4319
4386
  result: outcome.result,
4320
4387
  browserSessions: sessions.map((s) => s.record),
4321
4388
  terminalSessions,
4389
+ artifacts: artifactCollector.artifacts,
4322
4390
  }
4323
4391
  : {
4324
4392
  ok: false,
@@ -4327,6 +4395,7 @@ async function evalCode(
4327
4395
  installed,
4328
4396
  browserSessions: sessions.map((s) => s.record),
4329
4397
  terminalSessions,
4398
+ artifacts: artifactCollector.artifacts,
4330
4399
  error: outcome.error,
4331
4400
  };
4332
4401
  }
package/src/ids.ts ADDED
@@ -0,0 +1,89 @@
1
+ // Stripe-style resource ids minted in-VM — format-compatible with the
2
+ // control plane's `crates/control-plane/src/ids.rs` (`generate`):
3
+ // `<prefix>_0<20 chars>` where the chars are Crockford base32 (lowercase,
4
+ // minus the ambiguous i/l/o/u) and the `0` after the underscore is the
5
+ // format-version digit. If the scheme changes there, change it here in
6
+ // lockstep (`env.rs::valid_artifact_id` checks this exact shape at the
7
+ // trust boundary).
8
+ //
9
+ // LANDMINE — why the entropy here is NOT just `crypto.getRandomValues`:
10
+ // Bun serves those from a userspace pool that is FROZEN into snapshots, so
11
+ // sibling forks of one snapshot draw identical bytes (verified 2026-07-14:
12
+ // two forks' first screenshot() minted the same id). We therefore hash
13
+ // together, per mint:
14
+ // - 16 bytes read straight off /dev/urandom (bypasses Bun's pool; the
15
+ // guest kernel CRNG reseeds across restores — see the VM-determinism
16
+ // notes — so forks diverge),
17
+ // - Date.now() (wall clock is re-synced per post-mortem fork start) and
18
+ // performance.now() (sub-ms), which de-collide even if the kernel pool
19
+ // hasn't diverged yet,
20
+ // - a process-local counter (multiple mints in one tick).
21
+ // This makes duplicates practically impossible, but the control plane
22
+ // still treats an id here as untrusted: shape-validated, and a duplicate
23
+ // insert is flagged per-artifact rather than clobbering anything.
24
+
25
+ import { createHash } from "node:crypto";
26
+ import { closeSync, openSync, readSync } from "node:fs";
27
+
28
+ /** Crockford base32, lowercased, minus `i`/`l`/`o`/`u` — mirrors ids.rs. */
29
+ const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
30
+
31
+ /** Random base32 chars after the version digit (100 bits of entropy). */
32
+ const RANDOM_LEN = 20;
33
+
34
+ let mintCounter = 0;
35
+
36
+ /** Mint a fresh id, e.g. `generateId("art")` → `art_0c3k9mpq…`. */
37
+ export function generateId(prefix: string): string {
38
+ const digest = createHash("sha256")
39
+ .update(urandom(16))
40
+ .update(String(Date.now()))
41
+ .update(String(performance.now()))
42
+ .update(String(mintCounter++))
43
+ .digest();
44
+ return `${prefix}_0${base32Crockford(digest.subarray(0, 16)).slice(0, RANDOM_LEN)}`;
45
+ }
46
+
47
+ /** Read `n` bytes directly from /dev/urandom (NOT Bun's cached pool). */
48
+ function urandom(n: number): Uint8Array {
49
+ const buf = new Uint8Array(n);
50
+ try {
51
+ const fd = openSync("/dev/urandom", "r");
52
+ try {
53
+ let off = 0;
54
+ while (off < n) {
55
+ const read = readSync(fd, buf, off, n - off, null);
56
+ if (read <= 0) break;
57
+ off += read;
58
+ }
59
+ } finally {
60
+ closeSync(fd);
61
+ }
62
+ } catch {
63
+ // Non-Linux host (SDK types compiled locally) or exotic failure — the
64
+ // clock + counter inputs still make the hash unique in practice.
65
+ crypto.getRandomValues(buf);
66
+ }
67
+ return buf;
68
+ }
69
+
70
+ /** Encode bytes as Crockford base32 (no padding). 16 bytes → 26 chars. */
71
+ function base32Crockford(bytes: Uint8Array): string {
72
+ let out = "";
73
+ let acc = 0;
74
+ let bits = 0;
75
+ for (const b of bytes) {
76
+ acc = (acc << 8) | b;
77
+ bits += 8;
78
+ while (bits >= 5) {
79
+ bits -= 5;
80
+ out += ALPHABET[(acc >> bits) & 0x1f];
81
+ }
82
+ // Keep the accumulator within 32-bit safe range for the `<<` operator.
83
+ acc &= (1 << bits) - 1;
84
+ }
85
+ if (bits > 0) {
86
+ out += ALPHABET[(acc << (5 - bits)) & 0x1f];
87
+ }
88
+ return out;
89
+ }
package/src/index.ts CHANGED
@@ -44,12 +44,7 @@ export { SQL, type SqlClient, type SqlOptions } from "./sql.js";
44
44
  export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js";
45
45
  export { S3Client, type S3ClientLike, type S3File, type S3ClientOptions } from "./s3.js";
46
46
 
47
- export type {
48
- Browser,
49
- BrowserOptions,
50
- ScreenshotFormat,
51
- ScreenshotOptions,
52
- } from "./browser.js";
47
+ export type { Browser, BrowserOptions } from "./browser.js";
53
48
 
54
49
  import type { Browser, BrowserOptions } from "./browser.js";
55
50
 
package/src/mobile.ts CHANGED
@@ -23,7 +23,6 @@ import type {
23
23
  BrowserSessionRecorder,
24
24
  MobileBackend,
25
25
  SafeAreaInsets,
26
- ScreenshotOptions,
27
26
  } from "./browser.js";
28
27
  import type { Locator, Page } from "playwright-core";
29
28
  import type { Wrapped } from "./inspect.js";
@@ -312,8 +311,13 @@ export interface Mobile {
312
311
  expression: string,
313
312
  options?: { timeoutMs?: number; intervalMs?: number },
314
313
  ): Promise<Wrapped<T>>;
315
- /** Capture a screenshot of the viewport. */
316
- screenshot(options?: ScreenshotOptions): Promise<Uint8Array>;
314
+ /**
315
+ * Capture a PNG screenshot of the viewport and upload it as a
316
+ * downloadable artifact. Resolves to the artifact's `art_…` id
317
+ * (`spectest artifact download <id>`). Eval-only for now — throws with a
318
+ * clear message during test runs.
319
+ */
320
+ screenshot(): Promise<string>;
317
321
  /** Close the session. Idempotent; drains pending rrweb events. */
318
322
  close(): Promise<void>;
319
323
  }
@@ -386,8 +390,8 @@ function wrapMobile(backend: MobileBackend): Mobile {
386
390
  waitFor(description, expression, options) {
387
391
  return backend.waitFor(description, expression, options);
388
392
  },
389
- screenshot(options) {
390
- return backend.screenshot(options);
393
+ screenshot() {
394
+ return backend.screenshot();
391
395
  },
392
396
  close() {
393
397
  return backend.close();