diff --git a/qa/scenarios/ui/control-ui-exec-approvals-agents.yaml b/qa/scenarios/ui/control-ui-exec-approvals-agents.yaml new file mode 100644 index 000000000000..d43071be6e60 --- /dev/null +++ b/qa/scenarios/ui/control-ui-exec-approvals-agents.yaml @@ -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. diff --git a/qa/scenarios/ui/control-ui-skills-nodes.yaml b/qa/scenarios/ui/control-ui-skills-nodes.yaml new file mode 100644 index 000000000000..94c81bd27345 --- /dev/null +++ b/qa/scenarios/ui/control-ui-skills-nodes.yaml @@ -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. diff --git a/ui/src/e2e/operator-admin.e2e.test.ts b/ui/src/e2e/operator-admin.e2e.test.ts new file mode 100644 index 000000000000..730513d3068e --- /dev/null +++ b/ui/src/e2e/operator-admin.e2e.test.ts @@ -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 { + if (!request.params || typeof request.params !== "object" || Array.isArray(request.params)) { + return {}; + } + return request.params as Record; +} + +async function waitForRequest( + gateway: MockGatewayControls, + method: string, + predicate: (params: Record) => boolean, +) { + await expect + .poll(async () => + (await gateway.getRequests(method)).some((request) => predicate(requestParams(request))), + ) + .toBe(true); +} + +async function createContext(): Promise { + 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(); + } + }); +});