From 836f1a9c23f7c59a09c6112f2e73c3f875687e80 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 14 Sep 2026 21:25:38 +0200 Subject: [PATCH] fix(ci): consolidate E2E repairs and wait for Metro readiness (#4858) * fix(desktop): wait for Metro HTTP readiness before launching Electron Expo briefly listens while probing port availability. A successful TCP connection can therefore launch Electron before Metro starts and leave it exiting on connection refusal. Require the configured renderer origin to report the running packager status. * test(ci): consolidate fixture and readiness corrections Await the connection and UI states each journey owns, isolate Git fixture contents before observation, and hold real Markdown frames at asserted checkpoints. Keep creation completion diagnostics explicit without claiming the original Windows failure is fixed. * test(desktop): describe Metro readiness through HTTP states Keep protocol mechanics in the fixture and assert readiness only after a successful packager response. Cover non-success status and unrelated HTTP content without inline synchronization in the test body. --- .../e2e/browser/composer-autocomplete.spec.ts | 3 +- .../e2e/browser/composer-diff-stat.spec.ts | 9 ++- .../owned-subscriptions-old-daemon.spec.ts | 1 + .../settings-toggle-tab-regression.spec.ts | 14 ++-- .../e2e/browser/streaming-markdown.spec.ts | 2 +- .../support/helpers/agent-timeline-gate.ts | 68 +++++++++++++++++++ .../command-center-workspace-actions.ts | 2 +- .../app/e2e/support/helpers/import-session.ts | 6 +- .../e2e/support/helpers/streaming-markdown.ts | 27 ++++++-- packages/desktop/e2e/browser-tabs.e2e.mjs | 22 +++--- .../desktop/scripts/dev-runner-readiness.mjs | 17 +++++ .../scripts/dev-runner-readiness.test.mjs | 41 +++++++++++ packages/desktop/scripts/dev-runner.mjs | 33 +-------- .../server/src/server/creation/index.test.ts | 2 +- 14 files changed, 183 insertions(+), 64 deletions(-) create mode 100644 packages/desktop/scripts/dev-runner-readiness.mjs create mode 100644 packages/desktop/scripts/dev-runner-readiness.test.mjs diff --git a/packages/app/e2e/browser/composer-autocomplete.spec.ts b/packages/app/e2e/browser/composer-autocomplete.spec.ts index 4119f7c692..01bdfd45d2 100644 --- a/packages/app/e2e/browser/composer-autocomplete.spec.ts +++ b/packages/app/e2e/browser/composer-autocomplete.spec.ts @@ -9,6 +9,7 @@ import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/arch import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { getServerId } from "../support/helpers/server-id"; import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui"; +import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar"; const TEST_COMMANDS = [ { @@ -671,7 +672,7 @@ test.describe("Composer autocomplete", () => { }); await page.getByRole("button", { name: "Open menu" }).click(); - await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 }); + await expectMobileAgentSidebarVisible(page); const popoverBox = await popover.boundingBox(); expect(popoverBox).not.toBeNull(); diff --git a/packages/app/e2e/browser/composer-diff-stat.spec.ts b/packages/app/e2e/browser/composer-diff-stat.spec.ts index 5926f30c73..455c58180a 100644 --- a/packages/app/e2e/browser/composer-diff-stat.spec.ts +++ b/packages/app/e2e/browser/composer-diff-stat.spec.ts @@ -1,4 +1,4 @@ -import { rm, writeFile } from "node:fs/promises"; +import { writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; @@ -30,10 +30,13 @@ async function seedChangedAgent(repoPrefix: string) { const workspace = await seedMockAgentWorkspace({ repoPrefix, title: "Composer diff stat", - repo: { withRemote: true }, + repo: { + withRemote: true, + // Exclude the local remote before the daemon starts observing this checkout. + files: [{ path: ".gitignore", content: "/remote.git/\n" }], + }, }); try { - await rm(path.join(workspace.cwd, "remote.git"), { recursive: true }); await writeFile( path.join(workspace.cwd, "README.md"), "# Temp Repo\nexport const one = 1;\nexport const two = 2;\n", diff --git a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts index 1bd1f549cf..5040cf9f46 100644 --- a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts +++ b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts @@ -113,6 +113,7 @@ test("does not repeat an assistant block when the current app paginates a publis page, "compatibility-after-reconnect: emit 1 coalesced agent stream updates", ); + await agent.client.connect(); await agent.client.waitForFinish(agent.agentId, 20_000); await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") }); }); diff --git a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts index 5b52c7025d..2c1fd0cc24 100644 --- a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts +++ b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts @@ -1,6 +1,6 @@ import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; import { expect, test } from "../support/fixtures"; -import { createIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab"; +import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab"; import { waitForTabBar, expectAgentTabActive } from "../support/helpers/launcher"; import { seedWorkspace } from "../support/helpers/seed-client"; import { getServerId } from "../support/helpers/server-id"; @@ -104,12 +104,12 @@ test.describe("Settings toggle tab regression", () => { const workspace = await seedWorkspace({ repoPrefix: "settings-toggle-tab-" }); try { - const firstAgent = await createIdleAgent(workspace.client, { + const firstAgent = await createMockIdleAgent(workspace.client, { cwd: workspace.repoPath, workspaceId: workspace.workspaceId, title: `settings-toggle-a-${Date.now()}`, }); - const secondAgent = await createIdleAgent(workspace.client, { + const secondAgent = await createMockIdleAgent(workspace.client, { cwd: workspace.repoPath, workspaceId: workspace.workspaceId, title: `settings-toggle-b-${Date.now()}`, @@ -165,12 +165,12 @@ test.describe("Settings toggle tab regression", () => { const workspace = await seedWorkspace({ repoPrefix: "settings-composer-height-" }); try { - const firstAgent = await createIdleAgent(workspace.client, { + const firstAgent = await createMockIdleAgent(workspace.client, { cwd: workspace.repoPath, workspaceId: workspace.workspaceId, title: `settings-composer-height-a-${Date.now()}`, }); - const secondAgent = await createIdleAgent(workspace.client, { + const secondAgent = await createMockIdleAgent(workspace.client, { cwd: workspace.repoPath, workspaceId: workspace.workspaceId, title: `settings-composer-height-b-${Date.now()}`, @@ -225,12 +225,12 @@ test.describe("Settings toggle tab regression", () => { const workspace = await seedWorkspace({ repoPrefix: "agent-route-refresh-" }); try { - const firstAgent = await createIdleAgent(workspace.client, { + const firstAgent = await createMockIdleAgent(workspace.client, { cwd: workspace.repoPath, workspaceId: workspace.workspaceId, title: `agent-route-refresh-a-${Date.now()}`, }); - const secondAgent = await createIdleAgent(workspace.client, { + const secondAgent = await createMockIdleAgent(workspace.client, { cwd: workspace.repoPath, workspaceId: workspace.workspaceId, title: `agent-route-refresh-b-${Date.now()}`, diff --git a/packages/app/e2e/browser/streaming-markdown.spec.ts b/packages/app/e2e/browser/streaming-markdown.spec.ts index 00aa16e7cc..b1c76f3d89 100644 --- a/packages/app/e2e/browser/streaming-markdown.spec.ts +++ b/packages/app/e2e/browser/streaming-markdown.spec.ts @@ -14,7 +14,7 @@ test("formats unfinished Markdown while streaming and preserves the completed re await withStreamingMarkdown(page, testInfo, async (agent) => { await requestStreamingMarkdown(agent); await expectUnfinishedBold(page); - await expectUnfinishedLink(page, testInfo); + await expectUnfinishedLink(page, agent, testInfo); await expectFinishedMarkdown(page, agent, testInfo); await expectReloadedMarkdown(page); }); diff --git a/packages/app/e2e/support/helpers/agent-timeline-gate.ts b/packages/app/e2e/support/helpers/agent-timeline-gate.ts index 9e435068ad..7651f32a94 100644 --- a/packages/app/e2e/support/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/support/helpers/agent-timeline-gate.ts @@ -609,3 +609,71 @@ async function delayAgentTimelineResponse( waitForDelayedResponse: () => delayedResponse, }; } + +/** Holds real incoming frames so intermediate rendering assertions do not race the producer. */ +export async function holdAssistantStream(page: Page, agentId: string) { + const pending: Array<{ forward(): void; text: string }> = []; + let holding = false; + let released = false; + let forwardedText = ""; + let notifyFrame: (() => void) | undefined; + let notifyInitialTimeline!: () => void; + const initialTimeline = new Promise((resolve) => { + notifyInitialTimeline = resolve; + }); + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => server.send(message)); + server.onMessage((message) => { + const session = getSessionMessage(message); + const payload = session ? getPayload(session) : null; + if (session?.type === "fetch_agent_timeline_response" && payload?.agentId === agentId) { + notifyInitialTimeline(); + } + const event = payload?.event as + | { type?: string; item?: { type?: string; text?: string } } + | undefined; + const text = + session?.type === "agent_stream" && + payload?.agentId === agentId && + event?.type === "timeline" && + event.item?.type === "assistant_message" + ? (event.item.text ?? "") + : ""; + if (text) holding = true; + if (holding && !released) { + pending.push({ forward: () => ws.send(message), text }); + notifyFrame?.(); + } else { + ws.send(message); + } + }); + }); + + return { + waitForInitialTimeline: () => initialTimeline, + async showThrough(prefix: string): Promise { + while (forwardedText.length < prefix.length) { + const frame = pending.shift(); + if (!frame) { + await new Promise((resolve) => { + notifyFrame = resolve; + }); + continue; + } + forwardedText += frame.text; + frame.forward(); + } + if (forwardedText !== prefix) { + throw new Error( + `Stream did not stop at ${JSON.stringify(prefix)}: ${JSON.stringify(forwardedText)}`, + ); + } + }, + release() { + released = true; + for (const frame of pending.splice(0)) frame.forward(); + }, + }; +} diff --git a/packages/app/e2e/support/helpers/command-center-workspace-actions.ts b/packages/app/e2e/support/helpers/command-center-workspace-actions.ts index 5aba36208a..f3908dea28 100644 --- a/packages/app/e2e/support/helpers/command-center-workspace-actions.ts +++ b/packages/app/e2e/support/helpers/command-center-workspace-actions.ts @@ -28,7 +28,7 @@ export async function openWorkspaceFromCommandCenter( ): Promise { const panel = await openCommandCenter(page); await panel.getByTestId("command-center-input").fill(title); - await page.keyboard.press("Enter"); + await action(panel, title).click(); await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), { timeout: 30_000, }); diff --git a/packages/app/e2e/support/helpers/import-session.ts b/packages/app/e2e/support/helpers/import-session.ts index 86818f6f4e..0bf2fc16e9 100644 --- a/packages/app/e2e/support/helpers/import-session.ts +++ b/packages/app/e2e/support/helpers/import-session.ts @@ -23,8 +23,7 @@ export class ImportSessionFlow { await expectMobileAgentSidebarVisible(this.page); const button = this.page.getByTestId("sidebar-import-session"); await expect(button).toHaveAccessibleName("Import session"); - await button.hover(); - await expect(this.page.getByText("Import session", { exact: true })).toBeVisible(); + await expect(button).toBeInViewport(); } async openGlobally() { await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible(); @@ -49,6 +48,9 @@ export class ImportSessionFlow { before?: [string, string]; folders?: Array<[string, string]>; }) { + for (const id of input.first ?? input.before ?? []) { + await expect(this.page.getByTestId(rowTestId(id))).toHaveCount(1); + } const ids = await this.rowIds(); if (input.first) expect(ids.slice(0, input.first.length)).toEqual(input.first.map(rowTestId)); if (input.before) { diff --git a/packages/app/e2e/support/helpers/streaming-markdown.ts b/packages/app/e2e/support/helpers/streaming-markdown.ts index 144b706f5d..3609641d15 100644 --- a/packages/app/e2e/support/helpers/streaming-markdown.ts +++ b/packages/app/e2e/support/helpers/streaming-markdown.ts @@ -1,10 +1,15 @@ +import { holdAssistantStream } from "./agent-timeline-gate"; import { expect, type Page, type TestInfo } from "@playwright/test"; import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; +interface StreamingMarkdownAgent extends MockAgentWorkspace { + stream: Awaited>; +} + export async function withStreamingMarkdown( page: Page, testInfo: TestInfo, - run: (agent: MockAgentWorkspace) => Promise, + run: (agent: StreamingMarkdownAgent) => Promise, ): Promise { testInfo.setTimeout(120_000); const agent = await seedMockAgentWorkspace({ @@ -16,16 +21,22 @@ export async function withStreamingMarkdown( mockStreamingAssistantIntervalMs: 400, }, }); + const stream = await holdAssistantStream(page, agent.agentId); try { await openAgentRoute(page, agent); - await run(agent); + await stream.waitForInitialTimeline(); + await run({ ...agent, stream }); } finally { + stream.release(); await agent.cleanup(); } } -export async function requestStreamingMarkdown(agent: MockAgentWorkspace): Promise { +export async function requestStreamingMarkdown(agent: StreamingMarkdownAgent): Promise { await agent.client.sendAgentMessage(agent.agentId, "Show the formatted streaming response."); + // Exercise late assertions: the producer finishes before the browser consumes its frames. + await agent.client.waitForFinish(agent.agentId, 30_000); + await agent.stream.showThrough("**Bold"); } export async function expectUnfinishedBold(page: Page): Promise { @@ -37,8 +48,13 @@ export async function expectUnfinishedBold(page: Page): Promise { await expect(message).not.toContainText("*"); } -export async function expectUnfinishedLink(page: Page, testInfo: TestInfo): Promise { +export async function expectUnfinishedLink( + page: Page, + agent: StreamingMarkdownAgent, + testInfo: TestInfo, +): Promise { const message = page.getByTestId("assistant-message").last(); + await agent.stream.showThrough("**Bold text stays bold** and [Paseo docs"); await expect(message).toContainText("Paseo docs"); await expect(message.getByRole("link", { name: "Paseo docs" })).toHaveCount(0); await expect(message).not.toContainText("["); @@ -48,9 +64,10 @@ export async function expectUnfinishedLink(page: Page, testInfo: TestInfo): Prom export async function expectFinishedMarkdown( page: Page, - agent: MockAgentWorkspace, + agent: StreamingMarkdownAgent, testInfo: TestInfo, ): Promise { + agent.stream.release(); await agent.client.waitForFinish(agent.agentId, 30_000); await expectCompletedMarkdown(page); await captureMarkdown(page, testInfo, "completed-markdown"); diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 20c9866bc0..208661e033 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -390,17 +390,12 @@ async function clickGuestElement(page, client, browserId, selector) { }); const elementRect = JSON.parse(evaluated.resultJson); assert(elementRect, `Guest element ${selector} was unavailable`); - const webviewRect = await page.evaluate((id) => { - const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`); - if (!(webview instanceof HTMLElement)) return null; - const rect = webview.getBoundingClientRect(); - return { x: rect.x, y: rect.y }; - }, browserId); - assert(webviewRect, `Browser webview ${browserId} was unavailable`); - await page.mouse.click( - webviewRect.x + elementRect.x + elementRect.width / 2, - webviewRect.y + elementRect.y + elementRect.height / 2, - ); + await page.locator(`[data-paseo-browser-id="${browserId}"]`).click({ + position: { + x: elementRect.x + elementRect.width / 2, + y: elementRect.y + elementRect.height / 2, + }, + }); } async function selectDeviceSize(page, label) { @@ -614,7 +609,10 @@ async function runRegression({ await page.waitForFunction( (id) => { const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`); - return webview && webview.parentElement?.id !== "paseo-browser-resident-webviews"; + const surface = webview?.parentElement; + return ( + surface?.getAttribute("aria-hidden") === "false" && surface.style.pointerEvents === "auto" + ); }, browserId, { timeout: timeoutMs }, diff --git a/packages/desktop/scripts/dev-runner-readiness.mjs b/packages/desktop/scripts/dev-runner-readiness.mjs new file mode 100644 index 0000000000..b3fc07eb6e --- /dev/null +++ b/packages/desktop/scripts/dev-runner-readiness.mjs @@ -0,0 +1,17 @@ +export async function waitForMetro(url, timeoutMs = 60_000) { + const statusUrl = new URL("/status", url); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const response = await fetch(statusUrl, { signal: AbortSignal.timeout(1_000) }); + const body = (await response.text()).trim(); + if (response.status === 200 && body === "packager-status:running") { + return; + } + } catch { + // Expo temporarily binds the port while checking availability, before Metro starts. + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for Metro at ${url}`); +} diff --git a/packages/desktop/scripts/dev-runner-readiness.test.mjs b/packages/desktop/scripts/dev-runner-readiness.test.mjs new file mode 100644 index 0000000000..c3e2cc9fcb --- /dev/null +++ b/packages/desktop/scripts/dev-runner-readiness.test.mjs @@ -0,0 +1,41 @@ +import http from "node:http"; +import { expect, test } from "vitest"; +import { waitForMetro } from "./dev-runner-readiness.mjs"; + +async function startMetroStatusServer(responses) { + let lastResponse = null; + let nextResponse = 0; + const server = http.createServer((request, response) => { + const reply = responses[Math.min(nextResponse++, responses.length - 1)]; + lastResponse = { path: request.url, ...reply }; + response.writeHead(reply.status); + response.end(reply.body); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return { + url: `http://127.0.0.1:${server.address().port}`, + lastResponse: () => lastResponse, + async close() { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }, + }; +} + +test("Metro readiness rejects an unrelated listener before accepting the packager status", async () => { + const metro = await startMetroStatusServer([ + { status: 503, body: "packager-status:running" }, + { status: 200, body: "unrelated HTTP server" }, + { status: 200, body: "packager-status:running" }, + ]); + try { + await waitForMetro(metro.url, 2_000); + expect(metro.lastResponse()).toEqual({ + path: "/status", + status: 200, + body: "packager-status:running", + }); + } finally { + await metro.close(); + } +}); diff --git a/packages/desktop/scripts/dev-runner.mjs b/packages/desktop/scripts/dev-runner.mjs index 852bf7d85c..008b77d15f 100644 --- a/packages/desktop/scripts/dev-runner.mjs +++ b/packages/desktop/scripts/dev-runner.mjs @@ -1,5 +1,4 @@ #!/usr/bin/env node -import net from "node:net"; import path from "node:path"; import { execFileSync, spawn } from "node:child_process"; import { createRequire } from "node:module"; @@ -10,6 +9,7 @@ import { resolveChildKillTarget, } from "./dev-runner-config.mjs"; +import { waitForMetro } from "./dev-runner-readiness.mjs"; import { resolveDevElectronArgs } from "./dev-runner-args.mjs"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); @@ -134,35 +134,6 @@ function stopAll(signal) { }, 50); } -async function waitForPort(port, host = "127.0.0.1", timeoutMs = 60_000) { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - if (await canConnect(port, host)) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - throw new Error(`Timed out waiting for ${host}:${port}`); -} - -function canConnect(port, host) { - return new Promise((resolve) => { - const socket = net.createConnection({ port, host }); - socket.setTimeout(1000); - socket.once("connect", () => { - socket.destroy(); - resolve(true); - }); - socket.once("timeout", () => { - socket.destroy(); - resolve(false); - }); - socket.once("error", () => resolve(false)); - }); -} - registerDevRunnerShutdownSignals({ signalSource: process, stop: stopAll }); spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], { @@ -179,7 +150,7 @@ spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], { }); try { - await waitForPort(expoPort); + await waitForMetro(expoDevUrl); } catch (error) { console.error(`[dev] ${error.message}`); exitCode = 1; diff --git a/packages/server/src/server/creation/index.test.ts b/packages/server/src/server/creation/index.test.ts index 2f2c024078..f4f0205225 100644 --- a/packages/server/src/server/creation/index.test.ts +++ b/packages/server/src/server/creation/index.test.ts @@ -176,7 +176,7 @@ test("conflicting intents and resource IDs cannot start more work", async () => "workspace_id_conflict", ); f.provider.resolve(); - await first; + expect(await first).toMatchObject({ phase: "completed", error: null }); expect(f.calls).toEqual(["workspace", "agent", "prompt"]); });