mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-06 10:02:30 +00:00
test(ui): cover operator administration flows (#118836)
This commit is contained in:
31
qa/scenarios/ui/control-ui-exec-approvals-agents.yaml
Normal file
31
qa/scenarios/ui/control-ui-exec-approvals-agents.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
title: Control UI agent-scoped exec approvals administration
|
||||
|
||||
scenario:
|
||||
id: control-ui-exec-approvals-agents
|
||||
surface: control-ui
|
||||
category: control-ui.operator-console
|
||||
coverage:
|
||||
primary:
|
||||
- control-ui.exec-approvals-agents
|
||||
objective: >-
|
||||
Prove an operator can select an agent's exec approval policy, edit it,
|
||||
save with optimistic concurrency, and observe the applied Gateway state.
|
||||
successCriteria:
|
||||
- Chromium lists the configured agents and selects the reviewer approval scope.
|
||||
- The operator edits security, prompt, fallback, skill CLI, and allowlist policy fields.
|
||||
- Saving sends the complete reviewer policy with the loaded snapshot hash.
|
||||
- The reloaded Gateway snapshot renders the applied values and a clean saved state.
|
||||
docsRefs:
|
||||
- docs/web/control-ui.md
|
||||
- docs/help/testing.md
|
||||
codeRefs:
|
||||
- ui/src/e2e/operator-admin.e2e.test.ts
|
||||
- ui/src/pages/nodes/view-exec-approvals.ts
|
||||
- ui/src/lib/nodes/index.ts
|
||||
execution:
|
||||
kind: playwright
|
||||
path: ui/src/e2e/operator-admin.e2e.test.ts
|
||||
testNamePattern: edits, saves, and reapplies reviewer-scoped exec approvals
|
||||
summary: >-
|
||||
Real Chromium coverage for agent-scoped exec approval listing, editing,
|
||||
hash-bound save, reload, and applied visible state.
|
||||
31
qa/scenarios/ui/control-ui-skills-nodes.yaml
Normal file
31
qa/scenarios/ui/control-ui-skills-nodes.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
title: Control UI Skills and Nodes operator administration
|
||||
|
||||
scenario:
|
||||
id: control-ui-skills-nodes
|
||||
surface: control-ui
|
||||
category: control-ui.operator-console
|
||||
coverage:
|
||||
primary:
|
||||
- control-ui.skills-nodes
|
||||
objective: >-
|
||||
Prove an operator can administer agent-scoped Skills and inspect the
|
||||
connected Nodes inventory through the Control UI.
|
||||
successCriteria:
|
||||
- Chromium switches Skills administration from the default agent to a named agent.
|
||||
- A skill install action sends the named agent and installer identity through the Gateway.
|
||||
- The post-install Gateway response renders a visible successful outcome.
|
||||
- Chromium renders a connected node with its capabilities and commands.
|
||||
docsRefs:
|
||||
- docs/web/control-ui.md
|
||||
- docs/help/testing.md
|
||||
codeRefs:
|
||||
- ui/src/e2e/operator-admin.e2e.test.ts
|
||||
- ui/src/pages/skills/skills-page.ts
|
||||
- ui/src/pages/nodes/nodes-page.ts
|
||||
execution:
|
||||
kind: playwright
|
||||
path: ui/src/e2e/operator-admin.e2e.test.ts
|
||||
testNamePattern: administers agent-scoped Skills and inspects the connected Nodes inventory
|
||||
summary: >-
|
||||
Real Chromium coverage for agent-scoped Skills requests, visible install
|
||||
results, and connected Nodes inventory inspection.
|
||||
383
ui/src/e2e/operator-admin.e2e.test.ts
Normal file
383
ui/src/e2e/operator-admin.e2e.test.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
// Control UI E2E coverage for operator-facing Skills, Nodes, and exec approvals administration.
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
canRunPlaywrightChromium,
|
||||
installMockGateway,
|
||||
resolvePlaywrightChromiumExecutablePath,
|
||||
startControlUiE2eServer,
|
||||
type ControlUiE2eServer,
|
||||
type MockGatewayControls,
|
||||
type MockGatewayRequest,
|
||||
} from "../test-helpers/control-ui-e2e.ts";
|
||||
|
||||
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
|
||||
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
|
||||
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
|
||||
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
|
||||
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
|
||||
const proofDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "operator-admin");
|
||||
const viewport = { height: 960, width: 1440 };
|
||||
|
||||
let browser: Browser;
|
||||
let server: ControlUiE2eServer;
|
||||
|
||||
const agentRoster = [
|
||||
{ id: "main", identity: { name: "Main" }, name: "Main" },
|
||||
{ id: "reviewer", identity: { name: "Reviewer" }, name: "Reviewer" },
|
||||
];
|
||||
|
||||
const operatorConfig = {
|
||||
agents: {
|
||||
entries: {
|
||||
main: { default: true, name: "Main" },
|
||||
reviewer: { name: "Reviewer" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function skillStatus(eligible: boolean) {
|
||||
return {
|
||||
workspaceDir: "/tmp/openclaw-e2e/workspace",
|
||||
managedSkillsDir: "/tmp/openclaw-e2e/skills",
|
||||
skills: [
|
||||
{
|
||||
name: "Deploy Helper",
|
||||
description: "Prepare reviewed deployments.",
|
||||
source: "openclaw-bundled",
|
||||
bundled: true,
|
||||
filePath: "/tmp/openclaw-e2e/skills/deploy-helper/SKILL.md",
|
||||
baseDir: "/tmp/openclaw-e2e/skills/deploy-helper",
|
||||
skillKey: "deploy-helper",
|
||||
always: false,
|
||||
disabled: false,
|
||||
blockedByAllowlist: false,
|
||||
blockedByAgentFilter: false,
|
||||
eligible,
|
||||
platformIncompatible: false,
|
||||
modelVisible: eligible,
|
||||
userInvocable: true,
|
||||
commandVisible: eligible,
|
||||
requirements: {
|
||||
bins: ["deploy-helper"],
|
||||
anyBins: [],
|
||||
env: [],
|
||||
config: [],
|
||||
os: [],
|
||||
},
|
||||
missing: {
|
||||
bins: eligible ? [] : ["deploy-helper"],
|
||||
anyBins: [],
|
||||
env: [],
|
||||
config: [],
|
||||
os: [],
|
||||
},
|
||||
configChecks: [],
|
||||
install: [
|
||||
{
|
||||
id: "node-deploy-helper",
|
||||
kind: "node",
|
||||
label: "Install Deploy Helper",
|
||||
bins: ["deploy-helper"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function configResponse() {
|
||||
const raw = JSON.stringify(operatorConfig);
|
||||
return {
|
||||
config: operatorConfig,
|
||||
sourceConfig: operatorConfig,
|
||||
hash: "config-hash-1",
|
||||
issues: [],
|
||||
raw,
|
||||
valid: true,
|
||||
};
|
||||
}
|
||||
|
||||
function requestParams(request: MockGatewayRequest): Record<string, unknown> {
|
||||
if (!request.params || typeof request.params !== "object" || Array.isArray(request.params)) {
|
||||
return {};
|
||||
}
|
||||
return request.params as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function waitForRequest(
|
||||
gateway: MockGatewayControls,
|
||||
method: string,
|
||||
predicate: (params: Record<string, unknown>) => boolean,
|
||||
) {
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await gateway.getRequests(method)).some((request) => predicate(requestParams(request))),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function createContext(): Promise<BrowserContext> {
|
||||
if (captureUiProof) {
|
||||
await mkdir(proofDir, { recursive: true });
|
||||
}
|
||||
return browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport,
|
||||
...(captureUiProof ? { recordVideo: { dir: proofDir, size: viewport } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function screenshot(page: Page, name: string) {
|
||||
if (!captureUiProof) {
|
||||
return;
|
||||
}
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
fullPage: true,
|
||||
path: path.join(proofDir, name),
|
||||
});
|
||||
}
|
||||
|
||||
describeControlUiE2e("Control UI operator administration", () => {
|
||||
beforeAll(async () => {
|
||||
if (!chromiumAvailable) {
|
||||
throw new Error(`Playwright Chromium is unavailable at ${chromiumExecutablePath}`);
|
||||
}
|
||||
server = await startControlUiE2eServer();
|
||||
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
it("administers agent-scoped Skills and inspects the connected Nodes inventory", async () => {
|
||||
const context = await createContext();
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"agents.list": {
|
||||
agents: agentRoster,
|
||||
defaultId: "main",
|
||||
mainKey: "main",
|
||||
scope: "agent",
|
||||
},
|
||||
"config.get": configResponse(),
|
||||
"device.pair.list": { paired: [], pending: [] },
|
||||
"exec.approvals.get": {
|
||||
path: "/tmp/openclaw-e2e/exec-approvals.json",
|
||||
exists: true,
|
||||
hash: "approval-hash-1",
|
||||
file: {
|
||||
defaults: {
|
||||
security: "deny",
|
||||
ask: "on-miss",
|
||||
askFallback: "deny",
|
||||
autoAllowSkills: false,
|
||||
},
|
||||
agents: {},
|
||||
},
|
||||
},
|
||||
"node.list": {
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "build-node",
|
||||
displayName: "Build Node",
|
||||
platform: "linux",
|
||||
version: "2026.8.3",
|
||||
caps: ["browser", "filesystem"],
|
||||
commands: ["system.run", "system.execApprovals.get", "system.execApprovals.set"],
|
||||
connected: true,
|
||||
paired: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
"skills.install": { message: "Installed Deploy Helper" },
|
||||
"skills.status": {
|
||||
cases: [
|
||||
{ match: { agentId: "reviewer" }, response: skillStatus(false) },
|
||||
{ response: skillStatus(true) },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await page.goto(`${server.baseUrl}skills`);
|
||||
expect(response?.status()).toBe(200);
|
||||
await gateway.waitForRequest("skills.status");
|
||||
|
||||
const agentSelect = page.locator('openclaw-agent-select[name="skills-agent"]');
|
||||
await agentSelect.locator(".agent-select__trigger").click();
|
||||
await agentSelect
|
||||
.locator("wa-dropdown-item[data-agent-option]")
|
||||
.filter({ hasText: "Reviewer" })
|
||||
.click();
|
||||
await waitForRequest(gateway, "skills.status", (params) => params.agentId === "reviewer");
|
||||
await expect
|
||||
.poll(async () => (await agentSelect.locator(".agent-select__label").textContent())?.trim())
|
||||
.toBe("Reviewer");
|
||||
|
||||
await page.getByRole("button", { name: "Open Deploy Helper details" }).click();
|
||||
const dialog = page.locator("openclaw-modal-dialog", { hasText: "Deploy Helper" });
|
||||
await expect
|
||||
.poll(() => dialog.getByRole("button", { name: "Install Deploy Helper" }).isVisible())
|
||||
.toBe(true);
|
||||
await gateway.setMethodResponse("skills.status", skillStatus(true));
|
||||
await dialog.getByRole("button", { name: "Install Deploy Helper" }).click();
|
||||
const installRequest = await gateway.waitForRequest("skills.install");
|
||||
expect(installRequest.params).toMatchObject({
|
||||
agentId: "reviewer",
|
||||
name: "Deploy Helper",
|
||||
installId: "node-deploy-helper",
|
||||
dangerouslyForceUnsafeInstall: false,
|
||||
});
|
||||
await expect.poll(() => dialog.getByText("Installed Deploy Helper").isVisible()).toBe(true);
|
||||
await screenshot(page, "01-reviewer-skill-installed.png");
|
||||
|
||||
await page.goto(`${server.baseUrl}nodes`);
|
||||
await Promise.all([
|
||||
gateway.waitForRequest("node.list"),
|
||||
gateway.waitForRequest("device.pair.list"),
|
||||
gateway.waitForRequest("exec.approvals.get"),
|
||||
]);
|
||||
await expect.poll(() => page.getByText("Build Node", { exact: true }).isVisible()).toBe(true);
|
||||
await expect.poll(() => page.getByText("connected", { exact: true }).isVisible()).toBe(true);
|
||||
await page.getByText("Details", { exact: true }).click();
|
||||
await expect
|
||||
.poll(() => page.getByText(/Capabilities: browser, filesystem/).isVisible())
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.getByText(
|
||||
/Commands: system\.run, system\.execApprovals\.get, system\.execApprovals\.set/,
|
||||
)
|
||||
.isVisible(),
|
||||
)
|
||||
.toBe(true);
|
||||
await screenshot(page, "02-connected-node-inventory.png");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("edits, saves, and reapplies reviewer-scoped exec approvals", async () => {
|
||||
const context = await createContext();
|
||||
const page = await context.newPage();
|
||||
const initialApprovals = {
|
||||
path: "/tmp/openclaw-e2e/exec-approvals.json",
|
||||
exists: true,
|
||||
hash: "approval-hash-1",
|
||||
file: {
|
||||
defaults: {
|
||||
security: "deny",
|
||||
ask: "on-miss",
|
||||
askFallback: "deny",
|
||||
autoAllowSkills: false,
|
||||
},
|
||||
agents: {
|
||||
reviewer: {
|
||||
security: "allowlist",
|
||||
ask: "on-miss",
|
||||
askFallback: "deny",
|
||||
autoAllowSkills: false,
|
||||
allowlist: [{ pattern: "/usr/bin/git" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const appliedApprovals = {
|
||||
...initialApprovals,
|
||||
hash: "approval-hash-2",
|
||||
file: {
|
||||
...initialApprovals.file,
|
||||
agents: {
|
||||
reviewer: {
|
||||
security: "full",
|
||||
ask: "always",
|
||||
askFallback: "allowlist",
|
||||
autoAllowSkills: true,
|
||||
allowlist: [{ pattern: "/usr/bin/gh" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"config.get": configResponse(),
|
||||
"device.pair.list": { paired: [], pending: [] },
|
||||
"exec.approvals.get": initialApprovals,
|
||||
"exec.approvals.set": { ok: true },
|
||||
"node.list": { nodes: [] },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await page.goto(`${server.baseUrl}nodes`);
|
||||
expect(response?.status()).toBe(200);
|
||||
await gateway.waitForRequest("exec.approvals.get");
|
||||
|
||||
const scopeSelect = page.locator("openclaw-agent-select.agent-select--settings");
|
||||
await scopeSelect.locator(".agent-select__trigger").click();
|
||||
await scopeSelect
|
||||
.locator("wa-dropdown-item[data-agent-option]")
|
||||
.filter({ hasText: "Reviewer (reviewer)" })
|
||||
.click();
|
||||
await expect
|
||||
.poll(async () => (await scopeSelect.locator(".agent-select__label").textContent())?.trim())
|
||||
.toBe("Reviewer (reviewer)");
|
||||
|
||||
const modeSelects = page.getByRole("combobox", { name: "Mode" });
|
||||
await modeSelects.nth(0).selectOption("full");
|
||||
await modeSelects.nth(1).selectOption("always");
|
||||
await page.getByRole("combobox", { name: "Fallback" }).selectOption("allowlist");
|
||||
const autoAllowSwitch = page
|
||||
.locator(".settings-row", { hasText: "Auto-allow skill CLIs" })
|
||||
.locator("wa-switch");
|
||||
await autoAllowSwitch.click();
|
||||
await page.getByRole("textbox", { name: "Pattern" }).fill("/usr/bin/gh");
|
||||
await screenshot(page, "03-reviewer-approval-edits.png");
|
||||
|
||||
await gateway.setMethodResponse("exec.approvals.get", appliedApprovals);
|
||||
const getRequestsBeforeSave = (await gateway.getRequests("exec.approvals.get")).length;
|
||||
const approvalsSection = page.locator(".settings-section", { hasText: "Exec approvals" });
|
||||
const saveButton = approvalsSection.getByRole("button", { name: "Save", exact: true });
|
||||
await saveButton.click();
|
||||
const saveRequest = await gateway.waitForRequest("exec.approvals.set");
|
||||
expect(saveRequest.params).toEqual({
|
||||
baseHash: "approval-hash-1",
|
||||
file: appliedApprovals.file,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("exec.approvals.get")).length)
|
||||
.toBeGreaterThan(getRequestsBeforeSave);
|
||||
await expect.poll(() => modeSelects.nth(0).inputValue()).toBe("full");
|
||||
await expect.poll(() => modeSelects.nth(1).inputValue()).toBe("always");
|
||||
await expect
|
||||
.poll(() => page.getByRole("combobox", { name: "Fallback" }).inputValue())
|
||||
.toBe("allowlist");
|
||||
await expect
|
||||
.poll(() =>
|
||||
autoAllowSwitch.evaluate(
|
||||
(element) => (element as HTMLElement & { checked: boolean }).checked,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(() => page.getByRole("textbox", { name: "Pattern" }).inputValue())
|
||||
.toBe("/usr/bin/gh");
|
||||
await expect.poll(() => saveButton.isDisabled()).toBe(true);
|
||||
expect(await page.getByRole("alert").count()).toBe(0);
|
||||
await screenshot(page, "04-reviewer-approval-applied.png");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user