From a974f09520c5ed55aca166d03734d35a46eaeafd Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:09:05 +0200 Subject: [PATCH] fix(e2e): restore computer-update progress chip after reload (#93) * fix(e2e): restore computer-update progress chip after reload Harden the computer maintenance Playwright flow so mocked update state survives a full page reload: stable background-chip test id, explicit aria-label, wait for shell readiness and the updates RPC, and explicit JSON route fulfillment. Co-authored-by: cynarAI * fix(ci): bump Expo SDK 57 patches and harden flaky web e2e Sync mobile Expo packages for frozen-lockfile typecheck and read RPC fetch metadata before response bodies in Playwright route helpers. Stabilize empty-project Starten, onboarding model catalog, and secret-card reload specs. Co-authored-by: cynarAI * fix(e2e): stabilize RPC intercepts and project Starten flow Fulfill patched RPC routes with string bodies, ignore benign route races, target project Starten by test id, and wait for thread hydration after reload. Co-authored-by: cynarAI * fix(e2e): drop threads/get wait after secret-card reload Reload does not always refetch threads/get; poll the saved ask card instead. Co-authored-by: cynarAI * fix(e2e): reserve unreachable setup port per desktop smoke run Avoid CI races where a port reserved in beforeAll is rebound before the unreachable-address check runs. Co-authored-by: cynarAI * ci: retry product checks after runner OOM on web e2e Previous Web E2E job exited 137 during the Playwright suite; no failing spec was reported. Co-authored-by: cynarAI * fix(e2e): wait for shell before opening user settings After reload, the user menu is not interactive until bootstrap finishes. Co-authored-by: cynarAI --------- Co-authored-by: Cursor Agent Co-authored-by: cynarAI --- apps/desktop/e2e/setup.spec.ts | 3 +- apps/mobile/package.json | 12 +- apps/web/e2e/computer-update.spec.ts | 67 ++-- apps/web/e2e/empty-project-starten.spec.ts | 30 +- apps/web/e2e/group-chats.spec.ts | 10 +- apps/web/e2e/helpers.ts | 42 ++- apps/web/e2e/host-computer-prompt.spec.ts | 14 +- .../web/e2e/onboarding-model-required.spec.ts | 24 +- apps/web/e2e/secret-card.spec.ts | 10 +- .../src/components/ComputerUpdateProgress.tsx | 2 + pnpm-lock.yaml | 292 +++++++++--------- 11 files changed, 309 insertions(+), 197 deletions(-) diff --git a/apps/desktop/e2e/setup.spec.ts b/apps/desktop/e2e/setup.spec.ts index aeb1df6..1a6bf91 100644 --- a/apps/desktop/e2e/setup.spec.ts +++ b/apps/desktop/e2e/setup.spec.ts @@ -201,7 +201,8 @@ test("an unreachable address is reported instead of being saved", async () => { const setup = await app.firstWindow(); await setup.getByRole("radio", { name: /Existing instance/ }).check(); - await setup.locator("#server-url").fill(closedUrl); + const unreachableUrl = await reserveClosedPort(); + await setup.locator("#server-url").fill(unreachableUrl); await setup.getByRole("button", { name: "Check connection" }).click(); await expect(setup.locator("#status")).toHaveAttribute("data-tone", "error"); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 07aa82e..d5bb46b 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -24,26 +24,26 @@ "@rakazo/ui-tokens": "workspace:*", "@react-native-vector-icons/ionicons": "13.1.4", "@react-native-vector-icons/material-design-icons": "13.1.4", - "expo": "57.0.25", + "expo": "57.0.26", "expo-application": "57.0.3", "expo-audio": "57.0.5", "expo-clipboard": "57.0.2", - "expo-constants": "57.0.19", - "expo-document-picker": "57.0.2", + "expo-constants": "57.0.20", + "expo-document-picker": "57.0.3", "expo-file-system": "57.0.7", "expo-font": "57.0.4", "expo-image-picker": "57.0.20", "expo-linking": "57.0.11", "expo-localization": "57.0.2", - "expo-modules-core": "57.0.19", + "expo-modules-core": "57.0.20", "expo-notifications": "57.0.21", - "expo-router": "57.0.23", + "expo-router": "57.0.24", "expo-secure-store": "57.0.4", "expo-sharing": "57.0.22", "expo-splash-screen": "57.0.9", "expo-status-bar": "57.0.1", "expo-symbols": "57.0.3", - "expo-updates": "57.0.23", + "expo-updates": "57.0.24", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.3", diff --git a/apps/web/e2e/computer-update.spec.ts b/apps/web/e2e/computer-update.spec.ts index 05d7c26..bd3d2c1 100644 --- a/apps/web/e2e/computer-update.spec.ts +++ b/apps/web/e2e/computer-update.spec.ts @@ -2,6 +2,10 @@ import { expect, test } from "@playwright/test"; import type { ComputerUpdate } from "@rakazo/contracts"; import { activeBotId, captureScreenshot, completeOnboarding, signup } from "./helpers"; +test.afterEach(async ({ page }) => { + await page.unrouteAll({ behavior: "wait" }); +}); + test("computer maintenance shows durable background progress and failure recovery", async ({ page, }, testInfo) => { @@ -9,7 +13,7 @@ test("computer maintenance shows durable background progress and failure recover await signup(page, `computer-update-${Date.now()}@rakazo.test`, "password12", "Computer Update"); await completeOnboarding(page); const botId = activeBotId(page); - let updates: ComputerUpdate[] = []; + const mock = { updates: [] as ComputerUpdate[] }; const updating: ComputerUpdate = { id: "update-example", botId, @@ -19,25 +23,40 @@ test("computer maintenance shows durable background progress and failure recover status: "running", stage: "restoring", }; - await page.route("**/rpc/computer/updates", (route) => - route.fulfill({ json: { json: updates } }), - ); - await page.route("**/rpc/computer/update", (route) => { - updates = [updating]; - return route.fulfill({ json: { json: updating } }); + const fulfillUpdates = async () => { + await page.route("**/rpc/computer/updates", async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ json: mock.updates }), + }); + }); + }; + await fulfillUpdates(); + await page.route("**/rpc/computer/update", async (route) => { + mock.updates = [updating]; + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ json: updating }), + }); }); - await page.route("**/rpc/computer/recover", (route) => { - updates = [{ ...updating, id: "recovery-example", action: "recover", stage: "preparing" }]; - return route.fulfill({ json: { json: updates[0] } }); + await page.route("**/rpc/computer/recover", async (route) => { + mock.updates = [{ ...updating, id: "recovery-example", action: "recover", stage: "preparing" }]; + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ json: mock.updates[0] }), + }); }); let releases = 0; - await page.route("**/rpc/computer/releaseInterrupted", (route) => { + await page.route("**/rpc/computer/releaseInterrupted", async (route) => { expect(route.request().postDataJSON()).toMatchObject({ json: { id: updating.id, workersStopped: true }, }); releases++; - updates = [{ ...updating, status: "failed" }]; - return route.fulfill({ json: { json: { ok: true } } }); + mock.updates = [{ ...updating, status: "failed" }]; + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ json: { ok: true } }), + }); }); await page.getByTitle("Agent computer").click(); await expect(page.getByTestId("computer-preview")).toBeVisible(); @@ -52,17 +71,27 @@ test("computer maintenance shows durable background progress and failure recover await captureScreenshot(page, testInfo, "computer-update-progress"); await dialog.getByRole("button", { name: "Continue in Background" }).click(); await expect(dialog).not.toBeVisible(); + await expect(page.getByTestId("computer-update-background-chip")).toBeVisible(); await captureScreenshot(page, testInfo, "computer-update-background"); - await page.reload(); - await page.getByRole("button", { name: /Updating Team Computer/ }).click(); + const updatesListed = page.waitForResponse( + (response) => response.url().includes("/rpc/computer/updates") && response.ok(), + ); + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", { + timeout: 60_000, + }); + await updatesListed; + const chip = page.getByTestId("computer-update-background-chip"); + await expect(chip).toBeVisible(); + await chip.click(); await expect(dialog).toBeVisible(); - updates = [{ ...updating, status: "interrupted" }]; + mock.updates = [{ ...updating, status: "interrupted" }]; await expect( dialog.getByText("Recovery is unavailable until the previous operation has stopped."), ).toBeVisible(); await expect(dialog.getByRole("button", { name: "Recover computer" })).toHaveCount(0); await captureScreenshot(page, testInfo, "computer-update-interrupted"); - updates = [{ ...updating, status: "interrupted", canReleaseReservation: true }]; + mock.updates = [{ ...updating, status: "interrupted", canReleaseReservation: true }]; await dialog.getByRole("button", { name: "Release computer", exact: true }).click(); const confirmation = page.getByRole("alertdialog"); await captureScreenshot(page, testInfo, "computer-update-release-confirmation"); @@ -77,7 +106,7 @@ test("computer maintenance shows durable background progress and failure recover await dialog.getByRole("button", { name: "Recover computer" }).click(); await expect(dialog.getByRole("heading")).toHaveText("Recovering Team Computer"); await captureScreenshot(page, testInfo, "computer-recovery-progress"); - updates = []; + mock.updates = []; await expect(dialog).not.toBeVisible(); - await expect(page.getByRole("button", { name: /Recovering Team Computer/ })).toHaveCount(0); + await expect(page.getByTestId("computer-update-background-chip")).toHaveCount(0); }); diff --git a/apps/web/e2e/empty-project-starten.spec.ts b/apps/web/e2e/empty-project-starten.spec.ts index 3e5d102..f840c59 100644 --- a/apps/web/e2e/empty-project-starten.spec.ts +++ b/apps/web/e2e/empty-project-starten.spec.ts @@ -1,6 +1,19 @@ -import { expect, test } from "@playwright/test"; +import { expect, type Locator, test } from "@playwright/test"; import { completeOnboarding, rpc, signup, unterhaltungenOnly } from "./helpers"; +async function selectProjectSpace(projekte: Locator, name: string) { + const row = projekte.locator('[data-sidebar-group^="project-space:"]').filter({ hasText: name }); + await row.getByRole("button", { name: `Select ${name}` }).click(); +} + +async function projectSidebarStart(projekte: Locator, name: string) { + const row = projekte.locator('[data-sidebar-group^="project-space:"]').filter({ hasText: name }); + const groupAttr = await row.getAttribute("data-sidebar-group"); + const spaceId = groupAttr?.match(/^project-space:([^:]+)/)?.[1]; + if (!spaceId) throw new Error(`missing project space id for ${name}`); + return projekte.getByTestId(`project-start-${spaceId}`); +} + test("empty project shows Starten instead of a fallback bot", async ({ page }) => { const stamp = Date.now(); await signup(page, `empty-project-${stamp}@rakazo.test`, "password12", "Empty Project"); @@ -34,14 +47,25 @@ test("Starten in an onboarded project opens the group create flow", async ({ pag await dialog.getByLabel("Name").fill("Website"); await dialog.getByRole("button", { name: "Create project", exact: true }).click(); await page.waitForURL(/\/app(?:\/)?$/); + await selectProjectSpace(projekte, "Website"); + await page.waitForURL(/\/app(?:\/)?$/); await page.goto("/onboarding"); await completeOnboarding(page); + await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", { + timeout: 60_000, + }); + await selectProjectSpace(projekte, "Website"); + await page.waitForURL(/\/app(?:\/)?$/); await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0); + const sidebarStart = await projectSidebarStart(projekte, "Website"); + await expect(sidebarStart).toBeVisible({ timeout: 30_000 }); - await projekte.getByRole("button", { name: "Starten", exact: true }).click(); - await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "create-group"); + await sidebarStart.click(); + await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "create-group", { + timeout: 30_000, + }); await expect(page.getByText("New group", { exact: true })).toBeVisible(); await expect( page.getByTestId("side-panel").getByRole("button", { name: /^Chief/ }), diff --git a/apps/web/e2e/group-chats.spec.ts b/apps/web/e2e/group-chats.spec.ts index 46046dc..4f1a8b2 100644 --- a/apps/web/e2e/group-chats.spec.ts +++ b/apps/web/e2e/group-chats.spec.ts @@ -3,6 +3,7 @@ import { captureScreenshot, completeOnboarding, createNamedBot, + fetchRpcEnvelope, fulfillFetchedRpc, openNewGroup, rpc, @@ -32,11 +33,10 @@ function patchGroupMembers(groups: unknown) { /** Sidebar avatars read bootstrap and spaces/list, not groups/list. */ async function showFirstMemberWorking(route: Route) { - const response = await route.fetch(); - const body = (await response.json()) as { json?: Record }; - const payload = body.json; + const fetched = await fetchRpcEnvelope(route); + const payload = fetched.body.json; if (!payload) { - await fulfillFetchedRpc(route, response, body); + await fulfillFetchedRpc(route, fetched); return; } if ("groups" in payload) payload.groups = patchGroupMembers(payload.groups); @@ -50,7 +50,7 @@ async function showFirstMemberWorking(route: Route) { const current = payload.current as { groups?: unknown }; current.groups = patchGroupMembers(current.groups); } - await fulfillFetchedRpc(route, response, body); + await fulfillFetchedRpc(route, fetched); } test("create group from + and see two bots in one transcript", async ({ page }, testInfo) => { diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts index 31a5c9b..6729bc8 100644 --- a/apps/web/e2e/helpers.ts +++ b/apps/web/e2e/helpers.ts @@ -2,6 +2,10 @@ import { expect, type Locator, type Page, type Route, type TestInfo } from "@pla type RpcEnvelope = { json?: Record }; +function isBenignRouteError(error: unknown) { + return /disposed|already handled|Target closed|Request context disposed/i.test(String(error)); +} + /** Unterhaltungen rows only — `.sidebar-projekte` is nested inside the same shell block on main. */ export function unterhaltungenOnly(page: Page) { return page.locator(".sidebar-unterhaltungen").locator(":scope > *:not(.sidebar-projekte)"); @@ -35,16 +39,25 @@ export async function rpc(page: Page, procedure: string, body: unknown): Prom return parsed.json as T; } +/** Read status and headers before the body so Playwright does not dispose the response early. */ +export async function fetchRpcEnvelope(route: Route) { + const response = await route.fetch(); + const status = response.status(); + const headers = response.headers(); + const body = (await response.json()) as RpcEnvelope; + return { status, headers, body }; +} + /** Fulfill a fetched RPC response without passing a consumed `response` into `route.fulfill`. */ export async function fulfillFetchedRpc( route: Route, - response: Awaited>, - body: RpcEnvelope, + fetched: { status: number; headers: { [key: string]: string }; body: RpcEnvelope }, ) { await route.fulfill({ - status: response.status(), - headers: response.headers(), - json: body, + status: fetched.status, + headers: fetched.headers, + contentType: "application/json", + body: JSON.stringify(fetched.body), }); } @@ -55,9 +68,18 @@ export async function routePatchRpc( patch: (json: Record) => Record, ) { await page.route(`**/rpc/${procedure}`, async (route) => { - const response = await route.fetch(); - const body = (await response.json()) as { json: Record }; - await fulfillFetchedRpc(route, response, { json: patch(body.json) }); + try { + const fetched = await fetchRpcEnvelope(route); + const json = fetched.body.json; + if (!json) { + await fulfillFetchedRpc(route, fetched); + return; + } + await fulfillFetchedRpc(route, { ...fetched, body: { json: patch(json) } }); + } catch (error) { + if (isBenignRouteError(error)) return; + throw error; + } }); } @@ -205,6 +227,10 @@ export async function openUserSettings( | "computer" | "updates", ) { + await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", { + timeout: 60_000, + }); + await expect(page.getByTestId("user-menu-trigger")).toBeVisible({ timeout: 30_000 }); await page.getByTestId("user-menu-trigger").click(); await page.getByRole("button", { name: "Settings", exact: true }).click(); const settings = page.getByTestId("user-settings"); diff --git a/apps/web/e2e/host-computer-prompt.spec.ts b/apps/web/e2e/host-computer-prompt.spec.ts index f91c1ed..9d66fe3 100644 --- a/apps/web/e2e/host-computer-prompt.spec.ts +++ b/apps/web/e2e/host-computer-prompt.spec.ts @@ -1,5 +1,11 @@ import { expect, test } from "@playwright/test"; -import { captureScreenshot, completeOnboarding, fulfillFetchedRpc, signup } from "./helpers"; +import { + captureScreenshot, + completeOnboarding, + fetchRpcEnvelope, + fulfillFetchedRpc, + signup, +} from "./helpers"; for (const platform of ["darwin", "win32"]) { test(`host computer choice explains file access on ${platform}`, async ({ page }, testInfo) => { @@ -28,8 +34,8 @@ for (const platform of ["darwin", "win32"]) { }); }, platform); await page.route(/\/rpc\/(me|bootstrap)$/, async (route) => { - const response = await route.fetch(); - const body = (await response.json()) as { + const fetched = await fetchRpcEnvelope(route); + const body = fetched.body as { json: Record & { me?: Record }; }; const me = route.request().url().endsWith("/me") ? body.json : body.json.me; @@ -37,7 +43,7 @@ for (const platform of ["darwin", "win32"]) { me.canChooseHostComputer = true; me.computerHost = null; } - await fulfillFetchedRpc(route, response, body); + await fulfillFetchedRpc(route, fetched); }); await page.reload(); const dialog = page.getByRole("dialog", { name: "Where should bots run?" }); diff --git a/apps/web/e2e/onboarding-model-required.spec.ts b/apps/web/e2e/onboarding-model-required.spec.ts index aa4e7b0..bfd722e 100644 --- a/apps/web/e2e/onboarding-model-required.spec.ts +++ b/apps/web/e2e/onboarding-model-required.spec.ts @@ -5,6 +5,26 @@ async function forceNeedsModel(page: Page) { await routePatchRpc(page, "me", (json) => ({ ...json, needsModel: true })); } +async function stubModelCatalog(page: Page, unavailable: "empty" | "failed") { + await page.route("**/rpc/models/list", async (route) => { + try { + if (unavailable === "failed") { + await route.abort(); + return; + } + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ json: [] }), + }); + } catch (error) { + if (/disposed|already handled|Target closed|Request context disposed/i.test(String(error))) { + return; + } + throw error; + } + }); +} + test("onboarding shows the look picker when a model is still required", async ({ page }) => { await forceNeedsModel(page); const stamp = Date.now(); @@ -30,9 +50,7 @@ test("onboarding shows the look picker when a model is still required", async ({ for (const unavailable of ["empty", "failed"] as const) { test(`onboarding still opens when the model catalog is ${unavailable}`, async ({ page }) => { await forceNeedsModel(page); - await page.route("**/rpc/models/list", (route) => - unavailable === "failed" ? route.abort() : route.fulfill({ json: { json: [] } }), - ); + await stubModelCatalog(page, unavailable); const stamp = Date.now(); await signup(page, `catalog-${unavailable}-${stamp}@rakazo.test`, "password12", "Model setup"); await page.waitForURL(/\/onboarding/, { timeout: 20_000 }); diff --git a/apps/web/e2e/secret-card.spec.ts b/apps/web/e2e/secret-card.spec.ts index ccc4b81..45899c5 100644 --- a/apps/web/e2e/secret-card.spec.ts +++ b/apps/web/e2e/secret-card.spec.ts @@ -124,8 +124,14 @@ test("renders masked secret card and saves without putting the value in chat", a const history = await rpc(page, "threads/messages", { botId }); expect(JSON.stringify(history)).not.toContain(secretValue); await page.reload({ waitUntil: "domcontentloaded" }); - await expect(card.getByText("Saved", { exact: true })).toBeVisible(); - await expect(card.locator("input")).toHaveCount(0); + await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", { + timeout: 60_000, + }); + await expect(page.getByPlaceholder(/Message/)).toBeVisible({ timeout: 15_000 }); + const savedCard = page.getByTestId("secret-ask-card"); + await expect(savedCard).toBeVisible({ timeout: 30_000 }); + await expect(savedCard.getByText("Saved", { exact: true })).toBeVisible({ timeout: 60_000 }); + await expect(savedCard.locator("input")).toHaveCount(0); await expect(page.getByText(secretValue)).toHaveCount(0); await captureScreenshot(page, testInfo, "secret-card-saved"); }); diff --git a/apps/web/src/components/ComputerUpdateProgress.tsx b/apps/web/src/components/ComputerUpdateProgress.tsx index 1f4afa7..55de607 100644 --- a/apps/web/src/components/ComputerUpdateProgress.tsx +++ b/apps/web/src/components/ComputerUpdateProgress.tsx @@ -72,6 +72,8 @@ export function ComputerUpdateProgress({ onCompleted }: { onCompleted: () => voi {updates.map((update) => (