diff --git a/qa/scenarios/ui/control-ui-device-pairing.yaml b/qa/scenarios/ui/control-ui-device-pairing.yaml new file mode 100644 index 000000000000..d2aa3071d480 --- /dev/null +++ b/qa/scenarios/ui/control-ui-device-pairing.yaml @@ -0,0 +1,31 @@ +title: Control UI mobile device pairing + +scenario: + id: control-ui-device-pairing + surface: control-ui + category: control-ui.browser-access-and-trust + coverage: + primary: + - control-ui.device-pairing + objective: >- + Prove an operator can open mobile pairing, choose full or limited access, + create a setup code, observe pending device requests, and continue to + device management through the Control UI. + successCriteria: + - Real Chromium opens mobile pairing from the operator identity menu and the Security page. + - Full access is the default before issuance and reopening resets the choice to full. + - Setup-code requests preserve the selected full or limited access profile. + - The UI renders a QR code, Gateway URL, pending-device count, and device-management action. + - Browser screenshots, video, and a credential-free behavior summary are retained as artifacts. + docsRefs: + - docs/web/control-ui.md + codeRefs: + - ui/src/e2e/mobile-pairing.e2e.test.ts + - ui/src/lib/device-pair-setup.ts + execution: + kind: playwright + path: ui/src/e2e/mobile-pairing.e2e.test.ts + testNamePattern: defaults to full before issuance, supports limited fallback, and resets when reopened + summary: >- + Real Chromium coverage for the operator mobile-pairing flow and its + observable setup-code access selections. diff --git a/qa/scenarios/ui/control-ui-token-password-auth.yaml b/qa/scenarios/ui/control-ui-token-password-auth.yaml new file mode 100644 index 000000000000..d991011ed41e --- /dev/null +++ b/qa/scenarios/ui/control-ui-token-password-auth.yaml @@ -0,0 +1,32 @@ +title: Control UI token and password credential submission + +scenario: + id: control-ui-token-password-auth + surface: control-ui + category: control-ui.browser-access-and-trust + coverage: + primary: + - control-ui.token-password-auth + objective: >- + Prove the Control UI submits operator-entered token and password + credentials in browser connect requests and renders deterministic accepted, + rejected, and recovered outcomes. + successCriteria: + - Real Chromium renders the login gate after the mocked Gateway requests credentials. + - Token submission appears only in the connect token field and visibly reaches the connected shell. + - Password submission appears only in the connect password field. + - A rejected password renders visible recovery guidance and the mismatch reason without mounting the shell. + - A replacement password visibly reconnects, with screenshots, video, and a credential-free behavior summary retained as artifacts. + docsRefs: + - docs/web/control-ui.md + codeRefs: + - ui/src/e2e/control-ui-credentials.e2e.test.ts + - ui/src/components/login-gate.ts + - ui/src/api/gateway.ts + execution: + kind: playwright + path: ui/src/e2e/control-ui-credentials.e2e.test.ts + testNamePattern: submits token and password credentials with visible acceptance and rejection recovery + summary: >- + Real Chromium coverage for Control UI credential selection, connect-frame + consumption, and visible authentication recovery at the browser boundary. diff --git a/ui/src/e2e/control-ui-credentials.e2e.test.ts b/ui/src/e2e/control-ui-credentials.e2e.test.ts new file mode 100644 index 000000000000..b44bd6bdf3e2 --- /dev/null +++ b/ui/src/e2e/control-ui-credentials.e2e.test.ts @@ -0,0 +1,229 @@ +// Control UI tests cover browser credential submission and visible recovery. +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { chromium, type Browser, type BrowserContext, type Page } from "playwright"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js"; +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 artifactDir = path.resolve( + process.cwd(), + process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim() || + ".artifacts/control-ui-e2e/control-ui-credentials", +); +const viewport = { height: 900, width: 1280 }; + +let browser: Browser; +let server: ControlUiE2eServer; +const openContexts = new Set(); + +function requireRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Expected object value"); + } + return value as Record; +} + +function readConnectAuth(request: MockGatewayRequest): Record | undefined { + const auth = requireRecord(request.params).auth; + return auth == null ? undefined : requireRecord(auth); +} + +async function createCredentialPage(): Promise<{ + context: BrowserContext; + gateway: MockGatewayControls; + page: Page; +}> { + await mkdir(artifactDir, { recursive: true }); + const context = await browser.newContext({ + locale: "en-US", + recordVideo: { dir: artifactDir, size: viewport }, + serviceWorkers: "block", + viewport, + }); + openContexts.add(context); + const page = await context.newPage(); + page.setDefaultTimeout(10_000); + const gateway = await installMockGateway(page, { deferredMethods: ["connect"] }); + const response = await page.goto(server.baseUrl); + expect(response?.status()).toBe(200); + return { context, gateway, page }; +} + +async function waitForNextConnect( + gateway: MockGatewayControls, + previousCount: number, +): Promise { + await expect + .poll(async () => (await gateway.getRequests("connect")).length) + .toBe(previousCount + 1); + const request = (await gateway.getRequests("connect")).at(-1); + if (!request) { + throw new Error("Expected a new Control UI connect request"); + } + return request; +} + +async function rejectInitialConnect( + page: Page, + gateway: MockGatewayControls, + code: string, + message: string, +): Promise { + const initialConnect = await gateway.waitForRequest("connect"); + expect(readConnectAuth(initialConnect)).toBeUndefined(); + await gateway.rejectDeferred("connect", { + code: "UNAUTHORIZED", + details: { code }, + message, + }); + await page.locator("openclaw-login-gate").waitFor(); +} + +describeControlUiE2e("Control UI token and password credentials E2E", () => { + beforeAll(async () => { + if (!chromiumAvailable) { + throw new Error( + `Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}.`, + ); + } + server = await startControlUiE2eServer(); + browser = await chromium.launch({ executablePath: chromiumExecutablePath }); + }); + + afterAll(async () => { + await Promise.all([...openContexts].map((context) => context.close().catch(() => {}))); + await browser?.close(); + await server?.close(); + }); + + afterEach(async () => { + await Promise.all([...openContexts].map((context) => context.close().catch(() => {}))); + openContexts.clear(); + }); + + it("submits token and password credentials with visible acceptance and rejection recovery", async () => { + const tokenFlow = await createCredentialPage(); + const tokenPageErrors: string[] = []; + tokenFlow.page.on("pageerror", (error) => tokenPageErrors.push(String(error))); + await rejectInitialConnect( + tokenFlow.page, + tokenFlow.gateway, + ConnectErrorDetailCodes.AUTH_TOKEN_MISSING, + "unauthorized: gateway token required", + ); + + const tokenInput = tokenFlow.page.getByLabel(/Gateway token/i); + const tokenConnectCount = (await tokenFlow.gateway.getRequests("connect")).length; + await tokenFlow.gateway.deferNext("connect"); + await tokenInput.fill("token-accepted-by-mock"); + await tokenFlow.page.getByRole("button", { name: "Connect", exact: true }).click(); + + const tokenConnect = await waitForNextConnect(tokenFlow.gateway, tokenConnectCount); + expect(readConnectAuth(tokenConnect)).toMatchObject({ + token: "token-accepted-by-mock", + }); + expect(readConnectAuth(tokenConnect)?.password).toBeUndefined(); + await tokenFlow.gateway.resolveDeferred("connect"); + await tokenFlow.page.locator("openclaw-app-shell").waitFor(); + expect(await tokenFlow.page.locator("openclaw-login-gate").count()).toBe(0); + await tokenFlow.page.screenshot({ + fullPage: true, + path: path.join(artifactDir, "01-token-connected.png"), + }); + expect(tokenPageErrors).toEqual([]); + await tokenFlow.context.close(); + openContexts.delete(tokenFlow.context); + + const passwordFlow = await createCredentialPage(); + const passwordPageErrors: string[] = []; + passwordFlow.page.on("pageerror", (error) => passwordPageErrors.push(String(error))); + await rejectInitialConnect( + passwordFlow.page, + passwordFlow.gateway, + ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING, + "unauthorized: gateway password required", + ); + + const passwordInput = passwordFlow.page.getByLabel(/Password \(not stored\)/i); + const rejectedConnectCount = (await passwordFlow.gateway.getRequests("connect")).length; + await passwordFlow.gateway.deferNext("connect"); + await passwordInput.fill("password-rejected-by-mock"); + await passwordFlow.page.getByRole("button", { name: "Connect", exact: true }).click(); + + const rejectedConnect = await waitForNextConnect(passwordFlow.gateway, rejectedConnectCount); + expect(readConnectAuth(rejectedConnect)).toMatchObject({ + password: "password-rejected-by-mock", + }); + expect(readConnectAuth(rejectedConnect)?.token).toBeUndefined(); + await passwordFlow.gateway.rejectDeferred("connect", { + code: "UNAUTHORIZED", + details: { code: ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH }, + message: "unauthorized: gateway password mismatch", + }); + + const failure = passwordFlow.page.locator(".login-gate__failure"); + await failure.waitFor(); + expect(await failure.getAttribute("role")).toBe("alert"); + expect( + (await failure.locator(".login-gate__failure-raw").textContent())?.toLowerCase(), + ).toContain("gateway password mismatch"); + expect(await failure.locator(".login-gate__failure-steps").isVisible()).toBe(true); + expect(await passwordFlow.page.locator("openclaw-app-shell").count()).toBe(0); + await passwordFlow.page.screenshot({ + fullPage: true, + path: path.join(artifactDir, "02-password-rejected.png"), + }); + + const recoveredConnectCount = (await passwordFlow.gateway.getRequests("connect")).length; + await passwordFlow.gateway.deferNext("connect"); + await passwordInput.fill("password-accepted-by-mock"); + await passwordFlow.page.getByRole("button", { name: "Connect", exact: true }).click(); + + const recoveredConnect = await waitForNextConnect(passwordFlow.gateway, recoveredConnectCount); + expect(readConnectAuth(recoveredConnect)).toMatchObject({ + password: "password-accepted-by-mock", + }); + expect(readConnectAuth(recoveredConnect)?.token).toBeUndefined(); + await passwordFlow.gateway.resolveDeferred("connect"); + await passwordFlow.page.locator("openclaw-app-shell").waitFor(); + expect(await passwordFlow.page.locator("openclaw-login-gate").count()).toBe(0); + await passwordFlow.page.screenshot({ + fullPage: true, + path: path.join(artifactDir, "03-password-recovered.png"), + }); + expect(passwordPageErrors).toEqual([]); + + await writeFile( + path.join(artifactDir, "behavior-summary.json"), + `${JSON.stringify( + { + password: { + rejectedOutcome: "visible-error", + recoveredOutcome: "connected", + submittedInConnectFrame: true, + }, + token: { + acceptedOutcome: "connected", + submittedInConnectFrame: true, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + }); +});