Eliminate recurring cross-platform CI flakes (#4171)

* fix(ci): eliminate recent cross-platform flakes

Synchronize tests on owned state instead of elapsed time or transient identities. Preserve file rename collision safety when platform file IDs are unavailable.

* fix(ci): close follow-up synchronization gaps

Keep fetches in flight through buffered-event classification, scope mock agents to navigation tests, and wait for live workspace hydration before opening agent routes.

* fix(ci): synchronize browser lifecycle boundaries
This commit is contained in:
Mohamed Boudra
2026-09-01 19:25:16 +02:00
committed by GitHub
parent 06fae60809
commit 59661f2bb8
13 changed files with 200 additions and 90 deletions
@@ -39,7 +39,10 @@ import {
expectResumeOverflowFallsBackToOneTail,
rememberTimelineRequestCounts,
} from "../support/helpers/timeline-resume";
import { workspaceDeckEntryLocator } from "../support/helpers/workspace-ui";
import {
waitForWorkspaceInSidebar,
workspaceDeckEntryLocator,
} from "../support/helpers/workspace-ui";
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
import {
scrollTimelineToNewestLoadedEdge,
@@ -468,6 +471,10 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
await target.client.waitForFinish(target.agentId, 30_000);
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
workspaceId: target.workspaceId,
});
await openAgentRoute(page, target);
await expectComposerVisible(page);
await subscriptions.waitForSubscribedAgents([target.agentId]);
@@ -13,7 +13,6 @@ import {
expectTurnCopyButton,
} from "../support/helpers/agent-stream";
import {
expectReconnectingToastDebounced,
expectReconnectingToastGone,
expectReconnectingToastVisible,
} from "../support/helpers/workspace-ui";
@@ -181,13 +180,17 @@ test.describe("Viewed agent timelines", () => {
"true",
);
await gate.drop();
await expectReconnectingToastDebounced(page);
await expectReconnectingToastVisible(page);
await gate.waitForBlockedConnection();
await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects.");
await expect(
page.getByText("Committed while the chat reconnects.", { exact: true }),
).toHaveCount(0);
// Hold the first authoritative catch-up response so the assertion observes
// the reconnect boundary instead of racing a socket that has not reopened yet.
gate.holdNextServerMessage("fetch_agent_timeline_response");
gate.restore();
await gate.waitForHeldServerMessage("fetch_agent_timeline_response");
gate.releaseHeldServerMessage("fetch_agent_timeline_response");
await expectReconnectingToastGone(page);
const recoveredMessage = page.getByText("Committed while the chat reconnects.", {
exact: true,
@@ -209,6 +212,7 @@ test.describe("Viewed agent timelines", () => {
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await selectAgent(page, "First viewed chat");
await gate.drop();
await gate.waitForBlockedConnection();
await expectReconnectingToastVisible(page);
await selectAgent(page, "Second viewed chat");
@@ -6,7 +6,7 @@ import {
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import {
createIdleAgent,
createMockIdleAgent,
expectWorkspaceTabHidden,
expectWorkspaceTabVisible,
openWorkspaceWithAgents,
@@ -159,7 +159,7 @@ test.describe("Workspace navigation regression", () => {
const workspace = await seedWorkspace({ repoPrefix: "workspace-reconnect-" });
try {
const agent = await createIdleAgent(workspace.client, {
const agent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `workspace-reconnect-${Date.now()}`,
@@ -180,6 +180,7 @@ test.describe("Workspace navigation regression", () => {
await expectWorkspaceTabVisible(page, agent.id);
await daemonGate.drop();
await daemonGate.waitForBlockedConnection();
await expectReconnectingToastVisible(page);
await expectWorkspaceHeader(page, {
title: workspace.workspaceName,
@@ -217,12 +218,12 @@ test.describe("Workspace navigation regression", () => {
try {
await Promise.all([
createIdleAgent(primaryWorkspace.client, {
createMockIdleAgent(primaryWorkspace.client, {
cwd: primaryWorkspace.repoPath,
workspaceId: primaryWorkspace.workspaceId,
title: "Active host agent",
}),
createIdleAgent(secondaryWorkspace.client, {
createMockIdleAgent(secondaryWorkspace.client, {
cwd: secondaryWorkspace.repoPath,
workspaceId: secondaryWorkspace.workspaceId,
title: "Inactive host agent",
@@ -319,12 +320,12 @@ test.describe("Workspace navigation regression", () => {
const secondWorkspace = await seedWorkspace({ repoPrefix: "workspace-nav-reg-b-" });
try {
const firstAgent = await createIdleAgent(firstWorkspace.client, {
const firstAgent = await createMockIdleAgent(firstWorkspace.client, {
cwd: firstWorkspace.repoPath,
workspaceId: firstWorkspace.workspaceId,
title: `workspace-nav-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(secondWorkspace.client, {
const secondAgent = await createMockIdleAgent(secondWorkspace.client, {
cwd: secondWorkspace.repoPath,
workspaceId: secondWorkspace.workspaceId,
title: `workspace-nav-b-${Date.now()}`,
@@ -1,4 +1,4 @@
import { expect, type Page } from "@playwright/test";
import { expect, type Locator, type Page } from "@playwright/test";
const NEAR_BOTTOM_THRESHOLD_PX = 72;
const DEFAULT_SCROLL_TOLERANCE_PX = 24;
@@ -14,6 +14,31 @@ function getVisibleChatScroll(page: Page) {
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
}
interface PositionedToolCall {
locator: Locator;
bounds: { x: number; y: number; width: number; height: number };
}
async function readRenderedToolCalls(toolCalls: Locator): Promise<PositionedToolCall[]> {
const entries = await Promise.all(
Array.from({ length: await toolCalls.count() }, async (_, index) => ({
locator: toolCalls.nth(index),
bounds: await toolCalls.nth(index).boundingBox(),
})),
);
return entries.filter(
(entry): entry is PositionedToolCall => entry.bounds !== null && entry.bounds.width > 0,
);
}
function closestToolCallToY(toolCalls: PositionedToolCall[], targetY: number) {
return toolCalls.toSorted(
(left, right) =>
Math.abs(left.bounds.y + left.bounds.height / 2 - targetY) -
Math.abs(right.bounds.y + right.bounds.height / 2 - targetY),
)[0];
}
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
return getVisibleChatScroll(page).evaluate((root: Element) => {
const candidates = [root, ...Array.from(root.querySelectorAll("*"))]
@@ -180,58 +205,53 @@ export async function clickToolCallBesideScrollToBottomButton(page: Page): Promi
const visibleButtonBounds = buttonBounds!;
const toolCalls = page.locator('[data-testid="tool-call-badge"] [role="button"]');
const toolCallBounds = await Promise.all(
Array.from({ length: await toolCalls.count() }, async (_, index) => ({
index,
bounds: await toolCalls.nth(index).boundingBox(),
})),
);
const buttonCenterY = visibleButtonBounds.y + visibleButtonBounds.height / 2;
const candidate = toolCallBounds
.filter(
(entry): entry is { index: number; bounds: NonNullable<typeof entry.bounds> } =>
entry.bounds !== null && entry.bounds.width > 0,
)
.sort(
(left, right) =>
Math.abs(left.bounds.y + left.bounds.height / 2 - buttonCenterY) -
Math.abs(right.bounds.y + right.bounds.height / 2 - buttonCenterY),
)[0];
const initialToolCalls = await readRenderedToolCalls(toolCalls);
const initialCandidate = closestToolCallToY(initialToolCalls, buttonCenterY);
expect(
candidate,
initialCandidate,
`Expected at least one rendered tool-call badge: ${JSON.stringify({
buttonBounds,
scrollMetrics: await readScrollMetrics(page),
toolCallBounds,
toolCallBounds: initialToolCalls.map(({ bounds }) => bounds),
})}`,
).toBeDefined();
const visibleToolCall = candidate!;
const initialToolCallCenterY = visibleToolCall.bounds.y + visibleToolCall.bounds.height / 2;
await getVisibleChatScroll(page).evaluate((scroll, deltaY) => {
(scroll as HTMLElement).scrollTop += deltaY;
}, initialToolCallCenterY - buttonCenterY);
const alignedToolCall = toolCalls.nth(visibleToolCall.index);
await expect
.poll(async () => {
const [currentButtonBounds, currentToolCallBounds] = await Promise.all([
scrollToBottomButton.boundingBox(),
alignedToolCall.boundingBox(),
]);
if (!currentButtonBounds || !currentToolCallBounds) {
return false;
}
const toolCallCenterY = currentToolCallBounds.y + currentToolCallBounds.height / 2;
return (
const currentButtonBounds = await scrollToBottomButton.boundingBox();
if (!currentButtonBounds) return false;
const currentButtonCenterY = currentButtonBounds.y + currentButtonBounds.height / 2;
const closest = closestToolCallToY(
await readRenderedToolCalls(toolCalls),
currentButtonCenterY,
);
if (!closest) return false;
const toolCallCenterY = closest.bounds.y + closest.bounds.height / 2;
if (
toolCallCenterY >= currentButtonBounds.y &&
toolCallCenterY <= currentButtonBounds.y + currentButtonBounds.height
);
) {
return true;
}
const deltaY = Math.max(-200, Math.min(200, toolCallCenterY - currentButtonCenterY));
await getVisibleChatScroll(page).evaluate((scroll, delta) => {
(scroll as HTMLElement).scrollTop += delta;
}, deltaY);
return false;
})
.toBe(true);
const currentButtonBounds = await scrollToBottomButton.boundingBox();
expect(currentButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
const currentButtonCenterY = currentButtonBounds!.y + currentButtonBounds!.height / 2;
const alignedToolCall = closestToolCallToY(
await readRenderedToolCalls(toolCalls),
currentButtonCenterY,
)?.locator;
expect(alignedToolCall, "Expected an aligned tool-call badge").toBeDefined();
const [alignedButtonBounds, visibleToolCallBounds] = await Promise.all([
scrollToBottomButton.boundingBox(),
alignedToolCall.boundingBox(),
alignedToolCall!.boundingBox(),
]);
expect(alignedButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
expect(
@@ -245,7 +265,7 @@ export async function clickToolCallBesideScrollToBottomButton(page: Page): Promi
x: finalToolCallBounds.x + 24,
y: finalToolCallBounds.y + finalToolCallBounds.height / 2,
};
const toolCallReceivesPointer = await alignedToolCall.evaluate((toolCall, point) => {
const toolCallReceivesPointer = await alignedToolCall!.evaluate((toolCall, point) => {
const hit = document.elementFromPoint(point.x, point.y);
return hit !== null && toolCall.contains(hit);
}, clickPoint);
@@ -52,18 +52,18 @@ export interface IdleAgentSeedClient {
): Promise<{ status: string }>;
}
export async function createIdleAgent(
async function seedIdleAgent(
client: IdleAgentSeedClient,
input: { cwd: string; workspaceId: string; title: string },
provider: {
provider: string;
model: string;
modeId: string;
featureValues?: Record<string, unknown>;
},
): Promise<ArchiveTabAgent> {
const created = await client.createAgent({
provider: "opencode",
model: "opencode/gpt-5-nano",
// OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with
// auto_accept for unattended full access — mode validation now rejects modes
// the provider doesn't define.
modeId: "build",
featureValues: { auto_accept: true },
...provider,
cwd: input.cwd,
workspaceId: input.workspaceId,
title: input.title,
@@ -84,6 +84,32 @@ export async function createIdleAgent(
};
}
export async function createIdleAgent(
client: IdleAgentSeedClient,
input: { cwd: string; workspaceId: string; title: string },
): Promise<ArchiveTabAgent> {
return seedIdleAgent(client, input, {
provider: "opencode",
model: "opencode/gpt-5-nano",
// OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with
// auto_accept for unattended full access — mode validation now rejects modes
// the provider doesn't define.
modeId: "build",
featureValues: { auto_accept: true },
});
}
export async function createMockIdleAgent(
client: IdleAgentSeedClient,
input: { cwd: string; workspaceId: string; title: string },
): Promise<ArchiveTabAgent> {
return seedIdleAgent(client, input, {
provider: "mock",
model: "e2e-fast-stream",
modeId: "load-test",
});
}
export async function archiveAgentFromDaemon(
client: { archiveAgent(agentId: string): Promise<{ archivedAt: string }> },
agentId: string,
@@ -315,6 +315,9 @@ export async function installDaemonWebSocketGate(page: Page) {
const suppressedAgentStreamEventTypes = new Set<string>();
const suppressedAgentStreamItemTypes = new Set<string>();
const activeSockets = new Set<WebSocketRoute>();
let blockedConnectionCount = 0;
let blockedConnectionCountAtDrop = 0;
const blockedConnectionWaiters = new Set<() => void>();
let latestServer: WebSocketRoute | null = null;
const directoryStarts: DirectoryRequestStartCounts = {
subscribed: { agents: 0, workspaces: 0 },
@@ -443,6 +446,9 @@ export async function installDaemonWebSocketGate(page: Page) {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
if (!acceptingConnections) {
blockedConnectionCount += 1;
for (const resolve of blockedConnectionWaiters) resolve();
blockedConnectionWaiters.clear();
void ws.close({ code: 1008, reason: "Blocked by reconnect test." });
return;
}
@@ -583,6 +589,7 @@ export async function installDaemonWebSocketGate(page: Page) {
forward?.();
},
async drop(): Promise<void> {
blockedConnectionCountAtDrop = blockedConnectionCount;
acceptingConnections = false;
const sockets = Array.from(activeSockets);
activeSockets.clear();
@@ -592,6 +599,10 @@ export async function installDaemonWebSocketGate(page: Page) {
),
);
},
async waitForBlockedConnection(): Promise<void> {
if (blockedConnectionCount > blockedConnectionCountAtDrop) return;
await new Promise<void>((resolve) => blockedConnectionWaiters.add(resolve));
},
restore(): void {
acceptingConnections = true;
},
@@ -112,14 +112,6 @@ export async function expectReconnectingToastGone(
});
}
export async function expectReconnectingToastDebounced(
page: Page,
options?: { durationMs?: number },
): Promise<void> {
await page.waitForTimeout(options?.durationMs ?? 750);
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, { timeout: 100 });
}
export async function expectHostConnectingOrOffline(
page: Page,
options?: { timeout?: number },
@@ -171,6 +171,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
const resized = canvas.width !== pixelWidth || canvas.height !== pixelHeight;
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
canvas.style.width = `${currentModel.viewportWidth}px`;
canvas.style.top = `${canvasTop}px`;
canvas.style.height = `${canvasHeight}px`;
const context = canvas.getContext("2d");
@@ -535,11 +536,10 @@ export function DiffSurface(props: DiffSurfaceProps) {
const canvasStyle = useMemo<React.CSSProperties>(
() => ({
...CANVAS_STYLE,
width: model.viewportWidth,
fontFamily: (loadedTypography ?? desiredTypography).family,
fontSize: (loadedTypography ?? desiredTypography).size,
}),
[desiredTypography, loadedTypography, model.viewportWidth],
[desiredTypography, loadedTypography],
);
return (
+15 -1
View File
@@ -232,6 +232,20 @@ async function callBrowserTool(client, name, args = {}) {
return mcpPayload(await client.callTool({ name, args }), name);
}
async function callBrowserToolUntilReady(client, name, args = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const result = await client.callTool({ name, args });
const payload = result.structuredContent;
if (payload?.ok === true) return payload.result;
if (payload?.ok !== false || payload.error?.retryable !== true) {
return mcpPayload(result, name);
}
await delay(100);
}
throw new Error(`${name} remained unavailable for ${timeoutMs}ms`);
}
async function waitForGuestSelector(client, browserId) {
const deadline = Date.now() + 5_000;
while (Date.now() < deadline) {
@@ -568,7 +582,7 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId,
await originalDeck.getByTestId(`workspace-tab-agent_${callerAgentId}`).click();
await page.waitForTimeout(500);
try {
await callBrowserTool(client, "browser_screenshot", { browserId });
await callBrowserToolUntilReady(client, "browser_screenshot", { browserId });
} catch (error) {
failures.push(`inactive browser remains captureable: ${String(error)}`);
}
@@ -1,6 +1,7 @@
import {
appendFile,
chmod,
link,
mkdir,
mkdtemp,
readFile,
@@ -547,10 +548,14 @@ describe("file explorer service", () => {
try {
await writeFile(path.join(root, "source.txt"), "source", "utf8");
await writeFile(path.join(root, "existing.txt"), "existing", "utf8");
await link(path.join(root, "source.txt"), path.join(root, "source-link.txt"));
await expect(
renameExplorerEntry({ root, relativePath: "source.txt", name: "existing.txt" }),
).resolves.toEqual({ status: "error", error: '"existing.txt" already exists' });
await expect(
renameExplorerEntry({ root, relativePath: "source.txt", name: "source-link.txt" }),
).resolves.toEqual({ status: "error", error: '"source-link.txt" already exists' });
await expect(
renameExplorerEntry({ root, relativePath: "source.txt", name: "../outside.txt" }),
).resolves.toEqual({ status: "error", error: "Name cannot contain path separators" });
@@ -1,4 +1,4 @@
import { constants, promises as fs, type BigIntStats } from "fs";
import { constants, promises as fs, type BigIntStats, type Stats } from "fs";
import type { FileHandle } from "fs/promises";
import path from "path";
import { randomUUID } from "crypto";
@@ -707,11 +707,10 @@ export async function renameExplorerEntry({
}
throw error;
});
if (
targetStats &&
(targetStats.dev !== sourceStats.dev || targetStats.ino !== sourceStats.ino)
) {
return { status: "error", error: `"${trimmedName}" already exists` };
if (targetStats) {
if (!(await isCaseOnlyRename(source, targetPath, sourceStats, targetStats))) {
return { status: "error", error: `"${trimmedName}" already exists` };
}
}
const sourcePath = normalizeRelativePath({ root, targetPath: source.requestedPath });
@@ -782,6 +781,25 @@ function isEntryExistsError(error: unknown): boolean {
);
}
async function isCaseOnlyRename(
source: ScopedPath,
targetPath: string,
sourceStats: Stats,
targetStats: Stats,
): Promise<boolean> {
const hasStableFileIds =
sourceStats.dev !== 0 ||
sourceStats.ino !== 0 ||
targetStats.dev !== 0 ||
targetStats.ino !== 0;
const isSameEntry = hasStableFileIds
? targetStats.dev === sourceStats.dev && targetStats.ino === sourceStats.ino
: !sourceStats.isSymbolicLink() &&
!targetStats.isSymbolicLink() &&
(await fs.realpath(targetPath)) === source.resolvedPath;
return isSameEntry && source.requestedPath.toLowerCase() === targetPath.toLowerCase();
}
async function resolveScopedPath({
root,
relativePath = ".",
@@ -899,7 +899,9 @@ describe("WorkspaceGitService checkout observation", () => {
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" },
]);
releaseFetch.resolve();
await flushPromises();
await vi.waitFor(() => {
expect(service.getMetrics().fetchInFlightCount).toBe(0);
});
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
@@ -3141,26 +3141,36 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
let result: WorkspaceGitFetchResult | null = null;
const eventsBeforeFetchSnapshot: FileChange[] = [];
try {
result = await this.deps.runGitFetch(
target.cwd,
{
onRefSnapshot: (phase) => {
const events = target.bufferedFetchMetadataEvents.splice(0);
if (phase === "before") {
eventsBeforeFetchSnapshot.push(...events);
}
try {
result = await this.deps.runGitFetch(
target.cwd,
{
onRefSnapshot: (phase) => {
const events = target.bufferedFetchMetadataEvents.splice(0);
if (phase === "before") {
eventsBeforeFetchSnapshot.push(...events);
}
},
},
},
createRunGitCommand("background-fetch"),
);
} catch (error) {
this.logger.warn(
{ err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
"Background git fetch failed",
);
createRunGitCommand("background-fetch"),
);
} catch (error) {
this.logger.warn(
{ err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
"Background git fetch failed",
);
}
this.applyRepoFetchResult(target, result, eventsBeforeFetchSnapshot);
} finally {
target.fetchInFlight = false;
}
}
private applyRepoFetchResult(
target: RepoGitTarget,
result: WorkspaceGitFetchResult | null,
eventsBeforeFetchSnapshot: FileChange[],
): void {
this.flushFetchMetadataEvents(target, eventsBeforeFetchSnapshot);
if (!result || result.changes === null) {
target.recentFetchRemoteRefChanges.clear();