From 1cf932295cd61a0a2d8ea446355161b2cce57285 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 14 Aug 2026 16:16:57 -0700 Subject: [PATCH] refactor(ui): trim pairing uncertainty state (#123856) * refactor(ui): trim pairing uncertainty state * test(ui): stabilize navigation and worker readiness * test(ui): drop popover flip geometry replay --- .../src/schema/devices.test.ts | 2 - ui/src/app/overlays-access.test-support.ts | 1 - ui/src/e2e/mobile-pairing.e2e.test.ts | 4 +- ui/src/e2e/model-providers.e2e.test.ts | 16 ++-- ui/src/e2e/service-worker-update.e2e.test.ts | 39 +++++----- ui/src/lib/device-pair-setup.test.ts | 3 +- ui/src/lib/device-pair-setup.ts | 7 +- ui/src/test-helpers/control-ui-e2e.ts | 76 +++++++++++-------- 8 files changed, 74 insertions(+), 74 deletions(-) diff --git a/packages/gateway-protocol/src/schema/devices.test.ts b/packages/gateway-protocol/src/schema/devices.test.ts index 5261c8226475..7544f38ee5d7 100644 --- a/packages/gateway-protocol/src/schema/devices.test.ts +++ b/packages/gateway-protocol/src/schema/devices.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; import { DevicePairSetupCodeResultSchema, DevicePairSetupCompletedEventSchema, - DevicePairSetupDeliveryUncertainEventSchema, DevicePairSetupStatusParamsSchema, DevicePairSetupStatusResultSchema, } from "./devices.js"; @@ -43,7 +42,6 @@ describe("device pairing setup schemas", () => { ts: 1_800_000_000_001, }; expect(Value.Check(DevicePairSetupCompletedEventSchema, event)).toBe(true); - expect(Value.Check(DevicePairSetupDeliveryUncertainEventSchema, event)).toBe(true); expect( Value.Check(DevicePairSetupCompletedEventSchema, { ...event, bootstrapToken: "secret" }), ).toBe(false); diff --git a/ui/src/app/overlays-access.test-support.ts b/ui/src/app/overlays-access.test-support.ts index 5fb12ee6b024..74b8f1c8d413 100644 --- a/ui/src/app/overlays-access.test-support.ts +++ b/ui/src/app/overlays-access.test-support.ts @@ -473,7 +473,6 @@ export function registerOverlayPairingAccessTests() { }); expect(overlays.snapshot.devicePairSetupLifecycle).toEqual({ phase: "delivery-uncertain", - deviceName: "Phone", access: "full", }); overlays.dispose(); diff --git a/ui/src/e2e/mobile-pairing.e2e.test.ts b/ui/src/e2e/mobile-pairing.e2e.test.ts index c8ed97c3f587..4bc20dafb264 100644 --- a/ui/src/e2e/mobile-pairing.e2e.test.ts +++ b/ui/src/e2e/mobile-pairing.e2e.test.ts @@ -198,9 +198,7 @@ suite.define(() => { const dialog = page.getByRole("dialog", { name: "Pair a device" }); const qr = page.getByAltText("OpenClaw mobile pairing QR code"); await dialog.waitFor(); - expect(await page.getByRole("button", { name: "Create setup code" }).isVisible()).toBe( - true, - ); + await page.getByRole("button", { name: "Create setup code" }).waitFor(); const dialogBox = await page.locator(".device-pair-setup").boundingBox(); expect(dialogBox?.width).toBeLessThanOrEqual(390); await captureUiProof(page, "01-mobile-access-selection.png"); diff --git a/ui/src/e2e/model-providers.e2e.test.ts b/ui/src/e2e/model-providers.e2e.test.ts index ae75800aca30..2457ed066441 100644 --- a/ui/src/e2e/model-providers.e2e.test.ts +++ b/ui/src/e2e/model-providers.e2e.test.ts @@ -412,18 +412,12 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => { skidding: Reflect.get(node, "skidding"), })), ).toEqual({ distance: 8, placement: "top", skidding: 0 }); - const helpButtonBox = await helpButton.boundingBox(); - const labelBox = await utilityLabel.boundingBox(); - const popoverBox = await popoverBody.boundingBox(); - if (!helpButtonBox || !labelBox || !popoverBox) { - throw new Error("expected utility label, help trigger, and popover bounds"); - } - expect(popoverBox.y + popoverBox.height).toBeLessThan(helpButtonBox.y); - const helpButtonCenter = helpButtonBox.x + helpButtonBox.width / 2; - const popoverCenter = popoverBox.x + popoverBox.width / 2; - expect(Math.abs(helpButtonCenter - popoverCenter)).toBeLessThanOrEqual(1); - if (recordVisuals) { + const labelBox = await utilityLabel.boundingBox(); + const popoverBox = await popoverBody.boundingBox(); + if (!labelBox || !popoverBox) { + throw new Error("expected utility label and popover bounds"); + } await page.screenshot({ animations: "disabled", fullPage: true, diff --git a/ui/src/e2e/service-worker-update.e2e.test.ts b/ui/src/e2e/service-worker-update.e2e.test.ts index 68329f911360..ad7d8aa87a92 100644 --- a/ui/src/e2e/service-worker-update.e2e.test.ts +++ b/ui/src/e2e/service-worker-update.e2e.test.ts @@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { buildProductionControlUiE2e, canRunPlaywrightChromium, + controlUiE2eWaitTimeoutMs, installMockGateway, resolvePlaywrightChromiumExecutablePath, startProductionControlUiE2eServer, @@ -300,26 +301,28 @@ describe("Control UI service-worker production update E2E", () => { await ensureControlledPage(page, pageErrors, buildA); await expect.poll(() => readWorkerUpdateVersions(page)).toContain(buildA); await expect - .poll(async () => - page.evaluate(() => { - const panel = document.querySelector("openclaw-terminal-panel") as - | (HTMLElement & { available: boolean }) - | null; - const shell = document.querySelector("openclaw-app-shell") as HTMLElement & { - runtime?: { - context?: { - config: { current: { terminalEnabled: boolean } }; - gateway: { snapshot: { phase: string; hello: unknown } }; + .poll( + async () => + page.evaluate(() => { + const panel = document.querySelector("openclaw-terminal-panel") as + | (HTMLElement & { available: boolean }) + | null; + const shell = document.querySelector("openclaw-app-shell") as HTMLElement & { + runtime?: { + context?: { + config: { current: { terminalEnabled: boolean } }; + gateway: { snapshot: { phase: string; hello: unknown } }; + }; }; }; - }; - return { - available: panel?.available ?? null, - phase: shell?.runtime?.context?.gateway.snapshot.phase ?? null, - terminalEnabled: shell?.runtime?.context?.config.current.terminalEnabled ?? null, - hasHello: shell?.runtime?.context?.gateway.snapshot.hello != null, - }; - }), + return { + available: panel?.available ?? null, + phase: shell?.runtime?.context?.gateway.snapshot.phase ?? null, + terminalEnabled: shell?.runtime?.context?.config.current.terminalEnabled ?? null, + hasHello: shell?.runtime?.context?.gateway.snapshot.hello != null, + }; + }), + { timeout: controlUiE2eWaitTimeoutMs }, ) .toMatchObject({ available: true, diff --git a/ui/src/lib/device-pair-setup.test.ts b/ui/src/lib/device-pair-setup.test.ts index 50d56a40264f..ac5606e99c58 100644 --- a/ui/src/lib/device-pair-setup.test.ts +++ b/ui/src/lib/device-pair-setup.test.ts @@ -361,12 +361,11 @@ describe("device pairing setup state", () => { ts: 1, }); - expect(parsed).not.toBeNull(); + expect(parsed).toEqual({ setupId: "setup-uncertain", access: "limited" }); expect(markDevicePairSetupDeliveryUncertain(state, parsed!)).toBe(true); expect(state.devicePairSetupLifecycle).toEqual({ phase: "delivery-uncertain", access: "limited", - deviceName: "Phone", }); }); diff --git a/ui/src/lib/device-pair-setup.ts b/ui/src/lib/device-pair-setup.ts index be7f92afb540..ef85a28c0bea 100644 --- a/ui/src/lib/device-pair-setup.ts +++ b/ui/src/lib/device-pair-setup.ts @@ -24,7 +24,7 @@ type DevicePairSetupCompletion = Pick & { deviceName?: string }; +>; export type DevicePairSetupLifecycle = | { phase: "selection"; access: DevicePairSetupAccess } @@ -51,7 +51,6 @@ export type DevicePairSetupLifecycle = | { phase: "delivery-uncertain"; access: DevicePairSetupDeliveryUncertain["access"]; - deviceName?: string; } | { phase: "expired"; access: DevicePairSetupAccess }; export function requestDevicePairJoinSetup(client: GatewayRequestClient) { @@ -263,7 +262,8 @@ export function parseDevicePairSetupCompletion(payload: unknown): DevicePairSetu export function parseDevicePairSetupDeliveryUncertain( payload: unknown, ): DevicePairSetupDeliveryUncertain | null { - return parseDevicePairSetupCompletion(payload); + const completion = parseDevicePairSetupCompletion(payload); + return completion ? { setupId: completion.setupId, access: completion.access } : null; } export function completeDevicePairSetup( @@ -310,7 +310,6 @@ export function markDevicePairSetupDeliveryUncertain( state.devicePairSetupLifecycle = { phase: "delivery-uncertain", access: outcome.access, - ...(outcome.deviceName ? { deviceName: outcome.deviceName } : {}), }; state.onDevicePairSetupChange(); return true; diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 80953a4fb8f2..3d2032f4a4b3 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -2444,42 +2444,52 @@ function createMockGatewayControls( }, policy); }, async waitForRequest(method) { - try { - await page.waitForFunction( - (targetMethod) => { - const gateway = ( - window as Window & { - openclawControlUiE2eGateway?: { - requests: MockGatewayRequest[]; - }; - } - ).openclawControlUiE2eGateway; - return Boolean(gateway?.requests.some((request) => request.method === targetMethod)); - }, - method, - // Request capture is non-rendering state. Interval polling avoids background-page - // requestAnimationFrame throttling when CI runs several headless pages concurrently. - { polling: 25, timeout: controlUiE2eWaitTimeoutMs }, - ); - } catch (error) { - if (error instanceof Error && error.name === "TimeoutError") { - try { - await captureControlUiE2eRequestTimeout(page, method, error, diagnosticEvents); - } catch (captureError) { - console.error("[control-ui-e2e] failed to capture request-timeout diagnostics", { - captureError, - method, - }); + const deadline = Date.now() + controlUiE2eWaitTimeoutMs; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + await page.waitForFunction( + (targetMethod) => { + const gateway = ( + window as Window & { + openclawControlUiE2eGateway?: { + requests: MockGatewayRequest[]; + }; + } + ).openclawControlUiE2eGateway; + return Boolean(gateway?.requests.some((request) => request.method === targetMethod)); + }, + method, + // Request capture is non-rendering state. Interval polling avoids background-page + // requestAnimationFrame throttling when CI runs several headless pages concurrently. + { polling: 25, timeout: Math.max(1, deadline - Date.now()) }, + ); + const request = (await getRequests(method)).at(-1); + if (request) { + return request; } + } catch (error) { + const contextReset = + error instanceof Error && + (error.message.includes("Execution context was destroyed") || + error.message.includes("Cannot find context with specified id")); + // Intentional stale-build reloads replace the page context once while connecting. + if (contextReset && attempt === 0 && !page.isClosed()) { + continue; + } + if (error instanceof Error && error.name === "TimeoutError") { + try { + await captureControlUiE2eRequestTimeout(page, method, error, diagnosticEvents); + } catch (captureError) { + console.error("[control-ui-e2e] failed to capture request-timeout diagnostics", { + captureError, + method, + }); + } + } + throw error; } - throw error; } - const requests = await getRequests(method); - const request = requests.at(-1); - if (!request) { - throw new Error(`No mock Gateway request found for ${method}`); - } - return request; + throw new Error(`No mock Gateway request found for ${method}`); }, }; }