114 lines
7.6 KiB
JavaScript
Executable File
114 lines
7.6 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
import { spawn } from "node:child_process";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { createServer } from "node:net";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
import { runWorkbenchCli } from "../tools/src/workbench-cli.ts";
|
|
|
|
const root = path.resolve(import.meta.dirname, "..");
|
|
const stateDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-workbench-native-"));
|
|
const children = [];
|
|
const logs = [];
|
|
const apiPort = await availablePort();
|
|
const webPort = await availablePort();
|
|
const apiUrl = `http://127.0.0.1:${apiPort}`;
|
|
const webUrl = `http://127.0.0.1:${webPort}/workbench`;
|
|
|
|
try {
|
|
const nativeEnv = {
|
|
...process.env,
|
|
WORKBENCH_MODE: "native-test",
|
|
WORKBENCH_NATIVE_STATE_FILE: path.join(stateDir, "state.json")
|
|
};
|
|
const api = start(["bun", "cmd/hwlab-workbench-api/main.ts"], {
|
|
...nativeEnv,
|
|
WORKBENCH_API_HOST: "127.0.0.1",
|
|
WORKBENCH_API_PORT: String(apiPort)
|
|
}, "api");
|
|
children.push(api);
|
|
await waitFor(`${apiUrl}/health/ready`);
|
|
if (!logs.some((item) => item.service === "api" && item.stream === "stderr" && item.line.includes('"code":"workbench_api_idle_timeout_defaulted"') && item.line.includes('"blocking":false'))) {
|
|
throw new Error("missing non-blocking idle timeout fallback warning");
|
|
}
|
|
|
|
const web = start(["bun", "run", "workbench:web:dev"], { ...process.env, WORKBENCH_WEB_PORT: String(webPort) }, "web");
|
|
children.push(web);
|
|
await waitFor(webUrl, { acceptHtml: true });
|
|
|
|
const commandHealth = await requiredJson(await fetch(`${apiUrl}/v1/workbench/commands`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ operation: "health" }) }), "command.health");
|
|
if (commandHealth.mode !== "native-test") throw new Error("native command transport did not expose mode=native-test");
|
|
|
|
const localSession = await runWorkbenchCli(["session", "create", "--actor-id", "usr_native", "--provider-profile", "native-test"], nativeEnv);
|
|
const localSessionId = localSession.identity?.sessionId;
|
|
if (localSession.transport !== "local" || !localSessionId) throw new Error("CLI local session transport contract failed");
|
|
const localTraceId = "trc_native_cli_local";
|
|
const localSubmit = await runWorkbenchCli(["turn", "submit", "--actor-id", "usr_native", "--session-id", localSessionId, "--trace-id", localTraceId, "--message", "native CLI local smoke"], nativeEnv);
|
|
if (localSubmit.transport !== "local" || localSubmit.data?.resultSynthesized !== false) throw new Error("CLI local submit authority contract failed");
|
|
const localCancel = await runWorkbenchCli(["turn", "cancel", "--actor-id", "usr_native", "--trace-id", localTraceId], nativeEnv);
|
|
if (localCancel.data?.status !== "cancel_requested") throw new Error("CLI local cancel was not accepted");
|
|
|
|
const overApiArgs = ["--over-api", "--api-url", apiUrl];
|
|
const apiHealth = await runWorkbenchCli(["health", ...overApiArgs], nativeEnv);
|
|
if (apiHealth.transport !== "api" || apiHealth.httpStatus !== 200) throw new Error("CLI --over-api health transport contract failed");
|
|
const apiSession = await runWorkbenchCli(["session", "create", "--actor-id", "usr_native", "--provider-profile", "native-test", ...overApiArgs], nativeEnv);
|
|
const apiSessionId = apiSession.identity?.sessionId;
|
|
if (apiSession.transport !== "api" || !apiSessionId) throw new Error("CLI --over-api session transport contract failed");
|
|
const apiTraceId = "trc_native_cli_over_api";
|
|
const apiSubmit = await runWorkbenchCli(["turn", "submit", "--actor-id", "usr_native", "--session-id", apiSessionId, "--trace-id", apiTraceId, "--message", "native CLI over API smoke", ...overApiArgs], nativeEnv);
|
|
if (apiSubmit.transport !== "api" || apiSubmit.data?.resultSynthesized !== false) throw new Error("CLI --over-api submit authority contract failed");
|
|
const apiCancel = await runWorkbenchCli(["turn", "cancel", "--actor-id", "usr_native", "--trace-id", apiTraceId, ...overApiArgs], nativeEnv);
|
|
if (apiCancel.data?.status !== "cancel_requested") throw new Error("CLI --over-api cancel was not accepted");
|
|
|
|
const actorHeaders = { "content-type": "application/json", cookie: "hwlab_session=native-test" };
|
|
const sessionResponse = await fetch(`${apiUrl}/v1/agent/sessions`, { method: "POST", headers: actorHeaders, body: JSON.stringify({ providerProfile: "native-test" }) });
|
|
const session = await requiredJson(sessionResponse, "session.create");
|
|
const sessionId = session.session?.sessionId;
|
|
if (!sessionId) throw new Error("native session.create returned no sessionId");
|
|
|
|
const traceId = "trc_native_smoke";
|
|
const submit = await requiredJson(await fetch(`${apiUrl}/v1/agent/chat`, { method: "POST", headers: actorHeaders, body: JSON.stringify({ sessionId, traceId, message: "native smoke" }) }), "turn.submit");
|
|
if (submit.orchestrationMode !== "native-test" || submit.resultSynthesized !== false) throw new Error("native submit mode/authority contract failed");
|
|
const cancel = await requiredJson(await fetch(`${apiUrl}/v1/agent/chat/cancel`, { method: "POST", headers: actorHeaders, body: JSON.stringify({ sessionId, traceId }) }), "turn.cancel");
|
|
if (cancel.status !== "cancel_requested") throw new Error("native cancel was not accepted");
|
|
|
|
process.stdout.write(`${JSON.stringify({ ok: true, mode: "native-test", probeEndpoints: { api: apiUrl, web: webUrl }, publicEndpoints: null, exposureAuthority: "owning-yaml-service-launcher", sessionId, traceId, cli: { local: "application-dispatcher", overApi: "POST /v1/workbench/commands" }, services: { api: "independent", web: "vite-hmr" }, terminalAuthority: "fixture-only", resultSynthesized: false })}\n`);
|
|
} catch (error) {
|
|
process.stderr.write(`${JSON.stringify({ ok: false, error: { code: "workbench_native_smoke_failed", message: error instanceof Error ? error.message : String(error) }, logs: logs.slice(-20) })}\n`);
|
|
process.exitCode = 1;
|
|
} finally {
|
|
for (const child of children.reverse()) child.kill("SIGTERM");
|
|
await Promise.all(children.map((child) => child.exitCode !== null || child.signalCode !== null ? Promise.resolve() : new Promise((resolve) => child.once("exit", resolve))));
|
|
await rm(stateDir, { recursive: true, force: true });
|
|
}
|
|
|
|
function start(command, env, service) {
|
|
const child = spawn(command[0], command.slice(1), { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
child.stdout.on("data", (chunk) => append(service, "stdout", chunk));
|
|
child.stderr.on("data", (chunk) => append(service, "stderr", chunk));
|
|
return child;
|
|
}
|
|
function append(service, stream, chunk) { for (const line of String(chunk).split("\n").filter(Boolean)) logs.push({ service, stream, line: line.slice(0, 500) }); if (logs.length > 100) logs.splice(0, logs.length - 100); }
|
|
async function waitFor(url, options = {}) {
|
|
const deadline = Date.now() + 30_000;
|
|
while (Date.now() < deadline) {
|
|
const response = await fetch(url).catch(() => null);
|
|
if (response?.ok && (!options.acceptHtml || String(response.headers.get("content-type")).includes("text/html"))) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
}
|
|
throw new Error(`timed out waiting for ${url}`);
|
|
}
|
|
async function requiredJson(response, operation) { const body = await response.json().catch(() => null); if (!response.ok || !body) throw new Error(`${operation} failed with HTTP ${response.status}`); return body; }
|
|
async function availablePort() {
|
|
return await new Promise((resolve, reject) => {
|
|
const server = createServer();
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
const port = typeof address === "object" && address ? address.port : 0;
|
|
server.close((error) => error ? reject(error) : resolve(port));
|
|
});
|
|
});
|
|
}
|