@specific.dev/spectest 0.89.0 → 0.90.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.
@@ -0,0 +1,178 @@
1
+ /** Docker/BuildKit policy for the generic sticky disk. No host publication API. */
2
+ import { execFile } from "node:child_process";
3
+ import { mkdir, readFile, statfs, writeFile } from "node:fs/promises";
4
+ export const STICKY_CAPABILITY = "sticky-disks-v1";
5
+ export const STICKY_COMPATIBILITY = "docker29-ctr21-buildkit032-overlayfs-256g-v2";
6
+ export const STICKY_RUNTIME = "/run/spectest-sticky";
7
+ function exec(program, args, timeout = 60_000) {
8
+ return new Promise((resolve, reject) => {
9
+ execFile(program, args, { timeout, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
10
+ if (error)
11
+ reject(new Error(`${program} failed: ${stderr || stdout || error.message}`));
12
+ else
13
+ resolve(stdout.trim());
14
+ });
15
+ });
16
+ }
17
+ async function ready(program, args) {
18
+ const deadline = Date.now() + 30_000;
19
+ let last;
20
+ do {
21
+ try {
22
+ await exec(program, args, 2000);
23
+ return;
24
+ }
25
+ catch (error) {
26
+ last = error;
27
+ }
28
+ await Bun.sleep(100);
29
+ } while (Date.now() < deadline);
30
+ throw last;
31
+ }
32
+ export function validateStickyConfig(config) {
33
+ if (config.path !== "/var/lib/spectest-sticky" || config.compatibility !== STICKY_COMPATIBILITY ||
34
+ !Number.isSafeInteger(config.capacityBytes) || config.capacityBytes <= 0 ||
35
+ !Number.isSafeInteger(config.headroomBytes) || config.headroomBytes < 16 * 1024 * 1024 ||
36
+ !Number.isSafeInteger(config.cacheTargetBytes) || config.cacheTargetBytes <= 0 ||
37
+ !Number.isSafeInteger(config.buildkitTargetBytes) || config.buildkitTargetBytes <= 0 ||
38
+ config.buildkitTargetBytes > config.cacheTargetBytes ||
39
+ config.cacheTargetBytes + config.headroomBytes >= config.capacityBytes) {
40
+ throw new Error("unsupported sticky consumer profile or budget");
41
+ }
42
+ }
43
+ export async function initializeStickyConsumer(config) {
44
+ validateStickyConfig(config);
45
+ await exec("mountpoint", ["-q", config.path]);
46
+ // The base must have stopped these before its RAM was captured. Refuse an
47
+ // unexpected writer rather than racing it for the standard daemon sockets.
48
+ for (const unit of ["docker.service", "docker.socket", "containerd.service"]) {
49
+ const state = await exec("systemctl", ["show", unit, "-p", "ActiveState", "--value"]);
50
+ if (!["inactive", "failed"].includes(state))
51
+ throw new Error(`sticky base has active legacy consumer: ${unit}: ${state}`);
52
+ }
53
+ for (const [program, version] of [["dockerd", "29.7.2"], ["containerd", "v2.1.5"], ["buildkitd", "v0.32.2"]]) {
54
+ if (!(await exec(program, ["--version"])).includes(version))
55
+ throw new Error(`unsupported ${program} version for sticky profile`);
56
+ }
57
+ await mkdir(STICKY_RUNTIME, { recursive: true });
58
+ for (const child of ["docker", "containerd", "buildkit"])
59
+ await mkdir(`${config.path}/${child}`, { recursive: true });
60
+ await writeFile(`${STICKY_RUNTIME}/consumer.json`, JSON.stringify(config));
61
+ await writeFile(`${STICKY_RUNTIME}/containerd.toml`, `version = 2
62
+ root = "${config.path}/containerd"
63
+ state = "/run/containerd"
64
+ disabled_plugins = ["io.containerd.grpc.v1.cri"]
65
+ [grpc]
66
+ address = "/run/containerd/containerd.sock"
67
+ `);
68
+ const dockerConfig = JSON.parse(await readFile("/etc/docker/daemon.json", "utf8"));
69
+ dockerConfig["data-root"] = `${config.path}/docker`;
70
+ dockerConfig.features = { "containerd-snapshotter": true };
71
+ dockerConfig["storage-driver"] = "overlayfs";
72
+ await writeFile(`${STICKY_RUNTIME}/docker.json`, JSON.stringify(dockerConfig));
73
+ await writeFile(`${STICKY_RUNTIME}/buildkit.toml`, `root = "${config.path}/buildkit"
74
+ [grpc]
75
+ address = ["tcp://127.0.0.1:1234"]
76
+ [worker.oci]
77
+ enabled = false
78
+ [worker.containerd]
79
+ enabled = true
80
+ address = "/run/containerd/containerd.sock"
81
+ namespace = "moby"
82
+ snapshotter = "overlayfs"
83
+ gc = true
84
+ reservedSpace = "1GB"
85
+ maxUsedSpace = ${config.buildkitTargetBytes}
86
+ minFreeSpace = ${config.headroomBytes}
87
+ `);
88
+ const start = async (unit, program, args) => {
89
+ await exec("systemd-run", ["--quiet", "--unit", unit, "-p", "TimeoutStopSec=30",
90
+ // Pinned BuildKit 0.32.2 returns 1 after its normal first-SIGTERM
91
+ // shutdown (util/appcontext). A timeout/signal still fails the unit.
92
+ ...(unit === "spectest-sticky-buildkit" ? ["-p", "SuccessExitStatus=1"] : []),
93
+ program, ...args]);
94
+ };
95
+ await start("spectest-sticky-containerd", "containerd", ["--config", `${STICKY_RUNTIME}/containerd.toml`]);
96
+ await ready("ctr", ["version"]);
97
+ await start("spectest-sticky-docker", "dockerd", ["--config-file", `${STICKY_RUNTIME}/docker.json`,
98
+ "--host", "unix:///run/docker.sock", "--exec-root", "/run/docker", "--pidfile", "/run/docker.pid",
99
+ "--containerd", "/run/containerd/containerd.sock", "--containerd-namespace", "moby"]);
100
+ await ready("docker", ["info"]);
101
+ await start("spectest-sticky-buildkit", "buildkitd", ["--config", `${STICKY_RUNTIME}/buildkit.toml`]);
102
+ await ready("buildctl", ["--addr", "tcp://127.0.0.1:1234", "debug", "workers"]);
103
+ await exec("docker", ["buildx", "create", "--name", "spectest-local", "--driver", "remote", "tcp://127.0.0.1:1234"]);
104
+ await exec("docker", ["buildx", "inspect", "--bootstrap", "spectest-local"]);
105
+ }
106
+ /** Filesystem pressure takes precedence over cache age/BuildKit record sizes.
107
+ * Only called after runtime containers/volumes have been removed on the
108
+ * isolated publication clone. Never removes files behind the daemons' backs.
109
+ */
110
+ export async function collectStickyCache(config, run = exec, usage = async () => {
111
+ const fs = await statfs(config.path);
112
+ return { freeBytes: fs.bavail * fs.bsize, totalBytes: fs.blocks * fs.bsize };
113
+ }) {
114
+ validateStickyConfig(config);
115
+ const buildctl = ["--addr", "tcp://127.0.0.1:1234", "prune"];
116
+ await run("buildctl", [...buildctl, "--keep-duration", "168h"], 120_000);
117
+ await run("docker", ["image", "prune", "-af", "--filter", "until=168h"], 120_000);
118
+ const withinTarget = (fs) => fs.freeBytes >= config.headroomBytes && fs.totalBytes - fs.freeBytes <= config.cacheTargetBytes;
119
+ let fs = await usage();
120
+ // Always enforce the independent BuildKit target. Its accounting alone
121
+ // cannot bound shared images/content, so also request real filesystem space.
122
+ const pruneBuildkit = async () => {
123
+ await run("buildctl", [...buildctl, "--all", "--keep-storage",
124
+ String(Math.floor(config.buildkitTargetBytes / 1e6)), "--free-storage",
125
+ String(Math.ceil(Math.max(config.headroomBytes, fs.totalBytes - config.cacheTargetBytes) / 1e6))], 120_000);
126
+ fs = await usage();
127
+ };
128
+ await pruneBuildkit();
129
+ if (withinTarget(fs))
130
+ return;
131
+ // Pressure can be entirely in recent Docker images. Drop oldest images
132
+ // first, even within the age window, until the whole filesystem fits.
133
+ const ids = [...new Set((await run("docker", ["image", "ls", "-aq", "--no-trunc"])).split(/\s+/).filter(Boolean))];
134
+ const images = ids.length
135
+ ? JSON.parse(await run("docker", ["image", "inspect", ...ids])) : [];
136
+ images.sort((a, b) => a.Created.localeCompare(b.Created) || a.Id.localeCompare(b.Id));
137
+ for (const image of images) {
138
+ await run("docker", ["image", "rm", "-f", image.Id], 120_000);
139
+ await pruneBuildkit();
140
+ if (withinTarget(fs))
141
+ return;
142
+ }
143
+ // Containerd GC may lag reference removal; allow bounded additional passes.
144
+ for (let pass = 0; pass < 3 && !withinTarget(fs); pass++)
145
+ await pruneBuildkit();
146
+ if (!withinTarget(fs))
147
+ throw new Error("sticky cache cannot meet filesystem retention target and write headroom");
148
+ }
149
+ /** Only on an isolated maintenance clone; never on the live ready environment. */
150
+ export async function prepareStickyConsumer(artifact = false) {
151
+ const config = JSON.parse(await readFile(`${STICKY_RUNTIME}/consumer.json`, "utf8"));
152
+ validateStickyConfig(config);
153
+ if (!artifact) {
154
+ const containers = (await exec("docker", ["ps", "-aq"])).split(/\s+/).filter(Boolean);
155
+ if (containers.length)
156
+ await exec("docker", ["rm", "-f", ...containers]);
157
+ const volumes = (await exec("docker", ["volume", "ls", "-q"])).split(/\s+/).filter(Boolean);
158
+ if (volumes.length)
159
+ await exec("docker", ["volume", "rm", ...volumes]);
160
+ await collectStickyCache(config);
161
+ }
162
+ const units = ["spectest-sticky-buildkit", "spectest-sticky-docker", "spectest-sticky-containerd"];
163
+ for (const unit of units) {
164
+ const active = await exec("systemctl", ["show", unit, "-p", "ActiveState", "--value"]);
165
+ if (active !== "active")
166
+ throw new Error(`consumer exited before sealing: ${unit}: ${active}`);
167
+ await exec("systemctl", ["stop", unit], 45_000);
168
+ const result = await exec("systemctl", ["show", unit, "-p", "Result", "--value"]);
169
+ if (result !== "success")
170
+ throw new Error(`unclean consumer shutdown: ${unit}: ${result}`);
171
+ }
172
+ const fs = await statfs(config.path);
173
+ const freeBytes = fs.bavail * fs.bsize;
174
+ if (freeBytes < config.headroomBytes)
175
+ throw new Error("sticky publication has insufficient write headroom");
176
+ // The supervisor verifies and unmounts; only the worker seals/publishes.
177
+ return { prepared: true, freeBytes };
178
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ // Dedicated entry point: no daemon.ts, project modules, bootstrap or tests.
2
+ import { prepareStickyConsumer } from "./sticky-consumer";
3
+ if (import.meta.main) {
4
+ try {
5
+ console.log(JSON.stringify(await prepareStickyConsumer(process.argv.includes("--artifact"))));
6
+ }
7
+ catch (error) {
8
+ console.error(error);
9
+ process.exitCode = 1;
10
+ }
11
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The ingress WebSocket bridge: one client WebSocket on an ingress
3
+ * listener, one upstream WebSocket to the service, frames copied in both
4
+ * directions.
5
+ *
6
+ * The handshake is the part that matters. A client that sends
7
+ * `Sec-WebSocket-Protocol: console, <ticket>` expects the upstream to see
8
+ * the full list, and expects to get back the one the upstream selected.
9
+ * The first version of this bridge opened the upstream with no protocols
10
+ * and no headers, so a ticket or a cookie that rode on the handshake never
11
+ * reached the service, and the service refused the connection after the
12
+ * client already saw a 101. So the bridge now opens the upstream FIRST,
13
+ * with the client's protocol list and forwardable headers, waits for the
14
+ * upstream handshake, and only then upgrades the client with the protocol
15
+ * the upstream selected. An upstream that refuses the handshake becomes a
16
+ * 502 to the client, the same as the HTTP proxy path.
17
+ *
18
+ * `daemon.ts` owns the listener and the route lookup; this module owns
19
+ * the handshake and the frame relay so the two can be tested on loopback
20
+ * without a VM.
21
+ */
22
+ /** Per-bridge context stored on `ws.data` of the client socket. */
23
+ export interface WsBridgeData {
24
+ upstreamUrl: string;
25
+ /** Open by the time the client socket exists: the handshake awaited it. */
26
+ upstream: WebSocket;
27
+ }
28
+ /** `Sec-WebSocket-Protocol` is a comma-separated list; each token is
29
+ * trimmed, empty tokens are dropped, order is kept (RFC 6455 §4.1 lets
30
+ * the server pick from the list, so order is the client's preference). */
31
+ export declare function parseSubprotocols(header: string | null | undefined): string[];
32
+ /**
33
+ * The headers the upstream handshake carries: everything forwardable from
34
+ * the client's request (cookies, authorization, origin, user-agent …),
35
+ * minus the client's own handshake fields, plus the proxy provenance
36
+ * headers and a Host that names the service.
37
+ */
38
+ export declare function upstreamHandshakeHeaders(req: Headers, opts: {
39
+ service: string;
40
+ port: number;
41
+ proto: string;
42
+ clientIp?: string;
43
+ }): Record<string, string>;
44
+ export type UpstreamOpen = {
45
+ ok: true;
46
+ socket: WebSocket;
47
+ } | {
48
+ ok: false;
49
+ reason: string;
50
+ };
51
+ /**
52
+ * Open the upstream WebSocket and wait for its handshake. Resolves once
53
+ * the socket is open, or once it failed; the handshake listeners are
54
+ * removed either way so a later close does not fire them again.
55
+ */
56
+ export declare function openUpstreamWebSocket(url: string, opts: {
57
+ protocols: string[];
58
+ headers: Record<string, string>;
59
+ timeoutMs?: number;
60
+ }): Promise<UpstreamOpen>;
61
+ /** The minimum of Bun's ServerWebSocket that the relay uses. */
62
+ interface ClientSocket {
63
+ data: WsBridgeData;
64
+ send(data: string | ArrayBuffer | Uint8Array): unknown;
65
+ close(code?: number, reason?: string): unknown;
66
+ }
67
+ /**
68
+ * The `websocket` handler set for `Bun.serve`. The upstream is already
69
+ * open when `open()` runs, so frames go straight through in both
70
+ * directions, and a close on either side closes the other with the same
71
+ * code and reason.
72
+ */
73
+ export declare const WS_BRIDGE_HANDLERS: {
74
+ open(ws: ClientSocket): void;
75
+ message(ws: ClientSocket, message: string | Buffer): void;
76
+ close(ws: ClientSocket, code: number, reason: string): void;
77
+ };
78
+ export {};
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The ingress WebSocket bridge: one client WebSocket on an ingress
3
+ * listener, one upstream WebSocket to the service, frames copied in both
4
+ * directions.
5
+ *
6
+ * The handshake is the part that matters. A client that sends
7
+ * `Sec-WebSocket-Protocol: console, <ticket>` expects the upstream to see
8
+ * the full list, and expects to get back the one the upstream selected.
9
+ * The first version of this bridge opened the upstream with no protocols
10
+ * and no headers, so a ticket or a cookie that rode on the handshake never
11
+ * reached the service, and the service refused the connection after the
12
+ * client already saw a 101. So the bridge now opens the upstream FIRST,
13
+ * with the client's protocol list and forwardable headers, waits for the
14
+ * upstream handshake, and only then upgrades the client with the protocol
15
+ * the upstream selected. An upstream that refuses the handshake becomes a
16
+ * 502 to the client, the same as the HTTP proxy path.
17
+ *
18
+ * `daemon.ts` owns the listener and the route lookup; this module owns
19
+ * the handshake and the frame relay so the two can be tested on loopback
20
+ * without a VM.
21
+ */
22
+ import { forwardableHeaders } from "./http-proxy.js";
23
+ /** Headers that belong to the client's own WebSocket handshake. Bun's
24
+ * WebSocket client mints its own key and version, and the protocol list
25
+ * goes through the constructor option, so these never travel as plain
26
+ * headers. */
27
+ const HANDSHAKE_HEADERS = new Set([
28
+ "sec-websocket-key",
29
+ "sec-websocket-version",
30
+ "sec-websocket-protocol",
31
+ "sec-websocket-extensions",
32
+ "sec-websocket-accept",
33
+ ]);
34
+ /** `Sec-WebSocket-Protocol` is a comma-separated list; each token is
35
+ * trimmed, empty tokens are dropped, order is kept (RFC 6455 §4.1 lets
36
+ * the server pick from the list, so order is the client's preference). */
37
+ export function parseSubprotocols(header) {
38
+ if (!header)
39
+ return [];
40
+ return header
41
+ .split(",")
42
+ .map((s) => s.trim())
43
+ .filter((s) => s.length > 0);
44
+ }
45
+ /**
46
+ * The headers the upstream handshake carries: everything forwardable from
47
+ * the client's request (cookies, authorization, origin, user-agent …),
48
+ * minus the client's own handshake fields, plus the proxy provenance
49
+ * headers and a Host that names the service.
50
+ */
51
+ export function upstreamHandshakeHeaders(req, opts) {
52
+ const out = {};
53
+ forwardableHeaders(req).forEach((value, key) => {
54
+ if (HANDSHAKE_HEADERS.has(key))
55
+ return;
56
+ out[key] = value;
57
+ });
58
+ const priorXff = req.get("x-forwarded-for");
59
+ const xff = opts.clientIp
60
+ ? priorXff
61
+ ? `${priorXff}, ${opts.clientIp}`
62
+ : opts.clientIp
63
+ : priorXff;
64
+ if (xff)
65
+ out["x-forwarded-for"] = xff;
66
+ out["x-forwarded-proto"] = opts.proto;
67
+ const publicHost = req.get("host");
68
+ if (publicHost)
69
+ out["x-forwarded-host"] = publicHost;
70
+ out["host"] = `${opts.service}:${opts.port}`;
71
+ return out;
72
+ }
73
+ /**
74
+ * Open the upstream WebSocket and wait for its handshake. Resolves once
75
+ * the socket is open, or once it failed; the handshake listeners are
76
+ * removed either way so a later close does not fire them again.
77
+ */
78
+ export function openUpstreamWebSocket(url, opts) {
79
+ const Ctor = WebSocket;
80
+ let socket;
81
+ try {
82
+ socket = new Ctor(url, { protocols: opts.protocols, headers: opts.headers });
83
+ }
84
+ catch (err) {
85
+ return Promise.resolve({ ok: false, reason: String(err) });
86
+ }
87
+ // ArrayBuffer so binary frames can be sent to the client verbatim.
88
+ socket.binaryType = "arraybuffer";
89
+ return new Promise((resolve) => {
90
+ let settled = false;
91
+ const timeoutMs = opts.timeoutMs ?? 30_000;
92
+ const timer = setTimeout(() => finish({ ok: false, reason: "upstream handshake timed out" }), timeoutMs);
93
+ const onOpen = () => finish({ ok: true, socket });
94
+ const onError = (ev) => {
95
+ const msg = ev.message;
96
+ finish({ ok: false, reason: msg ? `upstream handshake failed: ${msg}` : "upstream handshake failed" });
97
+ };
98
+ const onClose = (ev) => finish({ ok: false, reason: `upstream closed during handshake (${ev.code}${ev.reason ? ` ${ev.reason}` : ""})` });
99
+ const finish = (result) => {
100
+ if (settled)
101
+ return;
102
+ settled = true;
103
+ clearTimeout(timer);
104
+ socket.removeEventListener("open", onOpen);
105
+ socket.removeEventListener("error", onError);
106
+ socket.removeEventListener("close", onClose);
107
+ if (!result.ok) {
108
+ try {
109
+ socket.close();
110
+ }
111
+ catch {
112
+ /* already closed */
113
+ }
114
+ }
115
+ resolve(result);
116
+ };
117
+ socket.addEventListener("open", onOpen);
118
+ socket.addEventListener("error", onError);
119
+ socket.addEventListener("close", onClose);
120
+ });
121
+ }
122
+ /**
123
+ * The `websocket` handler set for `Bun.serve`. The upstream is already
124
+ * open when `open()` runs, so frames go straight through in both
125
+ * directions, and a close on either side closes the other with the same
126
+ * code and reason.
127
+ */
128
+ export const WS_BRIDGE_HANDLERS = {
129
+ open(ws) {
130
+ const { upstream } = ws.data;
131
+ upstream.addEventListener("message", (ev) => {
132
+ try {
133
+ ws.send(ev.data);
134
+ }
135
+ catch {
136
+ /* client gone */
137
+ }
138
+ });
139
+ upstream.addEventListener("close", (ev) => {
140
+ try {
141
+ ws.close(ev.code, ev.reason);
142
+ }
143
+ catch {
144
+ /* already closed */
145
+ }
146
+ });
147
+ upstream.addEventListener("error", () => {
148
+ try {
149
+ ws.close(1011, "upstream error");
150
+ }
151
+ catch {
152
+ /* already closed */
153
+ }
154
+ });
155
+ // The upstream can have closed between the handshake and this
156
+ // callback; the close listener above was not attached yet then.
157
+ if (upstream.readyState !== WebSocket.OPEN) {
158
+ try {
159
+ ws.close(1011, "upstream closed");
160
+ }
161
+ catch {
162
+ /* already closed */
163
+ }
164
+ }
165
+ },
166
+ message(ws, message) {
167
+ const { upstream } = ws.data;
168
+ const payload = typeof message === "string" ? message : new Uint8Array(message);
169
+ if (upstream.readyState === WebSocket.OPEN)
170
+ upstream.send(payload);
171
+ },
172
+ close(ws, code, reason) {
173
+ try {
174
+ ws.data.upstream.close(code, reason);
175
+ }
176
+ catch {
177
+ /* ignore */
178
+ }
179
+ },
180
+ };
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { Android, AndroidApp } from "./android.js";
2
+ export type { Android, AndroidApp, AndroidElement, AndroidRecordingOptions } from "./android.js";
1
3
  import { strict as nodeAssert } from "node:assert";
2
4
  export type { Carrier, Wrapped, WrappedObject, WrappedArray, WrappedHeaders, WrappedBuffer, WrappedBinaryView, WrappedBytes, WrappedBlob, WrappedFile, WrappedFormData, WrappedStream, WrappedReader, WrappedBYOBReader, WrappedReadResult, WrappedResponse, Provenanced, SpectestFetch, SpectestRequestInit, Unwrap, } from "./inspect.js";
3
5
  import type { Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
@@ -1315,6 +1317,8 @@ export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap,
1315
1317
  * like named desktop browsers.
1316
1318
  */
1317
1319
  mobile(app: MobileApp, name?: string): Promise<Mobile>;
1320
+ /** Native Android via Appium. Recording is automatic for test cases; the app and session survive forks. */
1321
+ android(app: AndroidApp): Promise<Android>;
1318
1322
  /** Drive a real Electron renderer window through locators and user input.
1319
1323
  * Acquires the app started by electron(); keeps its live state across forks.
1320
1324
  * Each test gets a self-contained graphical replay. Selecting another window
@@ -564,7 +564,7 @@ export interface EventReservation {
564
564
  }
565
565
  export declare function pauseRecording(): void;
566
566
  export declare function resumeRecording(): void;
567
- export declare function startRecording(): void;
567
+ export declare function startRecording(startedAt?: number): void;
568
568
  export declare function stopRecording(): TestEvent[];
569
569
  export declare function isRecording(): boolean;
570
570
  /** Reserve an ordering slot at the start of an op so nested events it triggers
package/dist/recorder.js CHANGED
@@ -11,9 +11,12 @@ import { clearPendingNullish } from "./inspect.js";
11
11
  import { scopeAllowsRecording } from "./harness/instrumentation-scope.js";
12
12
  const OUTPUT_SNIPPET_BYTES = 256 * 1024;
13
13
  class Recorder {
14
+ start;
14
15
  events = [];
15
16
  seq = 0;
16
- start = Date.now();
17
+ constructor(start = Date.now()) {
18
+ this.start = start;
19
+ }
17
20
  /** Claim a seq + start offset now; pass the result to `push` at finish. */
18
21
  reserve() {
19
22
  return { seq: this.seq++, tOffsetMs: Date.now() - this.start };
@@ -120,8 +123,8 @@ export function resumeRecording() {
120
123
  function active() {
121
124
  return current !== null && paused === 0 && scopeAllowsRecording();
122
125
  }
123
- export function startRecording() {
124
- current = new Recorder();
126
+ export function startRecording(startedAt = Date.now()) {
127
+ current = new Recorder(startedAt);
125
128
  paused = 0;
126
129
  }
127
130
  export function stopRecording() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.89.0",
3
+ "version": "0.90.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -49,7 +49,8 @@
49
49
  },
50
50
  "publishConfig": {
51
51
  "registry": "https://registry.npmjs.org",
52
- "access": "public"
52
+ "access": "public",
53
+ "tag": "latest"
53
54
  },
54
55
  "dependencies": {
55
56
  "@kubernetes/client-node": "^1.0.0",
@@ -61,5 +62,8 @@
61
62
  "@types/dns-packet": "^5.6.5",
62
63
  "@types/bun": "^1.3.0",
63
64
  "typescript": "^5.6.3"
64
- }
65
+ },
66
+ "spectestCapabilities": [
67
+ "sticky-disks-v1"
68
+ ]
65
69
  }