Skip to content

Commit 9262391

Browse files
authored
Add JS SDK conformance harness skeleton (#106)
* Add JS SDK conformance harness skeleton - Adds a dependency-free Node harness for TypeScript, Go, and external runtime targets with random ports, readiness polling, and captured logs. - Adds lifecycle smoke coverage that starts the TypeScript CLI and verifies GitHub rate-limit readiness. - Keeps SDK conformance isolated under tests/sdk-js so future official SDK suites can be added as dev-only test dependencies. * Fix SDK conformance harness wiring * Fix SDK conformance harness scheduling
1 parent a864d08 commit 9262391

5 files changed

Lines changed: 421 additions & 0 deletions

File tree

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ packages:
33
- "packages/@emulators/*"
44
- "apps/*"
55
- "examples/*"
6+
- "tests/*"

tests/sdk-js/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "@emulators/sdk-js-conformance",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"test": "node --test test/*.test.mjs"
7+
},
8+
"devDependencies": {
9+
"emulate": "workspace:*"
10+
}
11+
}

tests/sdk-js/src/harness.mjs

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
import { spawn } from "node:child_process";
2+
import { access, mkdtemp, rm } from "node:fs/promises";
3+
import net from "node:net";
4+
import os from "node:os";
5+
import path from "node:path";
6+
import { fileURLToPath } from "node:url";
7+
8+
const DEFAULT_HOST = "127.0.0.1";
9+
const DEFAULT_SERVICE = "github";
10+
const DEFAULT_READY_TIMEOUT_MS = 15_000;
11+
const DEFAULT_READY_INTERVAL_MS = 100;
12+
const DEFAULT_REQUEST_TIMEOUT_MS = 1_000;
13+
const DEFAULT_LOG_LIMIT_BYTES = 64 * 1024;
14+
15+
const harnessDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
16+
export const repoRoot = path.resolve(harnessDir, "../..");
17+
18+
export async function allocatePort(host = DEFAULT_HOST) {
19+
return new Promise((resolve, reject) => {
20+
const server = net.createServer();
21+
server.once("error", reject);
22+
server.listen(0, host, () => {
23+
const address = server.address();
24+
server.close((err) => {
25+
if (err) {
26+
reject(err);
27+
return;
28+
}
29+
if (!address || typeof address === "string") {
30+
reject(new Error("Port allocation did not return a TCP address"));
31+
return;
32+
}
33+
resolve(address.port);
34+
});
35+
});
36+
});
37+
}
38+
39+
export function selectRuntime(env = process.env) {
40+
if (env.EMULATE_SDK_RUNTIME) return env.EMULATE_SDK_RUNTIME;
41+
if (env.EMULATE_TARGET_URL) return "external";
42+
return "typescript";
43+
}
44+
45+
export async function startRuntime(options = {}) {
46+
const env = { ...process.env, ...options.env };
47+
const runtime = options.runtime ?? selectRuntime(env);
48+
if (runtime === "external") {
49+
const target = connectRuntime({
50+
readinessPath: options.readinessPath ?? env.EMULATE_SDK_READY_PATH,
51+
url: options.url ?? options.targetUrl ?? env.EMULATE_TARGET_URL,
52+
runtime,
53+
service: options.service,
54+
});
55+
if (target.readyUrl) {
56+
await waitForHttp(target.readyUrl, {
57+
timeoutMs: options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
58+
intervalMs: options.readyIntervalMs ?? DEFAULT_READY_INTERVAL_MS,
59+
requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
60+
});
61+
}
62+
return target;
63+
}
64+
65+
const service = options.service ?? DEFAULT_SERVICE;
66+
const host = options.host ?? DEFAULT_HOST;
67+
const port = options.port ?? (await allocatePort(host));
68+
const baseUrl = normalizeBaseUrl(options.baseUrl ?? `http://${host}:${port}`);
69+
const spec = await runtimeCommand({ ...options, baseUrl, env, host, port, runtime, service });
70+
const child = spawn(spec.command, spec.args, {
71+
cwd: spec.cwd,
72+
env: {
73+
...env,
74+
...options.processEnv,
75+
FORCE_COLOR: "0",
76+
NO_COLOR: "1",
77+
},
78+
stdio: ["ignore", "pipe", "pipe"],
79+
});
80+
const logs = captureLogs(child, options.logLimitBytes ?? DEFAULT_LOG_LIMIT_BYTES);
81+
const exit = waitForExit(child);
82+
let stopped = false;
83+
const stop = async () => {
84+
if (stopped) return;
85+
stopped = true;
86+
try {
87+
await stopChild(child, exit, options.stopTimeoutMs);
88+
} finally {
89+
await spec.cleanup?.();
90+
}
91+
};
92+
const readinessPath = options.readinessPath ?? env.EMULATE_SDK_READY_PATH ?? spec.readinessPath;
93+
const readyUrl = new URL(readinessPath, `${baseUrl}/`).toString();
94+
95+
try {
96+
await Promise.race([
97+
waitForHttp(readyUrl, {
98+
timeoutMs: options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
99+
intervalMs: options.readyIntervalMs ?? DEFAULT_READY_INTERVAL_MS,
100+
requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
101+
}),
102+
exit.then((result) => {
103+
throw runtimeExitError(spec.label, result);
104+
}),
105+
]);
106+
} catch (err) {
107+
await stop();
108+
throw withRuntimeLogs(err, spec.label, logs);
109+
}
110+
111+
return {
112+
baseUrl,
113+
child,
114+
logs,
115+
port,
116+
readyUrl,
117+
runtime,
118+
service,
119+
stop,
120+
};
121+
}
122+
123+
export function connectRuntime(options = {}) {
124+
const baseUrl = normalizeBaseUrl(options.url ?? options.targetUrl);
125+
if (!baseUrl) {
126+
throw new Error("External runtime selected but no target URL was provided");
127+
}
128+
return {
129+
baseUrl,
130+
child: null,
131+
logs: emptyLogs(),
132+
port: null,
133+
readyUrl: options.readinessPath ? new URL(options.readinessPath, `${baseUrl}/`).toString() : null,
134+
runtime: options.runtime ?? "external",
135+
service: options.service ?? DEFAULT_SERVICE,
136+
stop: async () => {},
137+
};
138+
}
139+
140+
export async function waitForHttp(url, options = {}) {
141+
const timeoutMs = options.timeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
142+
const intervalMs = options.intervalMs ?? DEFAULT_READY_INTERVAL_MS;
143+
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
144+
const acceptStatus = options.acceptStatus ?? ((status) => status >= 200 && status < 500);
145+
const deadline = Date.now() + timeoutMs;
146+
let lastError = null;
147+
148+
while (Date.now() <= deadline) {
149+
try {
150+
const result = await fetchWithTimeout(url, requestTimeoutMs);
151+
if (acceptStatus(result.status, result.body)) {
152+
return result;
153+
}
154+
lastError = new Error(`GET ${url} returned ${result.status}`);
155+
} catch (err) {
156+
lastError = err;
157+
}
158+
159+
const remaining = deadline - Date.now();
160+
if (remaining <= 0) break;
161+
await sleep(Math.min(intervalMs, remaining));
162+
}
163+
164+
const reason = lastError ? ` Last error: ${formatError(lastError)}` : "";
165+
throw new Error(`Timed out waiting for ${url}.${reason}`);
166+
}
167+
168+
async function runtimeCommand(options) {
169+
if (options.runtime === "typescript") return typeScriptCommand(options);
170+
if (options.runtime === "go") return goCommand(options);
171+
throw new Error(`Unsupported runtime: ${options.runtime}`);
172+
}
173+
174+
async function typeScriptCommand(options) {
175+
const cliPath =
176+
options.cliPath ?? options.env.EMULATE_TYPESCRIPT_CLI ?? path.join(repoRoot, "packages/emulate/dist/index.js");
177+
await assertExecutableFile(cliPath, "TypeScript CLI");
178+
const workingDirectory = await runtimeWorkingDirectory(options);
179+
return {
180+
args: [
181+
cliPath,
182+
"start",
183+
"--port",
184+
String(options.port),
185+
"--service",
186+
options.service,
187+
"--base-url",
188+
options.baseUrl,
189+
],
190+
command: process.execPath,
191+
cwd: workingDirectory.cwd,
192+
cleanup: workingDirectory.cleanup,
193+
label: "TypeScript runtime",
194+
readinessPath: "/rate_limit",
195+
};
196+
}
197+
198+
async function goCommand(options) {
199+
const binary = options.binary ?? options.env.EMULATE_GO_BINARY;
200+
if (!binary) {
201+
throw new Error("Go runtime selected but no binary was provided");
202+
}
203+
await assertExecutableFile(binary, "Go runtime binary");
204+
const workingDirectory = await runtimeWorkingDirectory(options);
205+
return {
206+
args: ["start", "--port", String(options.port), "--service", options.service, "--base-url", options.baseUrl],
207+
command: binary,
208+
cwd: workingDirectory.cwd,
209+
cleanup: workingDirectory.cleanup,
210+
label: "Go runtime",
211+
readinessPath: "/_emulate/health",
212+
};
213+
}
214+
215+
async function runtimeWorkingDirectory(options) {
216+
if (options.cwd) {
217+
return { cwd: options.cwd, cleanup: null };
218+
}
219+
const cwd = await mkdtemp(path.join(os.tmpdir(), "emulate-sdk-js-"));
220+
return {
221+
cwd,
222+
cleanup: async () => {
223+
await rm(cwd, { recursive: true, force: true });
224+
},
225+
};
226+
}
227+
228+
async function assertExecutableFile(filePath, label) {
229+
try {
230+
await access(filePath);
231+
} catch {
232+
throw new Error(`${label} not found at ${filePath}`);
233+
}
234+
}
235+
236+
function captureLogs(child, maxBytes) {
237+
const logs = {
238+
stderr: "",
239+
stdout: "",
240+
text() {
241+
return formatRuntimeLogs(logs);
242+
},
243+
};
244+
child.stdout?.on("data", (chunk) => {
245+
logs.stdout = appendBounded(logs.stdout, chunk, maxBytes);
246+
});
247+
child.stderr?.on("data", (chunk) => {
248+
logs.stderr = appendBounded(logs.stderr, chunk, maxBytes);
249+
});
250+
return logs;
251+
}
252+
253+
function emptyLogs() {
254+
return {
255+
stderr: "",
256+
stdout: "",
257+
text() {
258+
return formatRuntimeLogs(this);
259+
},
260+
};
261+
}
262+
263+
function appendBounded(current, chunk, maxBytes) {
264+
const next = current + Buffer.from(chunk).toString("utf8");
265+
if (Buffer.byteLength(next) <= maxBytes) return next;
266+
return Buffer.from(next).subarray(-maxBytes).toString("utf8");
267+
}
268+
269+
function formatRuntimeLogs(logs) {
270+
return [
271+
"stdout:",
272+
logs.stdout.trimEnd() || "(empty)",
273+
"",
274+
"stderr:",
275+
logs.stderr.trimEnd() || "(empty)",
276+
].join("\n");
277+
}
278+
279+
function waitForExit(child) {
280+
return new Promise((resolve) => {
281+
child.once("error", (error) => resolve({ error }));
282+
child.once("exit", (code, signal) => resolve({ code, signal }));
283+
});
284+
}
285+
286+
function runtimeExitError(label, result) {
287+
if (result.error) {
288+
return new Error(`${label} failed to start: ${formatError(result.error)}`);
289+
}
290+
return new Error(`${label} exited before it became ready with code ${result.code} and signal ${result.signal}`);
291+
}
292+
293+
async function stopChild(child, exit, timeoutMs = 5_000) {
294+
if (child.exitCode !== null || child.signalCode !== null) return;
295+
child.kill("SIGTERM");
296+
const result = await Promise.race([exit, sleep(timeoutMs).then(() => null)]);
297+
if (result) return;
298+
child.kill("SIGKILL");
299+
await exit;
300+
}
301+
302+
function withRuntimeLogs(err, label, logs) {
303+
const message = err instanceof Error ? err.message : String(err);
304+
return new Error(`${label} readiness failed: ${message}\n${logs.text()}`);
305+
}
306+
307+
async function fetchWithTimeout(url, timeoutMs) {
308+
const controller = new AbortController();
309+
const timer = setTimeout(() => controller.abort(), timeoutMs);
310+
try {
311+
const response = await fetch(url, { signal: controller.signal });
312+
return {
313+
status: response.status,
314+
body: await response.text(),
315+
};
316+
} finally {
317+
clearTimeout(timer);
318+
}
319+
}
320+
321+
function sleep(ms) {
322+
return new Promise((resolve) => {
323+
setTimeout(resolve, ms);
324+
});
325+
}
326+
327+
function normalizeBaseUrl(url) {
328+
if (!url) return "";
329+
return String(url).replace(/\/+$/, "");
330+
}
331+
332+
function formatError(err) {
333+
return err instanceof Error ? err.message : String(err);
334+
}

0 commit comments

Comments
 (0)