426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
1270 lines
37 KiB
TypeScript
1270 lines
37 KiB
TypeScript
/** Supports app-core build, packaging, or development orchestration for playwright ui live stack ts. */
|
|
import {
|
|
type ChildProcessWithoutNullStreams,
|
|
execFileSync,
|
|
spawn,
|
|
} from "node:child_process";
|
|
import { existsSync } from "node:fs";
|
|
import {
|
|
access,
|
|
cp,
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import {
|
|
createServer,
|
|
type IncomingMessage,
|
|
type Server,
|
|
type ServerResponse,
|
|
} from "node:http";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
import { WebSocket, WebSocketServer } from "ws";
|
|
import { buildFirstRunRuntimeConfig } from "../src/first-run/first-run-config.ts";
|
|
import { createLiveRuntimeChildEnv } from "../test/helpers/live-child-env.ts";
|
|
import {
|
|
getFirstRunProviderForLiveProvider,
|
|
selectLiveProviderAsync,
|
|
} from "../test/helpers/live-provider.ts";
|
|
import { resolveMainAppDir } from "./lib/app-dir.mjs";
|
|
import { shouldForceStubStack } from "./lib/ui-smoke-stub-decision.mjs";
|
|
import { viteRendererBuildNeeded } from "./lib/vite-renderer-dist-stale.mjs";
|
|
import {
|
|
clearPendingWebSocketQueue,
|
|
createPendingWebSocketQueueState,
|
|
DEFAULT_PENDING_WEBSOCKET_QUEUE_LIMITS,
|
|
drainPendingWebSocketQueue,
|
|
enqueuePendingWebSocketMessage,
|
|
type WebSocketSendData,
|
|
} from "./lib/websocket-pending-queue.ts";
|
|
|
|
const REPO_ROOT = path.resolve(import.meta.dirname, "..", "..", "..");
|
|
const CLEANUP_HELPER_SCRIPT = path.join(
|
|
REPO_ROOT,
|
|
"packages",
|
|
"scripts",
|
|
"rm-path-recursive.mjs",
|
|
);
|
|
const APP_DIR = resolveMainAppDir(REPO_ROOT, "app");
|
|
const APP_DIST_DIR = path.join(APP_DIR, "dist");
|
|
const COMPANION_PUBLIC_DIR = path.join(
|
|
REPO_ROOT,
|
|
"plugins",
|
|
"app-companion",
|
|
"public",
|
|
);
|
|
const UI_SMOKE_STUB_SCRIPT = path.join(
|
|
import.meta.dirname,
|
|
"playwright-ui-smoke-api-stub.mjs",
|
|
);
|
|
const READY_TIMEOUT_MS = 180_000;
|
|
const API_PORT = Number(process.env.ELIZA_UI_SMOKE_API_PORT ?? "31337");
|
|
const UI_PORT = Number(process.env.ELIZA_UI_SMOKE_PORT ?? "2138");
|
|
const UI_SMOKE_RUN_ID = process.env.ELIZA_UI_SMOKE_RUN_ID?.trim() ?? "";
|
|
const LIVE_PROVIDER = await selectLiveProviderAsync();
|
|
// Precedence (force-stub > live opt-in > CI default) lives in one tested helper.
|
|
// The key behavior: ELIZA_UI_SMOKE_LIVE_STACK=1 overrides the CI-based stub force
|
|
// so a genuinely-real lane is possible (GitHub Actions always sets CI=true, which
|
|
// would otherwise re-force the stub even when a provider key was supplied).
|
|
const FORCE_STUB_STACK = shouldForceStubStack(process.env);
|
|
const LIVE_STACK_OPTIONAL_VIEW_PLUGIN_ENTRIES = [
|
|
"calendar",
|
|
"inbox",
|
|
"todos",
|
|
"wallet-ui",
|
|
] as const;
|
|
// Extra optional plugin entry ids (comma-separated, e.g. "personal-assistant")
|
|
// seeded as `{ enabled: true }` into the live-stack eliza.json alongside the
|
|
// default view set. Opt-in via ELIZA_UI_SMOKE_PLUGIN_ENTRIES: specs that need a
|
|
// plugin outside the default view set set it alongside ELIZA_UI_SMOKE_LIVE_STACK=1
|
|
// (e.g. the scheduled-reminder live spec enables @elizaos/plugin-personal-assistant
|
|
// so the LifeOps scheduler tick drives the ScheduledTask runner and its in_app
|
|
// notification dispatch). The plugin must already be resolvable/built. Ignored by
|
|
// the stub stack.
|
|
const LIVE_STACK_EXTRA_PLUGIN_ENTRIES: readonly string[] = (
|
|
process.env.ELIZA_UI_SMOKE_PLUGIN_ENTRIES ?? ""
|
|
)
|
|
.split(",")
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0);
|
|
const LIVE_STACK_OPTIONAL_VIEW_PLUGIN_PACKAGES: ReadonlyArray<{
|
|
id: (typeof LIVE_STACK_OPTIONAL_VIEW_PLUGIN_ENTRIES)[number];
|
|
dir: string;
|
|
requiredBuildOutputs: readonly string[];
|
|
}> = [
|
|
{
|
|
id: "calendar",
|
|
dir: "plugin-calendar",
|
|
requiredBuildOutputs: [
|
|
"dist/index.js",
|
|
"dist/plugin.js",
|
|
"dist/views/bundle.js",
|
|
],
|
|
},
|
|
{
|
|
id: "inbox",
|
|
dir: "plugin-inbox",
|
|
requiredBuildOutputs: [
|
|
"dist/index.js",
|
|
"dist/plugin.js",
|
|
"dist/views/bundle.js",
|
|
],
|
|
},
|
|
{
|
|
id: "todos",
|
|
dir: "plugin-todos",
|
|
requiredBuildOutputs: ["dist/index.js", "dist/views/bundle.js"],
|
|
},
|
|
{
|
|
id: "wallet-ui",
|
|
dir: "plugin-wallet-ui",
|
|
requiredBuildOutputs: ["dist/index.js", "dist/views/bundle.js"],
|
|
},
|
|
];
|
|
const pendingStateDirs = new Set<string>();
|
|
const ownedStateDirs = new Set<string>();
|
|
|
|
type StartedStack = {
|
|
apiBase: string;
|
|
apiChild: ChildProcessWithoutNullStreams;
|
|
stateDir: string;
|
|
uiBase: string;
|
|
uiServer: Server;
|
|
};
|
|
|
|
async function createStateDir(prefix: string): Promise<string> {
|
|
const stateDir = await mkdtemp(path.join(os.tmpdir(), prefix));
|
|
pendingStateDirs.add(stateDir);
|
|
if (UI_SMOKE_RUN_ID) {
|
|
await writeFile(
|
|
path.join(stateDir, ".eliza-ui-smoke-run-id"),
|
|
`${UI_SMOKE_RUN_ID}\n`,
|
|
"utf8",
|
|
);
|
|
}
|
|
return stateDir;
|
|
}
|
|
|
|
function markStateDirOwnedByStack(stateDir: string): void {
|
|
pendingStateDirs.delete(stateDir);
|
|
ownedStateDirs.add(stateDir);
|
|
}
|
|
|
|
async function removePathRecursive(targetPath: string): Promise<void> {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const child = spawn(process.execPath, [CLEANUP_HELPER_SCRIPT, targetPath], {
|
|
cwd: REPO_ROOT,
|
|
stdio: "inherit",
|
|
});
|
|
child.on("error", reject);
|
|
child.on("exit", (code, signal) => {
|
|
if (signal) {
|
|
reject(new Error(`rm-path-recursive exited due to signal ${signal}`));
|
|
return;
|
|
}
|
|
if ((code ?? 1) !== 0) {
|
|
reject(
|
|
new Error(
|
|
`rm-path-recursive failed for ${targetPath} with status ${
|
|
code ?? 1
|
|
}`,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
async function cleanupPendingStateDirs(): Promise<void> {
|
|
const stateDirs = Array.from(pendingStateDirs);
|
|
pendingStateDirs.clear();
|
|
await Promise.all(stateDirs.map(removePathRecursive));
|
|
}
|
|
|
|
function cleanupKnownStateDirsSync(): void {
|
|
const stateDirs = new Set([...pendingStateDirs, ...ownedStateDirs]);
|
|
pendingStateDirs.clear();
|
|
ownedStateDirs.clear();
|
|
for (const stateDir of stateDirs) {
|
|
try {
|
|
execFileSync(process.execPath, [CLEANUP_HELPER_SCRIPT, stateDir], {
|
|
cwd: REPO_ROOT,
|
|
stdio: "ignore",
|
|
});
|
|
} catch {
|
|
// Best effort during process teardown.
|
|
}
|
|
}
|
|
}
|
|
|
|
function resolveBunCommand(): string {
|
|
const bunFromEnv = process.env.BUN?.trim();
|
|
if (bunFromEnv) {
|
|
if (existsSync(bunFromEnv)) {
|
|
return bunFromEnv;
|
|
}
|
|
const bunEnvFromPath = resolveExecutableFromPath(bunFromEnv);
|
|
if (bunEnvFromPath) {
|
|
return bunEnvFromPath;
|
|
}
|
|
}
|
|
|
|
const bunInstallRoot = process.env.BUN_INSTALL?.trim();
|
|
if (bunInstallRoot) {
|
|
const bunFromInstall = path.join(
|
|
bunInstallRoot,
|
|
"bin",
|
|
process.platform === "win32" ? "bun.exe" : "bun",
|
|
);
|
|
if (existsSync(bunFromInstall)) {
|
|
return bunFromInstall;
|
|
}
|
|
}
|
|
|
|
const homeBun = path.join(
|
|
os.homedir(),
|
|
".bun",
|
|
"bin",
|
|
process.platform === "win32" ? "bun.exe" : "bun",
|
|
);
|
|
if (existsSync(homeBun)) {
|
|
return homeBun;
|
|
}
|
|
|
|
const bunFromPath = resolveExecutableFromPath("bun");
|
|
if (bunFromPath) {
|
|
return bunFromPath;
|
|
}
|
|
|
|
return process.platform === "win32" ? "bun.exe" : "bun";
|
|
}
|
|
|
|
/**
|
|
* NODE_OPTIONS with the `--conditions=eliza-source` token removed, for build
|
|
* subprocesses that must resolve workspace packages through their normal
|
|
* (dist/browser) exports rather than source.
|
|
*
|
|
* The Playwright runner exports `NODE_OPTIONS=--conditions=eliza-source` so its
|
|
* spec collector resolves app-core/agent source on a fresh `--ignore-scripts`
|
|
* install (run-ui-playwright.mjs, #15764). That condition must NOT reach the
|
|
* Vite renderer build: `node vite.js build` loads `vite.config.ts` under node's
|
|
* native ESM/type-stripping loader, which — unlike tsx — does not rewrite
|
|
* `.js`→`.ts` import specifiers. Under `eliza-source` a package like
|
|
* `@elizaos/cloud-routing` resolves to `src/index.ts`, whose internal
|
|
* `import "./features.js"` has no on-disk match, and the whole build dies at
|
|
* config load (#15759). The renderer build resolves those packages via Vite's
|
|
* own bundler/aliases and their built dist, so it never needs the condition.
|
|
*/
|
|
function nodeOptionsWithoutElizaSource(): string {
|
|
const tokens = (process.env.NODE_OPTIONS ?? "").split(/\s+/).filter(Boolean);
|
|
const kept = tokens.filter((token, index) => {
|
|
if (token === "--conditions=eliza-source") return false;
|
|
// Also handle the space-separated `--conditions eliza-source` form.
|
|
if (token === "--conditions" && tokens[index + 1] === "eliza-source") {
|
|
return false;
|
|
}
|
|
if (token === "eliza-source" && tokens[index - 1] === "--conditions") {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
return kept.join(" ");
|
|
}
|
|
|
|
function resolveExecutableFromPath(command: string): string | null {
|
|
const pathValue = process.env.PATH ?? process.env.Path ?? "";
|
|
if (!pathValue) return null;
|
|
|
|
const hasExtension = path.extname(command).length > 0;
|
|
const pathExts =
|
|
process.platform === "win32"
|
|
? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
|
|
.split(";")
|
|
.map((ext) => ext.trim())
|
|
.filter(Boolean)
|
|
: [""];
|
|
const binaryNames =
|
|
process.platform === "win32" && !hasExtension
|
|
? pathExts.map((ext) => `${command}${ext.toLowerCase()}`)
|
|
: [command];
|
|
|
|
for (const dir of pathValue.split(path.delimiter)) {
|
|
if (!dir) continue;
|
|
for (const binaryName of binaryNames) {
|
|
const candidate = path.join(dir, binaryName);
|
|
if (existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function contentTypeFor(filePath: string): string {
|
|
switch (path.extname(filePath).toLowerCase()) {
|
|
case ".css":
|
|
return "text/css; charset=utf-8";
|
|
case ".html":
|
|
return "text/html; charset=utf-8";
|
|
case ".ico":
|
|
return "image/x-icon";
|
|
case ".jpeg":
|
|
case ".jpg":
|
|
return "image/jpeg";
|
|
case ".js":
|
|
return "application/javascript; charset=utf-8";
|
|
case ".json":
|
|
return "application/json; charset=utf-8";
|
|
case ".png":
|
|
return "image/png";
|
|
case ".svg":
|
|
return "image/svg+xml";
|
|
case ".woff":
|
|
return "font/woff";
|
|
case ".woff2":
|
|
return "font/woff2";
|
|
default:
|
|
return "application/octet-stream";
|
|
}
|
|
}
|
|
|
|
function resolveDistAssetPath(
|
|
requestedPath: string,
|
|
distDir: string,
|
|
): string | null {
|
|
const normalizedPath = requestedPath.replace(/^\/+/, "");
|
|
const segments = normalizedPath.split("/").filter(Boolean);
|
|
for (let index = 0; index < segments.length; index += 1) {
|
|
const candidatePath = path.resolve(
|
|
distDir,
|
|
segments.slice(index).join("/"),
|
|
);
|
|
if (
|
|
candidatePath.startsWith(distDir) &&
|
|
existsSync(candidatePath) &&
|
|
path.extname(candidatePath).length > 0
|
|
) {
|
|
return candidatePath;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveCompanionPublicAssetPath(requestedPath: string): string | null {
|
|
const normalizedPath = requestedPath.replace(/^\/+/, "");
|
|
if (
|
|
!normalizedPath.startsWith("animations/") &&
|
|
!normalizedPath.startsWith("vrm-decoders/") &&
|
|
!normalizedPath.startsWith("vrms/")
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const candidatePath = path.resolve(COMPANION_PUBLIC_DIR, normalizedPath);
|
|
if (
|
|
candidatePath.startsWith(COMPANION_PUBLIC_DIR) &&
|
|
existsSync(candidatePath) &&
|
|
path.extname(candidatePath).length > 0
|
|
) {
|
|
return candidatePath;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function readRequestBody(request: IncomingMessage): Promise<Buffer> {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of request) {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
}
|
|
return Buffer.concat(chunks);
|
|
}
|
|
|
|
/**
|
|
* Proxy the request to the API, retrying the transient undici keep-alive race
|
|
* (`UND_ERR_SOCKET: other side closed` → `TypeError: fetch failed`). The node
|
|
* HTTP API server closes idle keep-alive connections on its own timeout; under
|
|
* the app's concurrent boot fan-out undici reuses a socket the server just
|
|
* closed and the fetch throws before the request is ever sent — so retrying on
|
|
* a fresh connection is safe (the handler never ran) and is what keeps the app
|
|
* boot (plugins/config/WS) from degrading into a "Reconnecting" partial render.
|
|
*/
|
|
async function fetchApiWithRetry(
|
|
input: string,
|
|
init: RequestInit,
|
|
attempts = 5,
|
|
): Promise<Response> {
|
|
let lastError: unknown;
|
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
try {
|
|
return await fetch(input, init);
|
|
} catch (error) {
|
|
lastError = error;
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const cause =
|
|
error instanceof Error && error.cause instanceof Error
|
|
? error.cause.message
|
|
: "";
|
|
const transient =
|
|
message.includes("fetch failed") ||
|
|
cause.includes("other side closed") ||
|
|
cause.includes("UND_ERR_SOCKET") ||
|
|
cause.includes("ECONNRESET");
|
|
if (!transient || attempt === attempts - 1) {
|
|
throw error;
|
|
}
|
|
await sleep(50 * (attempt + 1));
|
|
}
|
|
}
|
|
throw lastError;
|
|
}
|
|
|
|
async function proxyUiRequest(args: {
|
|
apiBase: string;
|
|
request: IncomingMessage;
|
|
response: ServerResponse<IncomingMessage>;
|
|
uiDistDir: string;
|
|
}): Promise<void> {
|
|
const requestUrl = new URL(args.request.url ?? "/", "http://127.0.0.1");
|
|
|
|
if (requestUrl.pathname.startsWith("/api/")) {
|
|
const body = await readRequestBody(args.request);
|
|
const headers: Record<string, string> = {};
|
|
const contentType = args.request.headers["content-type"];
|
|
if (typeof contentType === "string") {
|
|
headers["content-type"] = contentType;
|
|
}
|
|
const authorization = args.request.headers.authorization;
|
|
if (typeof authorization === "string") {
|
|
headers.authorization = authorization;
|
|
}
|
|
|
|
const upstream = await fetchApiWithRetry(
|
|
`${args.apiBase}${requestUrl.pathname}${requestUrl.search}`,
|
|
{
|
|
body: body.byteLength > 0 ? body : undefined,
|
|
headers,
|
|
method: args.request.method ?? "GET",
|
|
},
|
|
);
|
|
const proxyHeaders: Record<string, string> = {};
|
|
upstream.headers.forEach((value, key) => {
|
|
if (key.toLowerCase() === "transfer-encoding") {
|
|
return;
|
|
}
|
|
proxyHeaders[key] = value;
|
|
});
|
|
args.response.writeHead(upstream.status, proxyHeaders);
|
|
args.response.end(Buffer.from(await upstream.arrayBuffer()));
|
|
return;
|
|
}
|
|
|
|
const requestedPath =
|
|
requestUrl.pathname === "/"
|
|
? "index.html"
|
|
: requestUrl.pathname.replace(/^\/+/, "");
|
|
let filePath =
|
|
resolveDistAssetPath(requestedPath, args.uiDistDir) ??
|
|
resolveCompanionPublicAssetPath(requestedPath);
|
|
const isAssetRequest = path.extname(requestedPath).length > 0;
|
|
const indexHtmlPath = path.join(args.uiDistDir, "index.html");
|
|
if (!filePath && isAssetRequest) {
|
|
args.response.writeHead(404, {
|
|
"Content-Type": "application/json",
|
|
});
|
|
args.response.end(JSON.stringify({ error: "Static asset not found" }));
|
|
return;
|
|
}
|
|
if (!filePath && !isAssetRequest) {
|
|
filePath = indexHtmlPath;
|
|
}
|
|
|
|
const primaryPath = filePath ?? indexHtmlPath;
|
|
let body: Buffer | null = null;
|
|
let resolvedPath = primaryPath;
|
|
const maxReadAttempts = 300;
|
|
for (let attempt = 0; attempt < maxReadAttempts; attempt++) {
|
|
try {
|
|
body = await readFile(primaryPath);
|
|
resolvedPath = primaryPath;
|
|
break;
|
|
} catch {
|
|
try {
|
|
body = await readFile(indexHtmlPath);
|
|
resolvedPath = indexHtmlPath;
|
|
break;
|
|
} catch {
|
|
await sleep(100);
|
|
}
|
|
}
|
|
}
|
|
if (!body) {
|
|
throw new Error(`UI dist unavailable after retries: ${indexHtmlPath}`);
|
|
}
|
|
|
|
args.response.writeHead(200, {
|
|
"Content-Type": contentTypeFor(resolvedPath),
|
|
});
|
|
args.response.end(body);
|
|
}
|
|
|
|
function relayWebSocket(args: {
|
|
apiBase: string;
|
|
request: IncomingMessage;
|
|
clientSocket: WebSocket;
|
|
}): void {
|
|
const requestUrl = new URL(args.request.url ?? "/ws", "http://127.0.0.1");
|
|
const upstreamUrl = new URL(args.apiBase);
|
|
upstreamUrl.protocol = upstreamUrl.protocol === "https:" ? "wss:" : "ws:";
|
|
upstreamUrl.pathname = requestUrl.pathname;
|
|
upstreamUrl.search = requestUrl.search;
|
|
|
|
const upstreamSocket = new WebSocket(upstreamUrl, {
|
|
headers:
|
|
typeof args.request.headers.authorization === "string"
|
|
? { authorization: args.request.headers.authorization }
|
|
: undefined,
|
|
});
|
|
|
|
const pendingClientQueue =
|
|
createPendingWebSocketQueueState<WebSocketSendData>();
|
|
|
|
const closeSocket = (socket: WebSocket, code?: number, reason?: string) => {
|
|
if (
|
|
socket.readyState === WebSocket.OPEN ||
|
|
socket.readyState === WebSocket.CONNECTING
|
|
) {
|
|
socket.close(code, reason);
|
|
}
|
|
};
|
|
|
|
args.clientSocket.on("message", (data, isBinary) => {
|
|
if (upstreamSocket.readyState === WebSocket.OPEN) {
|
|
upstreamSocket.send(data, { binary: isBinary });
|
|
return;
|
|
}
|
|
if (upstreamSocket.readyState === WebSocket.CONNECTING) {
|
|
const accepted = enqueuePendingWebSocketMessage(
|
|
pendingClientQueue,
|
|
{ data, isBinary },
|
|
DEFAULT_PENDING_WEBSOCKET_QUEUE_LIMITS,
|
|
);
|
|
if (!accepted) {
|
|
clearPendingWebSocketQueue(pendingClientQueue);
|
|
closeSocket(
|
|
args.clientSocket,
|
|
1009,
|
|
"Pending websocket queue overflow",
|
|
);
|
|
closeSocket(upstreamSocket);
|
|
}
|
|
}
|
|
});
|
|
|
|
upstreamSocket.on("open", () => {
|
|
for (const message of drainPendingWebSocketQueue(pendingClientQueue)) {
|
|
upstreamSocket.send(message.data, { binary: message.isBinary });
|
|
}
|
|
});
|
|
|
|
upstreamSocket.on("message", (data, isBinary) => {
|
|
if (args.clientSocket.readyState !== WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
args.clientSocket.send(data, { binary: isBinary });
|
|
});
|
|
|
|
args.clientSocket.on("close", () => {
|
|
clearPendingWebSocketQueue(pendingClientQueue);
|
|
closeSocket(upstreamSocket);
|
|
});
|
|
upstreamSocket.on("close", () => {
|
|
clearPendingWebSocketQueue(pendingClientQueue);
|
|
closeSocket(args.clientSocket);
|
|
});
|
|
|
|
args.clientSocket.on("error", () => {
|
|
clearPendingWebSocketQueue(pendingClientQueue);
|
|
closeSocket(upstreamSocket);
|
|
});
|
|
upstreamSocket.on("error", () => {
|
|
clearPendingWebSocketQueue(pendingClientQueue);
|
|
closeSocket(args.clientSocket);
|
|
});
|
|
}
|
|
|
|
async function startUiProxyServer(args: {
|
|
apiBase: string;
|
|
port: number;
|
|
uiDistDir: string;
|
|
}): Promise<Server> {
|
|
const server = createServer(async (request, response) => {
|
|
try {
|
|
await proxyUiRequest({
|
|
apiBase: args.apiBase,
|
|
request,
|
|
response,
|
|
uiDistDir: args.uiDistDir,
|
|
});
|
|
} catch (error) {
|
|
if (response.headersSent || response.writableEnded) {
|
|
if (!response.writableEnded) {
|
|
response.end();
|
|
}
|
|
return;
|
|
}
|
|
console.error("[playwright-ui-live-stack] proxy error:", error);
|
|
response.writeHead(500, {
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
});
|
|
response.end(JSON.stringify({ error: "Internal proxy error" }));
|
|
}
|
|
});
|
|
const wss = new WebSocketServer({ noServer: true });
|
|
|
|
server.on("upgrade", (request, socket, head) => {
|
|
const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
if (requestUrl.pathname !== "/ws") {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
wss.handleUpgrade(request, socket, head, (clientSocket) => {
|
|
relayWebSocket({
|
|
apiBase: args.apiBase,
|
|
request,
|
|
clientSocket,
|
|
});
|
|
});
|
|
});
|
|
server.on("close", () => {
|
|
for (const client of wss.clients) {
|
|
client.close();
|
|
}
|
|
wss.close();
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(args.port, "127.0.0.1", () => resolve());
|
|
});
|
|
return server;
|
|
}
|
|
|
|
async function waitForChildExit(
|
|
child: ChildProcessWithoutNullStreams,
|
|
timeoutMs: number,
|
|
): Promise<boolean> {
|
|
if (child.exitCode != null) {
|
|
return true;
|
|
}
|
|
|
|
return await new Promise((resolve) => {
|
|
const timeout = setTimeout(() => {
|
|
cleanup();
|
|
resolve(false);
|
|
}, timeoutMs);
|
|
|
|
const handleExit = () => {
|
|
cleanup();
|
|
resolve(true);
|
|
};
|
|
|
|
const cleanup = () => {
|
|
clearTimeout(timeout);
|
|
child.off("exit", handleExit);
|
|
child.off("close", handleExit);
|
|
};
|
|
|
|
child.once("exit", handleExit);
|
|
child.once("close", handleExit);
|
|
});
|
|
}
|
|
|
|
async function closeUiServer(uiServer: Server | null): Promise<void> {
|
|
if (!uiServer) return;
|
|
try {
|
|
await new Promise<void>((resolve, reject) =>
|
|
uiServer.close((error) => (error ? reject(error) : resolve())),
|
|
);
|
|
} catch {
|
|
// Best effort during shutdown.
|
|
}
|
|
}
|
|
|
|
async function stopApiChild(
|
|
apiChild: ChildProcessWithoutNullStreams | null,
|
|
): Promise<void> {
|
|
if (!apiChild || apiChild.exitCode != null) return;
|
|
apiChild.kill("SIGTERM");
|
|
const exitedAfterTerm = await waitForChildExit(apiChild, 5_000);
|
|
if (!exitedAfterTerm && apiChild.exitCode == null) {
|
|
apiChild.kill("SIGKILL");
|
|
await waitForChildExit(apiChild, 5_000);
|
|
}
|
|
}
|
|
|
|
async function fetchJson<T>(url: string): Promise<T> {
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
throw new Error(`Request failed (${response.status}): ${url}`);
|
|
}
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
async function waitForJson<T>(
|
|
url: string,
|
|
timeoutMs: number = READY_TIMEOUT_MS,
|
|
): Promise<T> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastError: unknown = null;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
return await fetchJson<T>(url);
|
|
} catch (error) {
|
|
lastError = error;
|
|
await sleep(1_000);
|
|
}
|
|
}
|
|
throw lastError instanceof Error
|
|
? lastError
|
|
: new Error(`Timed out waiting for ${url}`);
|
|
}
|
|
|
|
async function waitForJsonPredicate<T>(
|
|
url: string,
|
|
predicate: (value: T) => boolean,
|
|
timeoutMs: number = READY_TIMEOUT_MS,
|
|
): Promise<T> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastValue: T | null = null;
|
|
let lastError: unknown = null;
|
|
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const value = await fetchJson<T>(url);
|
|
lastValue = value;
|
|
if (predicate(value)) {
|
|
return value;
|
|
}
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
await sleep(1_000);
|
|
}
|
|
|
|
if (lastValue != null) {
|
|
throw new Error(`Timed out waiting for predicate match: ${url}`);
|
|
}
|
|
|
|
throw lastError instanceof Error
|
|
? lastError
|
|
: new Error(`Timed out waiting for ${url}`);
|
|
}
|
|
|
|
function createProcessLogSignal(matchText: string): {
|
|
observe: (chunk: Buffer | string) => void;
|
|
wait: (timeoutMs: number, label: string) => Promise<void>;
|
|
} {
|
|
let matched = false;
|
|
let tail = "";
|
|
const waiters = new Set<() => void>();
|
|
|
|
return {
|
|
observe(chunk) {
|
|
if (matched) {
|
|
return;
|
|
}
|
|
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
tail = `${tail}${text}`.slice(-Math.max(matchText.length * 2, 4096));
|
|
if (!tail.includes(matchText)) {
|
|
return;
|
|
}
|
|
matched = true;
|
|
tail = "";
|
|
for (const resolve of waiters) {
|
|
resolve();
|
|
}
|
|
waiters.clear();
|
|
},
|
|
async wait(timeoutMs, label) {
|
|
if (matched) {
|
|
return;
|
|
}
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
cleanup();
|
|
reject(new Error(`Timed out waiting for ${label}`));
|
|
}, timeoutMs);
|
|
const finish = () => {
|
|
cleanup();
|
|
resolve();
|
|
};
|
|
const cleanup = () => {
|
|
clearTimeout(timeout);
|
|
waiters.delete(finish);
|
|
};
|
|
waiters.add(finish);
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
async function ensureUiDistReady(): Promise<void> {
|
|
const distIndex = path.join(APP_DIST_DIR, "index.html");
|
|
let needsBuild = false;
|
|
|
|
try {
|
|
await access(distIndex);
|
|
needsBuild = viteRendererBuildNeeded(APP_DIR, REPO_ROOT);
|
|
} catch {
|
|
needsBuild = true;
|
|
}
|
|
|
|
// Escape hatch symmetric to ELIZA_DESKTOP_RENDERER_BUILD=always: when the dist
|
|
// is known-current and only the stub API or specs changed, skip the ~12 min
|
|
// rebuild. The mtime heuristic false-positives whenever an unrelated bulk op
|
|
// (git checkout, repo-wide formatter) bumps source mtimes without touching the
|
|
// renderer. Only honored when a built index.html already exists.
|
|
if (needsBuild && process.env.ELIZA_UI_SMOKE_SKIP_BUILD === "1") {
|
|
try {
|
|
await access(distIndex);
|
|
needsBuild = false;
|
|
} catch {
|
|
throw new Error(
|
|
`ELIZA_UI_SMOKE_SKIP_BUILD=1 but no built renderer at ${distIndex}. Build once (bun run --cwd packages/app build:web) before skipping.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!needsBuild) {
|
|
return;
|
|
}
|
|
|
|
await removePathRecursive(path.join(APP_DIR, ".vite"));
|
|
|
|
const logs: string[] = [];
|
|
const child = spawn(resolveBunCommand(), ["run", "build:web"], {
|
|
cwd: APP_DIR,
|
|
env: {
|
|
...process.env,
|
|
// The renderer build resolves workspace packages via Vite/dist, not the
|
|
// Playwright collector's `eliza-source` condition; leaving it in leaks
|
|
// into `node vite.js build`'s config load and kills it (#15759).
|
|
NODE_OPTIONS: nodeOptionsWithoutElizaSource(),
|
|
FORCE_COLOR: "0",
|
|
VITE_ELIZA_RENDER_TELEMETRY: "1",
|
|
// ui-smoke serves the dist locally and never ships it, so skip the
|
|
// memory-heavy esbuild minify pass that otherwise OOMs (EPIPE) on a
|
|
// ~6.5MB bundle in CI/sandbox builds.
|
|
ELIZA_DESKTOP_VITE_FAST_DIST: "1",
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
child.stdout.on("data", (chunk) => logs.push(String(chunk)));
|
|
child.stderr.on("data", (chunk) => logs.push(String(chunk)));
|
|
|
|
// Cold renderer build transforms ~3000 modules and measures ~12 min on a
|
|
// clean dist; cap at 18 min so a legitimately slow first build is not killed
|
|
// mid-flight (the previous 5/10 min caps produced spurious "service stopped").
|
|
const RENDERER_BUILD_TIMEOUT_MS = 1_080_000;
|
|
const exited = await waitForChildExit(child, RENDERER_BUILD_TIMEOUT_MS);
|
|
if (!exited) {
|
|
child.kill("SIGKILL");
|
|
throw new Error(
|
|
`app renderer build timed out after ${RENDERER_BUILD_TIMEOUT_MS}ms.\n${logs.join("").slice(-8_000)}`,
|
|
);
|
|
}
|
|
if (child.exitCode !== 0) {
|
|
throw new Error(
|
|
`app renderer build failed (exit ${child.exitCode}).\n${logs.join("").slice(-8_000)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function snapshotUiDist(stateDir: string): Promise<string> {
|
|
const snapshotDir = path.join(stateDir, "ui-dist");
|
|
await cp(APP_DIST_DIR, snapshotDir, {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
await access(path.join(snapshotDir, "index.html"));
|
|
return snapshotDir;
|
|
}
|
|
|
|
async function submitFirstRun(apiBase: string): Promise<void> {
|
|
if (!LIVE_PROVIDER) {
|
|
throw new Error(
|
|
"UI smoke needs a real provider. Set OPENAI_API_KEY, GROQ_API_KEY, ANTHROPIC_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, OPENROUTER_API_KEY, or ELIZAOS_CLOUD_API_KEY.",
|
|
);
|
|
}
|
|
|
|
const runtimeConfig = buildFirstRunRuntimeConfig({
|
|
firstRunRuntimeTarget: "local",
|
|
firstRunCloudApiKey: "",
|
|
firstRunProvider: getFirstRunProviderForLiveProvider(LIVE_PROVIDER),
|
|
firstRunApiKey: LIVE_PROVIDER.apiKey,
|
|
firstRunVoiceProvider: "",
|
|
firstRunVoiceApiKey: "",
|
|
firstRunPrimaryModel: LIVE_PROVIDER.largeModel,
|
|
firstRunOpenRouterModel: LIVE_PROVIDER.largeModel,
|
|
firstRunRemoteConnected: false,
|
|
firstRunRemoteApiBase: "",
|
|
firstRunRemoteToken: "",
|
|
firstRunSmallModel: LIVE_PROVIDER.smallModel,
|
|
firstRunLargeModel: LIVE_PROVIDER.largeModel,
|
|
});
|
|
|
|
const response = await fetch(`${apiBase}/api/first-run`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
name: "Playwright Smoke",
|
|
bio: ["A real runtime used by the UI smoke suite."],
|
|
systemPrompt: "Concise assistant for Playwright smoke tests.",
|
|
language: "en",
|
|
presetId: "default",
|
|
avatarIndex: 0,
|
|
deploymentTarget: runtimeConfig.deploymentTarget,
|
|
...(runtimeConfig.linkedAccounts
|
|
? { linkedAccounts: runtimeConfig.linkedAccounts }
|
|
: {}),
|
|
...(runtimeConfig.serviceRouting
|
|
? { serviceRouting: runtimeConfig.serviceRouting }
|
|
: {}),
|
|
...(runtimeConfig.credentialInputs
|
|
? { credentialInputs: runtimeConfig.credentialInputs }
|
|
: {}),
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Onboarding failed with ${response.status}: ${await response.text()}`,
|
|
);
|
|
}
|
|
|
|
await waitForJsonPredicate<{ complete: boolean }>(
|
|
`${apiBase}/api/first-run/status`,
|
|
(status) => status.complete === true,
|
|
READY_TIMEOUT_MS,
|
|
);
|
|
}
|
|
|
|
async function seedLiveStackConfig(stateDir: string): Promise<void> {
|
|
await mkdir(stateDir, { recursive: true });
|
|
const configPath = path.join(stateDir, "eliza.json");
|
|
await writeFile(
|
|
configPath,
|
|
`${JSON.stringify(
|
|
{
|
|
logging: { level: "error" },
|
|
plugins: {
|
|
entries: Object.fromEntries(
|
|
[
|
|
...LIVE_STACK_OPTIONAL_VIEW_PLUGIN_ENTRIES,
|
|
...LIVE_STACK_EXTRA_PLUGIN_ENTRIES,
|
|
].map((pluginId) => [pluginId, { enabled: true }]),
|
|
),
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
async function ensureLiveStackOptionalViewPluginsReady(): Promise<void> {
|
|
for (const plugin of LIVE_STACK_OPTIONAL_VIEW_PLUGIN_PACKAGES) {
|
|
const pluginDir = path.join(REPO_ROOT, "plugins", plugin.dir);
|
|
const outputPaths = plugin.requiredBuildOutputs.map((output) =>
|
|
path.join(pluginDir, output),
|
|
);
|
|
const missingOutputPaths: string[] = [];
|
|
for (const outputPath of outputPaths) {
|
|
try {
|
|
await access(outputPath);
|
|
} catch {
|
|
missingOutputPaths.push(outputPath);
|
|
}
|
|
}
|
|
if (missingOutputPaths.length === 0) continue;
|
|
|
|
const logs: string[] = [];
|
|
const child = spawn(resolveBunCommand(), ["run", "build"], {
|
|
cwd: pluginDir,
|
|
env: {
|
|
...process.env,
|
|
// Same reason as the renderer build: a plugin's own build must resolve
|
|
// through dist/Vite, not the collector's `eliza-source` condition
|
|
// (#15759).
|
|
NODE_OPTIONS: nodeOptionsWithoutElizaSource(),
|
|
FORCE_COLOR: "0",
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
child.stdout.on("data", (chunk) => logs.push(String(chunk)));
|
|
child.stderr.on("data", (chunk) => logs.push(String(chunk)));
|
|
|
|
const BUILD_TIMEOUT_MS = 300_000;
|
|
const exited = await waitForChildExit(child, BUILD_TIMEOUT_MS);
|
|
if (!exited) {
|
|
child.kill("SIGKILL");
|
|
throw new Error(
|
|
`Timed out building optional live-stack plugin ${plugin.id} after ${BUILD_TIMEOUT_MS}ms.\n${logs.join("").slice(-8_000)}`,
|
|
);
|
|
}
|
|
if (child.exitCode !== 0) {
|
|
throw new Error(
|
|
`Failed to build optional live-stack plugin ${plugin.id}.\n${logs.join("").slice(-8_000)}`,
|
|
);
|
|
}
|
|
for (const outputPath of outputPaths) {
|
|
await access(outputPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function startStubStack(): Promise<StartedStack> {
|
|
const stateDir = await createStateDir("eliza-ui-smoke-stub-");
|
|
let apiChild: ChildProcessWithoutNullStreams | null = null;
|
|
let uiServer: Server | null = null;
|
|
try {
|
|
const uiDistDir = await snapshotUiDist(stateDir);
|
|
const apiBase = `http://127.0.0.1:${API_PORT}`;
|
|
apiChild = spawn("node", [UI_SMOKE_STUB_SCRIPT], {
|
|
cwd: REPO_ROOT,
|
|
env: {
|
|
...process.env,
|
|
FORCE_COLOR: "0",
|
|
ELIZA_UI_SMOKE_API_PORT: String(API_PORT),
|
|
ELIZA_UI_SMOKE_STUB_IGNORE_SIGTERM: "1",
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
apiChild.stdout.on("data", (chunk) => {
|
|
process.stdout.write(`[ui-smoke][stub] ${chunk}`);
|
|
});
|
|
apiChild.stderr.on("data", (chunk) => {
|
|
process.stdout.write(`[ui-smoke][stub-err] ${chunk}`);
|
|
});
|
|
|
|
await waitForJson<{ complete: boolean }>(`${apiBase}/api/first-run/status`);
|
|
await waitForJsonPredicate<{ state?: string }>(
|
|
`${apiBase}/api/status`,
|
|
(status) => status.state === "running",
|
|
READY_TIMEOUT_MS,
|
|
);
|
|
await waitForJsonPredicate<{ session?: { kind?: string } }>(
|
|
`${apiBase}/api/auth/me`,
|
|
(me) => me.session?.kind === "local",
|
|
READY_TIMEOUT_MS,
|
|
);
|
|
|
|
uiServer = await startUiProxyServer({
|
|
apiBase,
|
|
port: UI_PORT,
|
|
uiDistDir,
|
|
});
|
|
process.env.ELIZA_API_PORT = String(API_PORT);
|
|
markStateDirOwnedByStack(stateDir);
|
|
const startedApiChild = apiChild;
|
|
const startedUiServer = uiServer;
|
|
|
|
return {
|
|
apiBase,
|
|
apiChild: startedApiChild,
|
|
stateDir,
|
|
uiBase: `http://127.0.0.1:${UI_PORT}`,
|
|
uiServer: startedUiServer,
|
|
};
|
|
} catch (error) {
|
|
await closeUiServer(uiServer);
|
|
await stopApiChild(apiChild);
|
|
await removePathRecursive(stateDir);
|
|
pendingStateDirs.delete(stateDir);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function startRealStack(): Promise<StartedStack> {
|
|
await ensureUiDistReady();
|
|
|
|
if (FORCE_STUB_STACK || !LIVE_PROVIDER) {
|
|
return startStubStack();
|
|
}
|
|
|
|
await ensureLiveStackOptionalViewPluginsReady();
|
|
|
|
const stateDir = await createStateDir("eliza-ui-smoke-live-");
|
|
let apiChild: ChildProcessWithoutNullStreams | null = null;
|
|
let uiServer: Server | null = null;
|
|
try {
|
|
await seedLiveStackConfig(stateDir);
|
|
const uiDistDir = await snapshotUiDist(stateDir);
|
|
const apiBase = `http://127.0.0.1:${API_PORT}`;
|
|
const deferredBootComplete = createProcessLogSignal(
|
|
"[eliza-boot] deferred:complete",
|
|
);
|
|
apiChild = spawn(
|
|
"node",
|
|
[
|
|
path.join(REPO_ROOT, "packages/app-core/scripts/run-node-tsx.mjs"),
|
|
path.join(REPO_ROOT, "packages/app-core/src/runtime/eliza.ts"),
|
|
],
|
|
{
|
|
cwd: REPO_ROOT,
|
|
env: createLiveRuntimeChildEnv({
|
|
...(LIVE_PROVIDER?.env ?? {}),
|
|
ALLOW_NO_DATABASE: "",
|
|
FORCE_COLOR: "0",
|
|
ELIZA_API_PORT: String(API_PORT),
|
|
ELIZA_HOME_PORT: String(UI_PORT),
|
|
ELIZA_PORT: String(API_PORT),
|
|
ELIZA_STATE_DIR: stateDir,
|
|
}),
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
},
|
|
);
|
|
|
|
apiChild.stdout.on("data", (chunk) => {
|
|
deferredBootComplete.observe(chunk);
|
|
process.stdout.write(`[ui-smoke][api] ${chunk}`);
|
|
});
|
|
apiChild.stderr.on("data", (chunk) => {
|
|
deferredBootComplete.observe(chunk);
|
|
process.stdout.write(`[ui-smoke][api-err] ${chunk}`);
|
|
});
|
|
|
|
await waitForJson<{ complete: boolean }>(`${apiBase}/api/first-run/status`);
|
|
// Cloud-live mode (ELIZA_UI_SMOKE_CLOUD_LIVE=1) leaves first-run UNcompleted so
|
|
// the spec can drive the real cloud onboarding (login -> provision) through the
|
|
// UI against real Eliza Cloud. The default lane auto-completes a local first-run
|
|
// so chat/view specs land on a ready agent.
|
|
const skipAutoFirstRun = process.env.ELIZA_UI_SMOKE_CLOUD_LIVE === "1";
|
|
if (!skipAutoFirstRun) {
|
|
const onboardingStatus = await fetchJson<{ complete: boolean }>(
|
|
`${apiBase}/api/first-run/status`,
|
|
);
|
|
if (!onboardingStatus.complete) {
|
|
await submitFirstRun(apiBase);
|
|
}
|
|
|
|
await waitForJsonPredicate<{ complete: boolean }>(
|
|
`${apiBase}/api/first-run/status`,
|
|
(status) => status.complete === true,
|
|
READY_TIMEOUT_MS,
|
|
);
|
|
}
|
|
await waitForJsonPredicate<{ state?: string }>(
|
|
`${apiBase}/api/status`,
|
|
(status) => status.state === "running",
|
|
READY_TIMEOUT_MS,
|
|
);
|
|
await waitForJsonPredicate<{ session?: { kind?: string } }>(
|
|
`${apiBase}/api/auth/me`,
|
|
(me) => me.session?.kind === "local",
|
|
READY_TIMEOUT_MS,
|
|
);
|
|
if (!skipAutoFirstRun) {
|
|
// App-control and plugin views are deferred capabilities. Treat the live
|
|
// harness as ready only after those runtime plugins have had a chance to
|
|
// register; otherwise chat-driven view switching can race boot.
|
|
await deferredBootComplete.wait(
|
|
READY_TIMEOUT_MS,
|
|
"deferred runtime plugin registration",
|
|
);
|
|
}
|
|
|
|
uiServer = await startUiProxyServer({
|
|
apiBase,
|
|
port: UI_PORT,
|
|
uiDistDir,
|
|
});
|
|
process.env.ELIZA_API_PORT = String(API_PORT);
|
|
markStateDirOwnedByStack(stateDir);
|
|
const startedApiChild = apiChild;
|
|
const startedUiServer = uiServer;
|
|
|
|
return {
|
|
apiBase,
|
|
apiChild: startedApiChild,
|
|
stateDir,
|
|
uiBase: `http://127.0.0.1:${UI_PORT}`,
|
|
uiServer: startedUiServer,
|
|
};
|
|
} catch (error) {
|
|
await closeUiServer(uiServer);
|
|
await stopApiChild(apiChild);
|
|
await removePathRecursive(stateDir);
|
|
pendingStateDirs.delete(stateDir);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function stopRealStack(stack: StartedStack | null): Promise<void> {
|
|
if (!stack) {
|
|
return;
|
|
}
|
|
|
|
await closeUiServer(stack.uiServer);
|
|
await stopApiChild(stack.apiChild);
|
|
|
|
await removePathRecursive(stack.stateDir);
|
|
ownedStateDirs.delete(stack.stateDir);
|
|
}
|
|
|
|
let stack: StartedStack | null = null;
|
|
let shuttingDown = false;
|
|
|
|
process.once("exit", cleanupKnownStateDirsSync);
|
|
|
|
async function shutdown(exitCode: number): Promise<void> {
|
|
if (shuttingDown) {
|
|
return;
|
|
}
|
|
shuttingDown = true;
|
|
await stopRealStack(stack);
|
|
await cleanupPendingStateDirs();
|
|
process.exit(exitCode);
|
|
}
|
|
|
|
process.once("SIGINT", () => {
|
|
void shutdown(0);
|
|
});
|
|
process.once("SIGTERM", () => {
|
|
void shutdown(0);
|
|
});
|
|
|
|
try {
|
|
stack = await startRealStack();
|
|
stack.apiChild.once("exit", (code, signal) => {
|
|
if (shuttingDown) {
|
|
return;
|
|
}
|
|
const reason =
|
|
signal != null ? `signal ${signal}` : `exit code ${String(code ?? 1)}`;
|
|
console.error(`[ui-smoke] runtime exited unexpectedly (${reason}).`);
|
|
void shutdown(1);
|
|
});
|
|
console.log(`[ui-smoke] live UI ready at ${stack.uiBase}`);
|
|
await new Promise(() => {});
|
|
} catch (error) {
|
|
console.error(
|
|
`[ui-smoke] failed to start live stack: ${
|
|
error instanceof Error ? (error.stack ?? error.message) : String(error)
|
|
}`,
|
|
);
|
|
await stopRealStack(stack);
|
|
await cleanupPendingStateDirs();
|
|
process.exit(1);
|
|
}
|