From 74028a3b6d9608a26cfeaf8cf06276cf40c3a59b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 19 Oct 2026 17:23:17 +0200 Subject: [PATCH] fix(test): align isolated test runtimes with Expo 57 Hoisted React consumers need the matching DOM renderer at the repository root. Automated Metro processes must not install native DevTools into disposable homes, and development toast overlays must not cover compact actions. Keep Explorer journeys on the shared host and synchronize setup fixtures on daemon output rather than a one-second file-marker window. A delayed real child start reproduces the readiness failure on every platform. --- docs/testing.md | 2 +- package-lock.json | 37 ++++++++++--------- package.json | 1 + .../browser/compact-git-actions-menu.spec.ts | 4 +- .../e2e/browser/composer-diff-stat.spec.ts | 9 +++-- .../agent-device/file-context-menu.ios.ad | 4 +- packages/app/e2e/support/fixtures.ts | 7 ++++ packages/app/e2e/support/global-setup.ts | 2 + packages/app/e2e/support/helpers/sidebar.ts | 13 +++++-- ...=> retained-chat-content.browser.test.tsx} | 1 - packages/desktop/e2e/browser-tabs.e2e.mjs | 1 + .../daemon-lifecycle-renderer.electron.mjs | 1 + ...rkspace-create-worktree-source.e2e.test.ts | 31 +++++++++------- 13 files changed, 69 insertions(+), 44 deletions(-) rename packages/app/src/panels/{retained-chat-content.test.tsx => retained-chat-content.browser.test.tsx} (98%) diff --git a/docs/testing.md b/docs/testing.md index 8b1565d86d..91340ad070 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -193,7 +193,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es - Playwright specs that exercise only the daemon import `daemonTest` from the shared fixtures so they do not create a browser context or page. - Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`. - Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics. -- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay. +- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay. Automated Metro launches set `EXPO_UNSTABLE_HEADLESS=1` so Expo does not download the native DevTools shell into temporary test homes; its read-only cache breaks teardown. Browser fixtures dismiss Expo's developer error toast through its close button while retaining console logs, so it cannot cover compact bottom actions. - The app Playwright harness boots on Windows as well as POSIX. Spawn Node entrypoints through `process.execPath`, not `npx` or `node_modules/.bin` shims: Node refuses to spawn `.cmd` or `.bat` without `shell: true`, and shell mode concatenates argv without escaping and sends kill signals to `cmd.exe` instead of the real child. - Teardown kills the process tree, because a Windows signal reaches only the direct child and leaves forked workers holding the listening port. - The `asdf`-backed local Elixir relay stays POSIX-only, so the `relay-deployment` Playwright project is unavailable on Windows. diff --git a/package-lock.json b/package-lock.json index 99af544b32..98566a2ec0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "oxlint-tsgolint": "^0.22.1", "patch-package": "^8.0.1", "playwright": "^1.56.1", + "react-dom": "19.2.3", "typescript": "^5.9.3", "ws": "^8.20.0" } @@ -29981,6 +29982,24 @@ } } }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-dom/node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", @@ -37730,18 +37749,6 @@ "dev": true, "license": "MIT" }, - "packages/app/node_modules/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.3" - } - }, "packages/app/node_modules/react-native-enriched-markdown": { "version": "1.2.0-nightly-20261014-766fc0e67", "resolved": "https://registry.npmjs.org/react-native-enriched-markdown/-/react-native-enriched-markdown-1.2.0-nightly-20261014-766fc0e67.tgz", @@ -37802,12 +37809,6 @@ "react-native": "*" } }, - "packages/app/node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, "packages/app/node_modules/turndown": { "version": "7.2.4", "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", diff --git a/package.json b/package.json index 5c46c66b25..67ca259ffa 100644 --- a/package.json +++ b/package.json @@ -137,6 +137,7 @@ "oxlint-tsgolint": "^0.22.1", "patch-package": "^8.0.1", "playwright": "^1.56.1", + "react-dom": "19.2.3", "typescript": "^5.9.3", "ws": "^8.20.0" }, diff --git a/packages/app/e2e/browser/compact-git-actions-menu.spec.ts b/packages/app/e2e/browser/compact-git-actions-menu.spec.ts index 8198ecea5f..e2e2cdb34f 100644 --- a/packages/app/e2e/browser/compact-git-actions-menu.spec.ts +++ b/packages/app/e2e/browser/compact-git-actions-menu.spec.ts @@ -45,10 +45,10 @@ async function openCompactActionsMenu(page: Page, workspaceId: string) { await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId)); await page.reload(); await page.getByTestId("workspace-explorer-toggle").first().click(); - const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true }); + const changesTab = page.getByTestId("workspace-tab-changes_tree").filter({ visible: true }); await expect(changesTab).toBeVisible({ timeout: 30_000 }); await changesTab.click(); - const explorer = page.getByTestId("explorer-content-area").filter({ visible: true }); + const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true }); await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 }); const trigger = explorer.getByTestId("changes-actions-menu-trigger"); diff --git a/packages/app/e2e/browser/composer-diff-stat.spec.ts b/packages/app/e2e/browser/composer-diff-stat.spec.ts index b3d1b56c12..9b80222432 100644 --- a/packages/app/e2e/browser/composer-diff-stat.spec.ts +++ b/packages/app/e2e/browser/composer-diff-stat.spec.ts @@ -125,7 +125,7 @@ test("composer diff stat opens the compact explorer instead of a Changes tab", a }); const closeExplorer = page - .getByTestId("explorer-header") + .getByTestId("workspace-explorer-sidebar") .getByRole("button", { name: "Close Explorer sidebar" }); await expect(closeExplorer).not.toBeInViewport(); @@ -809,7 +809,7 @@ async function expectUnmatchedFeedback(page: Page) { } function compactReviewSurface(page: Page) { - return page.getByTestId("explorer-content-area").filter({ visible: true }); + return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true }); } async function openCompactReviewExplorer( page: Page, @@ -847,7 +847,10 @@ async function scrollReviewToLastLine(page: Page) { async function revealCompactChanges(page: Page) { await page.getByTestId("workspace-explorer-toggle").first().click(); - await page.getByTestId("explorer-header").getByText("Changes", { exact: true }).click(); + await page + .getByTestId("workspace-explorer-sidebar") + .getByTestId("workspace-tab-changes_tree") + .click(); await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible(); } async function expectReviewEditorBody(page: Page, body: string) { diff --git a/packages/app/e2e/mobile/agent-device/file-context-menu.ios.ad b/packages/app/e2e/mobile/agent-device/file-context-menu.ios.ad index 9f758a2806..279009d380 100644 --- a/packages/app/e2e/mobile/agent-device/file-context-menu.ios.ad +++ b/packages/app/e2e/mobile/agent-device/file-context-menu.ios.ad @@ -4,8 +4,8 @@ env APP_ID=sh.paseo.debug open "${APP_ID}" wait "id=\"workspace-header-menu-trigger\"" 20000 press "id=\"workspace-explorer-toggle\"" -wait "id=\"explorer-tab-files\"" 10000 -press "id=\"explorer-tab-files\"" +wait "id=\"workspace-tab-files\"" 10000 +press "id=\"workspace-tab-files\"" wait "id=\"file-explorer-row-0\"" 10000 longpress "id=\"file-explorer-row-0\"" 600 wait "id=\"file-explorer-row-0-context-menu-content\"" 10000 diff --git a/packages/app/e2e/support/fixtures.ts b/packages/app/e2e/support/fixtures.ts index 43b4191b64..bd97ec1c9e 100644 --- a/packages/app/e2e/support/fixtures.ts +++ b/packages/app/e2e/support/fixtures.ts @@ -130,6 +130,13 @@ const test = daemonTest.extend<{ await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." }); }); + // Expo's development LogBox toast overlaps bottom actions on compact screens. + // Dismiss its UI through the close button; console errors still go into the test log. + const dismissDevToast = page + .locator("#error-toast") + .getByRole("button", { name: "", exact: true }); + await page.addLocatorHandler(dismissDevToast, () => dismissDevToast.click()); + const entries: string[] = []; page.on("console", (message) => { diff --git a/packages/app/e2e/support/global-setup.ts b/packages/app/e2e/support/global-setup.ts index 29257af299..00041de6a7 100644 --- a/packages/app/e2e/support/global-setup.ts +++ b/packages/app/e2e/support/global-setup.ts @@ -134,6 +134,8 @@ function startMetro(port: number, buffer: ReturnType): env: { ...process.env, BROWSER: "none", + // Automation never opens native DevTools or installs its desktop shell. + EXPO_UNSTABLE_HEADLESS: "1", ...(process.env.E2E_DESKTOP_RUNTIME === "1" ? { PASEO_WEB_PLATFORM: "electron" } : {}), }, stdio: ["ignore", "pipe", "pipe"], diff --git a/packages/app/e2e/support/helpers/sidebar.ts b/packages/app/e2e/support/helpers/sidebar.ts index 500ce8bd41..5b7b819bf0 100644 --- a/packages/app/e2e/support/helpers/sidebar.ts +++ b/packages/app/e2e/support/helpers/sidebar.ts @@ -276,15 +276,22 @@ export async function swipeOpenMobileExplorer(page: Page): Promise { } export async function closeMobileExplorer(page: Page): Promise { - const closeButton = page.getByTestId("explorer-close"); + const closeButton = page + .getByTestId("workspace-explorer-sidebar") + .getByRole("button", { name: "Close Explorer sidebar", exact: true }); await expect(closeButton).toBeInViewport({ ratio: 1, timeout: 5_000 }); await closeButton.click(); } export async function expectMobileExplorerVisible(page: Page): Promise { - await expect(page.getByTestId("explorer-header")).toBeInViewport({ ratio: 1, timeout: 5_000 }); + await expect(page.getByTestId("workspace-explorer-sidebar")).toBeInViewport({ + ratio: 1, + timeout: 5_000, + }); } export async function expectMobileExplorerHidden(page: Page): Promise { - await expect(page.getByTestId("explorer-header")).not.toBeInViewport({ timeout: 5_000 }); + await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeInViewport({ + timeout: 5_000, + }); } diff --git a/packages/app/src/panels/retained-chat-content.test.tsx b/packages/app/src/panels/retained-chat-content.browser.test.tsx similarity index 98% rename from packages/app/src/panels/retained-chat-content.test.tsx rename to packages/app/src/panels/retained-chat-content.browser.test.tsx index a4efcc3526..cba12e3cbd 100644 --- a/packages/app/src/panels/retained-chat-content.test.tsx +++ b/packages/app/src/panels/retained-chat-content.browser.test.tsx @@ -1,4 +1,3 @@ -/** @vitest-environment jsdom */ import { act, useLayoutEffect, useState, useSyncExternalStore } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, it } from "vitest"; diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index fb0958aad1..65879abdde 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -1173,6 +1173,7 @@ async function main() { cwd: rootDir, env: { ...commonEnv, + EXPO_UNSTABLE_HEADLESS: "1", EXPO_PORT: String(expoPort), EXPO_DEV_URL: `http://localhost:${expoPort}`, PASEO_ELECTRON_REMOTE_DEBUGGING_PORT: String(cdpPort), diff --git a/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs b/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs index d32b7d65b8..a98a158511 100644 --- a/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs +++ b/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs @@ -35,6 +35,7 @@ export async function verifyAttachedDaemonControls({ repo, root, env, home, port env: { ...env, EXPO_NO_DOTENV: "1", + EXPO_UNSTABLE_HEADLESS: "1", CI: "1", PASEO_WEB_PLATFORM: "electron", EXPO_PUBLIC_LOCAL_DAEMON: `127.0.0.1:${port}`, diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts index 6e757c3770..80208fe63d 100644 --- a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts +++ b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts @@ -625,8 +625,9 @@ test("setup details follow one workspace/source and restore after reconnect with path.join(repoDir, "setup.cjs"), ` const fs = require('node:fs'); +// Exercise a real child start beyond Vitest's default one-second polling window. +setTimeout(() => { console.log('setup started'); -fs.writeFileSync('started.txt', 'ready'); let wave = 0; const timer = setInterval(() => { if (fs.existsSync('wave-' + wave)) { @@ -636,6 +637,7 @@ const timer = setInterval(() => { } if (fs.existsSync('finish.txt')) { clearInterval(timer); process.exit(1); } }, 20); +}, 1500); `, ); writeFileSync(path.join(repoDir, ".gitignore"), "*.txt\nwave-*\nemitted-*\n"); @@ -672,15 +674,25 @@ const timer = setInterval(() => { const directory = quiet.observeWorkspaces(); observations.push(directory); await directory.ready; + const waitForSetupStarted = async (workspaceId: string) => { + // Process startup is asynchronous, especially under Windows PowerShell. The daemon's + // output snapshot is the readiness barrier; a marker file can precede PTY delivery. + await expect + .poll(async () => (await viewer.fetchWorkspaceSetupStatus(workspaceId)).snapshot, { + timeout: 30000, + }) + .toMatchObject({ + status: "running", + detail: { log: expect.stringContaining("setup started") }, + }); + }; for (const worktreeSlug of ["noisy-a", "noisy-b", "noisy-c"]) { const result = await creator.createWorkspace({ source: { kind: "worktree", cwd: repoDir, worktreeSlug }, }); if (!result.workspace || result.error) throw new Error(result.error ?? "No workspace"); workspaces.push(result.workspace); - await expect - .poll(() => existsSync(path.join(result.workspace!.workspaceDirectory!, "started.txt"))) - .toBe(true); + await waitForSetupStarted(result.workspace.id); } const external = await mcp.callTool({ name: "create_workspace", @@ -693,17 +705,8 @@ const timer = setInterval(() => { ); if (!externalWorkspace) throw new Error("Missing MCP directory entry"); workspaces.unshift(externalWorkspace); - await expect - .poll(() => existsSync(path.join(externalWorkspace.workspaceDirectory!, "started.txt"))) - .toBe(true); + await waitForSetupStarted(externalWorkspace.id); const workspace = workspaces[0]!; - // The child writes its marker before the terminal's output is delivered. - // Wait for the daemon snapshot, rather than treating the file as an output barrier. - await expect - .poll(async () => (await viewer.fetchWorkspaceSetupStatus(workspace.id)).snapshot, { - timeout: 30000, - }) - .toMatchObject({ detail: { log: expect.stringContaining("setup started") } }); const details = viewer.observeWorkspaceSetup(workspace.id); observations.push(details); const initial = await details.ready;