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) => (