mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
test: narrow canvas and context hotspots
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 = () => ({
|
||||
|
||||
Reference in New Issue
Block a user