Files
openclaw/extensions/workspaces/src/gateway.test.ts
Peter Steinberger 938bddddc1 refactor: privatize QA Matrix and Workspaces internals (#107973)
* refactor(qa-matrix): replace testing export facades

* refactor(workspaces): remove broadcast reset export

* chore(deadcode): shrink unused export baseline
2026-07-14 20:48:46 -07:00

298 lines
11 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it, vi } from "vitest";
import { workspaceBroadcast } from "./broadcast.js";
import { registerWorkspaceGatewayMethods } from "./gateway.js";
import { WorkspaceStore } from "./store.js";
type RegisteredMethod = {
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
opts: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[2];
};
async function withTempStateDir<T>(run: (stateDir: string) => Promise<T>): Promise<T> {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-gateway-"));
try {
return await run(stateDir);
} finally {
await fs.rm(stateDir, { recursive: true, force: true });
}
}
function createApi() {
const methods = new Map<string, RegisteredMethod>();
const api = {
registerGatewayMethod: vi.fn(
(method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => {
methods.set(method, { handler, opts });
},
),
} as unknown as OpenClawPluginApi;
return { api, methods };
}
async function callMethod(
method: RegisteredMethod,
params: Record<string, unknown>,
broadcast = vi.fn(),
) {
const respond = vi.fn();
await method.handler({
params,
respond,
context: { broadcast },
} as never);
return { broadcast, respond, response: respond.mock.calls[0] };
}
describe("workspace gateway methods", () => {
it("registers all L1 methods with read/write scopes", () => {
const { api, methods } = createApi();
registerWorkspaceGatewayMethods({
api,
store: new WorkspaceStore({ stateDir: "/tmp/unused" }),
});
expect([...methods.keys()]).toEqual([
"workspaces.get",
"workspaces.widget.frame",
"workspaces.tab.create",
"workspaces.tab.update",
"workspaces.tab.delete",
"workspaces.tab.reorder",
"workspaces.widget.add",
"workspaces.widget.update",
"workspaces.widget.move",
"workspaces.widget.remove",
"workspaces.widget.setLayout",
"workspaces.widget.scaffold",
"workspaces.widget.approve",
"workspaces.replace",
"workspaces.undo",
"workspaces.data.read",
]);
expect(methods.get("workspaces.get")?.opts).toEqual({ scope: "operator.read" });
expect(methods.get("workspaces.widget.frame")?.opts).toEqual({ scope: "operator.read" });
expect(methods.get("workspaces.data.read")?.opts).toEqual({ scope: "operator.read" });
// Approving agent-authored code is an approvals decision: operator.write alone
// must not be enough to mount an untrusted widget.
expect(methods.get("workspaces.widget.approve")?.opts).toEqual({
scope: "operator.approvals",
});
const readOnly = new Set(["workspaces.get", "workspaces.widget.frame", "workspaces.data.read"]);
for (const [name, method] of methods) {
if (readOnly.has(name) || name === "workspaces.widget.approve") {
continue;
}
expect(method.opts).toEqual({ scope: "operator.write" });
}
});
it("returns the workspace without broadcasting and broadcasts successful writes", async () => {
await withTempStateDir(async (stateDir) => {
const { api, methods } = createApi();
registerWorkspaceGatewayMethods({ api, store: new WorkspaceStore({ stateDir }) });
const broadcast = vi.fn();
const read = await callMethod(methods.get("workspaces.get")!, {}, broadcast);
expect(read.response?.[0]).toBe(true);
expect(read.response?.[1]).toMatchObject({ workspaceVersion: 1 });
expect(broadcast).not.toHaveBeenCalled();
// Provenance is derived from the caller. An RPC client must not be able to
// stamp `agent:<id>` on work a human did (or the reverse).
const forged = await callMethod(
methods.get("workspaces.tab.create")!,
{ title: "Finance Ops", actor: "agent:main" },
broadcast,
);
expect(forged.response?.[0]).toBe(false);
expect(forged.response?.[2]?.message).toContain("unexpected param: actor");
expect(broadcast).not.toHaveBeenCalled();
const created = await callMethod(
methods.get("workspaces.tab.create")!,
{ title: "Finance Ops" },
broadcast,
);
expect(created.response?.[0]).toBe(true);
expect(created.response?.[1]).toMatchObject({
doc: {
workspaceVersion: 2,
tabs: expect.arrayContaining([expect.objectContaining({ slug: "finance-ops" })]),
},
workspaceVersion: 2,
});
expect(broadcast).toHaveBeenCalledWith("plugin.workspaces.changed", {
workspaceVersion: 2,
changedTabSlug: "finance-ops",
actor: "user",
});
});
});
it("rejects unknown params and bad shapes without broadcasting", async () => {
await withTempStateDir(async (stateDir) => {
const { api, methods } = createApi();
registerWorkspaceGatewayMethods({ api, store: new WorkspaceStore({ stateDir }) });
const broadcast = vi.fn();
const response = await callMethod(
methods.get("workspaces.tab.create")!,
{ title: "Bad", unexpected: true },
broadcast,
);
expect(response.response?.[0]).toBe(false);
expect(response.response?.[2]?.message).toContain("unexpected param");
expect(broadcast).not.toHaveBeenCalled();
});
});
it("applies widget, workspace replace, undo, and data read methods", async () => {
await withTempStateDir(async (stateDir) => {
const { api, methods } = createApi();
registerWorkspaceGatewayMethods({
api,
store: new WorkspaceStore({ stateDir }),
});
const broadcast = vi.fn();
await callMethod(
methods.get("workspaces.tab.create")!,
{ slug: "ops", title: "Ops" },
broadcast,
);
await callMethod(
methods.get("workspaces.widget.add")!,
{
tab: "ops",
widget: {
kind: "builtin:markdown",
title: "Notes",
grid: { x: 0, y: 0, w: 4, h: 2 },
},
},
broadcast,
);
const updated = await callMethod(
methods.get("workspaces.widget.update")!,
{ tab: "ops", id: "notes", patch: { collapsed: true } },
broadcast,
);
expect(
updated.response?.[1]?.doc.tabs.find((tab: { slug: string }) => tab.slug === "ops"),
).toMatchObject({
widgets: [expect.objectContaining({ id: "notes", collapsed: true })],
});
await callMethod(
methods.get("workspaces.widget.move")!,
{ tab: "ops", id: "notes", grid: { x: 4, y: 0, w: 4, h: 2 } },
broadcast,
);
const ambiguousMove = await callMethod(
methods.get("workspaces.widget.move")!,
{ tab: "ops", id: "notes", grid: { x: 0, y: 0, w: 4, h: 2 }, toTab: "main" },
broadcast,
);
expect(ambiguousMove.response?.[0]).toBe(false);
expect(ambiguousMove.response?.[2]?.message).toContain("not both");
await callMethod(
methods.get("workspaces.widget.setLayout")!,
{ tab: "ops", layout: [{ id: "notes", grid: { x: 0, y: 3, w: 6, h: 3 } }] },
broadcast,
);
// Approval decides on a scaffolded widget; it cannot mint a registry entry.
const orphanApprove = await callMethod(
methods.get("workspaces.widget.approve")!,
{ name: "custom-chart", decision: "approved" },
broadcast,
);
expect(orphanApprove.response?.[0]).toBe(false);
expect(orphanApprove.response?.[2]?.message).toContain("not found");
const scaffolded = await callMethod(
methods.get("workspaces.widget.scaffold")!,
{ name: "custom-chart" },
broadcast,
);
expect(scaffolded.response?.[1]?.registry).toEqual({
status: "pending",
createdBy: "user",
});
const approved = await callMethod(
methods.get("workspaces.widget.approve")!,
{ name: "custom-chart", decision: "approved" },
broadcast,
);
// Approvals-only callers get the registry entry, never the whole document.
expect(approved.response?.[1]).toMatchObject({
name: "custom-chart",
registry: { status: "approved", approvedBy: "user" },
});
expect(approved.response?.[1]?.doc).toBeUndefined();
const frame = await callMethod(
methods.get("workspaces.widget.frame")!,
{ name: "custom-chart" },
broadcast,
);
expect(frame.response?.[0]).toBe(true);
expect(frame.response?.[1]).toMatchObject({
manifest: { name: "custom-chart", entrypoint: "index.html" },
frameToken: expect.stringMatching(/^[A-Za-z0-9_-]{40,}$/),
frameExpiresAt: expect.any(Number),
});
const data = await callMethod(
methods.get("workspaces.data.read")!,
{ binding: { source: "static", value: { ok: true } } },
broadcast,
);
expect(data.response?.[1]).toEqual({ data: { ok: true } });
const rpcData = await callMethod(
methods.get("workspaces.data.read")!,
{ binding: { source: "rpc", method: "sessions.list" } },
broadcast,
);
expect(rpcData.response?.[0]).toBe(false);
expect(rpcData.response?.[2]).toMatchObject({ code: "binding_client_resolved" });
const beforeReplace = await callMethod(methods.get("workspaces.get")!, {}, broadcast);
const replacement = structuredClone(beforeReplace.response?.[1]?.doc);
replacement.tabs = [replacement.tabs.find((tab: { slug: string }) => tab.slug === "ops")];
replacement.prefs.tabOrder = ["ops"];
await callMethod(methods.get("workspaces.replace")!, { doc: replacement }, broadcast);
const undo = await callMethod(methods.get("workspaces.undo")!, {}, broadcast);
expect(undo.response?.[0]).toBe(true);
expect(
undo.response?.[1]?.doc.tabs.some((tab: { slug: string }) => tab.slug === "main"),
).toBe(true);
// create + add + update + move + setLayout + scaffold + approve + replace + undo
expect(broadcast).toHaveBeenCalledTimes(9);
});
});
});
describe("workspace broadcast handle", () => {
it("keeps the current server broadcast so agent tools can announce changes off-request", async () => {
await withTempStateDir(async (stateDir) => {
const { api, methods } = createApi();
registerWorkspaceGatewayMethods({ api, store: new WorkspaceStore({ stateDir }) });
const initialBroadcast = vi.fn();
const currentBroadcast = vi.fn();
// A read populates the slot; agent turns started from a channel or cron have
// no gateway request scope and rely on it.
await callMethod(methods.get("workspaces.get")!, {}, initialBroadcast);
expect(workspaceBroadcast()).toBe(initialBroadcast);
await callMethod(methods.get("workspaces.get")!, {}, currentBroadcast);
expect(workspaceBroadcast()).toBe(currentBroadcast);
});
});
});