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.
This commit is contained in:
Mohamed Boudra
2026-09-14 21:25:38 +02:00
committed by GitHub
parent fdb5540a94
commit 836f1a9c23
14 changed files with 183 additions and 64 deletions
@@ -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();
@@ -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",
@@ -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") });
});
@@ -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()}`,
@@ -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);
});
@@ -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<void>((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<void> {
while (forwardedText.length < prefix.length) {
const frame = pending.shift();
if (!frame) {
await new Promise<void>((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();
},
};
}
@@ -28,7 +28,7 @@ export async function openWorkspaceFromCommandCenter(
): Promise<void> {
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,
});
@@ -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) {
@@ -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<ReturnType<typeof holdAssistantStream>>;
}
export async function withStreamingMarkdown(
page: Page,
testInfo: TestInfo,
run: (agent: MockAgentWorkspace) => Promise<void>,
run: (agent: StreamingMarkdownAgent) => Promise<void>,
): Promise<void> {
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<void> {
export async function requestStreamingMarkdown(agent: StreamingMarkdownAgent): Promise<void> {
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<void> {
@@ -37,8 +48,13 @@ export async function expectUnfinishedBold(page: Page): Promise<void> {
await expect(message).not.toContainText("*");
}
export async function expectUnfinishedLink(page: Page, testInfo: TestInfo): Promise<void> {
export async function expectUnfinishedLink(
page: Page,
agent: StreamingMarkdownAgent,
testInfo: TestInfo,
): Promise<void> {
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<void> {
agent.stream.release();
await agent.client.waitForFinish(agent.agentId, 30_000);
await expectCompletedMarkdown(page);
await captureMarkdown(page, testInfo, "completed-markdown");
+10 -12
View File
@@ -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 },
@@ -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}`);
}
@@ -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();
}
});
+2 -31
View File
@@ -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;
@@ -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"]);
});