test(ui): add credentials primary QA proof (#118790)

This commit is contained in:
Vincent Koc
2026-08-04 03:05:17 +08:00
committed by GitHub
parent b026d3572e
commit cd42cb7703
3 changed files with 292 additions and 0 deletions
@@ -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.
@@ -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.
@@ -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<BrowserContext>();
function requireRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Expected object value");
}
return value as Record<string, unknown>;
}
function readConnectAuth(request: MockGatewayRequest): Record<string, unknown> | 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<MockGatewayRequest> {
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<void> {
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",
);
});
});