test: narrow canvas and context hotspots

This commit is contained in:
Peter Steinberger
2026-04-17 19:42:55 +01:00
parent f70b651b12
commit 2745e5b3bd
2 changed files with 54 additions and 57 deletions

View File

@@ -1,13 +1,18 @@
import fs from "node:fs/promises";
import type { IncomingMessage } from "node:http";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import type { Duplex } from "node:stream";
import { setTimeout as sleep } from "node:timers/promises";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { defaultRuntime } from "../runtime.js";
import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH, injectCanvasLiveReload } from "./a2ui.js";
import {
A2UI_PATH,
CANVAS_HOST_PATH,
CANVAS_WS_PATH,
handleA2uiHttpRequest,
injectCanvasLiveReload,
} from "./a2ui.js";
type MockWatcher = {
on: (event: string, cb: (...args: unknown[]) => void) => MockWatcher;
@@ -30,11 +35,6 @@ type CapturedResponse = {
body: string;
};
function isLoopbackBindDenied(error: unknown) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
return code === "EPERM" || code === "EACCES";
}
function createMockWatcherState() {
const watchers: MockWatcher[] = [];
const createWatcher = () => {
@@ -95,6 +95,35 @@ async function captureHandlerResponse(
return response;
}
async function captureA2uiResponse(url: string, method = "GET"): Promise<CapturedResponse> {
const response: CapturedResponse = {
handled: false,
status: 200,
headers: {},
body: "",
};
const res = {
statusCode: 200,
setHeader(name: string, value: number | string | readonly string[]) {
const headerValue: number | string | string[] =
typeof value === "object" ? [...value] : value;
response.headers[name.toLowerCase()] = headerValue;
return this;
},
end(chunk?: string | Buffer) {
response.status = this.statusCode;
response.body = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : (chunk ?? "");
return this;
},
};
response.handled = await handleA2uiHttpRequest(
{ method, url } as IncomingMessage,
res as import("node:http").ServerResponse,
);
response.status = res.statusCode;
return response;
}
describe("canvas host", () => {
const quietRuntime = {
...defaultRuntime,
@@ -102,7 +131,6 @@ describe("canvas host", () => {
};
let createCanvasHostHandler: typeof import("./server.js").createCanvasHostHandler;
let startCanvasHost: typeof import("./server.js").startCanvasHost;
let realFetch: typeof import("undici").fetch;
let WebSocketServerClass: typeof import("ws").WebSocketServer;
let watcherState: ReturnType<typeof createMockWatcherState>;
let fixtureRoot = "";
@@ -114,29 +142,10 @@ describe("canvas host", () => {
return dir;
};
const startFixtureCanvasHost = async (
rootDir: string,
overrides: Partial<Parameters<typeof startCanvasHost>[0]> = {},
) =>
await startCanvasHost({
runtime: quietRuntime,
rootDir,
port: 0,
listenHost: "127.0.0.1",
allowInTests: true,
watchFactory: watcherState.watchFactory as unknown as Parameters<
typeof startCanvasHost
>[0]["watchFactory"],
webSocketServerClass: WebSocketServerClass,
...overrides,
});
beforeAll(async () => {
vi.doUnmock("undici");
vi.resetModules();
const require = createRequire(import.meta.url);
({ createCanvasHostHandler, startCanvasHost } = await import("./server.js"));
({ fetch: realFetch } = require("undici") as typeof import("undici"));
const wsModule = await vi.importActual<typeof import("ws")>("ws");
WebSocketServerClass = wsModule.WebSocketServer;
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-fixtures-"));
@@ -363,14 +372,12 @@ describe("canvas host", () => {
);
it("serves A2UI scaffold and blocks traversal/symlink escapes", async () => {
const dir = await createCaseDir();
const a2uiRoot = path.resolve(process.cwd(), "src/canvas-host/a2ui");
const bundlePath = path.join(a2uiRoot, "a2ui.bundle.js");
const linkName = `test-link-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`;
const linkPath = path.join(a2uiRoot, linkName);
let createdBundle = false;
let createdLink = false;
let server: Awaited<ReturnType<typeof startFixtureCanvasHost>> | undefined;
try {
await fs.stat(bundlePath);
@@ -383,37 +390,23 @@ describe("canvas host", () => {
createdLink = true;
try {
try {
server = await startFixtureCanvasHost(dir);
} catch (error) {
if (isLoopbackBindDenied(error)) {
return;
}
throw error;
}
const res = await realFetch(`http://127.0.0.1:${server.port}/__openclaw__/a2ui/`);
const html = await res.text();
const res = await captureA2uiResponse(`${A2UI_PATH}/`);
const html = res.body;
expect(res.status).toBe(200);
expect(html).toContain("openclaw-a2ui-host");
expect(html).toContain("openclawCanvasA2UIAction");
const bundleRes = await realFetch(
`http://127.0.0.1:${server.port}/__openclaw__/a2ui/a2ui.bundle.js`,
);
const js = await bundleRes.text();
const bundleRes = await captureA2uiResponse(`${A2UI_PATH}/a2ui.bundle.js`);
const js = bundleRes.body;
expect(bundleRes.status).toBe(200);
expect(js).toContain("openclawA2UI");
const traversalRes = await realFetch(
`http://127.0.0.1:${server.port}${A2UI_PATH}/%2e%2e%2fpackage.json`,
);
const traversalRes = await captureA2uiResponse(`${A2UI_PATH}/%2e%2e%2fpackage.json`);
expect(traversalRes.status).toBe(404);
expect(await traversalRes.text()).toBe("not found");
const symlinkRes = await realFetch(`http://127.0.0.1:${server.port}${A2UI_PATH}/${linkName}`);
expect(traversalRes.body).toBe("not found");
const symlinkRes = await captureA2uiResponse(`${A2UI_PATH}/${linkName}`);
expect(symlinkRes.status).toBe(404);
expect(await symlinkRes.text()).toBe("not found");
expect(symlinkRes.body).toBe("not found");
} finally {
await server?.close();
if (createdLink) {
await fs.rm(linkPath, { force: true });
}

View File

@@ -1009,12 +1009,16 @@ describe("Bundle chunk isolation (#40096)", () => {
it("shares registrations and keeps concurrent chunk registration visible", async () => {
const ts = Date.now().toString(36);
const registryUrl = new URL("./registry.ts", import.meta.url).href;
const chunks = await Promise.all(
Array.from(
{ length: 2 },
(_, i) => import(/* @vite-ignore */ `${registryUrl}?chunk=${ts}-${i}`),
),
);
const dynamicChunk = await import(/* @vite-ignore */ `${registryUrl}?chunk=${ts}-dynamic`);
const chunks = [
{
registerContextEngine,
getContextEngineFactory,
listContextEngineIds,
resolveContextEngine,
},
dynamicChunk,
];
const engineId = `cross-chunk-${ts}`;
const factory = () => ({