mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
test(ui): add credentials primary QA proof (#118790)
This commit is contained in:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user