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;