Files
openclaw/test/e2e/qa-lab/runtime/gateway-hosted-web.e2e.test.ts
Peter Steinberger ae55a4090c refactor(canvas): make the panel a widget presenter (#126030)
* refactor(canvas): retire legacy host and commands

* refactor(apple): narrow shared Canvas contracts

* refactor(macos): keep Canvas as widget presenter

* refactor(ios): remove Canvas client

* refactor(android): remove Canvas client

* refactor(linux): remove Canvas client

* fix(ci): isolate native locale artifacts

* fix(linux): regenerate companion lockfile

* fix(canvas): refresh native tool display metadata

* test(canvas): align coverage with presenter surface

* test(canvas): remove obsolete asset root seam

* test(canvas): stabilize retirement CI coverage

* refactor(swift): remove orphaned resource wrapper

* test(ios): remove retired canvas layout assertion

* fix(macos): reserve retired canvas command namespace

* refactor(macos): isolate canvas command policy

* fix(canvas): select only eligible macOS panels

* fix(canvas): keep panel selection plugin-owned
2026-08-19 08:21:07 -07:00

259 lines
11 KiB
TypeScript

// Gateway hosted web tests cover Control UI and public plugin routes on one real listener.
import fs from "node:fs/promises";
import path from "node:path";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { afterEach, describe, expect, it, vi } from "vitest";
import adminHttpRpcPlugin from "../../../../extensions/admin-http-rpc/index.js";
import canvasPlugin from "../../../../extensions/canvas/index.js";
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../../../../src/config/config.js";
import type { OpenClawConfig } from "../../../../src/config/types.openclaw.js";
import { startGatewayServer } from "../../../../src/gateway/server.js";
import {
connectGatewayClient,
disconnectGatewayClient,
getGatewayE2ePortBlock,
} from "../../../../src/gateway/test-helpers.e2e.js";
import { snapshotGatewayStartupEnv } from "../../../../src/gateway/test-helpers.env.js";
import {
registerPluginHttpRoute,
withPluginHttpRouteRegistry,
} from "../../../../src/plugins/http-registry.js";
import {
getActivePluginRegistry,
resetPluginRuntimeStateForTest,
} from "../../../../src/plugins/runtime.js";
import { withEnvAsync } from "../../../../src/test-utils/env.js";
import { useAutoCleanupTempDirTracker } from "../../../helpers/temp-dir.js";
const TOKEN = "qa-hosted-web-token";
const OPERATOR_SCOPES = ["operator.read", "operator.write", "operator.admin"];
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
afterEach(async () => {
vi.useRealTimers();
clearConfigCache();
clearRuntimeConfigSnapshot();
resetPluginRuntimeStateForTest();
});
function replaceCapability(scopedUrl: string, capability: string): string {
const url = new URL(scopedUrl);
const segments = url.pathname.split("/");
const capabilityIndex = segments.findIndex((segment) => segment === "cap") + 1;
if (capabilityIndex <= 0 || capabilityIndex >= segments.length) {
throw new Error(`expected capability-scoped URL, received ${url.pathname}`);
}
segments[capabilityIndex] = encodeURIComponent(capability);
url.pathname = segments.join("/");
return url.toString().replace(/\/$/, "");
}
describe("Gateway hosted web surfaces", () => {
it(
"serves the Control UI, admin RPC, and capability-scoped A2UI assets",
{ timeout: 90_000 },
async () => {
const root = tempDirs.make("openclaw-qa-hosted-web-");
const stateDir = path.join(root, "state");
const controlUiRoot = path.join(root, "control-ui");
const configPath = path.join(root, "openclaw.json");
await Promise.all([
fs.mkdir(stateDir, { recursive: true }),
fs.mkdir(controlUiRoot, { recursive: true }),
]);
await fs.writeFile(
path.join(controlUiRoot, "index.html"),
"<!doctype html><html><body>Gateway hosted Control UI</body></html>",
"utf8",
);
const config: OpenClawConfig = {
gateway: {
mode: "local",
bind: "loopback",
auth: { mode: "token", token: TOKEN },
controlUi: { enabled: true, root: controlUiRoot },
},
};
await fs.writeFile(configPath, `${JSON.stringify(config)}\n`, "utf8");
await withEnvAsync(
{
...snapshotGatewayStartupEnv(),
HOME: root,
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
OPENCLAW_HOME: root,
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1",
OPENCLAW_SKIP_CHANNELS: "1",
OPENCLAW_SKIP_CRON: "1",
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
OPENCLAW_SKIP_PROVIDERS: "1",
OPENCLAW_STATE_DIR: stateDir,
},
async () => {
clearConfigCache();
clearRuntimeConfigSnapshot();
const port = await getGatewayE2ePortBlock();
const server = await startGatewayServer(port, {
auth: { mode: "token", token: TOKEN },
bind: "loopback",
controlUiEnabled: true,
sidecarStartup: "defer",
});
// Deferred startup replaces the bootstrap registry; register routes only after the
// server publishes the settled runtime so requests do not target a retired registry.
await server.startupSettled;
const registry = getActivePluginRegistry();
if (!registry) {
throw new Error("gateway did not publish an active plugin registry");
}
const routeCleanups: Array<() => void> = [];
const services: Array<{ stop?: (context: never) => void | Promise<void> }> = [];
let operator: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
const registerEntry = (
pluginId: string,
entry: typeof adminHttpRpcPlugin,
pluginConfig: Record<string, unknown> = {},
) => {
entry.register(
createTestPluginApi({
id: pluginId,
name: pluginId,
config,
pluginConfig,
registerHttpRoute: (route) => {
const routeCount = registry.httpRoutes.length;
routeCleanups.push(
registerPluginHttpRoute({
...route,
pluginId,
registry,
source: `extensions/${pluginId}/index.ts`,
}),
);
if (pluginId === "admin-http-rpc") {
const registeredRoute = registry.httpRoutes[routeCount];
if (registeredRoute) {
registeredRoute.gatewayMethodDispatchAllowed = true;
}
}
const registeredRoute = registry.httpRoutes[routeCount];
if (registeredRoute && route.handleUpgrade) {
registeredRoute.handleUpgrade = route.handleUpgrade;
}
if (registeredRoute && route.nodeCapability) {
registeredRoute.nodeCapability = { ...route.nodeCapability };
}
},
registerService: (service) => services.push(service),
}),
);
};
try {
registerEntry("admin-http-rpc", adminHttpRpcPlugin);
registerEntry("canvas", canvasPlugin, { host: { enabled: true } });
expect(registry.httpRoutes.map((route) => route.path)).toEqual(
expect.arrayContaining(["/api/v1/admin/rpc", "/__openclaw__/a2ui"]),
);
const origin = `http://127.0.0.1:${port}`;
const controlUi = await fetch(`${origin}/`, {
headers: { authorization: `Bearer ${TOKEN}` },
});
expect(controlUi.status).toBe(200);
expect(controlUi.headers.get("content-type")).toContain("text/html");
expect(controlUi.headers.get("x-content-type-options")).toBe("nosniff");
expect(controlUi.headers.get("x-frame-options")).toBe("DENY");
expect(controlUi.headers.get("referrer-policy")).toBe("no-referrer");
expect(controlUi.headers.get("content-security-policy")).toContain(
"frame-ancestors 'none'",
);
expect(await controlUi.text()).toContain("Gateway hosted Control UI");
const unauthorizedAdmin = await fetch(`${origin}/api/v1/admin/rpc`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id: "unauthorized", method: "health", params: {} }),
});
expect(unauthorizedAdmin.status).toBe(401);
const admin = await fetch(`${origin}/api/v1/admin/rpc`, {
method: "POST",
headers: {
accept: "text/html",
authorization: `Bearer ${TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify({ id: "health", method: "health", params: {} }),
});
expect(admin.status).toBe(200);
expect(admin.headers.get("content-type")).toContain("application/json");
expect(await admin.json()).toMatchObject({
id: "health",
ok: true,
payload: { ok: true },
});
const capabilityIssuedAtMs = Date.now();
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(capabilityIssuedAtMs);
let helloCanvasUrl: string | undefined;
operator = await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
token: TOKEN,
role: "operator",
scopes: OPERATOR_SCOPES,
onHelloOk: (hello) => {
helloCanvasUrl = hello.pluginSurfaceUrls?.canvas;
},
});
expect(helloCanvasUrl).toMatch(
/^http:\/\/127\.0\.0\.1:\d+\/__openclaw__\/cap\/[^/]+$/u,
);
vi.useRealTimers();
const scopedCanvasUrl = helloCanvasUrl ?? "";
const wrongCapabilityUrl = replaceCapability(scopedCanvasUrl, "wrong-capability");
await withEnvAsync({ NODE_ENV: undefined, VITEST: undefined }, async () => {
for (const fileName of ["a2ui.bundle.js", "a2ui-v0.9.bundle.js"]) {
const asset = await fetch(`${scopedCanvasUrl}/__openclaw__/a2ui/${fileName}`);
expect(asset.status).toBe(200);
expect(asset.headers.get("content-type")).toContain("javascript");
expect((await asset.text()).length).toBeGreaterThan(100);
}
const wrongCapability = await fetch(
`${wrongCapabilityUrl}/__openclaw__/a2ui/a2ui.bundle.js`,
);
expect(wrongCapability.status).toBe(401);
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(capabilityIssuedAtMs + 24 * 60 * 60_000);
const expiredCapability = await fetch(
`${scopedCanvasUrl}/__openclaw__/a2ui/a2ui.bundle.js`,
);
expect(expiredCapability.status).toBe(401);
});
} finally {
if (operator) {
await disconnectGatewayClient(operator).catch(() => undefined);
}
for (const service of services.toReversed()) {
await withPluginHttpRouteRegistry(registry, async () => {
await service.stop?.({} as never);
});
}
for (const cleanup of routeCleanups.toReversed()) {
cleanup();
}
await server.close();
}
},
);
},
);
});