Download __tests__/scripts/dev-with-automation.test.ts from SaylorTwift/openhands: direct link, hf CLI and curl.
- Browser
- Download file 37.4 kB
-
https://huggingface.co/SaylorTwift/openhands/resolve/main/__tests__/scripts/dev-with-automation.test.ts
- Command line
-
hf download hf://SaylorTwift/openhands/__tests__/scripts/dev-with-automation.test.ts
-
curl -L -o dev-with-automation.test.ts https://huggingface.co/SaylorTwift/openhands/resolve/main/__tests__/scripts/dev-with-automation.test.ts
37.4 kB
| // @vitest-environment node | |
| // These tests load `scripts/dev-with-automation.mjs` and `scripts/dev-safe.mjs`, | |
| // which construct file:// URLs relative to their own location via | |
| // `new URL("../tools", import.meta.url)`. jsdom's URL constructor ignores | |
| // file:// base URLs (it falls back to its document base, e.g. | |
| // http://localhost:3000/), breaking that resolution; the Node environment | |
| // has the standard WHATWG URL behavior that honors the file:// base. | |
| import net from "node:net"; | |
| import { spawn } from "node:child_process"; | |
| import { once } from "node:events"; | |
| import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| import { homedir, tmpdir } from "node:os"; | |
| import path from "node:path"; | |
| import { setTimeout as delay } from "node:timers/promises"; | |
| import { fileURLToPath, pathToFileURL } from "node:url"; | |
| import { describe, expect, it, afterEach } from "vitest"; | |
| import { | |
| buildAgentServerAutomationEnv, | |
| buildAutomationCommand, | |
| buildAutomationTelemetryEnv, | |
| buildConfig, | |
| buildRouteArgs, | |
| buildViteBackendEnv, | |
| getAgentServerBaseUrl, | |
| getFrontendBackend, | |
| getLocalServiceRoutes, | |
| getRejectPrefixes, | |
| setServiceLogListener, | |
| spawnService, | |
| validateLocalAutomationPath, | |
| DEFAULT_AUTOMATION_REPO, | |
| DEFAULT_AUTOMATION_PACKAGE, | |
| DEFAULT_AUTOMATION_VERSION, | |
| DEFAULT_BACKEND_PORT, | |
| DEFAULT_AUTOMATION_PORT, | |
| } from "../../scripts/dev-with-automation.mjs"; | |
| import { | |
| buildAgentServerEnv, | |
| buildSafeDevConfig, | |
| resetPersistedSessionApiKeyCache, | |
| } from "../../scripts/dev-safe.mjs"; | |
| import { createRouter } from "../../scripts/proxy-utils.mjs"; | |
| const repoRoot = path.resolve( | |
| path.dirname(fileURLToPath(import.meta.url)), | |
| "../..", | |
| ); | |
| describe("buildAutomationCommand", () => { | |
| it("uses released PyPI version by default", () => { | |
| const cmd = buildAutomationCommand({}); | |
| expect(cmd.command).toBe("uvx"); | |
| expect(cmd.args).toContain("--from"); | |
| expect(cmd.args).toContain( | |
| `${DEFAULT_AUTOMATION_PACKAGE}==${DEFAULT_AUTOMATION_VERSION}`, | |
| ); | |
| expect(cmd.args).toContain("uvicorn"); | |
| expect(cmd.args).toContain("openhands.automation.app:app"); | |
| expect(cmd.source).toBe(`PyPI (${DEFAULT_AUTOMATION_VERSION}, default)`); | |
| }); | |
| it("uses custom git ref from OH_AUTOMATION_GIT_REF", () => { | |
| const cmd = buildAutomationCommand({ | |
| OH_AUTOMATION_GIT_REF: "feat/my-feature", | |
| }); | |
| expect(cmd.command).toBe("uvx"); | |
| expect(cmd.args).toContain("--from"); | |
| expect(cmd.args).toContain( | |
| `git+${DEFAULT_AUTOMATION_REPO}@feat/my-feature`, | |
| ); | |
| expect(cmd.source).toBe("git (feat/my-feature)"); | |
| }); | |
| it("uses custom repo with git ref", () => { | |
| const cmd = buildAutomationCommand({ | |
| OH_AUTOMATION_REPO: "https://github.com/MyOrg/my-automation", | |
| OH_AUTOMATION_GIT_REF: "main", | |
| }); | |
| expect(cmd.command).toBe("uvx"); | |
| expect(cmd.args).toContain( | |
| "git+https://github.com/MyOrg/my-automation@main", | |
| ); | |
| }); | |
| it("uses both custom repo and ref together", () => { | |
| const cmd = buildAutomationCommand({ | |
| OH_AUTOMATION_REPO: "https://github.com/MyOrg/my-automation", | |
| OH_AUTOMATION_GIT_REF: "v1.0.0", | |
| }); | |
| expect(cmd.command).toBe("uvx"); | |
| expect(cmd.args).toContain( | |
| "git+https://github.com/MyOrg/my-automation@v1.0.0", | |
| ); | |
| expect(cmd.source).toBe("git (v1.0.0)"); | |
| }); | |
| it("supports commit SHA as git ref", () => { | |
| const cmd = buildAutomationCommand({ | |
| OH_AUTOMATION_GIT_REF: "abc123def456", | |
| }); | |
| expect(cmd.command).toBe("uvx"); | |
| expect(cmd.args).toContain(`git+${DEFAULT_AUTOMATION_REPO}@abc123def456`); | |
| expect(cmd.source).toBe("git (abc123def456)"); | |
| }); | |
| it("uses specific PyPI version when OH_AUTOMATION_VERSION is set", () => { | |
| const cmd = buildAutomationCommand({ | |
| OH_AUTOMATION_VERSION: "1.0.0", | |
| }); | |
| expect(cmd.command).toBe("uvx"); | |
| expect(cmd.args).toContain(`${DEFAULT_AUTOMATION_PACKAGE}==1.0.0`); | |
| expect(cmd.source).toBe("PyPI (1.0.0)"); | |
| }); | |
| it("git ref takes precedence over version", () => { | |
| const cmd = buildAutomationCommand({ | |
| OH_AUTOMATION_GIT_REF: "main", | |
| OH_AUTOMATION_VERSION: "1.0.0", | |
| }); | |
| expect(cmd.command).toBe("uvx"); | |
| expect(cmd.args).toContain(`git+${DEFAULT_AUTOMATION_REPO}@main`); | |
| expect(cmd.args).not.toContain(`${DEFAULT_AUTOMATION_PACKAGE}==1.0.0`); | |
| expect(cmd.source).toBe("git (main)"); | |
| }); | |
| it("runs a local checkout in place, ahead of the other env vars", () => { | |
| const cmd = buildAutomationCommand({ | |
| OH_AUTOMATION_LOCAL_PATH: "/checkouts/automation", | |
| OH_AUTOMATION_GIT_REF: "main", | |
| OH_AUTOMATION_VERSION: "1.0.0", | |
| }); | |
| expect(cmd.command).toBe("uv"); | |
| expect(cmd.args).toEqual([ | |
| "run", | |
| "--project", | |
| "/checkouts/automation", | |
| "uvicorn", | |
| "openhands.automation.app:app", | |
| ]); | |
| expect(cmd.source).toBe("local (/checkouts/automation)"); | |
| }); | |
| }); | |
| describe("validateLocalAutomationPath", () => { | |
| const dirs: string[] = []; | |
| afterEach(() => { | |
| while (dirs.length > 0) { | |
| const dir = dirs.pop(); | |
| if (dir) rmSync(dir, { recursive: true, force: true }); | |
| } | |
| }); | |
| function makeCheckout({ withProjectFile = true } = {}) { | |
| const dir = mkdtempSync(path.join(tmpdir(), "automation-checkout-")); | |
| dirs.push(dir); | |
| if (withProjectFile) { | |
| writeFileSync(path.join(dir, "pyproject.toml"), "[project]\n"); | |
| } | |
| return dir; | |
| } | |
| it("accepts an absolute path to a Python project", () => { | |
| expect(() => validateLocalAutomationPath(makeCheckout())).not.toThrow(); | |
| }); | |
| // Without these, `uv run --project <bad path>` just exits and the rest of | |
| // the stack stays up, leaving the automations UI blaming the backend. | |
| it("rejects a relative path", () => { | |
| expect(() => validateLocalAutomationPath("../automation")).toThrow( | |
| /absolute path/i, | |
| ); | |
| }); | |
| it("rejects a path that does not exist", () => { | |
| const dir = makeCheckout(); | |
| rmSync(dir, { recursive: true, force: true }); | |
| expect(() => validateLocalAutomationPath(dir)).toThrow(/does not exist/i); | |
| }); | |
| it("rejects a directory that is not a Python project", () => { | |
| expect(() => | |
| validateLocalAutomationPath(makeCheckout({ withProjectFile: false })), | |
| ).toThrow(/pyproject\.toml/); | |
| }); | |
| }); | |
| describe("buildAgentServerAutomationEnv", () => { | |
| it("exposes the session API key as OPENHANDS_AUTOMATION_API_KEY for agent curl commands", () => { | |
| expect( | |
| buildAgentServerAutomationEnv({ sessionApiKey: "shared-session-key" }), | |
| ).toEqual({ | |
| OPENHANDS_AUTOMATION_API_KEY: "shared-session-key", | |
| }); | |
| }); | |
| }); | |
| describe("buildAutomationTelemetryEnv", () => { | |
| it("provides the shared frontend PostHog key to automation by default", () => { | |
| expect(buildAutomationTelemetryEnv({})).toEqual({ | |
| AUTOMATION_POSTHOG_API_KEY: | |
| "phc_kBtz5nKmxVRRQ7HtPwr2QX9eMC5j65zE86QKocVNwb4U", | |
| AUTOMATION_POSTHOG_HOST: "https://us.i.posthog.com", | |
| }); | |
| }); | |
| it("prefers explicit automation telemetry settings", () => { | |
| expect( | |
| buildAutomationTelemetryEnv({ | |
| AUTOMATION_POSTHOG_API_KEY: "phc_auto", | |
| AUTOMATION_POSTHOG_HOST: "https://auto.example", | |
| VITE_POSTHOG_API_KEY: "phc_frontend", | |
| VITE_POSTHOG_HOST: "https://frontend.example", | |
| }), | |
| ).toEqual({ | |
| AUTOMATION_POSTHOG_API_KEY: "phc_auto", | |
| AUTOMATION_POSTHOG_HOST: "https://auto.example", | |
| }); | |
| }); | |
| it("uses explicit frontend telemetry settings when automation settings are absent", () => { | |
| expect( | |
| buildAutomationTelemetryEnv({ | |
| VITE_POSTHOG_API_KEY: "phc_frontend", | |
| VITE_POSTHOG_HOST: "https://frontend.example", | |
| }), | |
| ).toEqual({ | |
| AUTOMATION_POSTHOG_API_KEY: "phc_frontend", | |
| AUTOMATION_POSTHOG_HOST: "https://frontend.example", | |
| }); | |
| }); | |
| it("does not default automation telemetry when do-not-track is enabled", () => { | |
| expect(buildAutomationTelemetryEnv({ VITE_DO_NOT_TRACK: "1" })).toEqual({}); | |
| }); | |
| }); | |
| describe("buildConfig", () => { | |
| const servers: net.Server[] = []; | |
| const keyDirs: string[] = []; | |
| afterEach(() => { | |
| for (const server of servers) { | |
| server.close(); | |
| } | |
| servers.length = 0; | |
| while (keyDirs.length > 0) { | |
| const dir = keyDirs.pop(); | |
| if (dir) rmSync(dir, { recursive: true, force: true }); | |
| } | |
| resetPersistedSessionApiKeyCache(); | |
| }); | |
| /** | |
| * Build an env that points persisted dev API key files at a fresh temp dir, | |
| * so tests don't write to the user's real ~/.openhands/agent-canvas files. | |
| * | |
| * Also redirects all service ports to high port numbers so that buildConfig's | |
| * assertPortsFree check passes even when a real dev stack is running on the | |
| * default ports (18000, 18001, 3001, 8000). | |
| */ | |
| function envWithIsolatedKeyPath( | |
| extra: Record<string, string> = {}, | |
| ): Record<string, string> { | |
| const dir = mkdtempSync(path.join(tmpdir(), "buildconfig-key-")); | |
| keyDirs.push(dir); | |
| return { | |
| OH_SESSION_API_KEY_PATH: path.join(dir, "session-api-key.txt"), | |
| // High ports that are almost certainly free, so assertPortsFree passes. | |
| PORT: "19902", | |
| OH_CANVAS_SAFE_BACKEND_PORT: "19900", | |
| OH_CANVAS_SAFE_AUTOMATION_PORT: "19901", | |
| OH_CANVAS_SAFE_VITE_PORT: "19903", | |
| ...extra, | |
| }; | |
| } | |
| it("builds default config with correct ports", async () => { | |
| const config = await buildConfig({}, envWithIsolatedKeyPath()); | |
| // Ports should be allocated (either defaults if free, or alternatives) | |
| expect(typeof config.ingressPort).toBe("number"); | |
| expect(config.ingressPort).toBeGreaterThan(0); | |
| expect(typeof config.agentServerPort).toBe("number"); | |
| expect(config.agentServerPort).toBeGreaterThan(0); | |
| expect(typeof config.autoBackendPort).toBe("number"); | |
| expect(config.autoBackendPort).toBeGreaterThan(0); | |
| expect(typeof config.vitePort).toBe("number"); | |
| expect(config.vitePort).toBeGreaterThan(0); | |
| expect(config.vscodePort).toBe(config.agentServerPort + 1000); | |
| // All four main ports should be unique | |
| const ports = new Set([ | |
| config.ingressPort, | |
| config.agentServerPort, | |
| config.autoBackendPort, | |
| config.vitePort, | |
| ]); | |
| expect(ports.size).toBe(4); | |
| }); | |
| it("lets --automation-git-ref win over an exported OH_AUTOMATION_LOCAL_PATH", async () => { | |
| // Otherwise someone with the checkout exported in their shell profile | |
| // reproduces a bug against their own working tree while believing they | |
| // are testing the ref they just passed. | |
| const env = envWithIsolatedKeyPath({ | |
| OH_AUTOMATION_LOCAL_PATH: "/checkouts/automation", | |
| }); | |
| await buildConfig({ automationGitRef: "abc123" }, env); | |
| expect(env.OH_AUTOMATION_GIT_REF).toBe("abc123"); | |
| expect(env.OH_AUTOMATION_LOCAL_PATH).toBeUndefined(); | |
| expect(buildAutomationCommand(env).source).toBe("git (abc123)"); | |
| }); | |
| it("keeps a local checkout when no ref is passed", async () => { | |
| const env = envWithIsolatedKeyPath({ | |
| OH_AUTOMATION_LOCAL_PATH: "/checkouts/automation", | |
| }); | |
| await buildConfig({}, env); | |
| expect(env.OH_AUTOMATION_LOCAL_PATH).toBe("/checkouts/automation"); | |
| }); | |
| it("respects preferred port from args when available", async () => { | |
| // Use a high port unlikely to be busy | |
| const preferredPort = 19500; | |
| const config = await buildConfig( | |
| { port: preferredPort }, | |
| envWithIsolatedKeyPath(), | |
| ); | |
| expect(config.ingressPort).toBe(preferredPort); | |
| }); | |
| it("throws when ingress port is busy", async () => { | |
| const busyPort = 8100; | |
| // Block port 8100 | |
| const server = net.createServer(); | |
| await new Promise<void>((resolve, reject) => { | |
| server.listen(busyPort, "127.0.0.1", () => { | |
| servers.push(server); | |
| resolve(); | |
| }); | |
| server.on("error", reject); | |
| }); | |
| // Should throw instead of falling back to a different port | |
| await expect( | |
| buildConfig({ port: busyPort }, envWithIsolatedKeyPath()), | |
| ).rejects.toThrow(/ingress.*port 8100/i); | |
| }); | |
| it("allocates valid ports for all services", async () => { | |
| const config = await buildConfig({}, envWithIsolatedKeyPath()); | |
| // All service ports should be valid | |
| expect(config.agentServerPort).toBeGreaterThan(0); | |
| expect(config.autoBackendPort).toBeGreaterThan(0); | |
| expect(config.vitePort).toBeGreaterThan(0); | |
| expect(config.vscodePort).toBeGreaterThan(0); | |
| // All service ports should be different from each other | |
| const servicePorts = [ | |
| config.agentServerPort, | |
| config.autoBackendPort, | |
| config.vitePort, | |
| config.ingressPort, | |
| ]; | |
| expect(new Set(servicePorts).size).toBe(servicePorts.length); | |
| }); | |
| it("respects preferred PORT from env when available", async () => { | |
| // Use a high port unlikely to be busy | |
| const preferredPort = "19501"; | |
| const config = await buildConfig( | |
| {}, | |
| envWithIsolatedKeyPath({ PORT: preferredPort }), | |
| ); | |
| expect(config.ingressPort).toBe(19501); | |
| }); | |
| it("args.port takes precedence over env.PORT", async () => { | |
| // Use high ports unlikely to be busy | |
| const config = await buildConfig( | |
| { port: 19502 }, | |
| envWithIsolatedKeyPath({ PORT: "19599" }), | |
| ); | |
| expect(config.ingressPort).toBe(19502); | |
| }); | |
| it("applies automationGitRef from args to env", async () => { | |
| const env = envWithIsolatedKeyPath(); | |
| await buildConfig({ automationGitRef: "my-branch" }, env); | |
| expect(env.OH_AUTOMATION_GIT_REF).toBe("my-branch"); | |
| }); | |
| it("applies automationRepo from args to env", async () => { | |
| const env = envWithIsolatedKeyPath(); | |
| await buildConfig({ automationRepo: "https://example.com/repo" }, env); | |
| expect(env.OH_AUTOMATION_REPO).toBe("https://example.com/repo"); | |
| }); | |
| it("uses correct state directory path", async () => { | |
| const config = await buildConfig({}, envWithIsolatedKeyPath()); | |
| expect(config.stateDir).toBe( | |
| path.join(homedir(), ".openhands", "agent-canvas"), | |
| ); | |
| }); | |
| it("passes verbose flag through", async () => { | |
| const config = await buildConfig( | |
| { verbose: true }, | |
| envWithIsolatedKeyPath(), | |
| ); | |
| expect(config.verbose).toBe(true); | |
| }); | |
| it("sessionApiKey is a 64-char hex string by default", async () => { | |
| const config = await buildConfig({}, envWithIsolatedKeyPath()); | |
| expect(config.sessionApiKey).toMatch(/^[0-9a-f]{64}$/); | |
| }); | |
| it("falls back to a freshly persisted session API key by default", async () => { | |
| const config = await buildConfig({}, envWithIsolatedKeyPath()); | |
| // Default is a 64-char hex string (256-bit random key) read from / | |
| // written to OH_SESSION_API_KEY_PATH. | |
| expect(config.sessionApiKey).toMatch(/^[0-9a-f]{64}$/); | |
| }); | |
| it("reuses the persisted session API key across calls (stable across restarts)", async () => { | |
| const env = envWithIsolatedKeyPath(); | |
| const first = await buildConfig({}, env); | |
| // Simulate a fresh process invocation (the file on disk should be | |
| // what makes the key stable). | |
| resetPersistedSessionApiKeyCache(); | |
| const second = await buildConfig({}, env); | |
| expect(second.sessionApiKey).toBe(first.sessionApiKey); | |
| }); | |
| it("reads sessionApiKey from LOCAL_BACKEND_API_KEY", async () => { | |
| const config = await buildConfig( | |
| {}, | |
| { ...envWithIsolatedKeyPath(), LOCAL_BACKEND_API_KEY: "my-api-key" }, | |
| ); | |
| expect(config.sessionApiKey).toBe("my-api-key"); | |
| }); | |
| }); | |
| describe("stack mode routing", () => { | |
| const keyDirs: string[] = []; | |
| afterEach(() => { | |
| while (keyDirs.length > 0) { | |
| const dir = keyDirs.pop(); | |
| if (dir) rmSync(dir, { recursive: true, force: true }); | |
| } | |
| resetPersistedSessionApiKeyCache(); | |
| }); | |
| function envWithIsolatedKeyPath( | |
| extra: Record<string, string> = {}, | |
| ): Record<string, string> { | |
| const dir = mkdtempSync(path.join(tmpdir(), "stack-mode-key-")); | |
| keyDirs.push(dir); | |
| return { | |
| OH_SESSION_API_KEY_PATH: path.join(dir, "session-api-key.txt"), | |
| PORT: "19802", | |
| OH_CANVAS_SAFE_BACKEND_PORT: "19800", | |
| OH_CANVAS_SAFE_AUTOMATION_PORT: "19801", | |
| OH_CANVAS_SAFE_VITE_PORT: "19803", | |
| ...extra, | |
| }; | |
| } | |
| it("uses only a frontend default route in frontend-only mode", async () => { | |
| const config = await buildConfig( | |
| { frontendOnly: true }, | |
| envWithIsolatedKeyPath(), | |
| ); | |
| expect(config.launchFrontend).toBe(true); | |
| expect(config.launchAgentServer).toBe(false); | |
| expect(config.launchAutomation).toBe(false); | |
| expect(getLocalServiceRoutes(config)).toEqual([]); | |
| expect(getFrontendBackend(config)).toBe( | |
| `http://localhost:${config.vitePort}`, | |
| ); | |
| expect(buildRouteArgs(getLocalServiceRoutes(config))).toEqual([]); | |
| }); | |
| it("does not bake a host workspace path in frontend-only mode by default", async () => { | |
| const config = await buildConfig( | |
| { frontendOnly: true }, | |
| envWithIsolatedKeyPath(), | |
| ); | |
| expect(config.viteWorkingDir).toBeUndefined(); | |
| }); | |
| it("honors explicit frontend-only VITE_WORKING_DIR values", async () => { | |
| const config = await buildConfig( | |
| { frontendOnly: true }, | |
| envWithIsolatedKeyPath({ VITE_WORKING_DIR: "workspace/project" }), | |
| ); | |
| expect(config.viteWorkingDir).toBe("workspace/project"); | |
| }); | |
| it("bakes the host workspace path when this launcher starts the agent-server", async () => { | |
| const config = await buildConfig({}, envWithIsolatedKeyPath()); | |
| expect(config.viteWorkingDir).toBe( | |
| path.join(config.stateDir, "workspaces"), | |
| ); | |
| }); | |
| it("points frontend-only Vite at a separately running backend by default", async () => { | |
| const config = await buildConfig( | |
| { frontendOnly: true }, | |
| envWithIsolatedKeyPath(), | |
| ); | |
| expect(buildViteBackendEnv(config, {})).toEqual({ | |
| VITE_BACKEND_HOST: "127.0.0.1:8000", | |
| }); | |
| }); | |
| it("keeps full-stack Vite pointed at this launcher's ingress", async () => { | |
| const config = await buildConfig({}, envWithIsolatedKeyPath()); | |
| expect(buildViteBackendEnv(config, {})).toEqual({ | |
| VITE_BACKEND_HOST: `127.0.0.1:${config.ingressPort}`, | |
| }); | |
| }); | |
| it("allows frontend-only Vite to target an explicit backend URL", async () => { | |
| const config = await buildConfig( | |
| { frontendOnly: true }, | |
| envWithIsolatedKeyPath(), | |
| ); | |
| expect( | |
| buildViteBackendEnv(config, { | |
| VITE_BACKEND_BASE_URL: "https://backend.example.test", | |
| }), | |
| ).toEqual({ | |
| VITE_BACKEND_HOST: "backend.example.test", | |
| VITE_USE_TLS: "true", | |
| }); | |
| }); | |
| it("respects an explicit VITE_USE_TLS override with VITE_BACKEND_BASE_URL", async () => { | |
| const config = await buildConfig( | |
| { frontendOnly: true }, | |
| envWithIsolatedKeyPath(), | |
| ); | |
| expect( | |
| buildViteBackendEnv(config, { | |
| VITE_BACKEND_BASE_URL: "https://backend.example.test", | |
| VITE_USE_TLS: "false", | |
| }), | |
| ).toEqual({ | |
| VITE_BACKEND_HOST: "backend.example.test", | |
| }); | |
| }); | |
| it("does not set VITE_USE_TLS for http:// VITE_BACKEND_BASE_URL", async () => { | |
| const config = await buildConfig( | |
| { frontendOnly: true }, | |
| envWithIsolatedKeyPath(), | |
| ); | |
| expect( | |
| buildViteBackendEnv(config, { | |
| VITE_BACKEND_BASE_URL: "http://backend.example.test", | |
| }), | |
| ).toEqual({ | |
| VITE_BACKEND_HOST: "backend.example.test", | |
| }); | |
| }); | |
| it("routes only local services through IPv4 in backend-only mode", async () => { | |
| const config = await buildConfig( | |
| { backendOnly: true }, | |
| envWithIsolatedKeyPath(), | |
| ); | |
| expect(config.launchFrontend).toBe(false); | |
| expect(config.launchAgentServer).toBe(true); | |
| expect(config.launchAutomation).toBe(true); | |
| expect(getFrontendBackend(config)).toBeNull(); | |
| const routes = getLocalServiceRoutes(config); | |
| expect(routes).toContainEqual([ | |
| "/api/automation", | |
| `http://127.0.0.1:${config.autoBackendPort}`, | |
| ]); | |
| expect(routes).toContainEqual([ | |
| "/api", | |
| `http://127.0.0.1:${config.agentServerPort}`, | |
| ]); | |
| const routeArgs = buildRouteArgs(routes); | |
| expect(routeArgs).toContain( | |
| `/api/automation=http://127.0.0.1:${config.autoBackendPort}`, | |
| ); | |
| expect(routeArgs).toContain( | |
| `/server_info=http://127.0.0.1:${config.agentServerPort}`, | |
| ); | |
| expect(routeArgs).not.toContain("--default"); | |
| }); | |
| it("routes the editor base path to the vscode port in the stock config", async () => { | |
| // The whole point of the base path is that a stock launcher — no | |
| // INGRESS_ROUTES, no OH_VSCODE_BASE_PATH — already reaches the editor | |
| // through the single ingress origin. Both the outer ingress and the | |
| // static-server route list are built from getLocalServiceRoutes, so | |
| // asserting it here covers both. | |
| const config = await buildConfig({}, envWithIsolatedKeyPath()); | |
| expect(config.vscodeBasePath).toBe("/vscode"); | |
| const routes = getLocalServiceRoutes(config); | |
| expect(routes).toContainEqual([ | |
| config.vscodeBasePath, | |
| `http://127.0.0.1:${config.vscodePort}`, | |
| ]); | |
| expect(buildRouteArgs(routes)).toContain( | |
| `/vscode=http://127.0.0.1:${config.vscodePort}`, | |
| ); | |
| // The editor must not collide with the agent-server or automation ports; | |
| // it is a separate process reached through the same origin. | |
| expect(config.vscodePort).not.toBe(config.agentServerPort); | |
| expect(config.vscodePort).not.toBe(config.autoBackendPort); | |
| }); | |
| it("passes the agent-server a base path matching the ingress route", async () => { | |
| // The advertised URL and the route that serves it come from two different | |
| // places (agent-server's /api/vscode/url vs. the proxy route table). They | |
| // only agree because both read the same config value — assert that rather | |
| // than each side in isolation. | |
| const config = await buildConfig({}, envWithIsolatedKeyPath()); | |
| const env = buildAgentServerEnv( | |
| buildSafeDevConfig(process.cwd(), { | |
| ...envWithIsolatedKeyPath(), | |
| OH_CANVAS_SAFE_BACKEND_PORT: String(config.agentServerPort), | |
| OH_CANVAS_SAFE_VSCODE_PORT: String(config.vscodePort), | |
| }), | |
| { vscodeBasePath: config.vscodeBasePath }, | |
| ); | |
| expect(env.OH_VSCODE_BASE_PATH).toBe(config.vscodeBasePath); | |
| expect(env.OH_VSCODE_PORT).toBe(String(config.vscodePort)); | |
| const [, vscodeBackend] = | |
| getLocalServiceRoutes(config).find( | |
| ([prefix]) => prefix === env.OH_VSCODE_BASE_PATH, | |
| ) ?? []; | |
| expect(vscodeBackend).toBe(`http://127.0.0.1:${env.OH_VSCODE_PORT}`); | |
| }); | |
| it("preserves the editor prefix rather than stripping it", async () => { | |
| // openvscode-server is launched with --server-base-path, so it generates | |
| // its HTTP and WebSocket URLs beneath the prefix and only answers there. | |
| // A router that stripped the prefix would 404 every asset. | |
| const config = await buildConfig({}, envWithIsolatedKeyPath()); | |
| const routes = Object.fromEntries(getLocalServiceRoutes(config)); | |
| const route = createRouter(routes); | |
| const vscodeBackend = `http://127.0.0.1:${config.vscodePort}`; | |
| expect(route("/vscode")).toBe(vscodeBackend); | |
| expect(route("/vscode/")).toBe(vscodeBackend); | |
| // Workbench assets and the WebSocket upgrade path both sit under the | |
| // prefix; the proxy forwards req.url unchanged, so matching is all that | |
| // is needed for the prefix to survive. | |
| expect(route("/vscode/static/out/vs/workbench/workbench.web.main.js")).toBe( | |
| vscodeBackend, | |
| ); | |
| expect(route("/vscode/stable-abc/?tkn=k")).toBe(vscodeBackend); | |
| // Longest-prefix matching must not let /vscode swallow /api or vice versa. | |
| expect(route("/api/vscode/url")).toBe( | |
| `http://127.0.0.1:${config.agentServerPort}`, | |
| ); | |
| }); | |
| it("addresses the agent-server over IPv4 for readiness and secret seeding", async () => { | |
| const config = await buildConfig({}, envWithIsolatedKeyPath()); | |
| // The launcher starts the agent-server with `--host 127.0.0.1`, so the | |
| // readiness probe (`/server_info`), the secret-seeding request, and the | |
| // automation backend's AUTOMATION_AGENT_SERVER_URL must all skip the | |
| // `localhost` lookup that resolves to ::1 first on Windows. | |
| expect(getAgentServerBaseUrl(config)).toBe( | |
| `http://127.0.0.1:${config.agentServerPort}`, | |
| ); | |
| }); | |
| it("rejects the editor prefix when no agent-server is launched", async () => { | |
| // Without an agent-server there is no editor behind the prefix. Falling | |
| // back to index.html would answer an editor request with the canvas shell. | |
| const config = await buildConfig( | |
| { frontendOnly: true }, | |
| envWithIsolatedKeyPath(), | |
| ); | |
| expect(getLocalServiceRoutes(config)).toEqual([]); | |
| expect(getRejectPrefixes(config)).toContain("/vscode"); | |
| }); | |
| it("rejects mutually exclusive partial-stack modes", async () => { | |
| await expect( | |
| buildConfig( | |
| { frontendOnly: true, backendOnly: true }, | |
| envWithIsolatedKeyPath(), | |
| ), | |
| ).rejects.toThrow(/cannot be used together/); | |
| }); | |
| }); | |
| describe("default constants", () => { | |
| it("has expected default automation repo", () => { | |
| expect(DEFAULT_AUTOMATION_REPO).toBe( | |
| "https://github.com/OpenHands/automation", | |
| ); | |
| }); | |
| it("has expected default automation package", () => { | |
| expect(DEFAULT_AUTOMATION_PACKAGE).toBe("openhands-automation"); | |
| }); | |
| it("has expected default backend port", () => { | |
| expect(DEFAULT_BACKEND_PORT).toBe(18000); | |
| }); | |
| it("has expected default automation port", () => { | |
| expect(DEFAULT_AUTOMATION_PORT).toBe(18001); | |
| }); | |
| }); | |
| describe("setServiceLogListener", () => { | |
| afterEach(() => { | |
| // Always clear the listener so it doesn't leak between tests. | |
| setServiceLogListener(null); | |
| }); | |
| it("forwards stdout, stderr, and exit lines to the listener", async () => { | |
| const captured: Array<{ name: string; line: string; level: string }> = []; | |
| setServiceLogListener((name: string, line: string, level: string) => { | |
| captured.push({ name, line, level }); | |
| }); | |
| // Spawn a tiny inline script that writes to stdout, stderr, and exits | |
| // non-zero so we exercise all three log paths. `process.execPath` is | |
| // the same Node binary running the test so this is portable. | |
| const proc = spawnService( | |
| "log-listener-test", | |
| process.execPath, | |
| [ | |
| "-e", | |
| // Use double-quoted JS to avoid shell quoting differences. | |
| 'process.stdout.write("hello-stdout\\n"); process.stderr.write("hello-stderr\\n"); process.exit(2);', | |
| ], | |
| {}, | |
| ); | |
| await once(proc, "exit"); | |
| // Give the stdout/stderr 'data' handlers and the exit handler a tick to | |
| // run after the process has actually exited. | |
| await delay(50); | |
| const lines = captured.map((c) => `${c.level}:${c.line}`); | |
| expect(lines).toContain("stdout:hello-stdout"); | |
| expect(lines).toContain("stderr:hello-stderr"); | |
| expect(lines).toContain("error:exited with code 2"); | |
| // Every captured line must be tagged with our service name. | |
| expect(captured.every((c) => c.name === "log-listener-test")).toBe(true); | |
| }); | |
| it("is a no-op once the listener is cleared", async () => { | |
| const captured: string[] = []; | |
| setServiceLogListener((_n: string, line: string) => captured.push(line)); | |
| setServiceLogListener(null); | |
| const proc = spawnService( | |
| "log-listener-clear-test", | |
| process.execPath, | |
| ["-e", 'process.stdout.write("should-not-be-seen\\n");'], | |
| {}, | |
| ); | |
| await once(proc, "exit"); | |
| await delay(50); | |
| expect(captured).toHaveLength(0); | |
| }); | |
| it("ignores non-function values without throwing", () => { | |
| // Passing anything that isn't a function (undefined, null, a string) must | |
| // be safe — main() relies on this so it can always call | |
| // setServiceLogListener(onServiceLog) regardless of whether the embedder | |
| // supplied a callback. | |
| expect(() => setServiceLogListener(undefined)).not.toThrow(); | |
| expect(() => setServiceLogListener(null)).not.toThrow(); | |
| expect(() => setServiceLogListener("not a function")).not.toThrow(); | |
| }); | |
| it("swallows errors thrown by the listener so a buggy embedder cannot kill the dev stack", async () => { | |
| // A listener that throws on every call must not propagate; the stdout | |
| // handler in spawnService is fire-and-forget. We can't easily observe | |
| // an unhandled rejection from within Vitest, but if we can run a process | |
| // to completion without the test failing, the catch is doing its job. | |
| setServiceLogListener(() => { | |
| throw new Error("buggy listener"); | |
| }); | |
| const proc = spawnService( | |
| "log-listener-throws-test", | |
| process.execPath, | |
| ["-e", 'process.stdout.write("line\\n"); process.exit(0);'], | |
| {}, | |
| ); | |
| const [code] = await once(proc, "exit"); | |
| expect(code).toBe(0); | |
| }); | |
| }); | |
| describe("dev-with-automation CLI", () => { | |
| it.skipIf(process.platform === "win32")( | |
| "cleans up detached services when the launcher receives SIGHUP", | |
| async () => { | |
| const moduleUrl = pathToFileURL( | |
| path.join(repoRoot, "scripts", "dev-with-automation.mjs"), | |
| ).href; | |
| const fixtureSource = [ | |
| 'import net from "node:net";', | |
| "const server = net.createServer(() => {});", | |
| 'server.listen(0, "127.0.0.1", () => console.log("READY", process.pid, server.address().port));', | |
| ].join("\n"); | |
| const supervisorSource = [ | |
| `import { spawnService } from ${JSON.stringify(moduleUrl)};`, | |
| `const fixture = spawnService("fixture", process.execPath, ["--input-type=module", "--eval", ${JSON.stringify(fixtureSource)}]);`, | |
| 'console.log("SPAWNED", fixture.pid);', | |
| "setInterval(() => {}, 1_000);", | |
| ].join("\n"); | |
| const supervisor = spawn( | |
| process.execPath, | |
| ["--input-type=module", "--eval", supervisorSource], | |
| { | |
| cwd: repoRoot, | |
| stdio: ["ignore", "pipe", "pipe"], | |
| }, | |
| ); | |
| let output = ""; | |
| let fixturePid: number | undefined; | |
| let fixturePort: number | undefined; | |
| const capture = (chunk: Buffer) => { | |
| output += chunk.toString(); | |
| const spawnedMatch = output.match(/SPAWNED (\d+)/); | |
| const readyMatch = output.match(/READY \d+ (\d+)/); | |
| if (spawnedMatch) fixturePid = Number(spawnedMatch[1]); | |
| if (readyMatch) fixturePort = Number(readyMatch[1]); | |
| }; | |
| supervisor.stdout.on("data", capture); | |
| supervisor.stderr.on("data", capture); | |
| try { | |
| const readyDeadline = Date.now() + 5_000; | |
| while (!fixturePort && Date.now() < readyDeadline) { | |
| if (supervisor.exitCode !== null) break; | |
| await delay(25); | |
| } | |
| expect(fixturePid, output).toBeDefined(); | |
| expect(fixturePort, output).toBeDefined(); | |
| supervisor.kill("SIGHUP"); | |
| const exitResult = await Promise.race([ | |
| once(supervisor, "exit").then(([code, signal]) => ({ | |
| code, | |
| signal, | |
| timedOut: false, | |
| })), | |
| delay(6_000).then(() => ({ | |
| code: null, | |
| signal: null, | |
| timedOut: true, | |
| })), | |
| ]); | |
| expect(exitResult.timedOut).toBe(false); | |
| expect(exitResult).toMatchObject({ code: 0, signal: null }); | |
| const releaseDeadline = Date.now() + 2_000; | |
| let portIsOpen = true; | |
| while (portIsOpen && Date.now() < releaseDeadline) { | |
| portIsOpen = await new Promise<boolean>((resolve) => { | |
| const socket = net.connect(fixturePort!, "127.0.0.1"); | |
| socket.setTimeout(250); | |
| socket.once("connect", () => { | |
| socket.destroy(); | |
| resolve(true); | |
| }); | |
| socket.once("error", () => resolve(false)); | |
| socket.once("timeout", () => { | |
| socket.destroy(); | |
| resolve(false); | |
| }); | |
| }); | |
| if (portIsOpen) await delay(50); | |
| } | |
| expect(portIsOpen).toBe(false); | |
| } finally { | |
| if (supervisor.exitCode === null) supervisor.kill("SIGKILL"); | |
| if (fixturePid) { | |
| try { | |
| process.kill(-fixturePid, "SIGKILL"); | |
| } catch (error) { | |
| if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; | |
| } | |
| } | |
| } | |
| }, | |
| 15_000, | |
| ); | |
| it("shows help with --help flag", async () => { | |
| const child = spawn( | |
| process.execPath, | |
| ["scripts/dev-with-automation.mjs", "--help"], | |
| { | |
| cwd: repoRoot, | |
| stdio: ["ignore", "pipe", "pipe"], | |
| }, | |
| ); | |
| let output = ""; | |
| child.stdout.on("data", (chunk) => { | |
| output += chunk.toString(); | |
| }); | |
| const [code] = await once(child, "exit"); | |
| expect(code).toBe(0); | |
| expect(output).toContain("Agent Canvas + Automation Development Stack"); | |
| expect(output).toContain("--port"); | |
| expect(output).toContain("--automation-ref"); | |
| expect(output).toContain("--automation-repo"); | |
| expect(output).toContain("--static"); | |
| expect(output).toContain("--dynamic"); | |
| expect(output).toContain("--frontend-only"); | |
| expect(output).toContain("--backend-only"); | |
| expect(output).toContain("OH_AUTOMATION_GIT_REF"); | |
| expect(output).toContain("OH_AGENT_SERVER_LOCAL_PATH"); | |
| expect(output).toContain("OPENHANDS_AUTOMATION_API_KEY"); | |
| expect(output).toContain("SECRETS:"); | |
| }); | |
| it("fails fast with a clear error when OH_AGENT_SERVER_LOCAL_PATH is invalid", async () => { | |
| // Arrange: an absolute but empty directory — `validateLocalAgentServerPath` | |
| // requires the four workspace subdirs (openhands-agent-server, openhands-sdk, | |
| // openhands-tools, openhands-workspace) and must reject this. | |
| const emptyDir = mkdtempSync(path.join(tmpdir(), "bad-sdk-")); | |
| // Stub a no-op `uvx` on PATH so `checkPrerequisites` passes even on CI | |
| // runners that don't have uv installed. The prerequisite check must | |
| // succeed so the LOCAL_PATH validation guard (the actual subject of this | |
| // test, which runs immediately after) is exercised. | |
| const isWindows = process.platform === "win32"; | |
| const stubBinDir = mkdtempSync(path.join(tmpdir(), "stub-bin-")); | |
| if (isWindows) { | |
| writeFileSync(path.join(stubBinDir, "uvx.cmd"), "@exit /b 0\r\n"); | |
| } else { | |
| writeFileSync(path.join(stubBinDir, "uvx"), "#!/bin/sh\nexit 0\n", { | |
| mode: 0o755, | |
| }); | |
| } | |
| // Act: spawn `dev-with-automation.mjs` with that path set. Stubbed `uvx` | |
| // is prepended to PATH so the prerequisite check passes; the validation | |
| // guard must trip *after* those checks but *before* port allocation, so | |
| // we can assert on both the error message and the absence of side effects. | |
| const child = spawn(process.execPath, ["scripts/dev-with-automation.mjs"], { | |
| cwd: repoRoot, | |
| env: { | |
| PATH: `${stubBinDir}${path.delimiter}${process.env.PATH ?? ""}`, | |
| HOME: process.env.HOME ?? "", | |
| OH_AGENT_SERVER_LOCAL_PATH: emptyDir, | |
| // Windows needs these for `where.exe` to resolve the stub and npm | |
| ...(isWindows | |
| ? { | |
| PATHEXT: process.env.PATHEXT ?? ".CMD;.EXE;.BAT;.COM", | |
| SystemRoot: process.env.SystemRoot ?? "", | |
| USERPROFILE: process.env.USERPROFILE ?? "", | |
| } | |
| : {}), | |
| }, | |
| stdio: ["ignore", "pipe", "pipe"], | |
| }); | |
| let output = ""; | |
| child.stdout.on("data", (chunk) => { | |
| output += chunk.toString(); | |
| }); | |
| child.stderr.on("data", (chunk) => { | |
| output += chunk.toString(); | |
| }); | |
| const exitResult = await Promise.race([ | |
| once(child, "exit").then(([code, signal]) => ({ | |
| code, | |
| signal, | |
| timedOut: false, | |
| })), | |
| delay(10_000).then(() => ({ code: null, signal: null, timedOut: true })), | |
| ]); | |
| if (exitResult.timedOut) { | |
| child.kill("SIGKILL"); | |
| } | |
| try { | |
| // Assert: process exits non-zero, surfaces the validator's error, and | |
| // never reaches `buildConfig` (no `[ports] Allocating ports...` log). | |
| expect(exitResult.timedOut).toBe(false); | |
| expect(exitResult.code).toBe(1); | |
| expect(output).toContain( | |
| "OH_AGENT_SERVER_LOCAL_PATH is missing expected workspace package", | |
| ); | |
| expect(output).not.toContain("Allocating ports"); | |
| } finally { | |
| rmSync(emptyDir, { recursive: true, force: true }); | |
| rmSync(stubBinDir, { recursive: true, force: true }); | |
| } | |
| }); | |
| it("exits promptly when uvx is missing", async () => { | |
| const child = spawn(process.execPath, ["scripts/dev-with-automation.mjs"], { | |
| cwd: repoRoot, | |
| env: { | |
| PATH: "", | |
| }, | |
| stdio: ["ignore", "pipe", "pipe"], | |
| }); | |
| let output = ""; | |
| child.stdout.on("data", (chunk) => { | |
| output += chunk.toString(); | |
| }); | |
| child.stderr.on("data", (chunk) => { | |
| output += chunk.toString(); | |
| }); | |
| const exitResult = await Promise.race([ | |
| once(child, "exit").then(([code, signal]) => ({ | |
| code, | |
| signal, | |
| timedOut: false, | |
| })), | |
| delay(4_000).then(() => ({ code: null, signal: null, timedOut: true })), | |
| ]); | |
| if (exitResult.timedOut) { | |
| child.kill("SIGKILL"); | |
| } | |
| expect(exitResult.timedOut).toBe(false); | |
| expect(exitResult.code).toBe(1); | |
| expect(output).toContain("uvx"); | |
| }); | |
| }); | |