Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
1129
scripts/dev/discord-acp-plain-language-smoke.ts
Normal file
1129
scripts/dev/discord-acp-plain-language-smoke.ts
Normal file
File diff suppressed because it is too large
Load Diff
241
scripts/dev/gateway-smoke.ts
Normal file
241
scripts/dev/gateway-smoke.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
// Gateway Smoke script supports OpenClaw repository automation.
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
MIN_CLIENT_PROTOCOL_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
} from "../../packages/gateway-protocol/src/version.js";
|
||||
import { createArgReader, createGatewayWsClient, resolveGatewayUrl } from "./gateway-ws-client.ts";
|
||||
|
||||
function writeStdoutLine(message: string): void {
|
||||
process.stdout.write(`${message}\n`);
|
||||
}
|
||||
|
||||
function writeStderrLine(message: string): void {
|
||||
process.stderr.write(`${message}\n`);
|
||||
}
|
||||
|
||||
function writeUsage(): void {
|
||||
writeStderrLine(usage());
|
||||
}
|
||||
|
||||
type GatewaySmokeClient = ReturnType<typeof createGatewayWsClient>;
|
||||
type GatewaySmokeCliOptions = {
|
||||
help: boolean;
|
||||
token?: string;
|
||||
urlRaw?: string;
|
||||
};
|
||||
|
||||
type GatewaySmokeDeps = {
|
||||
createClient?: typeof createGatewayWsClient;
|
||||
stderr?: (message: string) => void;
|
||||
stdout?: (message: string) => void;
|
||||
};
|
||||
|
||||
class GatewaySmokeArgError extends Error {}
|
||||
|
||||
const BOOLEAN_FLAGS = new Set(["--help", "-h"]);
|
||||
const VALUE_FLAGS = new Set(["--url", "--token"]);
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
"Usage: bun scripts/dev/gateway-smoke.ts --url <wss://host[:port]> --token <gateway.auth.token>",
|
||||
"Or set env: OPENCLAW_GATEWAY_URL / OPENCLAW_GATEWAY_TOKEN",
|
||||
"",
|
||||
"Options:",
|
||||
" --url <url> Gateway websocket URL",
|
||||
" --token <token> Gateway auth token",
|
||||
" -h, --help Show this help",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function validateArgs(argv: readonly string[]): void {
|
||||
const seen = new Set<string>();
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index] ?? "";
|
||||
if (BOOLEAN_FLAGS.has(arg)) {
|
||||
if (seen.has(arg)) {
|
||||
throw new GatewaySmokeArgError(`${arg} was provided more than once`);
|
||||
}
|
||||
seen.add(arg);
|
||||
continue;
|
||||
}
|
||||
if (VALUE_FLAGS.has(arg)) {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("-")) {
|
||||
throw new GatewaySmokeArgError(`${arg} requires a value`);
|
||||
}
|
||||
if (seen.has(arg)) {
|
||||
throw new GatewaySmokeArgError(`${arg} was provided more than once`);
|
||||
}
|
||||
seen.add(arg);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
throw new GatewaySmokeArgError(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseGatewaySmokeCli(
|
||||
argv = process.argv.slice(2),
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): GatewaySmokeCliOptions {
|
||||
validateArgs(argv);
|
||||
const { get: getArg, has } = createArgReader([...argv]);
|
||||
return {
|
||||
help: has("--help") || has("-h"),
|
||||
token: getArg("--token") ?? env.OPENCLAW_GATEWAY_TOKEN,
|
||||
urlRaw: getArg("--url") ?? env.OPENCLAW_GATEWAY_URL,
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasHealthSummaryPayload(response: unknown): boolean {
|
||||
if (!isRecord(response) || !isRecord(response.payload)) {
|
||||
return false;
|
||||
}
|
||||
const { payload } = response;
|
||||
return (
|
||||
payload.ok === true &&
|
||||
typeof payload.ts === "number" &&
|
||||
typeof payload.durationMs === "number" &&
|
||||
typeof payload.defaultAgentId === "string" &&
|
||||
payload.defaultAgentId.trim() !== "" &&
|
||||
Array.isArray(payload.agents) &&
|
||||
isRecord(payload.channels) &&
|
||||
Array.isArray(payload.channelOrder) &&
|
||||
isRecord(payload.sessions)
|
||||
);
|
||||
}
|
||||
|
||||
function hasStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function connectHelloScopes(response: unknown): string[] | null {
|
||||
if (!isRecord(response) || !isRecord(response.payload)) {
|
||||
return null;
|
||||
}
|
||||
const { payload } = response;
|
||||
if (
|
||||
payload.type !== "hello-ok" ||
|
||||
typeof payload.protocol !== "number" ||
|
||||
!isRecord(payload.features) ||
|
||||
!hasStringArray(payload.features.methods) ||
|
||||
!payload.features.methods.includes("health") ||
|
||||
!isRecord(payload.auth) ||
|
||||
payload.auth.role !== "operator" ||
|
||||
!hasStringArray(payload.auth.scopes)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return payload.auth.scopes;
|
||||
}
|
||||
|
||||
function hasConnectHelloPayload(response: unknown): boolean {
|
||||
return connectHelloScopes(response) !== null;
|
||||
}
|
||||
|
||||
function hasUnpairedOperatorScopes(response: unknown): boolean {
|
||||
const scopes = connectHelloScopes(response);
|
||||
if (!scopes) {
|
||||
return false;
|
||||
}
|
||||
return scopes.length > 0;
|
||||
}
|
||||
|
||||
export async function runGatewaySmoke(
|
||||
input: { token: string; urlRaw: string },
|
||||
deps: GatewaySmokeDeps = {},
|
||||
): Promise<number> {
|
||||
const url = resolveGatewayUrl(input.urlRaw);
|
||||
const createClient = deps.createClient ?? createGatewayWsClient;
|
||||
const stderr = deps.stderr ?? writeStderrLine;
|
||||
const stdout = deps.stdout ?? writeStdoutLine;
|
||||
const client: GatewaySmokeClient = createClient({
|
||||
url: url.toString(),
|
||||
onEvent: (evt) => {
|
||||
// Ignore noisy connect handshakes.
|
||||
void evt;
|
||||
},
|
||||
});
|
||||
const { request, waitOpen, close } = client;
|
||||
|
||||
try {
|
||||
await waitOpen();
|
||||
|
||||
// Match iOS "operator" session defaults: token auth, no device identity.
|
||||
const connectRes = await request("connect", {
|
||||
minProtocol: MIN_CLIENT_PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: "openclaw-ios",
|
||||
displayName: "openclaw gateway smoke test",
|
||||
version: "dev",
|
||||
platform: "dev",
|
||||
mode: "ui",
|
||||
instanceId: "openclaw-dev-smoke",
|
||||
},
|
||||
locale: "en-US",
|
||||
userAgent: "gateway-smoke",
|
||||
role: "operator",
|
||||
scopes: ["operator.read", "operator.write", "operator.admin"],
|
||||
caps: [],
|
||||
auth: { token: input.token },
|
||||
});
|
||||
|
||||
if (!connectRes.ok) {
|
||||
stderr(`connect failed: ${String(connectRes.error)}`);
|
||||
return 2;
|
||||
}
|
||||
if (!hasConnectHelloPayload(connectRes)) {
|
||||
stderr("connect failed: missing hello-ok payload");
|
||||
return 2;
|
||||
}
|
||||
if (hasUnpairedOperatorScopes(connectRes)) {
|
||||
stderr("connect failed: unpaired iOS smoke unexpectedly received operator scopes");
|
||||
return 2;
|
||||
}
|
||||
|
||||
const healthRes = await request("health");
|
||||
if (!healthRes.ok) {
|
||||
stderr(`health failed: ${String(healthRes.error)}`);
|
||||
return 3;
|
||||
}
|
||||
if (!hasHealthSummaryPayload(healthRes)) {
|
||||
stderr("health failed: missing health summary payload");
|
||||
return 3;
|
||||
}
|
||||
|
||||
stdout("ok: connected + health");
|
||||
return 0;
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
let cli: GatewaySmokeCliOptions;
|
||||
try {
|
||||
cli = parseGatewaySmokeCli();
|
||||
} catch (error) {
|
||||
writeStderrLine(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (cli.help) {
|
||||
writeStdoutLine(usage());
|
||||
} else if (!cli.urlRaw || !cli.token) {
|
||||
writeUsage();
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
process.exitCode = await runGatewaySmoke({ token: cli.token, urlRaw: cli.urlRaw });
|
||||
}
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
parseGatewaySmokeCli,
|
||||
usage,
|
||||
};
|
||||
2
scripts/dev/gateway-ws-client.ts
Normal file
2
scripts/dev/gateway-ws-client.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Gateway Ws Client script supports OpenClaw repository automation.
|
||||
export * from "../lib/gateway-ws-client.ts";
|
||||
406
scripts/dev/ios-node-e2e.ts
Normal file
406
scripts/dev/ios-node-e2e.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
// Ios Node E2E script supports OpenClaw repository automation.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
MIN_CLIENT_PROTOCOL_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
} from "../../packages/gateway-protocol/src/version.js";
|
||||
|
||||
function writeStdoutLine(message = ""): void {
|
||||
process.stdout.write(`${message}\n`);
|
||||
}
|
||||
|
||||
function writeStdoutJson(value: unknown): void {
|
||||
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function writeStderrLine(message: string): void {
|
||||
process.stderr.write(`${message}\n`);
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
"Usage: bun scripts/dev/ios-node-e2e.ts --url <wss://host[:port]> --token <gateway.auth.token> [options]",
|
||||
"Or set env: OPENCLAW_GATEWAY_URL / OPENCLAW_GATEWAY_TOKEN",
|
||||
"",
|
||||
"Options:",
|
||||
" --node <id|name-substring> Select a connected iOS node",
|
||||
" --wait-seconds <n> Seconds to wait for an iOS node (default: 25)",
|
||||
" --dangerous Include camera/screen commands",
|
||||
" --json Print JSON results",
|
||||
" -h, --help Show this help",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getArg = (flag: string) => {
|
||||
const index = argv.indexOf(flag);
|
||||
return index === -1 ? undefined : argv[index + 1];
|
||||
};
|
||||
const hasFlag = (flag: string) => argv.includes(flag);
|
||||
const BOOLEAN_FLAGS = new Set(["--dangerous", "--help", "-h", "--json"]);
|
||||
const VALUE_FLAGS = new Set(["--node", "--token", "--url", "--wait-seconds"]);
|
||||
|
||||
function isMissingOptionValue(value: string | undefined): boolean {
|
||||
return !value || BOOLEAN_FLAGS.has(value) || VALUE_FLAGS.has(value) || value.startsWith("--");
|
||||
}
|
||||
|
||||
function failCli(message: string): never {
|
||||
writeStderrLine(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function validateArgs(): void {
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index] ?? "";
|
||||
if (BOOLEAN_FLAGS.has(arg)) {
|
||||
continue;
|
||||
}
|
||||
if (VALUE_FLAGS.has(arg)) {
|
||||
const value = argv[index + 1];
|
||||
if (isMissingOptionValue(value)) {
|
||||
failCli(`${arg} requires a value`);
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
failCli(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
validateArgs();
|
||||
if (hasFlag("--help") || hasFlag("-h")) {
|
||||
writeStdoutLine(usage());
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
type NodeListPayload = {
|
||||
ts?: number;
|
||||
nodes?: Array<{
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
connected?: boolean;
|
||||
paired?: boolean;
|
||||
commands?: string[];
|
||||
permissions?: unknown;
|
||||
}>;
|
||||
};
|
||||
|
||||
type NodeListNode = NonNullable<NodeListPayload["nodes"]>[number];
|
||||
|
||||
const urlRaw = getArg("--url") ?? process.env.OPENCLAW_GATEWAY_URL;
|
||||
const token = getArg("--token") ?? process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
const nodeHint = getArg("--node");
|
||||
const dangerous = hasFlag("--dangerous") || process.env.OPENCLAW_RUN_DANGEROUS === "1";
|
||||
const jsonOut = hasFlag("--json");
|
||||
|
||||
if (!urlRaw || !token) {
|
||||
writeStderrLine(usage());
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const waitSeconds = parseWaitSeconds(getArg("--wait-seconds"));
|
||||
const { createGatewayWsClient, resolveGatewayUrl } = await import("./gateway-ws-client.ts");
|
||||
const url = resolveGatewayUrl(urlRaw);
|
||||
|
||||
const isoNow = () => new Date().toISOString();
|
||||
const isoMinusMs = (ms: number) => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
type TestCase = {
|
||||
id: string;
|
||||
command: string;
|
||||
params?: unknown;
|
||||
timeoutMs?: number;
|
||||
dangerous?: boolean;
|
||||
};
|
||||
|
||||
function formatErr(err: unknown): string {
|
||||
if (!err) {
|
||||
return "error";
|
||||
}
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return err.message || String(err);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(err);
|
||||
} catch {
|
||||
return Object.prototype.toString.call(err);
|
||||
}
|
||||
}
|
||||
|
||||
function parseWaitSeconds(raw: string | undefined): number {
|
||||
const value = raw ?? "25";
|
||||
const text = value.trim();
|
||||
if (!/^[1-9]\d*$/u.test(text)) {
|
||||
writeStderrLine(`--wait-seconds must be a positive integer; got: ${value}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const parsed = Number(text);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
writeStderrLine(`--wait-seconds must be a safe positive integer; got: ${value}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function payloadShapeError(command: string, payload: unknown): string | null {
|
||||
if (payload == null) {
|
||||
return `${command} returned no payload`;
|
||||
}
|
||||
if (Array.isArray(payload)) {
|
||||
return `${command} returned an array payload`;
|
||||
}
|
||||
if (!isRecord(payload)) {
|
||||
return `${command} returned a ${typeof payload} payload`;
|
||||
}
|
||||
if (Object.keys(payload).length === 0) {
|
||||
return `${command} returned an empty object payload`;
|
||||
}
|
||||
if (command === "device.info") {
|
||||
const hasSystemName =
|
||||
typeof payload.systemName === "string" && payload.systemName.trim().length > 0;
|
||||
const hasSystemVersion =
|
||||
typeof payload.systemVersion === "string" && payload.systemVersion.trim().length > 0;
|
||||
if (!hasSystemName || !hasSystemVersion) {
|
||||
return "device.info payload missing systemName/systemVersion";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function commandPayloadFromInvokePayload(payload: unknown): unknown {
|
||||
if (!isRecord(payload)) {
|
||||
return payload;
|
||||
}
|
||||
if (typeof payload.payloadJSON === "string") {
|
||||
try {
|
||||
return JSON.parse(payload.payloadJSON);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if ("payload" in payload && ("ok" in payload || "nodeId" in payload || "command" in payload)) {
|
||||
return commandPayloadFromInvokePayload(payload.payload);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function pickIosNode(list: NodeListPayload, hint?: string): NodeListNode | null {
|
||||
const nodes = (list.nodes ?? []).filter((n) => n && n.connected);
|
||||
const ios = nodes.filter((n) => (n.platform ?? "").toLowerCase().includes("ios"));
|
||||
if (ios.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!hint) {
|
||||
return ios[0] ?? null;
|
||||
}
|
||||
const h = hint.toLowerCase();
|
||||
return (
|
||||
ios.find((n) => n.nodeId.toLowerCase() === h) ??
|
||||
ios.find((n) => (n.displayName ?? "").toLowerCase().includes(h)) ??
|
||||
ios.find((n) => n.nodeId.toLowerCase().includes(h)) ??
|
||||
ios[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { request, waitOpen, close } = createGatewayWsClient({ url: url.toString() });
|
||||
await waitOpen();
|
||||
|
||||
const connectRes = await request("connect", {
|
||||
minProtocol: MIN_CLIENT_PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: "cli",
|
||||
displayName: "openclaw ios node e2e",
|
||||
version: "dev",
|
||||
platform: "dev",
|
||||
mode: "cli",
|
||||
instanceId: "openclaw-dev-ios-node-e2e",
|
||||
},
|
||||
locale: "en-US",
|
||||
userAgent: "ios-node-e2e",
|
||||
role: "operator",
|
||||
scopes: ["operator.read", "operator.write", "operator.admin"],
|
||||
caps: [],
|
||||
auth: { token },
|
||||
});
|
||||
|
||||
if (!connectRes.ok) {
|
||||
writeStderrLine(`connect failed: ${String(connectRes.error)}`);
|
||||
close();
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const healthRes = await request("health");
|
||||
if (!healthRes.ok) {
|
||||
writeStderrLine(`health failed: ${String(healthRes.error)}`);
|
||||
close();
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
const nodesRes = await request("node.list");
|
||||
if (!nodesRes.ok) {
|
||||
writeStderrLine(`node.list failed: ${String(nodesRes.error)}`);
|
||||
close();
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
const listPayload = (nodesRes.payload ?? {}) as NodeListPayload;
|
||||
let node = pickIosNode(listPayload, nodeHint);
|
||||
if (!node) {
|
||||
const deadline = Date.now() + Math.max(1, waitSeconds) * 1000;
|
||||
while (!node && Date.now() < deadline) {
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, 1000);
|
||||
});
|
||||
const res = await request("node.list").catch(() => null);
|
||||
if (!res?.ok) {
|
||||
continue;
|
||||
}
|
||||
node = pickIosNode((res.payload ?? {}) as NodeListPayload, nodeHint);
|
||||
}
|
||||
}
|
||||
if (!node) {
|
||||
writeStderrLine("No connected iOS nodes found. (Is the iOS app connected to the gateway?)");
|
||||
close();
|
||||
process.exit(5);
|
||||
}
|
||||
|
||||
const tests: TestCase[] = [
|
||||
{ id: "device.info", command: "device.info" },
|
||||
{ id: "device.status", command: "device.status" },
|
||||
{
|
||||
id: "system.notify",
|
||||
command: "system.notify",
|
||||
params: { title: "OpenClaw E2E", body: `ios-node-e2e @ ${isoNow()}`, delivery: "system" },
|
||||
},
|
||||
{
|
||||
id: "contacts.search",
|
||||
command: "contacts.search",
|
||||
params: { query: null, limit: 5 },
|
||||
},
|
||||
{
|
||||
id: "calendar.events",
|
||||
command: "calendar.events",
|
||||
params: { startISO: isoMinusMs(6 * 60 * 60 * 1000), endISO: isoNow(), limit: 10 },
|
||||
},
|
||||
{
|
||||
id: "reminders.list",
|
||||
command: "reminders.list",
|
||||
params: { status: "incomplete", limit: 10 },
|
||||
},
|
||||
{
|
||||
id: "motion.pedometer",
|
||||
command: "motion.pedometer",
|
||||
params: { startISO: isoMinusMs(60 * 60 * 1000), endISO: isoNow() },
|
||||
},
|
||||
{
|
||||
id: "photos.latest",
|
||||
command: "photos.latest",
|
||||
params: { limit: 1, maxWidth: 512, quality: 0.7 },
|
||||
},
|
||||
{
|
||||
id: "camera.snap",
|
||||
command: "camera.snap",
|
||||
params: { facing: "back", maxWidth: 768, quality: 0.7, format: "jpeg" },
|
||||
dangerous: true,
|
||||
timeoutMs: 20_000,
|
||||
},
|
||||
{
|
||||
id: "screen.record",
|
||||
command: "screen.record",
|
||||
params: { durationMs: 2_000, fps: 15, includeAudio: false },
|
||||
dangerous: true,
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
];
|
||||
|
||||
const run = tests.filter((t) => dangerous || !t.dangerous);
|
||||
|
||||
const results: Array<{
|
||||
id: string;
|
||||
ok: boolean;
|
||||
error?: unknown;
|
||||
payload?: unknown;
|
||||
}> = [];
|
||||
|
||||
for (const t of run) {
|
||||
const invokeRes = await request(
|
||||
"node.invoke",
|
||||
{
|
||||
nodeId: node.nodeId,
|
||||
command: t.command,
|
||||
params: t.params,
|
||||
timeoutMs: t.timeoutMs ?? 12_000,
|
||||
idempotencyKey: randomUUID(),
|
||||
},
|
||||
(t.timeoutMs ?? 12_000) + 2_000,
|
||||
).catch((err: unknown) => {
|
||||
results.push({ id: t.id, ok: false, error: formatErr(err) });
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!invokeRes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!invokeRes.ok) {
|
||||
results.push({ id: t.id, ok: false, error: invokeRes.error });
|
||||
continue;
|
||||
}
|
||||
|
||||
const commandPayload = commandPayloadFromInvokePayload(invokeRes.payload);
|
||||
const payloadError = payloadShapeError(t.command, commandPayload);
|
||||
if (payloadError) {
|
||||
results.push({ id: t.id, ok: false, error: payloadError, payload: invokeRes.payload });
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({ id: t.id, ok: true, payload: invokeRes.payload });
|
||||
}
|
||||
|
||||
if (jsonOut) {
|
||||
writeStdoutJson({
|
||||
gateway: url.toString(),
|
||||
node: {
|
||||
nodeId: node.nodeId,
|
||||
displayName: node.displayName,
|
||||
platform: node.platform,
|
||||
},
|
||||
dangerous,
|
||||
results,
|
||||
});
|
||||
} else {
|
||||
const pad = (s: string, n: number) => (s.length >= n ? s : s + " ".repeat(n - s.length));
|
||||
const rows = results.map((r) => ({
|
||||
cmd: r.id,
|
||||
ok: r.ok ? "ok" : "fail",
|
||||
note: r.ok ? "" : formatErr(r.error ?? "error"),
|
||||
}));
|
||||
const width = Math.min(64, Math.max(12, ...rows.map((r) => r.cmd.length)));
|
||||
writeStdoutLine(`node: ${node.displayName ?? node.nodeId} (${node.platform ?? "unknown"})`);
|
||||
writeStdoutLine(`dangerous: ${dangerous ? "on" : "off"}`);
|
||||
writeStdoutLine();
|
||||
for (const r of rows) {
|
||||
writeStdoutLine(`${pad(r.cmd, width)} ${pad(r.ok, 4)} ${r.note}`);
|
||||
}
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
close();
|
||||
|
||||
if (failed.length > 0) {
|
||||
process.exit(10);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
36
scripts/dev/ios-pull-gateway-log.sh
Executable file
36
scripts/dev/ios-pull-gateway-log.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 <device-udid> <bundle-id> [dest]" >&2
|
||||
echo " OPENCLAW_IOS_DEVICE_UDID=... OPENCLAW_IOS_BUNDLE_ID=... $0" >&2
|
||||
}
|
||||
|
||||
DEVICE_UDID="${1:-${OPENCLAW_IOS_DEVICE_UDID:-}}"
|
||||
BUNDLE_ID="${2:-${OPENCLAW_IOS_BUNDLE_ID:-}}"
|
||||
DEST="${3:-${OPENCLAW_IOS_GATEWAY_LOG_DEST:-}}"
|
||||
|
||||
if [[ -z "$DEVICE_UDID" || -z "$BUNDLE_ID" ]]; then
|
||||
usage
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -z "$DEST" ]]; then
|
||||
dest_dir="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-ios-gateway.XXXXXX")"
|
||||
DEST="$dest_dir/openclaw-gateway.log"
|
||||
fi
|
||||
|
||||
xcrun devicectl device copy from \
|
||||
--device "$DEVICE_UDID" \
|
||||
--domain-type appDataContainer \
|
||||
--domain-identifier "$BUNDLE_ID" \
|
||||
--source Documents/openclaw-gateway.log \
|
||||
--destination "$DEST" >/dev/null
|
||||
|
||||
if [[ ! -s "$DEST" ]]; then
|
||||
echo "Gateway log pull produced an empty file: $DEST" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Pulled to: $DEST"
|
||||
tail -n 200 "$DEST"
|
||||
852
scripts/dev/realtime-talk-live-smoke.ts
Normal file
852
scripts/dev/realtime-talk-live-smoke.ts
Normal file
@@ -0,0 +1,852 @@
|
||||
// Realtime Talk Live Smoke script supports OpenClaw repository automation.
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { readBoundedResponseText } from "../lib/bounded-response.ts";
|
||||
import {
|
||||
parseStrictIntegerOption,
|
||||
previewForDevToolLog,
|
||||
redactJsonValueForDevToolLog,
|
||||
} from "../lib/dev-tooling-safety.ts";
|
||||
|
||||
const OPENAI_REALTIME_MODEL =
|
||||
process.env.OPENCLAW_REALTIME_OPENAI_MODEL?.trim() || "gpt-realtime-2";
|
||||
const OPENAI_REALTIME_VOICE = process.env.OPENCLAW_REALTIME_OPENAI_VOICE?.trim() || "alloy";
|
||||
const DEFAULT_OPENAI_HTTP_TIMEOUT_MS = 30_000;
|
||||
const OPENAI_HTTP_RESPONSE_MAX_BYTES = 256 * 1024;
|
||||
const GOOGLE_REALTIME_MODEL =
|
||||
process.env.OPENCLAW_REALTIME_GOOGLE_MODEL?.trim() ||
|
||||
"gemini-2.5-flash-native-audio-preview-12-2025";
|
||||
const GOOGLE_REALTIME_VOICE = process.env.OPENCLAW_REALTIME_GOOGLE_VOICE?.trim() || "Kore";
|
||||
const GOOGLE_LIVE_WS_URL =
|
||||
"wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContentConstrained";
|
||||
|
||||
type RealtimeSmokeCliOptions = {
|
||||
help: boolean;
|
||||
};
|
||||
|
||||
// Keep live stacks behind their owning smoke paths so help and safety helpers stay lightweight.
|
||||
type Browser = import("playwright").Browser;
|
||||
type ViteDevServer = Awaited<ReturnType<(typeof import("vite"))["createServer"]>>;
|
||||
|
||||
type SmokeResult = {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type TimeoutOptions<T> = {
|
||||
label: string;
|
||||
timeoutMs: number;
|
||||
run: (signal: AbortSignal) => Promise<T>;
|
||||
};
|
||||
|
||||
type OpenAIHttpOptions = {
|
||||
fetchImpl?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type OpenAIRealtimeBrowserResponseReader = (
|
||||
response: Response,
|
||||
label: string,
|
||||
maxBytes: number,
|
||||
) => Promise<string>;
|
||||
|
||||
type OpenAIWebRtcSmokeGlobal = typeof globalThis & {
|
||||
openclawReadBoundedRealtimeResponseText?: OpenAIRealtimeBrowserResponseReader;
|
||||
};
|
||||
|
||||
class CliArgumentError extends Error {
|
||||
override name = "CliArgumentError";
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
"Usage: node --import tsx scripts/dev/realtime-talk-live-smoke.ts [options]",
|
||||
"",
|
||||
"Options:",
|
||||
" -h, --help Show this help",
|
||||
"",
|
||||
"Environment:",
|
||||
" OPENAI_API_KEY",
|
||||
" GEMINI_API_KEY or GOOGLE_API_KEY",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseRealtimeSmokeArgs(argv = process.argv.slice(2)): RealtimeSmokeCliOptions {
|
||||
for (const arg of argv) {
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
continue;
|
||||
}
|
||||
throw new CliArgumentError(`Unknown argument: ${arg}`);
|
||||
}
|
||||
return { help: argv.includes("--help") || argv.includes("-h") };
|
||||
}
|
||||
|
||||
function getEnv(name: string): string | undefined {
|
||||
const value = process.env[name]?.trim();
|
||||
return value ? value : undefined;
|
||||
}
|
||||
|
||||
function shortError(error: unknown): string {
|
||||
return previewForDevToolLog(error instanceof Error ? error.message : String(error), 800);
|
||||
}
|
||||
|
||||
async function readBoundedText(
|
||||
response: Response,
|
||||
label: string,
|
||||
maxBytes = OPENAI_HTTP_RESPONSE_MAX_BYTES,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
return await readBoundedResponseText(response, label, maxBytes, {
|
||||
createTooLargeError: (message) => new Error(message),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async function readBoundedJsonResponse(
|
||||
response: Response,
|
||||
label: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const text = await readBoundedText(response, label, OPENAI_HTTP_RESPONSE_MAX_BYTES, signal);
|
||||
return JSON.parse(text) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function resolveOpenAIHttpTimeoutMs(
|
||||
raw = process.env.OPENCLAW_REALTIME_OPENAI_HTTP_TIMEOUT_MS,
|
||||
): number {
|
||||
return parseStrictIntegerOption({
|
||||
fallback: DEFAULT_OPENAI_HTTP_TIMEOUT_MS,
|
||||
label: "OPENCLAW_REALTIME_OPENAI_HTTP_TIMEOUT_MS",
|
||||
min: 1,
|
||||
raw,
|
||||
});
|
||||
}
|
||||
|
||||
async function withTimeout<T>(options: TimeoutOptions<T>): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<T>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
const error = new Error(`${options.label} exceeded timeout of ${options.timeoutMs}ms`);
|
||||
reject(error);
|
||||
controller.abort(error);
|
||||
}, options.timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([options.run(controller.signal), timeoutPromise]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printResult(result: SmokeResult): void {
|
||||
console.log(
|
||||
`${result.name}: ${result.ok ? "ok" : "failed"}`,
|
||||
redactJsonValueForDevToolLog(result.details ?? {}),
|
||||
);
|
||||
}
|
||||
|
||||
function compareStrings(left: string | undefined, right: string | undefined): number {
|
||||
return (left ?? "").localeCompare(right ?? "");
|
||||
}
|
||||
|
||||
async function readOpenAIRealtimeBrowserResponseText(
|
||||
response: Response,
|
||||
label: string,
|
||||
maxBytes: number,
|
||||
): Promise<string> {
|
||||
const responseBodyTooLargeError = (errorLabel: string, errorMaxBytes: number): Error =>
|
||||
new Error(`${errorLabel} response body exceeded ${errorMaxBytes} bytes`);
|
||||
const rawContentLength = response.headers.get("content-length");
|
||||
if (rawContentLength && /^\d+$/u.test(rawContentLength)) {
|
||||
const contentLength = Number(rawContentLength);
|
||||
if (!Number.isSafeInteger(contentLength) || contentLength > maxBytes) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw responseBodyTooLargeError(label, maxBytes);
|
||||
}
|
||||
}
|
||||
if (!response.body) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const chunks: string[] = [];
|
||||
let totalBytes = 0;
|
||||
let canceled = false;
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
const tail = decoder.decode();
|
||||
if (tail) {
|
||||
chunks.push(tail);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > maxBytes) {
|
||||
canceled = true;
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw responseBodyTooLargeError(label, maxBytes);
|
||||
}
|
||||
chunks.push(decoder.decode(value, { stream: true }));
|
||||
}
|
||||
} finally {
|
||||
if (!canceled) {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
return chunks.join("");
|
||||
}
|
||||
|
||||
function openAIRealtimeBrowserResponseReaderInitScript(): string {
|
||||
return `globalThis.openclawReadBoundedRealtimeResponseText = ${readOpenAIRealtimeBrowserResponseText.toString()};`;
|
||||
}
|
||||
|
||||
async function createOpenAIClientSecret(
|
||||
apiKey: string,
|
||||
options: OpenAIHttpOptions = {},
|
||||
): Promise<string> {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const timeoutMs = options.timeoutMs ?? resolveOpenAIHttpTimeoutMs();
|
||||
const payload = await withTimeout({
|
||||
label: "OpenAI Realtime client secret request",
|
||||
timeoutMs,
|
||||
run: async (signal) => {
|
||||
const response = await fetchImpl("https://api.openai.com/v1/realtime/client_secrets", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session: {
|
||||
type: "realtime",
|
||||
model: OPENAI_REALTIME_MODEL,
|
||||
audio: {
|
||||
output: { voice: OPENAI_REALTIME_VOICE },
|
||||
},
|
||||
},
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`OpenAI Realtime client secret failed (${response.status}): ${previewForDevToolLog(
|
||||
await readBoundedText(
|
||||
response,
|
||||
"OpenAI Realtime client secret error",
|
||||
OPENAI_HTTP_RESPONSE_MAX_BYTES,
|
||||
signal,
|
||||
),
|
||||
600,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return await readBoundedJsonResponse(response, "OpenAI Realtime client secret", signal);
|
||||
},
|
||||
});
|
||||
const nested =
|
||||
payload.client_secret && typeof payload.client_secret === "object"
|
||||
? (payload.client_secret as Record<string, unknown>)
|
||||
: undefined;
|
||||
const value = typeof payload.value === "string" ? payload.value : undefined;
|
||||
const nestedValue = typeof nested?.value === "string" ? nested.value : undefined;
|
||||
const secret = value ?? nestedValue;
|
||||
if (!secret) {
|
||||
throw new Error("OpenAI Realtime client secret response did not include a value");
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
async function smokeOpenAIBackendBridge(apiKey: string): Promise<SmokeResult> {
|
||||
const { buildOpenAIRealtimeVoiceProvider } =
|
||||
await import("../../extensions/openai/realtime-voice-provider.ts");
|
||||
const provider = buildOpenAIRealtimeVoiceProvider();
|
||||
const events: string[] = [];
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: {
|
||||
apiKey,
|
||||
model: OPENAI_REALTIME_MODEL,
|
||||
voice: OPENAI_REALTIME_VOICE,
|
||||
},
|
||||
instructions: "OpenClaw backend realtime live smoke. Do not speak yet.",
|
||||
onAudio: () => {},
|
||||
onClearAudio: () => {},
|
||||
onEvent: (event) => {
|
||||
events.push(`${event.direction}:${event.type}`);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await bridge.connect();
|
||||
return {
|
||||
name: "openai-backend-bridge",
|
||||
ok: bridge.isConnected(),
|
||||
details: {
|
||||
model: OPENAI_REALTIME_MODEL,
|
||||
connected: bridge.isConnected(),
|
||||
events: events.slice(0, 10),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
name: "openai-backend-bridge",
|
||||
ok: false,
|
||||
details: { model: OPENAI_REALTIME_MODEL, error: shortError(error) },
|
||||
};
|
||||
} finally {
|
||||
bridge.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function smokeOpenAIWebRtc(browser: Browser, apiKey: string): Promise<SmokeResult> {
|
||||
try {
|
||||
const openAIHttpTimeoutMs = resolveOpenAIHttpTimeoutMs();
|
||||
const clientSecret = await createOpenAIClientSecret(apiKey, { timeoutMs: openAIHttpTimeoutMs });
|
||||
const context = await browser.newContext({
|
||||
permissions: ["microphone"],
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await page.evaluate("globalThis.__name = (fn) => fn");
|
||||
await page.evaluate(openAIRealtimeBrowserResponseReaderInitScript());
|
||||
const result = await page.evaluate(
|
||||
async ({ clientSecret: secret, sdpAnswerMaxBytes, timeoutMs }) => {
|
||||
const readBoundedTextLocal = (globalThis as OpenAIWebRtcSmokeGlobal)
|
||||
.openclawReadBoundedRealtimeResponseText;
|
||||
if (!readBoundedTextLocal) {
|
||||
throw new Error("OpenAI Realtime bounded response reader was not installed");
|
||||
}
|
||||
const withBrowserTimeout = async <T>(
|
||||
label: string,
|
||||
run: (signal: AbortSignal) => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const controller = new AbortController();
|
||||
let timeout: number | undefined;
|
||||
const timeoutPromise = new Promise<T>((_resolve, reject) => {
|
||||
timeout = window.setTimeout(() => {
|
||||
const error = new Error(`${label} exceeded timeout of ${timeoutMs}ms`);
|
||||
reject(error);
|
||||
controller.abort(error);
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([run(controller.signal), timeoutPromise]);
|
||||
} finally {
|
||||
if (timeout !== undefined) {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
};
|
||||
let media: MediaStream | undefined;
|
||||
let peer: RTCPeerConnection | undefined;
|
||||
try {
|
||||
if (navigator.mediaDevices?.getUserMedia) {
|
||||
media = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} else {
|
||||
const audioContext = new AudioContext();
|
||||
const destination = audioContext.createMediaStreamDestination();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
oscillator.connect(destination);
|
||||
oscillator.start();
|
||||
media = destination.stream;
|
||||
}
|
||||
peer = new RTCPeerConnection();
|
||||
for (const track of media.getAudioTracks()) {
|
||||
peer.addTrack(track, media);
|
||||
}
|
||||
const channel = peer.createDataChannel("oai-events");
|
||||
const connectionState = new Promise<string>((resolve) => {
|
||||
const timeout = window.setTimeout(
|
||||
() => resolve(peer?.connectionState ?? "timeout"),
|
||||
12_000,
|
||||
);
|
||||
peer?.addEventListener("connectionstatechange", () => {
|
||||
if (peer?.connectionState === "connected" || peer?.connectionState === "failed") {
|
||||
window.clearTimeout(timeout);
|
||||
resolve(peer.connectionState);
|
||||
}
|
||||
});
|
||||
channel.addEventListener("open", () => {
|
||||
window.clearTimeout(timeout);
|
||||
resolve(peer?.connectionState || "data-channel-open");
|
||||
});
|
||||
});
|
||||
const offer = await peer.createOffer();
|
||||
await peer.setLocalDescription(offer);
|
||||
const offerSdp = offer.sdp;
|
||||
if (!offerSdp) {
|
||||
throw new Error("OpenAI Realtime SDP offer did not include SDP");
|
||||
}
|
||||
const answer = await withBrowserTimeout(
|
||||
"OpenAI Realtime SDP offer request",
|
||||
async (signal) => {
|
||||
const response = await fetch("https://api.openai.com/v1/realtime/calls", {
|
||||
method: "POST",
|
||||
body: offerSdp,
|
||||
headers: {
|
||||
Authorization: `Bearer ${secret}`,
|
||||
"Content-Type": "application/sdp",
|
||||
},
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI Realtime SDP offer failed (${response.status})`);
|
||||
}
|
||||
return await readBoundedTextLocal(
|
||||
response,
|
||||
"OpenAI Realtime SDP answer",
|
||||
sdpAnswerMaxBytes,
|
||||
);
|
||||
},
|
||||
);
|
||||
await peer.setRemoteDescription({ type: "answer", sdp: answer });
|
||||
const state = await connectionState;
|
||||
return {
|
||||
answerHasAudio: answer.includes("m=audio"),
|
||||
remoteDescriptionApplied: peer.remoteDescription?.type === "answer",
|
||||
connectionState: state,
|
||||
};
|
||||
} finally {
|
||||
peer?.close();
|
||||
media?.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
},
|
||||
{
|
||||
clientSecret,
|
||||
sdpAnswerMaxBytes: OPENAI_HTTP_RESPONSE_MAX_BYTES,
|
||||
timeoutMs: openAIHttpTimeoutMs,
|
||||
},
|
||||
);
|
||||
return {
|
||||
name: "openai-webrtc-browser",
|
||||
ok: result.answerHasAudio && result.remoteDescriptionApplied,
|
||||
details: {
|
||||
model: OPENAI_REALTIME_MODEL,
|
||||
answerHasAudio: result.answerHasAudio,
|
||||
remoteDescriptionApplied: result.remoteDescriptionApplied,
|
||||
connectionState: result.connectionState,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
} catch (error) {
|
||||
return { name: "openai-webrtc-browser", ok: false, details: { error: shortError(error) } };
|
||||
}
|
||||
}
|
||||
|
||||
async function createGoogleLiveToken(apiKey: string): Promise<string> {
|
||||
const { GoogleGenAI, Modality } = await import("@google/genai");
|
||||
const ai = new GoogleGenAI({
|
||||
apiKey,
|
||||
httpOptions: { apiVersion: "v1alpha" },
|
||||
});
|
||||
const now = Date.now();
|
||||
const token = await ai.authTokens.create({
|
||||
config: {
|
||||
uses: 1,
|
||||
expireTime: new Date(now + 30 * 60 * 1000).toISOString(),
|
||||
newSessionExpireTime: new Date(now + 60 * 1000).toISOString(),
|
||||
liveConnectConstraints: {
|
||||
model: GOOGLE_REALTIME_MODEL,
|
||||
config: {
|
||||
responseModalities: [Modality.AUDIO],
|
||||
speechConfig: {
|
||||
voiceConfig: {
|
||||
prebuiltVoiceConfig: { voiceName: GOOGLE_REALTIME_VOICE },
|
||||
},
|
||||
},
|
||||
systemInstruction: "OpenClaw browser Talk live smoke.",
|
||||
inputAudioTranscription: {},
|
||||
outputAudioTranscription: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const name = token.name?.trim();
|
||||
if (!name) {
|
||||
throw new Error("Google Live auth token response did not include a token name");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
async function smokeGoogleLiveBrowserWs(browser: Browser, apiKey: string): Promise<SmokeResult> {
|
||||
try {
|
||||
const token = await createGoogleLiveToken(apiKey);
|
||||
const page = await browser.newPage();
|
||||
await page.evaluate("globalThis.__name = (fn) => fn");
|
||||
const result = await page.evaluate(
|
||||
async ({ model, tokenName, websocketUrl }) => {
|
||||
const debug: {
|
||||
opened: boolean;
|
||||
messages: string[];
|
||||
close?: { code: number; reason: string };
|
||||
error: boolean;
|
||||
} = { opened: false, messages: [], error: false };
|
||||
const dataToText = async (data: unknown): Promise<string> => {
|
||||
if (typeof data === "string") {
|
||||
return data;
|
||||
}
|
||||
if (data instanceof Blob) {
|
||||
return await data.text();
|
||||
}
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return new TextDecoder().decode(data);
|
||||
}
|
||||
return String(data);
|
||||
};
|
||||
const url = new URL(websocketUrl);
|
||||
url.searchParams.set("access_token", tokenName);
|
||||
const ws = new WebSocket(url.toString());
|
||||
const done = new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const timeout = window.setTimeout(
|
||||
() => reject(new Error(`Google Live setup timed out: ${JSON.stringify(debug)}`)),
|
||||
15_000,
|
||||
);
|
||||
ws.addEventListener("open", () => {
|
||||
debug.opened = true;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
setup: {
|
||||
model: model.startsWith("models/") ? model : `models/${model}`,
|
||||
generationConfig: { responseModalities: ["AUDIO"] },
|
||||
inputAudioTranscription: {},
|
||||
outputAudioTranscription: {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
ws.addEventListener("message", (event) => {
|
||||
void (async () => {
|
||||
const text = await dataToText(event.data);
|
||||
debug.messages.push(text.slice(0, 300));
|
||||
const message = JSON.parse(text) as { setupComplete?: unknown };
|
||||
if (!message.setupComplete) {
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(timeout);
|
||||
resolve({ setupComplete: true, readyState: ws.readyState });
|
||||
})().catch((error: unknown) => {
|
||||
window.clearTimeout(timeout);
|
||||
reject(toLintErrorObject(error, "Non-Error rejection"));
|
||||
});
|
||||
});
|
||||
ws.addEventListener("error", () => {
|
||||
debug.error = true;
|
||||
window.clearTimeout(timeout);
|
||||
reject(new Error("Google Live browser WebSocket errored"));
|
||||
});
|
||||
ws.addEventListener("close", (event) => {
|
||||
debug.close = { code: event.code, reason: event.reason };
|
||||
if (event.code !== 1000) {
|
||||
window.clearTimeout(timeout);
|
||||
reject(new Error(`Google Live browser WebSocket closed: ${JSON.stringify(debug)}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
const value = await done;
|
||||
ws.close(1000);
|
||||
return value;
|
||||
},
|
||||
{
|
||||
model: GOOGLE_REALTIME_MODEL,
|
||||
tokenName: token,
|
||||
websocketUrl: GOOGLE_LIVE_WS_URL,
|
||||
},
|
||||
);
|
||||
await page.close();
|
||||
return {
|
||||
name: "google-live-browser-ws",
|
||||
ok: result.setupComplete === true,
|
||||
details: { model: GOOGLE_REALTIME_MODEL, setupComplete: result.setupComplete === true },
|
||||
};
|
||||
} catch (error) {
|
||||
return { name: "google-live-browser-ws", ok: false, details: { error: shortError(error) } };
|
||||
}
|
||||
}
|
||||
|
||||
async function smokeGatewayRelayBrowser(browser: Browser): Promise<SmokeResult> {
|
||||
let server: ViteDevServer | undefined;
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-realtime-talk-"));
|
||||
try {
|
||||
const { createServer } = await import("vite");
|
||||
const repoRoot = process.cwd().replaceAll("\\", "/");
|
||||
const relayModulePath = JSON.stringify(
|
||||
`/@fs/${repoRoot}/ui/src/ui/chat/realtime-talk-gateway-relay.ts`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(dir, "index.html"),
|
||||
'<!doctype html><meta charset="utf-8"><script type="module" src="/main.ts"></script>',
|
||||
);
|
||||
await writeFile(
|
||||
path.join(dir, "main.ts"),
|
||||
`
|
||||
const { GatewayRelayRealtimeTalkTransport } = await import(${relayModulePath});
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const listeners = new Set();
|
||||
const requests = [];
|
||||
const statuses = [];
|
||||
const transcripts = [];
|
||||
|
||||
function emit(event) {
|
||||
for (const listener of [...listeners]) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
function base64ZeroPcm(bytes) {
|
||||
let text = "";
|
||||
for (let index = 0; index < bytes; index += 1) {
|
||||
text += String.fromCharCode(0);
|
||||
}
|
||||
return btoa(text);
|
||||
}
|
||||
|
||||
const client = {
|
||||
addEventListener(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
async request(method, params) {
|
||||
requests.push({ method, params });
|
||||
if (method === "talk.client.toolCall") {
|
||||
const runId = params.idempotencyKey || "run-smoke";
|
||||
window.setTimeout(() => {
|
||||
emit({ event: "chat", payload: { runId, state: "final", message: { text: "relay consult ok" } } });
|
||||
}, 50);
|
||||
return { runId };
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const transport = new GatewayRelayRealtimeTalkTransport(
|
||||
{
|
||||
provider: "smoke",
|
||||
transport: "gateway-relay",
|
||||
relaySessionId: "relay-live-smoke",
|
||||
audio: {
|
||||
inputEncoding: "pcm16",
|
||||
inputSampleRateHz: 24000,
|
||||
outputEncoding: "pcm16",
|
||||
outputSampleRateHz: 24000,
|
||||
},
|
||||
},
|
||||
{
|
||||
client,
|
||||
sessionKey: "main",
|
||||
callbacks: {
|
||||
onStatus: (status, detail) => statuses.push({ status, detail }),
|
||||
onTranscript: (entry) => transcripts.push(entry),
|
||||
},
|
||||
},
|
||||
);
|
||||
await transport.start();
|
||||
emit({ event: "talk.event", payload: { relaySessionId: "relay-live-smoke", type: "ready" } });
|
||||
emit({
|
||||
event: "talk.event",
|
||||
payload: { relaySessionId: "relay-live-smoke", type: "transcript", role: "user", text: "relay user", final: true },
|
||||
});
|
||||
emit({
|
||||
event: "talk.event",
|
||||
payload: { relaySessionId: "relay-live-smoke", type: "transcript", role: "assistant", text: "relay assistant", final: false },
|
||||
});
|
||||
emit({
|
||||
event: "talk.event",
|
||||
payload: { relaySessionId: "relay-live-smoke", type: "audio", audioBase64: base64ZeroPcm(480) },
|
||||
});
|
||||
const processor = transport.inputProcessor;
|
||||
processor?.onaudioprocess?.({
|
||||
inputBuffer: { getChannelData: () => new Float32Array(160).fill(0.01) },
|
||||
});
|
||||
emit({ event: "talk.event", payload: { relaySessionId: "relay-live-smoke", type: "mark" } });
|
||||
emit({
|
||||
event: "talk.event",
|
||||
payload: {
|
||||
relaySessionId: "relay-live-smoke",
|
||||
type: "toolCall",
|
||||
callId: "call-smoke",
|
||||
name: "openclaw_agent_consult",
|
||||
args: { question: "confirm relay consult path" },
|
||||
},
|
||||
});
|
||||
await delay(400);
|
||||
transport.stop();
|
||||
await delay(100);
|
||||
window.relaySmokeResult = { requests, statuses, transcripts };
|
||||
window.relaySmokeDone = true;
|
||||
} catch (error) {
|
||||
window.relaySmokeResult = { error: error instanceof Error ? error.message : String(error), requests, statuses, transcripts };
|
||||
window.relaySmokeDone = true;
|
||||
}
|
||||
`,
|
||||
);
|
||||
server = await createServer({
|
||||
root: dir,
|
||||
logLevel: "silent",
|
||||
server: { host: "127.0.0.1", port: 0 },
|
||||
});
|
||||
await server.listen();
|
||||
const address = server.httpServer?.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Vite did not expose a local port");
|
||||
}
|
||||
const url = `http://127.0.0.1:${address.port}/`;
|
||||
const context = await browser.newContext({ permissions: ["microphone"] });
|
||||
await context.grantPermissions(["microphone"], { origin: url });
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(
|
||||
() => (globalThis as Record<string, unknown>).relaySmokeDone === true,
|
||||
undefined,
|
||||
{
|
||||
timeout: 15_000,
|
||||
},
|
||||
);
|
||||
const result = (await page.evaluate(
|
||||
() => (globalThis as Record<string, unknown>).relaySmokeResult,
|
||||
)) as {
|
||||
error?: string;
|
||||
requests?: Array<{ method?: string }>;
|
||||
statuses?: Array<{ status?: string }>;
|
||||
transcripts?: Array<{ role?: string; text?: string }>;
|
||||
};
|
||||
await context.close();
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
const methods = new Set((result.requests ?? []).map((request) => request.method));
|
||||
const statusNames = new Set((result.statuses ?? []).map((entry) => entry.status));
|
||||
const transcriptTexts = new Set((result.transcripts ?? []).map((entry) => entry.text));
|
||||
const expectedMethods = [
|
||||
"talk.client.toolCall",
|
||||
"talk.session.appendAudio",
|
||||
"talk.session.submitToolResult",
|
||||
"talk.session.close",
|
||||
];
|
||||
const ok =
|
||||
expectedMethods.every((method) => methods.has(method)) &&
|
||||
statusNames.has("listening") &&
|
||||
statusNames.has("thinking") &&
|
||||
transcriptTexts.has("relay user") &&
|
||||
transcriptTexts.has("relay assistant");
|
||||
return {
|
||||
name: "gateway-relay-browser-adapter",
|
||||
ok,
|
||||
details: {
|
||||
methods: [...methods].toSorted(compareStrings),
|
||||
statuses: [...statusNames].toSorted(compareStrings),
|
||||
transcripts: [...transcriptTexts].toSorted(compareStrings),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
name: "gateway-relay-browser-adapter",
|
||||
ok: false,
|
||||
details: { error: shortError(error) },
|
||||
};
|
||||
} finally {
|
||||
await server?.close();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
const cli = parseRealtimeSmokeArgs(argv);
|
||||
if (cli.help) {
|
||||
console.log(usage());
|
||||
return;
|
||||
}
|
||||
const { chromium } = await import("playwright");
|
||||
const openAIKey = getEnv("OPENAI_API_KEY");
|
||||
const googleKey = getEnv("GEMINI_API_KEY") ?? getEnv("GOOGLE_API_KEY");
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
"--autoplay-policy=no-user-gesture-required",
|
||||
"--no-sandbox",
|
||||
"--use-fake-device-for-media-stream",
|
||||
"--use-fake-ui-for-media-stream",
|
||||
],
|
||||
});
|
||||
const results: SmokeResult[] = [];
|
||||
try {
|
||||
if (!openAIKey) {
|
||||
results.push({
|
||||
name: "openai-backend-bridge",
|
||||
ok: false,
|
||||
details: { error: "OPENAI_API_KEY missing" },
|
||||
});
|
||||
results.push({
|
||||
name: "openai-webrtc-browser",
|
||||
ok: false,
|
||||
details: { error: "OPENAI_API_KEY missing" },
|
||||
});
|
||||
} else {
|
||||
results.push(await smokeOpenAIBackendBridge(openAIKey));
|
||||
results.push(await smokeOpenAIWebRtc(browser, openAIKey));
|
||||
}
|
||||
if (!googleKey) {
|
||||
results.push({
|
||||
name: "google-live-browser-ws",
|
||||
ok: false,
|
||||
details: { error: "GEMINI_API_KEY or GOOGLE_API_KEY missing" },
|
||||
});
|
||||
} else {
|
||||
results.push(await smokeGoogleLiveBrowserWs(browser, googleKey));
|
||||
}
|
||||
results.push(await smokeGatewayRelayBrowser(browser));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
for (const result of results) {
|
||||
printResult(result);
|
||||
}
|
||||
if (results.some((result) => !result.ok)) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
await main().catch((error: unknown) => {
|
||||
console.error(error instanceof CliArgumentError ? error.message : shortError(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
OPENAI_HTTP_RESPONSE_MAX_BYTES,
|
||||
createOpenAIClientSecret,
|
||||
parseRealtimeSmokeArgs,
|
||||
readOpenAIRealtimeBrowserResponseText,
|
||||
readBoundedText,
|
||||
resolveOpenAIHttpTimeoutMs,
|
||||
usage,
|
||||
};
|
||||
|
||||
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Error(value);
|
||||
}
|
||||
const error = new Error(fallbackMessage, { cause: value });
|
||||
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
||||
Object.assign(error, value);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
207
scripts/dev/test-device-pair-telegram.ts
Normal file
207
scripts/dev/test-device-pair-telegram.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
// Test Device Pair Telegram script supports OpenClaw repository automation.
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { getRuntimeConfig } from "../../src/config/config.js";
|
||||
import { matchPluginCommand, executePluginCommand } from "../../src/plugins/commands.js";
|
||||
import { loadOpenClawPlugins } from "../../src/plugins/loader.js";
|
||||
|
||||
type SendMessageTelegram = (
|
||||
chatId: string,
|
||||
text: string,
|
||||
options: {
|
||||
accountId?: string;
|
||||
cfg?: ReturnType<typeof getRuntimeConfig>;
|
||||
},
|
||||
) => Promise<{ chatId?: string; messageId?: string }>;
|
||||
|
||||
type DevicePairTelegramDeps = {
|
||||
executePluginCommand: typeof executePluginCommand;
|
||||
getRuntimeConfig: typeof getRuntimeConfig;
|
||||
loadOpenClawPlugins: typeof loadOpenClawPlugins;
|
||||
matchPluginCommand: typeof matchPluginCommand;
|
||||
sendMessageTelegram: SendMessageTelegram;
|
||||
};
|
||||
|
||||
type DevicePairTelegramResult = {
|
||||
accountId?: string;
|
||||
chatId: string;
|
||||
messageId?: string;
|
||||
sent: boolean;
|
||||
};
|
||||
|
||||
class UsageError extends Error {
|
||||
readonly exitCode = 1;
|
||||
}
|
||||
|
||||
class CliArgumentError extends UsageError {}
|
||||
|
||||
type DevicePairTelegramArgs = {
|
||||
accountId?: string;
|
||||
chatId?: string;
|
||||
help: boolean;
|
||||
};
|
||||
|
||||
const BOOLEAN_FLAGS = new Set(["--help", "-h"]);
|
||||
const VALUE_FLAGS = new Set(["--account", "-a", "--chat", "-c"]);
|
||||
|
||||
function isMissingOptionValue(value: string | undefined): boolean {
|
||||
return !value || BOOLEAN_FLAGS.has(value) || VALUE_FLAGS.has(value) || value.startsWith("--");
|
||||
}
|
||||
|
||||
function writeStdoutLine(...parts: string[]): void {
|
||||
process.stdout.write(`${parts.join(" ")}\n`);
|
||||
}
|
||||
|
||||
function writeStderrLine(message: string): void {
|
||||
process.stderr.write(`${message}\n`);
|
||||
}
|
||||
|
||||
function readArg(args: string[], flag: string, short?: string): string | undefined {
|
||||
const idx = args.indexOf(flag);
|
||||
if (idx !== -1 && idx + 1 < args.length) {
|
||||
return args[idx + 1];
|
||||
}
|
||||
if (short) {
|
||||
const sidx = args.indexOf(short);
|
||||
if (sidx !== -1 && sidx + 1 < args.length) {
|
||||
return args[sidx + 1];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
"Usage: bun scripts/dev/test-device-pair-telegram.ts --chat <telegram-chat-id> [--account <accountId>]",
|
||||
"",
|
||||
"Options:",
|
||||
" --chat, -c <id> Telegram chat id",
|
||||
" --account, -a <id> Telegram account id",
|
||||
" -h, --help Show this help",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function validateArgs(args: readonly string[]): void {
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index] ?? "";
|
||||
if (BOOLEAN_FLAGS.has(arg)) {
|
||||
continue;
|
||||
}
|
||||
if (VALUE_FLAGS.has(arg)) {
|
||||
const value = args[index + 1];
|
||||
if (isMissingOptionValue(value)) {
|
||||
throw new CliArgumentError(`${arg} requires a value`);
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
throw new CliArgumentError(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseDevicePairTelegramArgs(args: readonly string[]): DevicePairTelegramArgs {
|
||||
validateArgs(args);
|
||||
return {
|
||||
accountId: readArg([...args], "--account", "-a"),
|
||||
chatId: readArg([...args], "--chat", "-c"),
|
||||
help: args.includes("--help") || args.includes("-h"),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadTelegramRuntimeSendMessage(): Promise<SendMessageTelegram> {
|
||||
const specifier = "../../extensions/telegram/runtime-api.js";
|
||||
const runtime = (await import(specifier)) as { sendMessageTelegram?: SendMessageTelegram };
|
||||
if (typeof runtime.sendMessageTelegram !== "function") {
|
||||
throw new Error("Telegram runtime-api.js did not export sendMessageTelegram");
|
||||
}
|
||||
return runtime.sendMessageTelegram;
|
||||
}
|
||||
|
||||
function createDefaultDeps(): DevicePairTelegramDeps {
|
||||
return {
|
||||
executePluginCommand,
|
||||
getRuntimeConfig,
|
||||
loadOpenClawPlugins,
|
||||
matchPluginCommand,
|
||||
sendMessageTelegram: async (...args) => {
|
||||
const sendMessageTelegram = await loadTelegramRuntimeSendMessage();
|
||||
return await sendMessageTelegram(...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runDevicePairTelegram(
|
||||
args = process.argv.slice(2),
|
||||
deps: DevicePairTelegramDeps = createDefaultDeps(),
|
||||
): Promise<DevicePairTelegramResult> {
|
||||
const { accountId, chatId, help } = parseDevicePairTelegramArgs(args);
|
||||
if (help) {
|
||||
throw new UsageError(usage());
|
||||
}
|
||||
if (!chatId) {
|
||||
throw new UsageError(usage());
|
||||
}
|
||||
|
||||
const cfg = deps.getRuntimeConfig();
|
||||
deps.loadOpenClawPlugins({ config: cfg });
|
||||
|
||||
const match = deps.matchPluginCommand("/pair", { channel: "telegram" });
|
||||
if (!match) {
|
||||
throw new Error("/pair plugin command not registered.");
|
||||
}
|
||||
|
||||
const result = await deps.executePluginCommand({
|
||||
command: match.command,
|
||||
args: match.args,
|
||||
senderId: chatId,
|
||||
channel: "telegram",
|
||||
channelId: "telegram",
|
||||
isAuthorizedSender: true,
|
||||
commandBody: "/pair",
|
||||
config: cfg,
|
||||
from: `telegram:${chatId}`,
|
||||
to: `telegram:${chatId}`,
|
||||
accountId,
|
||||
});
|
||||
|
||||
if (!result.text) {
|
||||
return { accountId, chatId, sent: false };
|
||||
}
|
||||
|
||||
const sent = await deps.sendMessageTelegram(chatId, result.text, {
|
||||
accountId,
|
||||
cfg,
|
||||
});
|
||||
|
||||
return {
|
||||
accountId,
|
||||
chatId: sent.chatId ?? chatId,
|
||||
messageId: sent.messageId,
|
||||
sent: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
writeStdoutLine(usage());
|
||||
return;
|
||||
}
|
||||
const result = await runDevicePairTelegram(args);
|
||||
writeStdoutLine(
|
||||
"Sent split /pair messages to",
|
||||
result.chatId,
|
||||
result.accountId ? `(${result.accountId})` : "",
|
||||
result.messageId ? `message=${result.messageId}` : "",
|
||||
);
|
||||
} catch (error) {
|
||||
writeStderrLine(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = error instanceof UsageError ? error.exitCode : 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
await main();
|
||||
}
|
||||
|
||||
export { parseDevicePairTelegramArgs, runDevicePairTelegram };
|
||||
528
scripts/dev/tui-pty-test-watch.ts
Normal file
528
scripts/dev/tui-pty-test-watch.ts
Normal file
@@ -0,0 +1,528 @@
|
||||
// Tui Pty Test Watch script supports OpenClaw repository automation.
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { mkdir, open, writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { sleep as delay } from "../lib/sleep.mjs";
|
||||
import { resolveWindowsTaskkillPath } from "../lib/windows-taskkill.mjs";
|
||||
|
||||
type Options = {
|
||||
altScreen: boolean;
|
||||
help: boolean;
|
||||
mirrorPath: string;
|
||||
mode: "fake" | "local" | "all";
|
||||
vitestArgs: string[];
|
||||
};
|
||||
|
||||
const DEFAULT_MIRROR_PATH = path.join(process.cwd(), ".artifacts", "tui-pty-mirror", "latest.ansi");
|
||||
const require = createRequire(import.meta.url);
|
||||
const MODE_TEST_FILES = {
|
||||
fake: ["src/tui/tui-pty-harness.e2e.test.ts"],
|
||||
local: ["src/tui/tui-pty-local.e2e.test.ts"],
|
||||
all: ["src/tui/tui-pty-harness.e2e.test.ts", "src/tui/tui-pty-local.e2e.test.ts"],
|
||||
} as const;
|
||||
const MIRROR_TERMINAL_QUERIES = ["\x1b[?u", "\x1b[16t"];
|
||||
const DEFAULT_PTY_COLS = 100;
|
||||
const DEFAULT_PTY_ROWS = 30;
|
||||
const CHILD_SIGTERM_GRACE_MS = 500;
|
||||
const CHILD_SIGKILL_GRACE_MS = 5_000;
|
||||
const MIRROR_READ_CHUNK_BYTES = 1024 * 1024;
|
||||
const CHILD_OUTPUT_TAIL_BYTES = 128 * 1024;
|
||||
const BOOLEAN_OPTIONS = new Set(["--help", "-h", "--no-alt-screen"]);
|
||||
const VALUE_OPTIONS = new Set(["--mode", "--mirror-path"]);
|
||||
|
||||
class CliArgumentError extends Error {
|
||||
override name = "CliArgumentError";
|
||||
}
|
||||
|
||||
type KillableChild = {
|
||||
pid?: number;
|
||||
kill(signal: NodeJS.Signals): boolean;
|
||||
};
|
||||
|
||||
type ChildStopper = {
|
||||
cancel: () => void;
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
type SignalChild = (child: KillableChild, signal: NodeJS.Signals) => void;
|
||||
|
||||
type RunTaskkill = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { stdio: "ignore" },
|
||||
) => { error?: unknown; status?: number | null } | undefined;
|
||||
|
||||
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
|
||||
(timer as { unref?: () => void }).unref?.();
|
||||
}
|
||||
|
||||
function readOption(args: string[], name: string): string | undefined {
|
||||
const idx = args.indexOf(name);
|
||||
if (idx < 0) {
|
||||
return undefined;
|
||||
}
|
||||
const value = args[idx + 1];
|
||||
if (!value || value.startsWith("-")) {
|
||||
throw new CliArgumentError(`${name} requires a value`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function readMode(args: string[]): Options["mode"] {
|
||||
const mode = readOption(args, "--mode") ?? "fake";
|
||||
if (mode === "fake" || mode === "local" || mode === "all") {
|
||||
return mode;
|
||||
}
|
||||
throw new CliArgumentError(`--mode must be fake, local, or all; got ${JSON.stringify(mode)}`);
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
"Usage: node --import tsx scripts/dev/tui-pty-test-watch.ts [options] [-- vitest args...]",
|
||||
"",
|
||||
"Options:",
|
||||
" --mode <fake|local|all> Select TUI PTY test group (default: fake)",
|
||||
" --mirror-path <path> Write/read mirrored ANSI output at this path",
|
||||
" --no-alt-screen Print without switching to the terminal alt screen",
|
||||
" -h, --help Show this help",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function validateOwnArgs(args: string[]): void {
|
||||
for (let idx = 0; idx < args.length; idx += 1) {
|
||||
const arg = args[idx] ?? "";
|
||||
if (BOOLEAN_OPTIONS.has(arg)) {
|
||||
continue;
|
||||
}
|
||||
if (VALUE_OPTIONS.has(arg)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
throw new CliArgumentError(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseOptions(args = process.argv.slice(2)): Options {
|
||||
const separator = args.indexOf("--");
|
||||
const ownArgs = separator >= 0 ? args.slice(0, separator) : args;
|
||||
const vitestArgs = separator >= 0 ? args.slice(separator + 1) : [];
|
||||
validateOwnArgs(ownArgs);
|
||||
const mirrorPathOption = readOption(ownArgs, "--mirror-path");
|
||||
return {
|
||||
altScreen: !ownArgs.includes("--no-alt-screen"),
|
||||
help: ownArgs.includes("--help") || ownArgs.includes("-h"),
|
||||
mirrorPath:
|
||||
mirrorPathOption !== undefined ? path.resolve(mirrorPathOption) : DEFAULT_MIRROR_PATH,
|
||||
mode: readMode(ownArgs),
|
||||
vitestArgs,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldUseAltScreen(options: Options) {
|
||||
return options.altScreen && process.stdout.isTTY;
|
||||
}
|
||||
|
||||
function resolveVitestCliEntry(): string {
|
||||
const vitestPackageJson = require.resolve("vitest/package.json");
|
||||
return path.join(path.dirname(vitestPackageJson), "vitest.mjs");
|
||||
}
|
||||
|
||||
function currentTerminalDimension(value: number | undefined, fallback: number): string {
|
||||
return String(value && value > 0 ? value : fallback);
|
||||
}
|
||||
|
||||
function signalWindowsProcessTree(
|
||||
pid: number,
|
||||
signal: NodeJS.Signals,
|
||||
runTaskkill: RunTaskkill = spawnSync,
|
||||
): boolean {
|
||||
const args = ["/PID", String(pid), "/T"];
|
||||
if (signal === "SIGKILL") {
|
||||
args.push("/F");
|
||||
}
|
||||
const result = runTaskkill(resolveWindowsTaskkillPath(), args, { stdio: "ignore" });
|
||||
return !result?.error && result?.status === 0;
|
||||
}
|
||||
|
||||
function signalWindowsProcessTreeOrForce(
|
||||
pid: number,
|
||||
signal: NodeJS.Signals,
|
||||
runTaskkill: RunTaskkill = spawnSync,
|
||||
): boolean {
|
||||
if (signalWindowsProcessTree(pid, signal, runTaskkill)) {
|
||||
return true;
|
||||
}
|
||||
return signal !== "SIGKILL" && signalWindowsProcessTree(pid, "SIGKILL", runTaskkill);
|
||||
}
|
||||
|
||||
function signalChildProcessTree(
|
||||
child: KillableChild,
|
||||
signal: NodeJS.Signals,
|
||||
{
|
||||
platform = process.platform,
|
||||
runTaskkill = spawnSync,
|
||||
useProcessGroup = platform !== "win32",
|
||||
}: {
|
||||
platform?: NodeJS.Platform;
|
||||
runTaskkill?: RunTaskkill;
|
||||
useProcessGroup?: boolean;
|
||||
} = {},
|
||||
): void {
|
||||
if (useProcessGroup && typeof child.pid === "number") {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
// Non-detached fallback or already-exited group; direct child signaling is
|
||||
// still useful on platforms without process groups.
|
||||
}
|
||||
}
|
||||
if (platform === "win32" && typeof child.pid === "number") {
|
||||
if (signalWindowsProcessTreeOrForce(child.pid, signal, runTaskkill)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
child.kill(signal);
|
||||
}
|
||||
|
||||
function createChildStopper(
|
||||
child: KillableChild,
|
||||
options: {
|
||||
signalChild?: SignalChild;
|
||||
sigtermGraceMs?: number;
|
||||
sigkillGraceMs?: number;
|
||||
} = {},
|
||||
): ChildStopper {
|
||||
const signalChild = options.signalChild ?? signalChildProcessTree;
|
||||
const sigtermGraceMs = options.sigtermGraceMs ?? CHILD_SIGTERM_GRACE_MS;
|
||||
const sigkillGraceMs = options.sigkillGraceMs ?? CHILD_SIGKILL_GRACE_MS;
|
||||
let stopping = false;
|
||||
let termTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const cancel = () => {
|
||||
if (termTimer) {
|
||||
clearTimeout(termTimer);
|
||||
termTimer = undefined;
|
||||
}
|
||||
if (killTimer) {
|
||||
clearTimeout(killTimer);
|
||||
killTimer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (stopping) {
|
||||
return;
|
||||
}
|
||||
stopping = true;
|
||||
signalChild(child, "SIGINT");
|
||||
termTimer = setTimeout(() => {
|
||||
signalChild(child, "SIGTERM");
|
||||
killTimer = setTimeout(() => {
|
||||
signalChild(child, "SIGKILL");
|
||||
}, sigkillGraceMs);
|
||||
unrefTimer(killTimer);
|
||||
}, sigtermGraceMs);
|
||||
unrefTimer(termTimer);
|
||||
};
|
||||
|
||||
return { cancel, stop };
|
||||
}
|
||||
|
||||
async function createMirrorFile(mirrorPath: string): Promise<void> {
|
||||
await mkdir(path.dirname(mirrorPath), { recursive: true });
|
||||
await writeFile(mirrorPath, "", "utf8");
|
||||
}
|
||||
|
||||
async function readNewMirrorData(
|
||||
mirrorPath: string,
|
||||
offset: number,
|
||||
maxChunkBytes = MIRROR_READ_CHUNK_BYTES,
|
||||
) {
|
||||
const file = await open(mirrorPath, "r");
|
||||
try {
|
||||
const stats = await file.stat();
|
||||
const readOffset = stats.size < offset ? 0 : offset;
|
||||
const availableBytes = stats.size - readOffset;
|
||||
if (availableBytes <= 0) {
|
||||
return { chunk: Buffer.alloc(0), offset: readOffset };
|
||||
}
|
||||
const bytesToRead = Math.min(availableBytes, maxChunkBytes);
|
||||
const buffer = Buffer.alloc(bytesToRead);
|
||||
const { bytesRead } = await file.read(buffer, 0, bytesToRead, readOffset);
|
||||
return { chunk: buffer.subarray(0, bytesRead), offset: readOffset + bytesRead };
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
}
|
||||
|
||||
function appendBufferTail(current: Buffer, chunk: Buffer, maxBytes = CHILD_OUTPUT_TAIL_BYTES) {
|
||||
if (chunk.byteLength >= maxBytes) {
|
||||
return chunk.subarray(chunk.byteLength - maxBytes);
|
||||
}
|
||||
if (current.byteLength + chunk.byteLength <= maxBytes) {
|
||||
return current.byteLength === 0 ? Buffer.from(chunk) : Buffer.concat([current, chunk]);
|
||||
}
|
||||
const keepBytes = maxBytes - chunk.byteLength;
|
||||
return Buffer.concat([current.subarray(current.byteLength - keepBytes), chunk]);
|
||||
}
|
||||
|
||||
async function drainNewMirrorData(
|
||||
mirrorPath: string,
|
||||
offset: number,
|
||||
onChunk: (chunk: Buffer) => void,
|
||||
maxChunkBytes = MIRROR_READ_CHUNK_BYTES,
|
||||
) {
|
||||
let nextOffset = offset;
|
||||
for (;;) {
|
||||
const result = await readNewMirrorData(mirrorPath, nextOffset, maxChunkBytes);
|
||||
nextOffset = result.offset;
|
||||
if (result.chunk.byteLength === 0) {
|
||||
return nextOffset;
|
||||
}
|
||||
onChunk(result.chunk);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const options = parseOptions();
|
||||
if (options.help) {
|
||||
process.stdout.write(`${usage()}\n`);
|
||||
return;
|
||||
}
|
||||
const useAltScreen = shouldUseAltScreen(options);
|
||||
await createMirrorFile(options.mirrorPath);
|
||||
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--no-maglev",
|
||||
resolveVitestCliEntry(),
|
||||
"run",
|
||||
"--config",
|
||||
"test/vitest/vitest.tui-pty.config.ts",
|
||||
...MODE_TEST_FILES[options.mode],
|
||||
"--reporter=dot",
|
||||
...options.vitestArgs,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
detached: process.platform !== "win32",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_TUI_PTY_MIRROR_PATH: options.mirrorPath,
|
||||
OPENCLAW_TUI_PTY_INCLUDE_LOCAL: options.mode === "fake" ? "0" : "1",
|
||||
OPENCLAW_TUI_PTY_COLS: currentTerminalDimension(process.stdout.columns, DEFAULT_PTY_COLS),
|
||||
OPENCLAW_TUI_PTY_ROWS: currentTerminalDimension(process.stdout.rows, DEFAULT_PTY_ROWS),
|
||||
OPENCLAW_TUI_PTY_TYPE_CHUNK_SIZE: process.env.OPENCLAW_TUI_PTY_TYPE_CHUNK_SIZE ?? "4",
|
||||
OPENCLAW_TUI_PTY_TYPE_DELAY_MS: process.env.OPENCLAW_TUI_PTY_TYPE_DELAY_MS ?? "25",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
|
||||
let childStdout = Buffer.alloc(0);
|
||||
let childStderr = Buffer.alloc(0);
|
||||
let restored = false;
|
||||
let mirrorOffset = 0;
|
||||
let mirrorFilterPending = "";
|
||||
let sawMirrorOutput = false;
|
||||
const startedAt = Date.now();
|
||||
|
||||
const filterMirrorTerminalQueries = (chunk: Buffer) => {
|
||||
const input = mirrorFilterPending + chunk.toString("utf8");
|
||||
let output = "";
|
||||
mirrorFilterPending = "";
|
||||
for (let idx = 0; idx < input.length; idx += 1) {
|
||||
const rest = input.slice(idx);
|
||||
const fullMatch = MIRROR_TERMINAL_QUERIES.find((query) => rest.startsWith(query));
|
||||
if (fullMatch) {
|
||||
idx += fullMatch.length - 1;
|
||||
continue;
|
||||
}
|
||||
const partialMatch = MIRROR_TERMINAL_QUERIES.find((query) => query.startsWith(rest));
|
||||
if (partialMatch) {
|
||||
mirrorFilterPending = rest;
|
||||
break;
|
||||
}
|
||||
output += input[idx];
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
const writeMirrorChunk = (chunk: Buffer) => {
|
||||
const filteredChunk = filterMirrorTerminalQueries(chunk);
|
||||
if (filteredChunk.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!sawMirrorOutput && useAltScreen) {
|
||||
process.stdout.write("\x1b[2J\x1b[H");
|
||||
}
|
||||
sawMirrorOutput = true;
|
||||
process.stdout.write(filteredChunk);
|
||||
};
|
||||
|
||||
const restoreScreen = () => {
|
||||
if (restored) {
|
||||
return;
|
||||
}
|
||||
restored = true;
|
||||
if (useAltScreen) {
|
||||
process.stdout.write("\x1b[?1049l");
|
||||
}
|
||||
};
|
||||
|
||||
const childStopper = createChildStopper(child);
|
||||
const stopChild = childStopper.stop;
|
||||
|
||||
const ignoredInput = (chunk: Buffer) => {
|
||||
if (chunk.includes(0x03)) {
|
||||
stopChild();
|
||||
}
|
||||
};
|
||||
const hadRawMode = process.stdin.isTTY && process.stdin.isRaw;
|
||||
if (useAltScreen && process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
process.stdin.on("data", ignoredInput);
|
||||
}
|
||||
|
||||
const restoreInput = () => {
|
||||
if (!process.stdin.isTTY) {
|
||||
return;
|
||||
}
|
||||
process.stdin.off("data", ignoredInput);
|
||||
process.stdin.setRawMode(hadRawMode);
|
||||
if (!hadRawMode) {
|
||||
process.stdin.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const drainParentInput = async () => {
|
||||
if (!useAltScreen || !process.stdin.isTTY) {
|
||||
return;
|
||||
}
|
||||
await delay(100);
|
||||
};
|
||||
|
||||
const renderWaitingStatus = () => {
|
||||
if (!useAltScreen || sawMirrorOutput) {
|
||||
return;
|
||||
}
|
||||
const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1000);
|
||||
process.stdout.write(
|
||||
[
|
||||
"\x1b[2J\x1b[H",
|
||||
"openclaw TUI PTY tests",
|
||||
"",
|
||||
`Mode: ${options.mode}`,
|
||||
`Waiting for the first TUI frame... ${elapsedSeconds}s`,
|
||||
`Mirror: ${options.mirrorPath}`,
|
||||
"",
|
||||
"Vitest output is buffered and will print after the mirrored TUI run exits.",
|
||||
].join("\n"),
|
||||
);
|
||||
};
|
||||
|
||||
if (useAltScreen) {
|
||||
process.stdout.write("\x1b[?1049h\x1b[?25l");
|
||||
renderWaitingStatus();
|
||||
}
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
childStdout = appendBufferTail(childStdout, chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
childStderr = appendBufferTail(childStderr, chunk);
|
||||
});
|
||||
|
||||
type ChildExit = { code: number | null; signal: NodeJS.Signals | null };
|
||||
let childExit: ChildExit | null = null;
|
||||
const childFinished = new Promise<ChildExit>((resolve) => {
|
||||
child.once("exit", (code, signal) => {
|
||||
childExit = { code, signal };
|
||||
childStopper.cancel();
|
||||
resolve(childExit);
|
||||
});
|
||||
});
|
||||
|
||||
const parentSignals: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"];
|
||||
for (const signal of parentSignals) {
|
||||
process.once(signal, stopChild);
|
||||
}
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
if (childExit) {
|
||||
break;
|
||||
}
|
||||
const result = await readNewMirrorData(options.mirrorPath, mirrorOffset);
|
||||
mirrorOffset = result.offset;
|
||||
if (result.chunk.byteLength > 0) {
|
||||
writeMirrorChunk(result.chunk);
|
||||
} else {
|
||||
renderWaitingStatus();
|
||||
}
|
||||
await delay(sawMirrorOutput ? 25 : 250);
|
||||
}
|
||||
|
||||
mirrorOffset = await drainNewMirrorData(options.mirrorPath, mirrorOffset, writeMirrorChunk);
|
||||
} finally {
|
||||
if (!childExit) {
|
||||
stopChild();
|
||||
}
|
||||
for (const signal of parentSignals) {
|
||||
process.off(signal, stopChild);
|
||||
}
|
||||
await drainParentInput();
|
||||
restoreInput();
|
||||
if (useAltScreen) {
|
||||
process.stdout.write("\x1b[?2026l\x1b[?2004l\x1b[>4;0m\x1b[?25h");
|
||||
}
|
||||
restoreScreen();
|
||||
}
|
||||
|
||||
if (!childExit) {
|
||||
childExit = await childFinished;
|
||||
}
|
||||
|
||||
if (childStdout.byteLength > 0) {
|
||||
process.stdout.write(childStdout);
|
||||
}
|
||||
if (childStderr.byteLength > 0) {
|
||||
process.stderr.write(childStderr);
|
||||
}
|
||||
|
||||
if (childExit.signal) {
|
||||
throw new Error(`TUI PTY tests exited with signal ${childExit.signal}`);
|
||||
}
|
||||
if (childExit.code !== 0) {
|
||||
process.exitCode = childExit.code ?? 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
main().catch((error: unknown) => {
|
||||
if (error instanceof CliArgumentError) {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.stack || error.message : String(error)}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
appendBufferTail,
|
||||
createChildStopper,
|
||||
drainNewMirrorData,
|
||||
parseOptions,
|
||||
readNewMirrorData,
|
||||
signalChildProcessTree,
|
||||
usage,
|
||||
};
|
||||
Reference in New Issue
Block a user