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"]); });