Download __tests__/scripts/dev-safe.test.ts from SaylorTwift/openhands: direct link, hf CLI and curl.
- Browser
- Download file 41 kB
-
https://huggingface.co/SaylorTwift/openhands/resolve/main/__tests__/scripts/dev-safe.test.ts
- Command line
-
hf download hf://SaylorTwift/openhands/__tests__/scripts/dev-safe.test.ts
-
curl -L -o dev-safe.test.ts https://huggingface.co/SaylorTwift/openhands/resolve/main/__tests__/scripts/dev-safe.test.ts
41 kB
| // @vitest-environment node | |
| // These tests load `scripts/dev-safe.mjs`, which constructs file:// URLs | |
| // relative to its 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 { homedir } from "node:os"; | |
| import path from "node:path"; | |
| import process from "node:process"; | |
| import { setTimeout as delay } from "node:timers/promises"; | |
| import { fileURLToPath } from "node:url"; | |
| import { describe, expect, it, afterEach } from "vitest"; | |
| import { | |
| assertPortsFree, | |
| buildSafeDevConfig, | |
| buildSafeDevConfigAsync, | |
| buildNpmScriptCommand, | |
| buildAgentServerCommand, | |
| buildAgentServerEnv, | |
| buildAgentServerTelemetryEnv, | |
| buildRuntimeServicesInfo, | |
| formatMissingUvxGuidance, | |
| formatMissingFrontendDependenciesGuidance, | |
| getMissingFrontendDependencyBins, | |
| validateFrontendDependencies, | |
| validateLocalAgentServerPath, | |
| findFreePort, | |
| findFreePorts, | |
| getOrCreatePersistedSessionApiKey, | |
| resetPersistedSessionApiKeyCache, | |
| } from "../../scripts/dev-safe.mjs"; | |
| import { | |
| chmodSync, | |
| mkdtempSync, | |
| mkdirSync, | |
| readFileSync, | |
| rmSync, | |
| writeFileSync, | |
| } from "node:fs"; | |
| import { tmpdir } from "node:os"; | |
| const repoRoot = path.resolve( | |
| path.dirname(fileURLToPath(import.meta.url)), | |
| "../..", | |
| ); | |
| describe("findFreePort", () => { | |
| const servers: net.Server[] = []; | |
| afterEach(() => { | |
| // Clean up any servers we created | |
| for (const server of servers) { | |
| server.close(); | |
| } | |
| servers.length = 0; | |
| }); | |
| it("returns preferred port when available", async () => { | |
| // Port 9999 should be free (unlikely to be in use during tests) | |
| const port = await findFreePort(9999, "127.0.0.1"); | |
| expect(port).toBe(9999); | |
| }); | |
| it("falls back to OS-assigned port when preferred is busy", async () => { | |
| // Create a server that holds a port | |
| const busyPort = await new Promise<number>((resolve, reject) => { | |
| const server = net.createServer(); | |
| server.listen(0, "127.0.0.1", () => { | |
| const addr = server.address(); | |
| if (addr && typeof addr === "object") { | |
| servers.push(server); | |
| resolve(addr.port); | |
| } else { | |
| server.close(); | |
| reject(new Error("Failed to get server address")); | |
| } | |
| }); | |
| }); | |
| // Now try to get that busy port | |
| const allocatedPort = await findFreePort(busyPort, "127.0.0.1"); | |
| // Should get a different port since busyPort is taken | |
| expect(allocatedPort).not.toBe(busyPort); | |
| expect(typeof allocatedPort).toBe("number"); | |
| expect(allocatedPort).toBeGreaterThan(0); | |
| }); | |
| it("returns OS-assigned port when preferredPort is 0", async () => { | |
| const port = await findFreePort(0, "127.0.0.1"); | |
| expect(typeof port).toBe("number"); | |
| expect(port).toBeGreaterThan(0); | |
| }); | |
| }); | |
| describe("findFreePorts", () => { | |
| const servers: net.Server[] = []; | |
| afterEach(() => { | |
| for (const server of servers) { | |
| server.close(); | |
| } | |
| servers.length = 0; | |
| }); | |
| it("allocates all requested ports when all preferred are available", async () => { | |
| // Use high ports unlikely to be in use | |
| const result = await findFreePorts([ | |
| { name: "portA", preferred: 19891 }, | |
| { name: "portB", preferred: 19892 }, | |
| ]); | |
| // Check ports are valid - they may be the preferred or fallbacks | |
| expect(typeof result.portA).toBe("number"); | |
| expect(result.portA).toBeGreaterThan(0); | |
| expect(typeof result.portB).toBe("number"); | |
| expect(result.portB).toBeGreaterThan(0); | |
| // Ports should be different | |
| expect(result.portA).not.toBe(result.portB); | |
| }); | |
| it("returns unique ports for each name", async () => { | |
| // Use preferred: 0 to get OS-assigned ports | |
| const result = await findFreePorts([ | |
| { name: "port1", preferred: 0 }, | |
| { name: "port2", preferred: 0 }, | |
| { name: "port3", preferred: 0 }, | |
| ]); | |
| const ports = [result.port1, result.port2, result.port3]; | |
| const uniquePorts = new Set(ports); | |
| expect(uniquePorts.size).toBe(3); | |
| for (const port of ports) { | |
| expect(typeof port).toBe("number"); | |
| expect(port).toBeGreaterThan(0); | |
| } | |
| }); | |
| it("falls back when preferred port is busy", async () => { | |
| // Create a server that holds a port | |
| const busyPort = await new Promise<number>((resolve, reject) => { | |
| const server = net.createServer(); | |
| server.listen(0, "127.0.0.1", () => { | |
| const addr = server.address(); | |
| if (addr && typeof addr === "object") { | |
| servers.push(server); | |
| resolve(addr.port); | |
| } else { | |
| server.close(); | |
| reject(new Error("Failed to get server address")); | |
| } | |
| }); | |
| }); | |
| const result = await findFreePorts([ | |
| { name: "busy", preferred: busyPort }, | |
| { name: "free", preferred: 19800 }, // high port unlikely to be busy | |
| ]); | |
| // "busy" should get a different port since it's taken | |
| expect(result.busy).not.toBe(busyPort); | |
| expect(typeof result.busy).toBe("number"); | |
| expect(result.busy).toBeGreaterThan(0); | |
| // "free" should get the requested port if available | |
| // (or a fallback if 19800 happens to be busy) | |
| expect(typeof result.free).toBe("number"); | |
| expect(result.free).toBeGreaterThan(0); | |
| }); | |
| }); | |
| describe("buildSafeDevConfigAsync", () => { | |
| const servers: net.Server[] = []; | |
| let keyTmp: string | null = null; | |
| afterEach(() => { | |
| for (const server of servers) { | |
| server.close(); | |
| } | |
| servers.length = 0; | |
| if (keyTmp) { | |
| rmSync(keyTmp, { recursive: true, force: true }); | |
| keyTmp = null; | |
| } | |
| resetPersistedSessionApiKeyCache(); | |
| }); | |
| function tempKeyPath(): string { | |
| keyTmp = mkdtempSync(path.join(tmpdir(), "dev-safe-async-key-")); | |
| return path.join(keyTmp, "session-api-key.txt"); | |
| } | |
| it("returns config with dynamically allocated ports", async () => { | |
| // Use a high port so the assertPortsFree check passes even when a real | |
| // dev stack is running on the default port (18000). | |
| const config = await buildSafeDevConfigAsync(repoRoot, { | |
| OH_CANVAS_SAFE_BACKEND_PORT: "19800", | |
| OH_SESSION_API_KEY_PATH: tempKeyPath(), | |
| }); | |
| expect(typeof config.backendPort).toBe("number"); | |
| expect(config.backendPort).toBeGreaterThan(0); | |
| expect(typeof config.vscodePort).toBe("number"); | |
| expect(config.vscodePort).toBeGreaterThan(0); | |
| // Ports should be different | |
| expect(config.backendPort).not.toBe(config.vscodePort); | |
| }); | |
| it("throws when preferred port is busy", async () => { | |
| // Block a specific high port we'll request | |
| const busyPort = 19600; | |
| 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); | |
| }); | |
| // Request the busy port via env var — should throw instead of falling back | |
| await expect( | |
| buildSafeDevConfigAsync(repoRoot, { | |
| OH_CANVAS_SAFE_BACKEND_PORT: busyPort.toString(), | |
| OH_SESSION_API_KEY_PATH: tempKeyPath(), | |
| }), | |
| ).rejects.toThrow(/agent-server.*port 19600/i); | |
| }); | |
| }); | |
| describe("assertPortsFree", () => { | |
| const servers: net.Server[] = []; | |
| afterEach(() => { | |
| for (const server of servers) { | |
| server.close(); | |
| } | |
| servers.length = 0; | |
| }); | |
| it("resolves when all ports are free", async () => { | |
| // High ports unlikely to be in use | |
| await expect( | |
| assertPortsFree([ | |
| { name: "svc-a", port: 19700 }, | |
| { name: "svc-b", port: 19701 }, | |
| ]), | |
| ).resolves.toBeUndefined(); | |
| }); | |
| it("throws when a single port is busy", async () => { | |
| const busyPort = await new Promise<number>((resolve, reject) => { | |
| const server = net.createServer(); | |
| server.listen(0, "127.0.0.1", () => { | |
| const addr = server.address(); | |
| if (addr && typeof addr === "object") { | |
| servers.push(server); | |
| resolve(addr.port); | |
| } else { | |
| server.close(); | |
| reject(new Error("Failed to get address")); | |
| } | |
| }); | |
| }); | |
| await expect( | |
| assertPortsFree([{ name: "agent-server", port: busyPort }]), | |
| ).rejects.toThrow(/agent-server.*port/i); | |
| }); | |
| it("names all busy ports in the error message", async () => { | |
| const [portA, portB] = await Promise.all( | |
| [0, 0].map( | |
| () => | |
| new Promise<number>((resolve, reject) => { | |
| const server = net.createServer(); | |
| server.listen(0, "127.0.0.1", () => { | |
| const addr = server.address(); | |
| if (addr && typeof addr === "object") { | |
| servers.push(server); | |
| resolve(addr.port); | |
| } else { | |
| server.close(); | |
| reject(new Error("Failed to get address")); | |
| } | |
| }); | |
| }), | |
| ), | |
| ); | |
| await expect( | |
| assertPortsFree([ | |
| { name: "ingress", port: portA }, | |
| { name: "vite", port: portB }, | |
| ]), | |
| ).rejects.toThrow(/ingress.*vite|vite.*ingress/is); | |
| }); | |
| }); | |
| describe("frontend dependency preflight", () => { | |
| let tempRoot: string | null = null; | |
| afterEach(() => { | |
| if (tempRoot) { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| tempRoot = null; | |
| } | |
| }); | |
| function makeTempRoot(): string { | |
| tempRoot = mkdtempSync(path.join(tmpdir(), "frontend-deps-")); | |
| return tempRoot; | |
| } | |
| function writeBin(root: string, name: string): void { | |
| const binDir = path.join(root, "node_modules", ".bin"); | |
| mkdirSync(binDir, { recursive: true }); | |
| writeFileSync(path.join(binDir, name), "#!/bin/sh\n"); | |
| } | |
| it("reports required npm binaries when node_modules has not been installed", () => { | |
| const root = makeTempRoot(); | |
| expect(getMissingFrontendDependencyBins(root, "linux")).toEqual([ | |
| "cross-env", | |
| "react-router", | |
| ]); | |
| }); | |
| it("passes when required npm binary shims exist", () => { | |
| const root = makeTempRoot(); | |
| writeBin(root, "cross-env"); | |
| writeBin(root, "react-router"); | |
| expect(getMissingFrontendDependencyBins(root, "linux")).toEqual([]); | |
| expect(() => validateFrontendDependencies(root, "linux")).not.toThrow(); | |
| }); | |
| it("accepts Windows command shims", () => { | |
| const root = makeTempRoot(); | |
| writeBin(root, "cross-env.cmd"); | |
| writeBin(root, "react-router.cmd"); | |
| expect(getMissingFrontendDependencyBins(root, "win32")).toEqual([]); | |
| }); | |
| it("formats an actionable npm ci message", () => { | |
| const guidance = formatMissingFrontendDependenciesGuidance( | |
| ["cross-env"], | |
| "/workspace/project/agent-canvas", | |
| ); | |
| expect(guidance).toContain("Frontend dependencies are not installed"); | |
| expect(guidance).toContain("Missing npm binaries: cross-env"); | |
| expect(guidance).toContain("npm ci"); | |
| expect(guidance).toContain("/workspace/project/agent-canvas"); | |
| }); | |
| }); | |
| describe("formatMissingUvxGuidance", () => { | |
| it("includes install, PATH, README, and fallback workflow hints", () => { | |
| const guidance = formatMissingUvxGuidance( | |
| "/workspace/project/agent-canvas", | |
| ); | |
| expect(guidance).toContain( | |
| "curl -LsSf https://astral.sh/uv/install.sh | sh", | |
| ); | |
| expect(guidance).toContain('export PATH="$HOME/.local/bin:$PATH"'); | |
| expect(guidance).toContain("command -v uvx"); | |
| expect(guidance).toContain( | |
| path.join("/workspace/project/agent-canvas", "README.md"), | |
| ); | |
| expect(guidance).toContain( | |
| "https://docs.astral.sh/uv/getting-started/installation/", | |
| ); | |
| expect(guidance).toContain("npm run dev:frontend"); | |
| expect(guidance).toContain("npm run dev:mock"); | |
| }); | |
| }); | |
| describe("buildAgentServerTelemetryEnv", () => { | |
| it("configures PostHog telemetry by default without seeding consent", () => { | |
| expect(buildAgentServerTelemetryEnv({})).toEqual({ | |
| OH_TELEMETRY_EXPORTER: "posthog", | |
| OH_TELEMETRY_POSTHOG_API_KEY: | |
| "phc_kBtz5nKmxVRRQ7HtPwr2QX9eMC5j65zE86QKocVNwb4U", | |
| OH_TELEMETRY_POSTHOG_HOST: "https://us.i.posthog.com", | |
| }); | |
| }); | |
| it("prefers explicit agent-server telemetry settings", () => { | |
| expect( | |
| buildAgentServerTelemetryEnv({ | |
| OH_TELEMETRY_EXPORTER: "http", | |
| OH_TELEMETRY_CONSENT: "denied", | |
| OH_TELEMETRY_POSTHOG_API_KEY: "phc_agent", | |
| OH_TELEMETRY_POSTHOG_HOST: "https://agent.example", | |
| VITE_POSTHOG_API_KEY: "phc_frontend", | |
| VITE_POSTHOG_HOST: "https://frontend.example", | |
| }), | |
| ).toEqual({ | |
| OH_TELEMETRY_EXPORTER: "http", | |
| OH_TELEMETRY_CONSENT: "denied", | |
| OH_TELEMETRY_POSTHOG_API_KEY: "phc_agent", | |
| OH_TELEMETRY_POSTHOG_HOST: "https://agent.example", | |
| }); | |
| }); | |
| it("uses frontend telemetry settings when agent-server settings are absent", () => { | |
| expect( | |
| buildAgentServerTelemetryEnv({ | |
| VITE_POSTHOG_API_KEY: "phc_frontend", | |
| VITE_POSTHOG_HOST: "https://frontend.example", | |
| }), | |
| ).toEqual({ | |
| OH_TELEMETRY_EXPORTER: "posthog", | |
| OH_TELEMETRY_POSTHOG_API_KEY: "phc_frontend", | |
| OH_TELEMETRY_POSTHOG_HOST: "https://frontend.example", | |
| }); | |
| }); | |
| it("maps frontend do-not-track to the agent-server kill switch", () => { | |
| expect(buildAgentServerTelemetryEnv({ VITE_DO_NOT_TRACK: "1" })).toEqual({ | |
| DO_NOT_TRACK: "1", | |
| }); | |
| }); | |
| it("includes telemetry defaults in the full agent-server environment", () => { | |
| const env = buildAgentServerEnv( | |
| { | |
| cwd: "/tmp/cwd", | |
| backendPort: 18000, | |
| tmuxTmpDir: "/tmp/tmux", | |
| stateDir: "/tmp/state", | |
| conversationsPath: "/tmp/conversations", | |
| workspacesPath: "/tmp/workspaces", | |
| bashEventsDir: "/tmp/bash-events", | |
| vscodePort: 19000, | |
| vscodeBasePath: "/vscode", | |
| secretKey: "secret", | |
| sessionApiKey: "session", | |
| backendBaseUrl: "http://127.0.0.1:18000", | |
| backendHost: "127.0.0.1:18000", | |
| workingDir: "/tmp/workspaces", | |
| canvasToolsDir: "/tmp/tools", | |
| }, | |
| { env: {} }, | |
| ); | |
| expect(env).toMatchObject({ | |
| OH_TELEMETRY_EXPORTER: "posthog", | |
| OH_SESSION_API_KEYS_0: "session", | |
| }); | |
| }); | |
| }); | |
| describe("buildAgentServerCommand", () => { | |
| it("uses released PyPI version by default with all packages pinned", () => { | |
| const cmd = buildAgentServerCommand({}); | |
| expect(cmd.command).toBe("uvx"); | |
| // Defaults to the released PyPI version with all SDK packages pinned to same version | |
| expect(cmd.args).toEqual([ | |
| "--from", | |
| "openhands-agent-server==1.46.0", | |
| "--with", | |
| "openhands-sdk==1.46.0", | |
| "--with", | |
| "openhands-tools==1.46.0", | |
| "--with", | |
| "openhands-workspace==1.46.0", | |
| "--with", | |
| "agent-client-protocol<0.11", | |
| "--with", | |
| "posthog>=6,<7", | |
| "agent-server", | |
| "--import-modules", | |
| "canvas_ui_tool", | |
| ]); | |
| expect(cmd.source).toBe("PyPI (1.46.0, default)"); | |
| }); | |
| it("uses specific PyPI version when OH_AGENT_SERVER_VERSION is set with all packages pinned", () => { | |
| const cmd = buildAgentServerCommand({ OH_AGENT_SERVER_VERSION: "1.18.0" }); | |
| expect(cmd.command).toBe("uvx"); | |
| // Uses --from syntax because executable name (agent-server) differs from package name (openhands-agent-server) | |
| // All SDK packages are pinned to the same version | |
| expect(cmd.args).toEqual([ | |
| "--from", | |
| "openhands-agent-server==1.18.0", | |
| "--with", | |
| "openhands-sdk==1.18.0", | |
| "--with", | |
| "openhands-tools==1.18.0", | |
| "--with", | |
| "openhands-workspace==1.18.0", | |
| "--with", | |
| "agent-client-protocol<0.11", | |
| "--with", | |
| "posthog>=6,<7", | |
| "agent-server", | |
| "--import-modules", | |
| "canvas_ui_tool", | |
| ]); | |
| expect(cmd.source).toBe("PyPI (1.18.0)"); | |
| }); | |
| it("uses git ref with subdirectory syntax for monorepo", () => { | |
| const cmd = buildAgentServerCommand({ | |
| OH_AGENT_SERVER_GIT_REF: "feature-branch", | |
| }); | |
| expect(cmd.command).toBe("uvx"); | |
| expect(cmd.args).toEqual([ | |
| "--reinstall", | |
| "--from", | |
| "git+https://github.com/OpenHands/software-agent-sdk@feature-branch#subdirectory=openhands-agent-server", | |
| "--with", | |
| "git+https://github.com/OpenHands/software-agent-sdk@feature-branch#subdirectory=openhands-sdk", | |
| "--with", | |
| "git+https://github.com/OpenHands/software-agent-sdk@feature-branch#subdirectory=openhands-tools", | |
| "--with", | |
| "git+https://github.com/OpenHands/software-agent-sdk@feature-branch#subdirectory=openhands-workspace", | |
| "--with", | |
| "posthog>=6,<7", | |
| "agent-server", | |
| "--import-modules", | |
| "canvas_ui_tool", | |
| ]); | |
| expect(cmd.source).toBe("git (feature-branch)"); | |
| }); | |
| it("uses git ref for commit SHA", () => { | |
| const cmd = buildAgentServerCommand({ OH_AGENT_SERVER_GIT_REF: "abc1234" }); | |
| expect(cmd.command).toBe("uvx"); | |
| expect(cmd.args).toEqual([ | |
| "--reinstall", | |
| "--from", | |
| "git+https://github.com/OpenHands/software-agent-sdk@abc1234#subdirectory=openhands-agent-server", | |
| "--with", | |
| "git+https://github.com/OpenHands/software-agent-sdk@abc1234#subdirectory=openhands-sdk", | |
| "--with", | |
| "git+https://github.com/OpenHands/software-agent-sdk@abc1234#subdirectory=openhands-tools", | |
| "--with", | |
| "git+https://github.com/OpenHands/software-agent-sdk@abc1234#subdirectory=openhands-workspace", | |
| "--with", | |
| "posthog>=6,<7", | |
| "agent-server", | |
| "--import-modules", | |
| "canvas_ui_tool", | |
| ]); | |
| expect(cmd.source).toBe("git (abc1234)"); | |
| }); | |
| it("git ref takes precedence over version", () => { | |
| const cmd = buildAgentServerCommand({ | |
| OH_AGENT_SERVER_VERSION: "1.18.0", | |
| OH_AGENT_SERVER_GIT_REF: "feature-branch", | |
| }); | |
| expect(cmd.command).toBe("uvx"); | |
| expect(cmd.args).toContain("--from"); | |
| expect(cmd.args).toContain( | |
| "git+https://github.com/OpenHands/software-agent-sdk@feature-branch#subdirectory=openhands-agent-server", | |
| ); | |
| expect(cmd.args).not.toContain("openhands-agent-server==1.18.0"); | |
| }); | |
| it("uses local path with editable workspace packages when OH_AGENT_SERVER_LOCAL_PATH is set", () => { | |
| const sdk = "/abs/path/to/software-agent-sdk"; | |
| const cmd = buildAgentServerCommand({ OH_AGENT_SERVER_LOCAL_PATH: sdk }); | |
| expect(cmd.command).toBe("uvx"); | |
| expect(cmd.args).toEqual([ | |
| "--reinstall", | |
| "--from", | |
| path.join(sdk, "openhands-agent-server"), | |
| "--with-editable", | |
| path.join(sdk, "openhands-sdk"), | |
| "--with-editable", | |
| path.join(sdk, "openhands-tools"), | |
| "--with-editable", | |
| path.join(sdk, "openhands-workspace"), | |
| "--with", | |
| "posthog>=6,<7", | |
| "agent-server", | |
| "--import-modules", | |
| "canvas_ui_tool", | |
| ]); | |
| expect(cmd.source).toBe(`local (${sdk})`); | |
| }); | |
| it("local path takes precedence over git ref and version", () => { | |
| const sdk = "/abs/path/to/software-agent-sdk"; | |
| const cmd = buildAgentServerCommand({ | |
| OH_AGENT_SERVER_LOCAL_PATH: sdk, | |
| OH_AGENT_SERVER_GIT_REF: "feature-branch", | |
| OH_AGENT_SERVER_VERSION: "1.18.0", | |
| }); | |
| expect(cmd.source).toBe(`local (${sdk})`); | |
| expect(cmd.args).toContain(path.join(sdk, "openhands-agent-server")); | |
| expect(cmd.args).not.toContain( | |
| "git+https://github.com/OpenHands/software-agent-sdk@feature-branch#subdirectory=openhands-agent-server", | |
| ); | |
| expect(cmd.args).not.toContain("openhands-agent-server==1.18.0"); | |
| }); | |
| it("passes --import-modules to the agent-server, after the executable, in every source mode", () => { | |
| // The flag must sit after "agent-server" so uvx hands it to the server | |
| // instead of parsing it itself. tools/canvas_ui_tool.py documents why the | |
| // module has to be imported before any conversation is created. | |
| const variants = [ | |
| {}, | |
| { OH_AGENT_SERVER_VERSION: "1.18.0" }, | |
| { OH_AGENT_SERVER_GIT_REF: "feature-branch" }, | |
| { OH_AGENT_SERVER_LOCAL_PATH: "/abs/path/to/software-agent-sdk" }, | |
| ]; | |
| for (const env of variants) { | |
| const { args } = buildAgentServerCommand(env); | |
| const executable = args.indexOf("agent-server"); | |
| expect(executable).toBeGreaterThan(-1); | |
| expect(args.slice(executable + 1)).toEqual([ | |
| "--import-modules", | |
| "canvas_ui_tool", | |
| ]); | |
| } | |
| }); | |
| it("rejects relative OH_AGENT_SERVER_LOCAL_PATH", () => { | |
| expect(() => | |
| buildAgentServerCommand({ | |
| OH_AGENT_SERVER_LOCAL_PATH: "./software-agent-sdk", | |
| }), | |
| ).toThrow(/must be an absolute path/); | |
| }); | |
| }); | |
| describe("validateLocalAgentServerPath", () => { | |
| it("passes when all four workspace packages exist", () => { | |
| const tmp = mkdtempSync(path.join(tmpdir(), "sdk-")); | |
| try { | |
| for (const subdir of [ | |
| "openhands-agent-server", | |
| "openhands-sdk", | |
| "openhands-tools", | |
| "openhands-workspace", | |
| ]) { | |
| mkdirSync(path.join(tmp, subdir)); | |
| } | |
| expect(() => validateLocalAgentServerPath(tmp)).not.toThrow(); | |
| } finally { | |
| rmSync(tmp, { recursive: true, force: true }); | |
| } | |
| }); | |
| it("throws when the path does not exist", () => { | |
| expect(() => | |
| validateLocalAgentServerPath("/definitely/does/not/exist/sdk"), | |
| ).toThrow(/does not exist/); | |
| }); | |
| it("throws when a workspace package subdirectory is missing", () => { | |
| const tmp = mkdtempSync(path.join(tmpdir(), "sdk-")); | |
| try { | |
| mkdirSync(path.join(tmp, "openhands-agent-server")); | |
| mkdirSync(path.join(tmp, "openhands-sdk")); | |
| mkdirSync(path.join(tmp, "openhands-tools")); | |
| // openhands-workspace is intentionally absent | |
| expect(() => validateLocalAgentServerPath(tmp)).toThrow( | |
| /openhands-workspace/, | |
| ); | |
| } finally { | |
| rmSync(tmp, { recursive: true, force: true }); | |
| } | |
| }); | |
| it("throws when given a relative path", () => { | |
| expect(() => validateLocalAgentServerPath("./sdk")).toThrow( | |
| /must be an absolute path/, | |
| ); | |
| }); | |
| }); | |
| describe("buildSafeDevConfig", () => { | |
| let keyTmp: string | null = null; | |
| afterEach(() => { | |
| if (keyTmp) { | |
| rmSync(keyTmp, { recursive: true, force: true }); | |
| keyTmp = null; | |
| } | |
| resetPersistedSessionApiKeyCache(); | |
| }); | |
| function tempKeyPath(): string { | |
| keyTmp = mkdtempSync(path.join(tmpdir(), "dev-safe-key-")); | |
| return path.join(keyTmp, "session-api-key.txt"); | |
| } | |
| it("builds isolated default paths and ports", () => { | |
| const cwd = "/workspace/project/agent-canvas"; | |
| const config = buildSafeDevConfig(cwd, { | |
| OH_SESSION_API_KEY_PATH: tempKeyPath(), | |
| }); | |
| expect(config.backendPort).toBe(18000); | |
| expect(config.vscodePort).toBe(18001); | |
| expect(config.backendBaseUrl).toBe("http://127.0.0.1:18000"); | |
| expect(config.backendHost).toBe("127.0.0.1:18000"); | |
| expect(config.workingDir).toBe(config.workspacesPath); | |
| expect(config.stateDir).toBe( | |
| path.join(homedir(), ".openhands", "agent-canvas"), | |
| ); | |
| expect(config.tmuxTmpDir).toBe(path.join(config.stateDir, "tmux")); | |
| expect(config.conversationsPath).toBe( | |
| path.join(config.stateDir, "dev_conversations"), | |
| ); | |
| expect(config.workspacesPath).toBe( | |
| path.join(config.stateDir, "workspaces"), | |
| ); | |
| expect(config.bashEventsDir).toBe( | |
| path.join(config.stateDir, "bash_events"), | |
| ); | |
| }); | |
| it("honors environment overrides", () => { | |
| const cwd = "/workspace/project/agent-canvas"; | |
| const config = buildSafeDevConfig(cwd, { | |
| OH_CANVAS_SAFE_BACKEND_PORT: "19000", | |
| OH_CANVAS_SAFE_VSCODE_PORT: "19010", | |
| OH_CANVAS_SAFE_STATE_DIR: ".tmp/dev-safe", | |
| VITE_WORKING_DIR: "/workspace/custom-repo", | |
| OH_SESSION_API_KEY_PATH: tempKeyPath(), | |
| }); | |
| expect(config.backendPort).toBe(19000); | |
| expect(config.vscodePort).toBe(19010); | |
| expect(config.backendBaseUrl).toBe("http://127.0.0.1:19000"); | |
| expect(config.backendHost).toBe("127.0.0.1:19000"); | |
| expect(config.stateDir).toBe(path.resolve(cwd, ".tmp", "dev-safe")); | |
| expect(config.workingDir).toBe("/workspace/custom-repo"); | |
| // tmux socket dir defaults to <stateDir>/tmux. | |
| expect(config.tmuxTmpDir).toBe(path.join(config.stateDir, "tmux")); | |
| }); | |
| it("honors TMUX_TMPDIR for hosts without socket-capable homes", () => { | |
| const config = buildSafeDevConfig("/workspace/project/agent-canvas", { | |
| TMUX_TMPDIR: "/tmp", | |
| OH_SESSION_API_KEY_PATH: tempKeyPath(), | |
| }); | |
| expect(config.tmuxTmpDir).toBe("/tmp"); | |
| }); | |
| it("falls back to the persisted session key file when no env override is set", () => { | |
| const keyPath = tempKeyPath(); | |
| const config = buildSafeDevConfig("/workspace/project/agent-canvas", { | |
| OH_SESSION_API_KEY_PATH: keyPath, | |
| }); | |
| // A fresh hex key was generated and persisted. | |
| expect(config.sessionApiKey).toMatch(/^[a-f0-9]{64}$/); | |
| expect(readFileSync(keyPath, "utf8").trim()).toBe(config.sessionApiKey); | |
| }); | |
| it("reuses the same key across config builds, simulating restarts", () => { | |
| const keyPath = tempKeyPath(); | |
| const first = buildSafeDevConfig("/workspace/project/agent-canvas", { | |
| OH_SESSION_API_KEY_PATH: keyPath, | |
| }); | |
| // Simulate a fresh process by clearing the in-memory cache; the file | |
| // on disk is what should make the key stable. | |
| resetPersistedSessionApiKeyCache(); | |
| const second = buildSafeDevConfig("/workspace/project/agent-canvas", { | |
| OH_SESSION_API_KEY_PATH: keyPath, | |
| }); | |
| expect(second.sessionApiKey).toBe(first.sessionApiKey); | |
| }); | |
| it("LOCAL_BACKEND_API_KEY takes precedence over the persisted file", () => { | |
| const keyPath = tempKeyPath(); | |
| // Pre-seed the file with one key. | |
| mkdirSync(path.dirname(keyPath), { recursive: true }); | |
| writeFileSync(keyPath, "persisted-key-value\n"); | |
| const config = buildSafeDevConfig("/workspace/project/agent-canvas", { | |
| LOCAL_BACKEND_API_KEY: "env-key-wins", | |
| OH_SESSION_API_KEY_PATH: keyPath, | |
| }); | |
| expect(config.sessionApiKey).toBe("env-key-wins"); | |
| // The file is left untouched. | |
| expect(readFileSync(keyPath, "utf8").trim()).toBe("persisted-key-value"); | |
| }); | |
| }); | |
| describe("getOrCreatePersistedSessionApiKey", () => { | |
| let dir: string | null = null; | |
| afterEach(() => { | |
| if (dir) { | |
| rmSync(dir, { recursive: true, force: true }); | |
| dir = null; | |
| } | |
| resetPersistedSessionApiKeyCache(); | |
| }); | |
| function tempPath(): string { | |
| dir = mkdtempSync(path.join(tmpdir(), "session-key-")); | |
| return path.join(dir, "nested", "session-api-key.txt"); | |
| } | |
| it("creates the file (and parent dirs) with a hex key on first call", () => { | |
| const filePath = tempPath(); | |
| const key = getOrCreatePersistedSessionApiKey(filePath); | |
| expect(key).toMatch(/^[a-f0-9]{64}$/); | |
| expect(readFileSync(filePath, "utf8").trim()).toBe(key); | |
| }); | |
| it("returns the existing key on subsequent calls (after cache reset)", () => { | |
| const filePath = tempPath(); | |
| const first = getOrCreatePersistedSessionApiKey(filePath); | |
| resetPersistedSessionApiKeyCache(); | |
| const second = getOrCreatePersistedSessionApiKey(filePath); | |
| expect(second).toBe(first); | |
| }); | |
| it("trims surrounding whitespace from the persisted file", () => { | |
| const filePath = tempPath(); | |
| mkdirSync(path.dirname(filePath), { recursive: true }); | |
| writeFileSync(filePath, " abcdef1234 \n"); | |
| const key = getOrCreatePersistedSessionApiKey(filePath); | |
| expect(key).toBe("abcdef1234"); | |
| }); | |
| it("regenerates and overwrites when the file is empty", () => { | |
| const filePath = tempPath(); | |
| mkdirSync(path.dirname(filePath), { recursive: true }); | |
| writeFileSync(filePath, " \n"); | |
| const key = getOrCreatePersistedSessionApiKey(filePath); | |
| expect(key).toMatch(/^[a-f0-9]{64}$/); | |
| expect(readFileSync(filePath, "utf8").trim()).toBe(key); | |
| }); | |
| }); | |
| describe("buildNpmScriptCommand", () => { | |
| it("runs npm through cmd.exe on Windows even when npm_execpath is set", () => { | |
| // npm_execpath points to a path with spaces like | |
| // "C:\Program Files\nodejs\node_modules\npm\bin\npm-cli.js". | |
| // spawnService uses shell:true on Windows, so passing that path as an | |
| // argument causes cmd.exe to split on the space and fail with | |
| // "'C:\Program' is not recognized as an internal or external command". | |
| // The win32 branch must fire BEFORE the npm_execpath branch. | |
| const command = buildNpmScriptCommand( | |
| "dev:frontend", | |
| "win32", | |
| { | |
| ComSpec: "C:\\Windows\\System32\\cmd.exe", | |
| npm_execpath: | |
| "C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js", | |
| npm_node_execpath: "C:\\Program Files\\nodejs\\node.exe", | |
| }, | |
| "C:\\Program Files\\nodejs\\node.exe", | |
| ); | |
| expect(command).toEqual({ | |
| command: "C:\\Windows\\System32\\cmd.exe", | |
| args: ["/d", "/s", "/c", "npm", "run", "dev:frontend"], | |
| }); | |
| }); | |
| it("reuses npm's own CLI path when available on POSIX", () => { | |
| const command = buildNpmScriptCommand( | |
| "dev:frontend", | |
| "linux", | |
| { | |
| npm_execpath: "/usr/lib/node_modules/npm/bin/npm-cli.js", | |
| npm_node_execpath: "/usr/bin/node", | |
| }, | |
| "/fallback/node", | |
| ); | |
| expect(command).toEqual({ | |
| command: "/usr/bin/node", | |
| args: ["/usr/lib/node_modules/npm/bin/npm-cli.js", "run", "dev:frontend"], | |
| }); | |
| }); | |
| it("runs npm directly on POSIX platforms", () => { | |
| const command = buildNpmScriptCommand("dev:frontend", "linux", {}); | |
| expect(command).toEqual({ | |
| command: "npm", | |
| args: ["run", "dev:frontend"], | |
| }); | |
| }); | |
| it("runs npm through cmd.exe on Windows", () => { | |
| const command = buildNpmScriptCommand("dev:frontend", "win32", { | |
| ComSpec: "C:\\Windows\\System32\\cmd.exe", | |
| }); | |
| expect(command).toEqual({ | |
| command: "C:\\Windows\\System32\\cmd.exe", | |
| args: ["/d", "/s", "/c", "npm", "run", "dev:frontend"], | |
| }); | |
| }); | |
| it("falls back to cmd.exe when ComSpec is unavailable on Windows", () => { | |
| const command = buildNpmScriptCommand("dev:frontend", "win32", {}); | |
| expect(command).toEqual({ | |
| command: "cmd.exe", | |
| args: ["/d", "/s", "/c", "npm", "run", "dev:frontend"], | |
| }); | |
| }); | |
| }); | |
| describe("dev-safe CLI startup", () => { | |
| it("exits promptly when uvx is missing", async () => { | |
| // Skip this test if uvx is globally installed via /usr/local/bin symlink | |
| // that may still be accessible even with a stripped PATH | |
| const child = spawn(process.execPath, ["scripts/dev-safe.mjs"], { | |
| cwd: repoRoot, | |
| env: { | |
| // Use empty PATH to ensure uvx is not found. | |
| PATH: "", | |
| // Redirect the agent-server port to a high free port so the | |
| // assertPortsFree pre-flight check passes when a real dev stack is | |
| // running on the default port (18000) — the test is about uvx, not | |
| // port detection. | |
| OH_CANVAS_SAFE_BACKEND_PORT: "19810", | |
| }, | |
| 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("Failed to start uvx"); | |
| expect(output).toContain("curl -LsSf https://astral.sh/uv/install.sh | sh"); | |
| expect(output).toContain( | |
| "https://docs.astral.sh/uv/getting-started/installation/", | |
| ); | |
| expect(output).toContain("README.md"); | |
| expect(output).toContain("npm run dev:mock"); | |
| expect(output).toContain("spawn uvx ENOENT"); | |
| }); | |
| it.skipIf(process.platform === "win32")( | |
| "cleans up the detached agent-server when the launcher receives SIGHUP", | |
| async () => { | |
| // Services are spawned detached (getProcessTreeSpawnOptions), so killing | |
| // the launcher does not kill them. Drive the real launcher with a stub | |
| // agent-server, then SIGHUP the launcher and assert the stub's port is | |
| // released rather than held by a survivor. | |
| const stubDir = mkdtempSync(path.join(tmpdir(), "dev-safe-sighup-")); | |
| const stubJs = path.join(stubDir, "stub-agent-server.mjs"); | |
| const uvxStub = path.join(stubDir, "uvx"); | |
| writeFileSync( | |
| stubJs, | |
| [ | |
| 'import net from "node:net";', | |
| 'const portIndex = process.argv.indexOf("--port");', | |
| "const port = Number(process.argv[portIndex + 1]);", | |
| "const server = net.createServer(() => {});", | |
| 'server.listen(port, "127.0.0.1", () => {', | |
| ' console.log("STUB_LISTENING", process.pid, port);', | |
| "});", | |
| "setInterval(() => {}, 1_000);", | |
| ].join("\n"), | |
| ); | |
| writeFileSync( | |
| uvxStub, | |
| `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(stubJs)} "$@"\n`, | |
| ); | |
| chmodSync(uvxStub, 0o755); | |
| const backendPort = await findFreePort(0); | |
| const isPortListening = async () => | |
| new Promise<boolean>((resolve) => { | |
| const socket = net | |
| .connect(backendPort, "127.0.0.1") | |
| .on("connect", () => { | |
| socket.destroy(); | |
| resolve(true); | |
| }) | |
| .on("error", () => resolve(false)); | |
| }); | |
| const launcher = spawn(process.execPath, ["scripts/dev-safe.mjs"], { | |
| cwd: repoRoot, | |
| env: { | |
| ...process.env, | |
| PATH: `${stubDir}${path.delimiter}${process.env.PATH ?? ""}`, | |
| OH_CANVAS_SAFE_BACKEND_PORT: String(backendPort), | |
| }, | |
| stdio: ["ignore", "pipe", "pipe"], | |
| }); | |
| let output = ""; | |
| let stubPid: number | undefined; | |
| const capture = (chunk: Buffer) => { | |
| output += chunk.toString(); | |
| const stubMatch = output.match(/STUB_LISTENING (\d+)/); | |
| if (stubMatch) stubPid = Number(stubMatch[1]); | |
| }; | |
| launcher.stdout.on("data", capture); | |
| launcher.stderr.on("data", capture); | |
| try { | |
| const readyDeadline = Date.now() + 20_000; | |
| let listening = false; | |
| while (!listening && Date.now() < readyDeadline) { | |
| if (launcher.exitCode !== null) break; | |
| listening = await isPortListening(); | |
| if (!listening) await delay(100); | |
| } | |
| expect(listening, output).toBe(true); | |
| launcher.kill("SIGHUP"); | |
| await Promise.race([once(launcher, "exit"), delay(10_000)]); | |
| // shutdown() forwards SIGTERM, then SIGKILLs after 3s. | |
| const freeDeadline = Date.now() + 12_000; | |
| let stillListening = true; | |
| while (stillListening && Date.now() < freeDeadline) { | |
| stillListening = await isPortListening(); | |
| if (stillListening) await delay(200); | |
| } | |
| expect(stillListening, output).toBe(false); | |
| } finally { | |
| if (launcher.exitCode === null) launcher.kill("SIGKILL"); | |
| // The stub is a detached process-group leader, so killing the launcher | |
| // does not reap it. Without this, the regression path this test exists | |
| // to catch would itself leave the stub holding its port indefinitely. | |
| if (stubPid !== undefined) { | |
| try { | |
| process.kill(-stubPid, "SIGKILL"); | |
| } catch { | |
| // Already gone, which is the passing path. | |
| } | |
| } | |
| rmSync(stubDir, { recursive: true, force: true }); | |
| } | |
| }, | |
| 45_000, | |
| ); | |
| }); | |
| interface RuntimeServiceEntry { | |
| kind?: string; | |
| description?: string; | |
| url_from_agent?: string; | |
| api_prefix?: string; | |
| docs_url?: string; | |
| openapi_url?: string; | |
| auth_env_var?: string; | |
| } | |
| interface RuntimeServicesInfoShape { | |
| mode: string; | |
| agent_host_alias: string; | |
| services: { | |
| agent_server?: RuntimeServiceEntry; | |
| ingress?: RuntimeServiceEntry; | |
| frontend?: RuntimeServiceEntry; | |
| automation?: RuntimeServiceEntry; | |
| }; | |
| } | |
| describe("buildRuntimeServicesInfo", () => { | |
| it("describes only the agent-server in a minimal dev-safe stack", () => { | |
| const info = buildRuntimeServicesInfo({ | |
| mode: "dev:safe", | |
| agentServerPort: 18000, | |
| }) as RuntimeServicesInfoShape; | |
| expect(info).toEqual({ | |
| mode: "dev:safe", | |
| agent_host_alias: "localhost", | |
| services: { | |
| agent_server: { | |
| description: expect.any(String), | |
| url_from_agent: "http://localhost:18000", | |
| }, | |
| }, | |
| }); | |
| }); | |
| it("includes ingress, frontend (vite), and automation entries when ports are provided", () => { | |
| const info = buildRuntimeServicesInfo({ | |
| mode: "dev:automation", | |
| agentServerPort: 18000, | |
| ingressPort: 8000, | |
| frontendPort: 3001, | |
| automation: { port: 18001 }, | |
| }) as RuntimeServicesInfoShape; | |
| expect(info.services.ingress?.url_from_agent).toBe("http://localhost:8000"); | |
| expect(info.services.frontend).toMatchObject({ | |
| kind: "vite", | |
| url_from_agent: "http://localhost:3001", | |
| }); | |
| expect(info.services.frontend?.description).toMatch(/Vite dev server/i); | |
| expect(info.services.automation).toMatchObject({ | |
| url_from_agent: "http://localhost:18001", | |
| api_prefix: "/api/automation", | |
| docs_url: "http://localhost:18001/api/automation/docs", | |
| openapi_url: "http://localhost:18001/api/automation/openapi.json", | |
| auth_env_var: "OPENHANDS_AUTOMATION_API_KEY", | |
| }); | |
| }); | |
| it("supports a custom agent host alias for remote setups", () => { | |
| const info = buildRuntimeServicesInfo({ | |
| mode: "custom", | |
| agentHostAlias: "custom-host", | |
| agentServerPort: 8000, | |
| ingressPort: 8000, | |
| frontendPort: 3001, | |
| frontendKind: "static", | |
| automation: { port: 18001 }, | |
| }) as RuntimeServicesInfoShape; | |
| // Agent-server URL is always localhost (the agent is *inside* it). | |
| expect(info.services.agent_server?.url_from_agent).toBe( | |
| "http://localhost:8000", | |
| ); | |
| // Host-side services use the custom alias. | |
| expect(info.services.ingress?.url_from_agent).toBe( | |
| "http://custom-host:8000", | |
| ); | |
| expect(info.services.frontend?.url_from_agent).toBe( | |
| "http://custom-host:3001", | |
| ); | |
| // Static-mode description, not "Vite dev server". | |
| expect(info.services.frontend?.kind).toBe("static"); | |
| expect(info.services.frontend?.description).toMatch(/Static-file server/i); | |
| expect(info.services.frontend?.description).not.toMatch(/Vite/i); | |
| expect(info.services.automation?.url_from_agent).toBe( | |
| "http://custom-host:18001", | |
| ); | |
| }); | |
| it("allows overriding the api prefix and auth env var", () => { | |
| const info = buildRuntimeServicesInfo({ | |
| mode: "dev:custom", | |
| agentServerPort: 18000, | |
| automation: { | |
| port: 9000, | |
| apiPrefix: "/v2/auto", | |
| authEnvVar: "MY_KEY", | |
| }, | |
| }) as RuntimeServicesInfoShape; | |
| expect(info.services.automation).toMatchObject({ | |
| api_prefix: "/v2/auto", | |
| docs_url: "http://localhost:9000/v2/auto/docs", | |
| openapi_url: "http://localhost:9000/v2/auto/openapi.json", | |
| auth_env_var: "MY_KEY", | |
| }); | |
| }); | |
| it("omits the automation entry when none is provided", () => { | |
| const info = buildRuntimeServicesInfo({ | |
| mode: "dev:safe", | |
| agentServerPort: 18000, | |
| ingressPort: 8000, | |
| }) as RuntimeServicesInfoShape; | |
| expect(info.services.automation).toBeUndefined(); | |
| }); | |
| it("omits the automation entry when the object lacks a port", () => { | |
| // A bare `{}` previously slipped through and produced | |
| // `http://localhost:undefined`; require the port explicitly. | |
| const info = buildRuntimeServicesInfo({ | |
| mode: "dev:safe", | |
| agentServerPort: 18000, | |
| automation: {}, | |
| }) as RuntimeServicesInfoShape; | |
| expect(info.services.automation).toBeUndefined(); | |
| }); | |
| it("throws when neither agentServerPort nor agentServerUrl is given", () => { | |
| expect(() => | |
| buildRuntimeServicesInfo({ | |
| mode: "dev:safe", | |
| }), | |
| ).toThrow(/agentServerPort or agentServerUrl is required/); | |
| }); | |
| it("accepts the legacy vitePort alias for frontendPort", () => { | |
| // dev-safe.mjs's `main()` and some external callers still pass the | |
| // older option name; keep them working for one release. | |
| const info = buildRuntimeServicesInfo({ | |
| mode: "dev:safe", | |
| agentServerPort: 18000, | |
| vitePort: 3001, | |
| }) as RuntimeServicesInfoShape; | |
| expect(info.services.frontend?.url_from_agent).toBe( | |
| "http://localhost:3001", | |
| ); | |
| }); | |
| }); | |