| import fs from "node:fs/promises"; |
| import { createServer } from "node:http"; |
| import type { AddressInfo } from "node:net"; |
| import os from "node:os"; |
| import path from "node:path"; |
| import { describe, expect, it, vi } from "vitest"; |
| import { WebSocket } from "ws"; |
| import { rawDataToString } from "../infra/ws.js"; |
| import { defaultRuntime } from "../runtime.js"; |
| import { CANVAS_HOST_PATH, CANVAS_WS_PATH, injectCanvasLiveReload } from "./a2ui.js"; |
| import { createCanvasHostHandler, startCanvasHost } from "./server.js"; |
|
|
| describe("canvas host", () => { |
| it("injects live reload script", () => { |
| const out = injectCanvasLiveReload("<html><body>Hello</body></html>"); |
| expect(out).toContain(CANVAS_WS_PATH); |
| expect(out).toContain("location.reload"); |
| expect(out).toContain("openclawCanvasA2UIAction"); |
| expect(out).toContain("openclawSendUserAction"); |
| }); |
|
|
| it("creates a default index.html when missing", async () => { |
| const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-")); |
|
|
| const server = await startCanvasHost({ |
| runtime: defaultRuntime, |
| rootDir: dir, |
| port: 0, |
| listenHost: "127.0.0.1", |
| allowInTests: true, |
| }); |
|
|
| try { |
| const res = await fetch(`http://127.0.0.1:${server.port}${CANVAS_HOST_PATH}/`); |
| const html = await res.text(); |
| expect(res.status).toBe(200); |
| expect(html).toContain("Interactive test page"); |
| expect(html).toContain("openclawSendUserAction"); |
| expect(html).toContain(CANVAS_WS_PATH); |
| } finally { |
| await server.close(); |
| await fs.rm(dir, { recursive: true, force: true }); |
| } |
| }); |
|
|
| it("skips live reload injection when disabled", async () => { |
| const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-")); |
| await fs.writeFile(path.join(dir, "index.html"), "<html><body>no-reload</body></html>", "utf8"); |
|
|
| const server = await startCanvasHost({ |
| runtime: defaultRuntime, |
| rootDir: dir, |
| port: 0, |
| listenHost: "127.0.0.1", |
| allowInTests: true, |
| liveReload: false, |
| }); |
|
|
| try { |
| const res = await fetch(`http://127.0.0.1:${server.port}${CANVAS_HOST_PATH}/`); |
| const html = await res.text(); |
| expect(res.status).toBe(200); |
| expect(html).toContain("no-reload"); |
| expect(html).not.toContain(CANVAS_WS_PATH); |
|
|
| const wsRes = await fetch(`http://127.0.0.1:${server.port}${CANVAS_WS_PATH}`); |
| expect(wsRes.status).toBe(404); |
| } finally { |
| await server.close(); |
| await fs.rm(dir, { recursive: true, force: true }); |
| } |
| }); |
|
|
| it("serves canvas content from the mounted base path", async () => { |
| const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-")); |
| await fs.writeFile(path.join(dir, "index.html"), "<html><body>v1</body></html>", "utf8"); |
|
|
| const handler = await createCanvasHostHandler({ |
| runtime: defaultRuntime, |
| rootDir: dir, |
| basePath: CANVAS_HOST_PATH, |
| allowInTests: true, |
| }); |
|
|
| const server = createServer((req, res) => { |
| void (async () => { |
| if (await handler.handleHttpRequest(req, res)) { |
| return; |
| } |
| res.statusCode = 404; |
| res.setHeader("Content-Type", "text/plain; charset=utf-8"); |
| res.end("Not Found"); |
| })(); |
| }); |
| server.on("upgrade", (req, socket, head) => { |
| if (handler.handleUpgrade(req, socket, head)) { |
| return; |
| } |
| socket.destroy(); |
| }); |
|
|
| await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); |
| const port = (server.address() as AddressInfo).port; |
|
|
| try { |
| const res = await fetch(`http://127.0.0.1:${port}${CANVAS_HOST_PATH}/`); |
| const html = await res.text(); |
| expect(res.status).toBe(200); |
| expect(html).toContain("v1"); |
| expect(html).toContain(CANVAS_WS_PATH); |
|
|
| const miss = await fetch(`http://127.0.0.1:${port}/`); |
| expect(miss.status).toBe(404); |
| } finally { |
| await handler.close(); |
| await new Promise<void>((resolve, reject) => |
| server.close((err) => (err ? reject(err) : resolve())), |
| ); |
| await fs.rm(dir, { recursive: true, force: true }); |
| } |
| }); |
|
|
| it("reuses a handler without closing it twice", async () => { |
| const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-")); |
| await fs.writeFile(path.join(dir, "index.html"), "<html><body>v1</body></html>", "utf8"); |
|
|
| const handler = await createCanvasHostHandler({ |
| runtime: defaultRuntime, |
| rootDir: dir, |
| basePath: CANVAS_HOST_PATH, |
| allowInTests: true, |
| }); |
| const originalClose = handler.close; |
| const closeSpy = vi.fn(async () => originalClose()); |
| handler.close = closeSpy; |
|
|
| const server = await startCanvasHost({ |
| runtime: defaultRuntime, |
| handler, |
| ownsHandler: false, |
| port: 0, |
| listenHost: "127.0.0.1", |
| allowInTests: true, |
| }); |
|
|
| try { |
| expect(server.port).toBeGreaterThan(0); |
| } finally { |
| await server.close(); |
| expect(closeSpy).not.toHaveBeenCalled(); |
| await originalClose(); |
| await fs.rm(dir, { recursive: true, force: true }); |
| } |
| }); |
|
|
| it("serves HTML with injection and broadcasts reload on file changes", async () => { |
| const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-")); |
| const index = path.join(dir, "index.html"); |
| await fs.writeFile(index, "<html><body>v1</body></html>", "utf8"); |
|
|
| const server = await startCanvasHost({ |
| runtime: defaultRuntime, |
| rootDir: dir, |
| port: 0, |
| listenHost: "127.0.0.1", |
| allowInTests: true, |
| }); |
|
|
| try { |
| const res = await fetch(`http://127.0.0.1:${server.port}${CANVAS_HOST_PATH}/`); |
| const html = await res.text(); |
| expect(res.status).toBe(200); |
| expect(html).toContain("v1"); |
| expect(html).toContain(CANVAS_WS_PATH); |
|
|
| const ws = new WebSocket(`ws://127.0.0.1:${server.port}${CANVAS_WS_PATH}`); |
| await new Promise<void>((resolve, reject) => { |
| const timer = setTimeout(() => reject(new Error("ws open timeout")), 5000); |
| ws.on("open", () => { |
| clearTimeout(timer); |
| resolve(); |
| }); |
| ws.on("error", (err) => { |
| clearTimeout(timer); |
| reject(err); |
| }); |
| }); |
|
|
| const msg = new Promise<string>((resolve, reject) => { |
| const timer = setTimeout(() => reject(new Error("reload timeout")), 10_000); |
| ws.on("message", (data) => { |
| clearTimeout(timer); |
| resolve(rawDataToString(data)); |
| }); |
| }); |
|
|
| await new Promise((resolve) => setTimeout(resolve, 100)); |
| await fs.writeFile(index, "<html><body>v2</body></html>", "utf8"); |
| expect(await msg).toBe("reload"); |
| ws.close(); |
| } finally { |
| await server.close(); |
| await fs.rm(dir, { recursive: true, force: true }); |
| } |
| }, 20_000); |
|
|
| it("serves the gateway-hosted A2UI scaffold", async () => { |
| const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-")); |
| const a2uiRoot = path.resolve(process.cwd(), "src/canvas-host/a2ui"); |
| const bundlePath = path.join(a2uiRoot, "a2ui.bundle.js"); |
| let createdBundle = false; |
|
|
| try { |
| await fs.stat(bundlePath); |
| } catch { |
| await fs.writeFile(bundlePath, "window.openclawA2UI = {};", "utf8"); |
| createdBundle = true; |
| } |
|
|
| const server = await startCanvasHost({ |
| runtime: defaultRuntime, |
| rootDir: dir, |
| port: 0, |
| listenHost: "127.0.0.1", |
| allowInTests: true, |
| }); |
|
|
| try { |
| const res = await fetch(`http://127.0.0.1:${server.port}/__openclaw__/a2ui/`); |
| const html = await res.text(); |
| expect(res.status).toBe(200); |
| expect(html).toContain("openclaw-a2ui-host"); |
| expect(html).toContain("openclawCanvasA2UIAction"); |
|
|
| const bundleRes = await fetch( |
| `http://127.0.0.1:${server.port}/__openclaw__/a2ui/a2ui.bundle.js`, |
| ); |
| const js = await bundleRes.text(); |
| expect(bundleRes.status).toBe(200); |
| expect(js).toContain("openclawA2UI"); |
| } finally { |
| await server.close(); |
| if (createdBundle) { |
| await fs.rm(bundlePath, { force: true }); |
| } |
| await fs.rm(dir, { recursive: true, force: true }); |
| } |
| }); |
| }); |
|
|