@specific.dev/spectest 0.118.0 → 0.119.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,265 @@
1
+ import { test, expect } from "bun:test";
2
+ import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { serveNetwork } from "./network";
6
+
7
+ test("network harness authenticates, rejects replay, and rebinds a restored heap", async () => {
8
+ const dir = mkdtempSync(join(tmpdir(), "spectest-network-"));
9
+ const bindingPath = join(dir, "binding.json");
10
+ const resolverPath = join(dir, "resolv.conf");
11
+ writeFileSync(resolverPath, "nameserver 10.0.2.42\n");
12
+ const token = "p".repeat(64), childToken = "c".repeat(64);
13
+ const bind = (instanceId: string, token: string) => writeFileSync(bindingPath, JSON.stringify({ instanceId, token }), { mode: 0o600 });
14
+ let counter = 0;
15
+ const server = serveNetwork({ increment: async () => ++counter, fail: async () => { throw Error("fixture failure"); } }, { bindingPath, hostname: "127.0.0.1", port: 0, resolverPath });
16
+ const call = (id: string, key: string, requestId: string, method = "increment") => fetch(`${server.url}rpc`, {
17
+ method: "POST", headers: { Authorization: `Bearer ${key}`, "X-Spectest-Instance": id },
18
+ body: JSON.stringify({ requestId, method, params: {} }),
19
+ });
20
+ try {
21
+ expect((await call("parent", token, "1")).status).toBe(503);
22
+ bind("parent", token);
23
+ expect((await call("parent", "wrong", "1")).status).toBe(401);
24
+ expect((await call("child", token, "1")).status).toBe(409);
25
+ expect(counter).toBe(0);
26
+ expect(readFileSync(resolverPath, "utf8")).toBe("nameserver 10.0.2.42\n");
27
+ expect(await (await call("parent", token, "1")).json()).toEqual({ instanceId: "parent", requestId: "1", result: 1 });
28
+ expect(readFileSync(resolverPath, "utf8")).toContain("nameserver 127.0.0.53");
29
+ expect((await call("parent", token, "1")).status).toBe(409);
30
+ expect(counter).toBe(1);
31
+ bind("child", childToken);
32
+ writeFileSync(resolverPath, "nameserver 10.0.2.42\n");
33
+ expect((await call("parent", token, "2")).status).toBe(401);
34
+ expect(await (await call("child", childToken, "1")).json()).toEqual({ instanceId: "child", requestId: "1", result: 2 });
35
+ expect(readFileSync(resolverPath, "utf8")).toContain("nameserver 127.0.0.53");
36
+ expect((await call("child", childToken, "2", "fail")).status).toBe(400);
37
+ expect((await call("child", childToken, "3", "constructor")).status).toBe(400);
38
+ expect((await call("child", childToken, "4")).status).toBe(200);
39
+ } finally { server.stop(true); rmSync(dir, { recursive: true }); }
40
+ });
41
+
42
+ test("rebound identity cannot enter a harness with an inherited active request", async () => {
43
+ const dir = mkdtempSync(join(tmpdir(), "spectest-network-"));
44
+ const bindingPath = join(dir, "binding.json"), token = "s".repeat(64);
45
+ const bind = (instanceId: string) => writeFileSync(bindingPath, JSON.stringify({ instanceId, token }));
46
+ let entered!: () => void, release!: () => void;
47
+ const started = new Promise<void>(r => entered = r);
48
+ const blocked = new Promise<void>(r => release = r);
49
+ const server = serveNetwork({ wait: async () => { entered(); await blocked; return {}; }, ping: async () => ({}) }, { bindingPath, hostname: "127.0.0.1", port: 0 });
50
+ const call = (instanceId: string, method: string) => fetch(`${server.url}rpc`, {
51
+ method: "POST", headers: { Authorization: `Bearer ${token}`, "X-Spectest-Instance": instanceId },
52
+ body: JSON.stringify({ requestId: crypto.randomUUID(), method }),
53
+ });
54
+ try {
55
+ bind("parent");
56
+ const pending = call("parent", "wait");
57
+ await started;
58
+ bind("child");
59
+ expect((await call("child", "ping")).status).toBe(409);
60
+ release();
61
+ expect((await pending).status).toBe(200);
62
+ expect((await call("child", "ping")).status).toBe(200);
63
+ } finally { release(); server.stop(true); rmSync(dir, { recursive: true }); }
64
+ });
65
+
66
+ test("async work survives polling, authenticates reads, and never replays after acknowledgement", async () => {
67
+ const dir = mkdtempSync(join(tmpdir(), "spectest-network-"));
68
+ const bindingPath = join(dir, "binding.json"), token = "s".repeat(64);
69
+ const bind = (instanceId: string) => writeFileSync(bindingPath, JSON.stringify({ instanceId, token }));
70
+ let release!: () => void, counter = 0;
71
+ const blocked = new Promise<void>(r => release = r);
72
+ const server = serveNetwork({ wait: async () => { counter++; await blocked; return "finished"; },
73
+ fail: async () => { throw Error("async failure"); } }, { bindingPath, hostname: "127.0.0.1", port: 0 });
74
+ const call = (method: string, requestId = "one", instanceId = "parent", key = token, handler = "wait") => fetch(`${server.url}rpc?requestId=${requestId}`, {
75
+ method, headers: { Authorization: `Bearer ${key}`, "X-Spectest-Instance": instanceId },
76
+ ...(method === "POST" ? { body: JSON.stringify({ requestId, method: handler, async: true }) } : {}),
77
+ });
78
+ try {
79
+ bind("parent");
80
+ expect((await call("POST")).status).toBe(202);
81
+ expect((await call("GET")).status).toBe(202);
82
+ expect((await call("DELETE")).status).toBe(202); // acknowledgement cannot cancel work
83
+ expect((await call("POST")).status).toBe(409);
84
+ expect((await call("GET", "one", "parent", "bad")).status).toBe(401);
85
+ expect((await call("GET", "one", "child")).status).toBe(409);
86
+ bind("child");
87
+ expect((await call("GET", "one", "child")).status).toBe(409);
88
+ bind("parent");
89
+ release();
90
+ for (let i = 0; i < 2; i++) expect(await (await call("GET")).json()).toEqual({ instanceId: "parent", requestId: "one", result: "finished" });
91
+ expect(counter).toBe(1);
92
+ expect((await call("DELETE")).status).toBe(200);
93
+ expect((await call("GET")).status).toBe(404);
94
+ expect((await call("POST")).status).toBe(409);
95
+ expect((await call("POST", "error", "parent", token, "fail")).status).toBe(202);
96
+ const failure = await call("GET", "error");
97
+ expect(failure.status).toBe(400);
98
+ expect((await failure.json()).message).toContain("async failure");
99
+ bind("child");
100
+ expect((await call("GET", "error", "child")).status).toBe(404);
101
+ expect((await call("POST", "one", "child")).status).toBe(202);
102
+ expect((await call("GET", "one", "child")).status).toBe(200);
103
+ expect(counter).toBe(2);
104
+ } finally { release(); server.stop(true); rmSync(dir, { recursive: true }); }
105
+ });
106
+
107
+ test("async results have bounded retention and expiration does not replay work", async () => {
108
+ const dir = mkdtempSync(join(tmpdir(), "spectest-network-"));
109
+ const bindingPath = join(dir, "binding.json"), token = "s".repeat(64);
110
+ writeFileSync(bindingPath, JSON.stringify({ instanceId: "parent", token }));
111
+ const server = serveNetwork({ large: async () => "x".repeat(2000) }, {
112
+ bindingPath, hostname: "127.0.0.1", port: 0, resultLimits: { count: 1, bytes: 1000, ttlMs: 100 },
113
+ });
114
+ const call = (method: string, requestId: string) => fetch(`${server.url}rpc?requestId=${requestId}`, {
115
+ method, headers: { Authorization: `Bearer ${token}`, "X-Spectest-Instance": "parent" },
116
+ ...(method === "POST" ? { body: JSON.stringify({ requestId, method: "large", async: true }) } : {}),
117
+ });
118
+ try {
119
+ expect((await call("POST", "one")).status).toBe(202);
120
+ expect((await call("GET", "one")).status).toBe(507);
121
+ expect((await call("POST", "two")).status).toBe(429);
122
+ await Bun.sleep(120);
123
+ expect((await call("GET", "one")).status).toBe(404);
124
+ expect((await call("POST", "one")).status).toBe(409);
125
+ expect((await call("POST", "two")).status).toBe(202);
126
+ } finally { server.stop(true); rmSync(dir, { recursive: true }); }
127
+ });
128
+
129
+ test("Smol ingress uses a separate guest credential and still fences restored identity", async () => {
130
+ const dir = mkdtempSync(join(tmpdir(), "spectest-smol-network-"));
131
+ const bindingPath = join(dir, "binding.json"), token = "s".repeat(64);
132
+ writeFileSync(bindingPath, JSON.stringify({ instanceId: "child", token }));
133
+ let calls = 0;
134
+ const server = serveNetwork({ ping: async () => ++calls }, { bindingPath, hostname: "127.0.0.1", port: 0 });
135
+ const call = (key: string, id = "child") => fetch(`${server.url}rpc`, {
136
+ method: "POST", headers: { "X-Spectest-Harness-Authorization": `Bearer ${key}`, "X-Spectest-Instance": id },
137
+ body: JSON.stringify({ requestId: crypto.randomUUID(), method: "ping" }),
138
+ });
139
+ try {
140
+ expect((await call("account-key-is-not-a-guest-key")).status).toBe(401);
141
+ expect((await call(token, "parent")).status).toBe(409);
142
+ expect((await call(token)).status).toBe(200);
143
+ expect(calls).toBe(1);
144
+ } finally { server.stop(true); rmSync(dir, { recursive: true }); }
145
+ });
146
+
147
+ test("keep-alive connections reauthenticate every request after rebinding", async () => {
148
+ const { createConnection } = await import("node:net");
149
+ const dir = mkdtempSync(join(tmpdir(), "spectest-keepalive-"));
150
+ const bindingPath = join(dir, "binding.json");
151
+ const parentToken = "p".repeat(64), childToken = "c".repeat(64);
152
+ const bind = (instanceId: string, token: string) => writeFileSync(bindingPath, JSON.stringify({ instanceId, token }));
153
+ bind("parent", parentToken);
154
+ const server = serveNetwork({ ping: async () => ({ ok: true }) }, { bindingPath, hostname: "127.0.0.1", port: 0 });
155
+ // Use one actual TCP connection: Bun's node:http shim does not expose reusedSocket.
156
+ const socket = createConnection({ host: "127.0.0.1", port: server.port! });
157
+ const call = (instanceId: string, token: string) => new Promise<number>((resolve, reject) => {
158
+ let data = "";
159
+ const cleanup = () => { socket.off("data", read); socket.off("error", fail); socket.off("end", closed); };
160
+ const fail = (error: Error) => { cleanup(); reject(error); };
161
+ const closed = () => fail(Error("server closed the reusable connection"));
162
+ const read = (chunk: Buffer) => {
163
+ data += chunk.toString();
164
+ const headerEnd = data.indexOf("\r\n\r\n");
165
+ if (headerEnd < 0) return;
166
+ const headers = data.slice(0, headerEnd);
167
+ const length = Number(headers.match(/content-length: (\d+)/i)?.[1]);
168
+ if (!Number.isFinite(length)) return fail(Error("missing response length"));
169
+ if (Buffer.byteLength(data.slice(headerEnd + 4)) < length) return;
170
+ cleanup(); resolve(Number(headers.split(" ")[1]));
171
+ };
172
+ socket.on("data", read); socket.once("error", fail); socket.once("end", closed);
173
+ const body = JSON.stringify({ requestId: crypto.randomUUID(), method: "ping" });
174
+ socket.write(`POST /rpc HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: keep-alive\r\nAuthorization: Bearer ${token}\r\nX-Spectest-Instance: ${instanceId}\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
175
+ });
176
+ try {
177
+ expect(await call("parent", parentToken)).toBe(200);
178
+ expect(await call("parent", parentToken)).toBe(200);
179
+ bind("child", childToken);
180
+ expect(await call("parent", parentToken)).toBe(401);
181
+ expect(await call("child", childToken)).toBe(200);
182
+ } finally { socket.destroy(); server.stop(true); rmSync(dir, { recursive: true }); }
183
+ });
184
+
185
+ test("relay forwards future SDK methods and deadlines without importing their registry", async () => {
186
+ const dir = mkdtempSync(join(tmpdir(), "spectest-forward-"));
187
+ const bindingPath = join(dir, "binding.json"), token = "s".repeat(64);
188
+ writeFileSync(bindingPath, JSON.stringify({ instanceId: "vm", token }));
189
+ const calls: unknown[] = [];
190
+ const server = serveNetwork({}, { bindingPath, hostname: "127.0.0.1", port: 0,
191
+ dispatch: async (method, params, timeoutMs) => { calls.push({ method, params, timeoutMs }); return { opaque: true }; },
192
+ });
193
+ const call = (key: string, timeoutMs: number) => fetch(`${server.url}rpc`, {
194
+ method: "POST", headers: { Authorization: `Bearer ${key}`, "X-Spectest-Instance": "vm" },
195
+ body: JSON.stringify({ requestId: crypto.randomUUID(), method: "futureSdkMethod", params: { x: 2 }, timeoutMs }),
196
+ });
197
+ try {
198
+ expect((await call("wrong", 1234)).status).toBe(401);
199
+ expect((await call(token, -1)).status).toBe(400);
200
+ expect(calls).toEqual([]);
201
+ expect((await (await call(token, 1234)).json()).result).toEqual({ opaque: true });
202
+ expect(calls).toEqual([{ method: "futureSdkMethod", params: { x: 2 }, timeoutMs: 1234 }]);
203
+ } finally { server.stop(true); rmSync(dir, { recursive: true }); }
204
+ });
205
+
206
+ test("result waits wake on completion, reconnect after disconnect, and do not block other calls", async () => {
207
+ const dir = mkdtempSync(join(tmpdir(), "spectest-result-wait-"));
208
+ const bindingPath = join(dir, "binding.json"), token = "s".repeat(64);
209
+ writeFileSync(bindingPath, JSON.stringify({ instanceId: "vm", token }));
210
+ let release!: () => void, calls = 0;
211
+ const blocked = new Promise<void>(resolve => release = resolve);
212
+ const server = serveNetwork({ run: async () => { calls++; await blocked; return "done"; },
213
+ health: async () => "healthy" }, { bindingPath, hostname: "127.0.0.1", port: 0 });
214
+ const headers = { Authorization: `Bearer ${token}`, "X-Spectest-Instance": "vm" };
215
+ const get = (waitMs: number, signal?: AbortSignal) => fetch(`${server.url}rpc?requestId=one&waitMs=${waitMs}`, { headers, signal });
216
+ try {
217
+ const accepted = await fetch(`${server.url}rpc`, { method: "POST", headers,
218
+ body: JSON.stringify({ requestId: "one", method: "run", async: true }) });
219
+ expect((await accepted.json()).resultWait).toBe(true);
220
+ expect((await get(-1)).status).toBe(400);
221
+ expect((await get(25_001)).status).toBe(400);
222
+ const before = performance.now();
223
+ expect((await get(40)).status).toBe(202);
224
+ expect(performance.now() - before).toBeGreaterThanOrEqual(30);
225
+
226
+ const abort = new AbortController();
227
+ const disconnected = get(25_000, abort.signal).then(() => false, () => true);
228
+ await Bun.sleep(20);
229
+ abort.abort();
230
+ expect(await disconnected).toBe(true);
231
+ let finished = false;
232
+ const waiting = get(25_000).then(r => { finished = true; return r; });
233
+ const health = await fetch(`${server.url}rpc`, { method: "POST", headers,
234
+ body: JSON.stringify({ requestId: "health", method: "health" }) });
235
+ expect((await health.json()).result).toBe("healthy");
236
+ expect(finished).toBe(false);
237
+ release();
238
+ expect((await (await waiting).json()).result).toBe("done");
239
+ expect((await (await get(25_000)).json()).result).toBe("done");
240
+ expect(calls).toBe(1);
241
+ } finally { release(); server.stop(true); rmSync(dir, { recursive: true }); }
242
+ });
243
+
244
+ test("an outstanding result wait cannot return the old VM's result after rebinding", async () => {
245
+ const dir = mkdtempSync(join(tmpdir(), "spectest-wait-rebind-"));
246
+ const bindingPath = join(dir, "binding.json"), token = "s".repeat(64);
247
+ const bind = (instanceId: string) => writeFileSync(bindingPath, JSON.stringify({ instanceId, token }));
248
+ bind("parent");
249
+ let release!: () => void;
250
+ const blocked = new Promise<void>(resolve => release = resolve);
251
+ const server = serveNetwork({ run: async () => { await blocked; return "parent data"; } },
252
+ { bindingPath, hostname: "127.0.0.1", port: 0 });
253
+ const headers = { Authorization: `Bearer ${token}`, "X-Spectest-Instance": "parent" };
254
+ try {
255
+ expect((await fetch(`${server.url}rpc`, { method: "POST", headers,
256
+ body: JSON.stringify({ requestId: "one", method: "run", async: true }) })).status).toBe(202);
257
+ const waiting = fetch(`${server.url}rpc?requestId=one&waitMs=25000`, { headers });
258
+ await Bun.sleep(20);
259
+ bind("child");
260
+ release();
261
+ const response = await waiting;
262
+ expect(response.status).toBe(409);
263
+ expect(await response.text()).not.toContain("parent data");
264
+ } finally { release(); server.stop(true); rmSync(dir, { recursive: true }); }
265
+ });
@@ -0,0 +1,178 @@
1
+ /** Experimental cloud transport. No VM agent or control-plane credential lives here.
2
+ * Read the binding on every request: checkpoint children inherit the process heap,
3
+ * but receive a new binding file before the controller dispatches work to them.
4
+ * Future guest-initiated forks must perform the same rebind before accepting work.
5
+ */
6
+ import { readFileSync, writeFileSync } from "node:fs";
7
+ import { timingSafeEqual } from "node:crypto";
8
+ import { codeOf } from "./methods";
9
+ import { describeThrow } from "./protocol";
10
+
11
+ export interface NetworkBinding { instanceId: string; token: string }
12
+ type PendingResult = { body?: string; status?: number; completedAt?: number; bytes: number;
13
+ waiters: Set<() => void> };
14
+ type Handler = (params: Record<string, unknown>) => Promise<unknown>;
15
+
16
+ export function serveNetwork(
17
+ registry: Record<string, Handler>,
18
+ options: { bindingPath: string; hostname?: string; port?: number; resolverPath?: string;
19
+ resultLimits?: { count?: number; bytes?: number; ttlMs?: number };
20
+ dispatch?: (method: string, params: Record<string, unknown>, timeoutMs?: number) => Promise<unknown> },
21
+ ) {
22
+ let session = "";
23
+ const seen = new Set<string>();
24
+ let active = 0;
25
+ const results = new Map<string, PendingResult>();
26
+ const maxResults = options.resultLimits?.count ?? 128;
27
+ const maxBytes = options.resultLimits?.bytes ?? 64 * 1024 * 1024;
28
+ const ttlMs = options.resultLimits?.ttlMs ?? 10 * 60_000;
29
+ let resultBytes = 0;
30
+ const forget = (id: string) => {
31
+ resultBytes -= results.get(id)?.bytes ?? 0;
32
+ results.delete(id);
33
+ };
34
+ const expire = () => {
35
+ for (const [id, result] of results)
36
+ if (result.completedAt !== undefined && Date.now() - result.completedAt >= ttlMs) forget(id);
37
+ };
38
+ const reply = (body: unknown, status = 200) => Response.json(body, {
39
+ status, headers: { "Cache-Control": "no-store" },
40
+ });
41
+ return Bun.serve({
42
+ hostname: options.hostname ?? "0.0.0.0",
43
+ port: options.port ?? 4318,
44
+ maxRequestBodySize: 16 * 1024 * 1024,
45
+ async fetch(req, server) {
46
+ const url = new URL(req.url);
47
+ if (url.pathname !== "/rpc" || !["POST", "GET", "DELETE"].includes(req.method))
48
+ return reply({ error: "not_found" }, 404);
49
+ let binding: NetworkBinding;
50
+ try {
51
+ binding = JSON.parse(readFileSync(options.bindingPath, "utf8"));
52
+ if (!binding.instanceId || !binding.token || binding.token.length < 32) throw Error();
53
+ } catch { return reply({ error: "unbound" }, 503); }
54
+ const supplied = Buffer.from(req.headers.get("X-Spectest-Harness-Authorization") ?? req.headers.get("Authorization") ?? "");
55
+ const expected = Buffer.from(`Bearer ${binding.token}`);
56
+ if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected))
57
+ return reply({ error: "unauthorized" }, 401);
58
+ if (req.headers.get("X-Spectest-Instance") !== binding.instanceId)
59
+ return reply({ error: "wrong_instance" }, 409);
60
+ // Neither parent request IDs nor network connections are child identity.
61
+ const nextSession = `${binding.instanceId}:${binding.token}`;
62
+ if (session !== nextSession) {
63
+ if (active) return reply({ error: "restore_during_request" }, 409);
64
+ session = nextSession;
65
+ seen.clear();
66
+ results.clear();
67
+ resultBytes = 0;
68
+ }
69
+ expire();
70
+ if (req.method !== "POST") {
71
+ const requestId = url.searchParams.get("requestId");
72
+ const envelope = { instanceId: binding.instanceId, requestId };
73
+ const result = requestId ? results.get(requestId) : undefined;
74
+ if (!result) return reply({ ...envelope, error: "result_not_found" }, 404);
75
+ if (req.method === "GET") {
76
+ const waitMs = Number(url.searchParams.get("waitMs") ?? 0);
77
+ if (!Number.isSafeInteger(waitMs) || waitMs < 0 || waitMs > 25_000)
78
+ return reply({ ...envelope, error: "invalid_wait" }, 400);
79
+ if (result.body === undefined && waitMs > 0 && !req.signal.aborted) {
80
+ server.timeout(req, 0);
81
+ // Disconnecting a waiter never cancels the operation. Remove its
82
+ // listener on every exit, including aborts and bounded timeouts.
83
+ await new Promise<void>(resolve => {
84
+ const done = () => {
85
+ clearTimeout(timer);
86
+ result.waiters.delete(done);
87
+ req.signal.removeEventListener("abort", done);
88
+ resolve();
89
+ };
90
+ const timer = setTimeout(done, waitMs);
91
+ result.waiters.add(done);
92
+ req.signal.addEventListener("abort", done, { once: true });
93
+ if (req.signal.aborted) done();
94
+ });
95
+ // A restored heap or a concurrent acknowledgement must not expose
96
+ // a stale result to a connection admitted under the old identity.
97
+ try {
98
+ const current: NetworkBinding = JSON.parse(readFileSync(options.bindingPath, "utf8"));
99
+ if (current.instanceId !== binding.instanceId || current.token !== binding.token)
100
+ return reply({ ...envelope, error: "stale_binding" }, 409);
101
+ } catch { return reply({ ...envelope, error: "unbound" }, 503); }
102
+ if (results.get(requestId!) !== result)
103
+ return reply({ ...envelope, error: "result_not_found" }, 404);
104
+ }
105
+ }
106
+ if (result.body === undefined) return reply({ ...envelope, state: "pending", resultWait: true }, 202);
107
+ if (req.method === "DELETE") {
108
+ forget(requestId!);
109
+ return reply({ ...envelope, state: "acknowledged" });
110
+ }
111
+ return new Response(result.body, { status: result.status,
112
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
113
+ }
114
+ let body: { requestId: string; method: string; params?: Record<string, unknown>; async?: boolean; timeoutMs?: number };
115
+ try {
116
+ body = await req.json();
117
+ if (!body || typeof body.requestId !== "string" || body.requestId.length > 100 ||
118
+ !body.requestId || typeof body.method !== "string" ||
119
+ (body.async !== undefined && typeof body.async !== "boolean") ||
120
+ (body.timeoutMs !== undefined && (!Number.isSafeInteger(body.timeoutMs) || body.timeoutMs <= 0)) ||
121
+ (body.params !== undefined && (!body.params || typeof body.params !== "object" || Array.isArray(body.params))))
122
+ throw Error();
123
+ } catch { return reply({ error: "invalid_request" }, 400); }
124
+ // A request body may have been in flight across a restore/rebind.
125
+ try {
126
+ const current: NetworkBinding = JSON.parse(readFileSync(options.bindingPath, "utf8"));
127
+ if (current.instanceId !== binding.instanceId || current.token !== binding.token)
128
+ return reply({ error: "stale_binding" }, 409);
129
+ } catch { return reply({ error: "unbound" }, 503); }
130
+ const envelope = { instanceId: binding.instanceId, requestId: body.requestId };
131
+ if (seen.has(body.requestId)) return reply({ ...envelope, error: "duplicate_request" }, 409);
132
+ const handler = Object.hasOwn(registry, body.method) ? registry[body.method]
133
+ : options.dispatch ? (params: Record<string, unknown>) => options.dispatch!(body.method, params, body.timeoutMs) : undefined;
134
+ if (!handler) return reply({ ...envelope, error: "unimplemented" }, 400);
135
+ if (active >= 32) return reply({ ...envelope, error: "too_many_requests" }, 429);
136
+ if (body.async && results.size >= maxResults)
137
+ return reply({ ...envelope, error: "result_capacity" }, 429);
138
+ seen.add(body.requestId);
139
+ if (seen.size > 4096) seen.delete(seen.values().next().value!);
140
+ // Long test calls are bounded by the caller and runner, not the HTTP idle timer.
141
+ server.timeout(req, 0);
142
+ active++;
143
+ const execute = async () => {
144
+ try {
145
+ // Unikraft rewrites the resolver on checkpoint restore AND native wake.
146
+ if (options.resolverPath) {
147
+ const resolver = "nameserver 127.0.0.53\noptions edns0\n";
148
+ if (readFileSync(options.resolverPath, "utf8") !== resolver)
149
+ writeFileSync(options.resolverPath, resolver);
150
+ }
151
+ return { body: JSON.stringify({ ...envelope, result: await handler(body.params ?? {}) }), status: 200 };
152
+ } catch (error) {
153
+ return { body: JSON.stringify({ ...envelope, error: codeOf(error), message: describeThrow(error) }), status: 400 };
154
+ } finally { active--; }
155
+ };
156
+ if (body.async) {
157
+ const result: PendingResult = { bytes: 0, waiters: new Set() };
158
+ results.set(body.requestId, result);
159
+ // Retain work and its outcome independently of any one HTTP connection.
160
+ void execute().then(completed => {
161
+ let bytes = Buffer.byteLength(completed.body);
162
+ if (resultBytes + bytes > maxBytes) {
163
+ completed = { body: JSON.stringify({ ...envelope, error: "result_too_large",
164
+ message: "Harness result storage limit exceeded; work must not be replayed" }), status: 507 };
165
+ bytes = Buffer.byteLength(completed.body);
166
+ }
167
+ Object.assign(result, completed, { bytes, completedAt: Date.now() });
168
+ resultBytes += bytes;
169
+ for (const done of result.waiters) done();
170
+ });
171
+ return reply({ ...envelope, state: "pending", resultWait: true }, 202);
172
+ }
173
+ const completed = await execute();
174
+ return new Response(completed.body, { status: completed.status,
175
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
176
+ },
177
+ });
178
+ }
@@ -0,0 +1,150 @@
1
+ import { expect, test } from "bun:test";
2
+ import { desktopStrategy, makeLocator, type LocatorBackend } from "./locator.js";
3
+ import type { RecordableFields } from "./browser.js";
4
+ import type { Page } from "playwright-core";
5
+
6
+ // Exercise public locator methods at the recording seam. Arguments must be
7
+ // available even when Playwright rejects an operation before it completes.
8
+ function recorder() {
9
+ const events: { action: string; fields: Partial<RecordableFields> }[] = [];
10
+ const backend: LocatorBackend = {
11
+ async pageOp(action, fields) {
12
+ events.push({ action, fields });
13
+ return undefined as never;
14
+ },
15
+ async silentRead() { throw new Error("unexpected read"); },
16
+ async rawTap() {},
17
+ async recordSettled() { return undefined; },
18
+ };
19
+ const locator = (css: string) => makeLocator(backend, desktopStrategy, { steps: [{ m: "locator", args: [css] }] });
20
+ return { events, locator };
21
+ }
22
+
23
+ test("records requested state, selection and drag destination before execution", async () => {
24
+ const { events, locator } = recorder();
25
+ const input = locator("#input");
26
+ await input.setChecked(false);
27
+ await input.selectOption({ label: "Sweden" });
28
+ await input.selectOption(["Red", "Blue"]);
29
+ await input.dragTo(locator("#drop zone").nth(2));
30
+ await input.waitFor({ state: "hidden" });
31
+ await input.waitFor();
32
+ expect(events[3].fields.destinationTarget).toEqual([
33
+ { method: "locator", args: ["#drop zone"] }, { method: "nth", args: [2] },
34
+ ]);
35
+ expect(events.map(({ action, fields: { target, destinationTarget, ...fields } }) => ({ action, ...fields }))).toEqual([
36
+ { action: "setChecked", selector: 'css "#input"', checked: false },
37
+ { action: "selectOption", selector: 'css "#input"', values: { label: "Sweden" } },
38
+ { action: "selectOption", selector: 'css "#input"', values: ["Red", "Blue"] },
39
+ { action: "dragTo", selector: 'css "#input"' },
40
+ { action: "waitFor", selector: 'css "#input"', state: "hidden" },
41
+ { action: "waitFor", selector: 'css "#input"', state: "visible" },
42
+ ]);
43
+ });
44
+
45
+ test("preserves empty input and file removal, script source and combined filters", async () => {
46
+ const { events, locator } = recorder();
47
+ const input = locator("#input");
48
+ await input.fill("");
49
+ await input.setInputFiles([]);
50
+ await input.evaluate("read text", (el) => el.textContent);
51
+ await input.filter({ hasText: "Hello", hasNotText: "Goodbye" }).first().focus();
52
+ expect(events[0].fields.target).toEqual([{ method: "locator", args: ["#input"] }]);
53
+ expect(events[0].fields.text).toBe("");
54
+ expect(events[1].fields.files).toEqual([]);
55
+ expect(events[2].fields.script).toContain("textContent");
56
+ expect(events[2].fields.description).toBe("read text");
57
+ expect(events[3].fields.selector).toBe('css "#input" ‹ filter(hasText: "Hello") .first()');
58
+ });
59
+
60
+
61
+ test("serializes nested locator and regex arguments as data, including all filter conditions", async () => {
62
+ const { events, locator } = recorder();
63
+ await locator("#form").getByRole("button", { name: /Submit|Save/i })
64
+ .filter({ has: locator("span").getByText("Confirm"), hasNotText: /Cancel/ }).nth(2).focus();
65
+ expect(JSON.parse(JSON.stringify(events[0].fields.target))).toEqual([
66
+ { method: "locator", args: ["#form"] },
67
+ { method: "getByRole", args: ["button", { name: { regex: "Submit|Save", flags: "i" } }] },
68
+ { method: "filter", args: [{ has: [{ method: "locator", args: ["span"] }, { method: "getByText", args: ["Confirm", null] }], hasNotText: { regex: "Cancel", flags: "" } }] },
69
+ { method: "nth", args: [2] },
70
+ ]);
71
+ });
72
+
73
+ function executingRecorder() {
74
+ const events: { action: string; fields: Partial<RecordableFields> }[] = [];
75
+ const state = { id: 42, probeFails: false, probes: 0, action: async () => "result" as unknown };
76
+ const subject = new Proxy({
77
+ evaluateAll: async () => {
78
+ state.probes++;
79
+ if (state.probeFails) throw new Error("recorder unavailable");
80
+ return state.id;
81
+ },
82
+ }, {
83
+ get: (target, key) => key === "evaluateAll" ? target.evaluateAll : () => state.action(),
84
+ });
85
+ const page = { locator: () => subject } as unknown as Page;
86
+ const backend: LocatorBackend = {
87
+ async pageOp(action, fields, fn) {
88
+ try { return await fn(page); }
89
+ finally { events.push({ action, fields: { ...fields } }); }
90
+ },
91
+ async silentRead(fn) { return fn(page); },
92
+ async rawTap() {},
93
+ async recordSettled() { return undefined; },
94
+ };
95
+ const locator = makeLocator(backend, desktopStrategy, { steps: [{ m: "locator", args: ["#target"] }] });
96
+ return { locator, state, events };
97
+ }
98
+
99
+ test("records node identity for element actions, reads and evaluation", async () => {
100
+ const { locator, events } = executingRecorder();
101
+ await locator.hover();
102
+ await locator.check();
103
+ await locator.selectOption("Sweden");
104
+ await locator.fill("hello");
105
+ await locator.focus();
106
+ await locator.scrollIntoViewIfNeeded();
107
+ await locator.waitFor();
108
+ expect(await locator.textContent()).toBe("result");
109
+ await locator.getAttribute("title");
110
+ await locator.evaluate("inspect element", (el) => el.textContent);
111
+ expect(events).toHaveLength(10);
112
+ for (const event of events) expect(event.fields.targetNodeId).toBe(42);
113
+ });
114
+
115
+ test("keeps the acted-on identity when the operation replaces its target", async () => {
116
+ const { locator, state, events } = executingRecorder();
117
+ state.action = async () => { state.id = 99; };
118
+ await locator.press("Enter");
119
+ expect(events[0]!.fields.targetNodeId).toBe(42);
120
+ expect(state.probes).toBe(1);
121
+ });
122
+
123
+ test("captures an auto-waited target after it appears", async () => {
124
+ const { locator, state, events } = executingRecorder();
125
+ state.id = 0;
126
+ state.action = async () => { state.id = 77; };
127
+ await locator.fill("hello");
128
+ expect(events[0]!.fields.targetNodeId).toBe(77);
129
+ });
130
+
131
+ test("target probing never replaces operation results or errors", async () => {
132
+ const { locator, state, events } = executingRecorder();
133
+ state.probeFails = true;
134
+ expect(await locator.textContent()).toBe("result");
135
+ const error = new Error("application failure");
136
+ state.action = async () => { throw error; };
137
+ await expect(locator.hover()).rejects.toBe(error);
138
+ expect(events.every((event) => event.fields.targetNodeId === undefined)).toBe(true);
139
+ });
140
+
141
+ test("does not choose an arbitrary target for collection reads or disappearance waits", async () => {
142
+ const { locator, state, events } = executingRecorder();
143
+ await locator.count();
144
+ await locator.allTextContents();
145
+ await locator.allInnerTexts();
146
+ await locator.waitFor({ state: "hidden" });
147
+ await locator.waitFor({ state: "detached" });
148
+ expect(state.probes).toBe(0);
149
+ expect(events.every((event) => event.fields.targetNodeId === undefined)).toBe(true);
150
+ });