Files
openclaw/test/helpers/gateway-e2e-harness.ts
Peter Steinberger fe261b0f59 chore(tooling): typecheck root test/** with a dedicated tsgo lane (#104475)
* chore(types): add declaration files for scripts/lib and scripts/e2e modules

* chore(types): add declaration files for top-level script modules (a-m)

* chore(types): add declaration files for top-level script modules (n-z)

* test: use a non-secret-shaped gateway token fixture

* test: type ci workflow guard helpers for the root test lane

* chore(tooling): typecheck root test/** with a dedicated tsgo lane

- test/tsconfig/tsconfig.test.root.json: root-test program (strict unused checks,
  fixtures excluded; two Docker E2E clients that import built dist/** stay out,
  same rationale as the scripts/e2e exclusion in tsconfig.scripts.json)
- tsgo:test:root wired into tsgo:test, check:test-types, scripts/check.mjs, and
  the ci.yml test-types shard, mirroring the tsgo:scripts lane (#104348)
- changed-lane routing: test/**/*.ts (excluding fixtures) and the lane tsconfig
  now trigger 'typecheck test root' in check:changed; previously test/ paths ran
  lint only, so harness type errors surfaced first in CI (#104287 envDir case)
- burn down all 1071 latent type errors in the program: precise param/local
  types across test/scripts, test/vitest, test/e2e, and transitive scripts/e2e
  program members; 205 sibling .d.mts declaration files for imported .mjs
  modules (committed separately); zero any, zero ts-expect-error
- resolve the pre-existing testing star-export ambiguity in
  scripts/e2e/parallels/common.ts with an explicit re-export

Closes #104388

* chore(types): correct declaration fidelity per structured review

- re-derive 51 .d.mts files from implementation data flow instead of
  initializers: fix a wrong never return (runTestProjectsDelegation returns
  the child), add encoding-sensitive exec/spawn overloads (plain-gh), restore
  the full release profile union, make parsed paths string | null, add missing
  parseArgs fields via help/non-help unions, add a missing sibling declaration
  (budget-number-args), drop 15 unused lint directives
- precise install-record/tuple typing removes the type-aware oxlint
  regressions the first declarations caused in scripts/e2e implementations
- route .mts declaration edits under test/ to the testRoot lane and reference
  the test-root project from tsconfig.projects.json so tsgo:all covers it
  (closes both review findings against the lane wiring)

* chore(scripts): keep telegram runner dist typing structural for the boundary guard

* chore(types): declare runtime pack and gateway readiness exports added on main

* test: pin the importTargetPlan form of the plugin-contract plan import

The guard expectation still referenced the raw await import( form that
7ae5996bb3 (#103975) replaced with the importTargetPlan fallback helper;
the assertion fails on current main.
2026-07-11 06:15:41 -07:00

242 lines
7.1 KiB
TypeScript

// Gateway E2E harness starts test gateway processes and HTTP probes.
import { request as httpRequest } from "node:http";
import path from "node:path";
import { GatewayClient } from "../../src/gateway/client.js";
import { connectGatewayClient } from "../../src/gateway/test-helpers.e2e.js";
import { loadOrCreateDeviceIdentity } from "../../src/infra/device-identity.js";
import { extractFirstTextBlock } from "../../src/shared/chat-message-content.js";
import { sleep } from "../../src/utils.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../src/utils/message-channel.js";
import { createOpenClawTestInstance, type OpenClawTestInstance } from "./openclaw-test-instance.js";
export { extractFirstTextBlock };
export type GatewayInstance = OpenClawTestInstance;
const GATEWAY_CONNECT_STATUS_TIMEOUT_MS = 10_000;
const GATEWAY_NODE_STATUS_TIMEOUT_MS = 15_000;
const GATEWAY_NODE_STATUS_POLL_MS = 20;
const POST_JSON_TIMEOUT_MS = 15_000;
const POST_JSON_MAX_RESPONSE_BYTES = 1024 * 1024;
export type PostJsonOptions = {
maxResponseBytes?: number;
timeoutMs?: number;
};
export async function spawnGatewayInstance(name: string): Promise<GatewayInstance> {
const inst = await createOpenClawTestInstance({ name });
try {
await inst.startGateway();
return inst;
} catch (err) {
await inst.cleanup();
throw err;
}
}
export async function stopGatewayInstance(inst: GatewayInstance) {
await inst.cleanup();
}
export async function postJson(
url: string,
body: unknown,
headers?: Record<string, string>,
options: PostJsonOptions = {},
): Promise<{ status: number; json: unknown }> {
const payload = JSON.stringify(body);
const parsed = new URL(url);
const timeoutMs = options.timeoutMs ?? POST_JSON_TIMEOUT_MS;
const maxResponseBytes = options.maxResponseBytes ?? POST_JSON_MAX_RESPONSE_BYTES;
return await new Promise<{ status: number; json: unknown }>((resolve, reject) => {
let settled = false;
let responseBytes = 0;
let timeout: NodeJS.Timeout | undefined;
const finish = (result: { status: number; json: unknown } | { error: Error }) => {
if (settled) {
return;
}
settled = true;
if (timeout) {
clearTimeout(timeout);
timeout = undefined;
}
if ("error" in result) {
reject(result.error);
return;
}
resolve(result);
};
const req = httpRequest(
{
method: "POST",
hostname: parsed.hostname,
port: Number(parsed.port),
path: `${parsed.pathname}${parsed.search}`,
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
...headers,
},
},
(res) => {
let data = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
responseBytes += Buffer.byteLength(chunk, "utf8");
if (responseBytes > maxResponseBytes) {
const error = new Error(`POST ${url} response exceeded ${maxResponseBytes} bytes`);
req.destroy(error);
res.destroy(error);
finish({ error });
return;
}
data += chunk;
});
res.on("end", () => {
let json: unknown = null;
if (data.trim()) {
try {
json = JSON.parse(data);
} catch {
json = data;
}
}
finish({ status: res.statusCode ?? 0, json });
});
res.on("error", (error) => finish({ error }));
},
);
timeout = setTimeout(() => {
req.destroy(new Error(`POST ${url} timed out after ${timeoutMs}ms`));
}, timeoutMs);
timeout.unref?.();
req.on("error", (error) => finish({ error }));
req.write(payload);
req.end();
});
}
export async function connectNode(
inst: GatewayInstance,
label: string,
): Promise<{ client: GatewayClient; nodeId: string }> {
const identityPath = path.join(inst.homeDir, `${label}-device.json`);
const deviceIdentity = loadOrCreateDeviceIdentity(identityPath);
const nodeId = deviceIdentity.deviceId;
const client = await connectGatewayClient({
url: `ws://127.0.0.1:${inst.port}`,
token: inst.gatewayToken,
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName: label,
clientVersion: "1.0.0",
platform: "ios",
mode: GATEWAY_CLIENT_MODES.NODE,
role: "node",
scopes: [],
caps: ["system"],
commands: ["system.run"],
deviceIdentity,
timeoutMessage: `timeout waiting for ${label} to connect`,
});
return { client, nodeId };
}
async function connectStatusClient(
inst: GatewayInstance,
timeoutMs = GATEWAY_CONNECT_STATUS_TIMEOUT_MS,
): Promise<GatewayClient> {
let settled = false;
let timer: NodeJS.Timeout | null = null;
return await new Promise<GatewayClient>((resolve, reject) => {
const finish = (err?: Error) => {
if (settled) {
return;
}
settled = true;
if (timer) {
clearTimeout(timer);
}
if (err) {
reject(err);
return;
}
resolve(client);
};
const client = new GatewayClient({
url: `ws://127.0.0.1:${inst.port}`,
connectChallengeTimeoutMs: 0,
token: inst.gatewayToken,
clientName: GATEWAY_CLIENT_NAMES.CLI,
clientDisplayName: `status-${inst.name}`,
clientVersion: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.CLI,
onHelloOk: () => {
finish();
},
onConnectError: (err) => finish(err),
onClose: (code, reason) => {
finish(new Error(`gateway closed (${code}): ${reason}`));
},
});
timer = setTimeout(() => {
finish(new Error(`timeout waiting for status client hello for ${inst.name}`));
}, timeoutMs);
client.start();
});
}
export async function waitForNodeStatus(
inst: GatewayInstance,
nodeId: string,
timeoutMs = GATEWAY_NODE_STATUS_TIMEOUT_MS,
) {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
let client: GatewayClient | undefined;
while (Date.now() < deadline) {
try {
client = await connectStatusClient(
inst,
Math.min(2_000, GATEWAY_CONNECT_STATUS_TIMEOUT_MS, Math.max(1, deadline - Date.now())),
);
break;
} catch (error) {
lastError = error;
await sleep(GATEWAY_NODE_STATUS_POLL_MS);
}
}
if (!client) {
break;
}
try {
while (Date.now() < deadline) {
const list = (await client.request("node.list", {})) as {
nodes?: Array<{ nodeId: string; connected?: boolean; paired?: boolean }>;
};
const match = list.nodes?.find((n) => n.nodeId === nodeId);
if (match?.connected && match?.paired) {
return;
}
await sleep(GATEWAY_NODE_STATUS_POLL_MS);
}
} catch (error) {
lastError = error;
await sleep(GATEWAY_NODE_STATUS_POLL_MS);
} finally {
client.stop();
}
}
const suffix = lastError instanceof Error ? `: ${lastError.message}` : "";
throw new Error(`timeout waiting for node status for ${nodeId}${suffix}`);
}