Skip to content

Commit 432ff25

Browse files
author
root
committed
Merge branch 'fix/terminal-reconnect-replay' into feature/refactor-ts
2 parents 801344a + 129b5f2 commit 432ff25

3 files changed

Lines changed: 1729 additions & 182 deletions

File tree

Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
1+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
2+
import { join, sep } from "node:path";
3+
import { expect, type Locator, type Page, test } from "@playwright/test";
4+
5+
const HOME_DIR = process.env.HOME ?? "/root";
6+
const TEMP_WORKSPACE_PARENT_DIR = join(HOME_DIR, "workspace");
7+
8+
function directoryRow(page: Page, name: string): Locator {
9+
return page
10+
.locator(".fp-dir")
11+
.filter({
12+
has: page.locator(".fp-dir-name").filter({ hasText: new RegExp(`^${escapeRegExp(name)}$`) }),
13+
})
14+
.first();
15+
}
16+
17+
function escapeRegExp(value: string): string {
18+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
19+
}
20+
21+
async function waitForWorkspaceEntry(page: Page): Promise<void> {
22+
await page.goto("/workspace");
23+
await waitForWorkspaceReady(page);
24+
}
25+
26+
async function waitForWorkspaceReady(page: Page): Promise<void> {
27+
await page.waitForFunction(
28+
() => {
29+
const loading = document.querySelector(
30+
'.app-loading-shell, [data-testid="workspace-resolving-shell"]'
31+
);
32+
const welcome = document.querySelector(".welcome-btn");
33+
const workspace = document.querySelector(
34+
".workspace-page, .agent-draft-launcher, .session-card.agent-pane, .bottom-terminal"
35+
);
36+
37+
return !loading && Boolean(welcome || workspace);
38+
},
39+
{ timeout: 20000 }
40+
);
41+
}
42+
43+
function createTempWorkspaceDir(): string {
44+
mkdirSync(TEMP_WORKSPACE_PARENT_DIR, { recursive: true });
45+
return mkdtempSync(join(TEMP_WORKSPACE_PARENT_DIR, "coder-studio-terminal-reconnect-"));
46+
}
47+
48+
function toWorkspaceSegments(workspacePath: string): string[] {
49+
const relativePath = workspacePath.startsWith(`${HOME_DIR}${sep}`)
50+
? workspacePath.slice(HOME_DIR.length + 1)
51+
: workspacePath;
52+
53+
return relativePath.split(sep).filter(Boolean);
54+
}
55+
56+
async function openWorkspacePath(page: Page, workspacePath: string): Promise<void> {
57+
const segments = toWorkspaceSegments(workspacePath);
58+
await waitForWorkspaceEntry(page);
59+
60+
const newWorkspaceButton = page.getByRole("button", { name: "New workspace" }).first();
61+
if (await newWorkspaceButton.isVisible().catch(() => false)) {
62+
await newWorkspaceButton.click();
63+
} else {
64+
await expect(page.getByRole("button", { name: "Open Workspace" })).toBeVisible({
65+
timeout: 15000,
66+
});
67+
await page.getByRole("button", { name: "Open Workspace" }).click();
68+
}
69+
70+
await expect(page.locator(".launch-modal")).toBeVisible({ timeout: 10000 });
71+
await expect(page.locator(".fp-dir-list .fp-dir").first()).toBeVisible({ timeout: 10000 });
72+
73+
for (const segment of segments.slice(0, -1)) {
74+
const row = directoryRow(page, segment);
75+
await expect(row).toBeVisible({ timeout: 10000 });
76+
await row.dblclick();
77+
await expect(page.locator(".fp-dir-list .directory-loading")).toHaveCount(0);
78+
}
79+
80+
const finalRow = directoryRow(page, segments.at(-1) ?? "");
81+
await expect(finalRow).toBeVisible({ timeout: 10000 });
82+
await finalRow.click();
83+
84+
const startButton = page.getByRole("button", { name: "Start Workspace" });
85+
await expect(startButton).toBeEnabled({ timeout: 10000 });
86+
await startButton.click();
87+
88+
await expect(page).toHaveURL(/\/workspace$/, { timeout: 15000 });
89+
await expect(
90+
page.locator(".agent-draft-launcher, .session-card.agent-pane, .bottom-terminal").first()
91+
).toBeVisible({
92+
timeout: 15000,
93+
});
94+
}
95+
96+
type RecordedCommand = {
97+
kind?: string;
98+
op?: string;
99+
args?: { terminalId?: string; lastSeq?: number };
100+
};
101+
102+
const DISCONNECT_ON_SNAPSHOT_STORAGE_KEY = "e2e.disconnectOnTerminalSnapshot";
103+
104+
declare global {
105+
interface Window {
106+
__terminalReconnectMessages?: RecordedCommand[];
107+
__trackedWebSockets?: WebSocket[];
108+
}
109+
}
110+
111+
async function waitForTrackedTerminalId(page: Page, startIndex: number): Promise<string> {
112+
await expect
113+
.poll(async () => {
114+
return await page.evaluate((commandIndex) => {
115+
const messages = window.__terminalReconnectMessages ?? [];
116+
const terminalCommand = messages.slice(commandIndex).find((message) => {
117+
if (message.kind !== "command") {
118+
return false;
119+
}
120+
121+
return (
122+
(message.op === "terminal.snapshot" || message.op === "terminal.replay") &&
123+
typeof message.args?.terminalId === "string"
124+
);
125+
});
126+
127+
return terminalCommand?.args?.terminalId ?? null;
128+
}, startIndex);
129+
})
130+
.not.toBeNull();
131+
132+
return await page.evaluate((commandIndex) => {
133+
const messages = window.__terminalReconnectMessages ?? [];
134+
const terminalCommand = messages.slice(commandIndex).find((message) => {
135+
if (message.kind !== "command") {
136+
return false;
137+
}
138+
139+
return (
140+
(message.op === "terminal.snapshot" || message.op === "terminal.replay") &&
141+
typeof message.args?.terminalId === "string"
142+
);
143+
});
144+
145+
return terminalCommand?.args?.terminalId ?? null;
146+
}, startIndex);
147+
}
148+
149+
test.describe("@phase1 terminal websocket reconnect", () => {
150+
test.beforeEach(async ({ page }) => {
151+
await page.addInitScript((disconnectOnSnapshotStorageKey: string) => {
152+
window.localStorage.setItem("ui.locale", JSON.stringify("en"));
153+
154+
const originalSend = WebSocket.prototype.send;
155+
const OriginalWebSocket = WebSocket;
156+
const messages: RecordedCommand[] = [];
157+
const sockets: WebSocket[] = [];
158+
const pendingDisconnectOnMessage = new WeakMap<WebSocket, boolean>();
159+
160+
const TrackedWebSocket = new Proxy(OriginalWebSocket, {
161+
construct(target, args, newTarget) {
162+
const socket = Reflect.construct(target, args, newTarget) as WebSocket;
163+
sockets.push(socket);
164+
pendingDisconnectOnMessage.set(socket, false);
165+
socket.addEventListener("message", (event) => {
166+
if (!pendingDisconnectOnMessage.get(socket)) {
167+
return;
168+
}
169+
170+
pendingDisconnectOnMessage.set(socket, false);
171+
window.localStorage.removeItem(disconnectOnSnapshotStorageKey);
172+
event.stopImmediatePropagation();
173+
174+
try {
175+
socket.close();
176+
} catch {
177+
// Ignore test-only socket close failures.
178+
}
179+
});
180+
return socket;
181+
},
182+
});
183+
184+
Object.defineProperty(window, "__terminalReconnectMessages", {
185+
configurable: true,
186+
value: messages,
187+
});
188+
Object.defineProperty(window, "__trackedWebSockets", {
189+
configurable: true,
190+
value: sockets,
191+
});
192+
193+
window.WebSocket = TrackedWebSocket as typeof WebSocket;
194+
195+
WebSocket.prototype.send = function patchedSend(
196+
data: string | ArrayBufferLike | Blob | ArrayBufferView
197+
) {
198+
if (typeof data === "string") {
199+
try {
200+
const parsed = JSON.parse(data) as RecordedCommand;
201+
messages.push({
202+
kind: parsed.kind,
203+
op: parsed.op,
204+
args: parsed.args,
205+
});
206+
207+
const disconnectConfig = window.localStorage.getItem(disconnectOnSnapshotStorageKey);
208+
if (
209+
disconnectConfig &&
210+
parsed.kind === "command" &&
211+
parsed.op === "terminal.snapshot" &&
212+
typeof parsed.args?.terminalId === "string"
213+
) {
214+
const targetTerminalId = JSON.parse(disconnectConfig) as string;
215+
if (parsed.args.terminalId === targetTerminalId) {
216+
pendingDisconnectOnMessage.set(this as WebSocket, true);
217+
}
218+
}
219+
} catch {
220+
// Ignore non-JSON websocket frames.
221+
}
222+
}
223+
224+
return originalSend.call(this, data);
225+
};
226+
}, DISCONNECT_ON_SNAPSHOT_STORAGE_KEY);
227+
});
228+
229+
test("websocket reconnect requests replay before any snapshot fallback", async ({ page }) => {
230+
const workspaceDir = createTempWorkspaceDir();
231+
try {
232+
await openWorkspacePath(page, workspaceDir);
233+
234+
const commandStartIndex = await page.evaluate(
235+
() => window.__terminalReconnectMessages?.length ?? 0
236+
);
237+
238+
await page.getByRole("button", { name: "New Terminal" }).first().click();
239+
240+
const terminalInput = page.locator(".bottom-terminal .xterm textarea").first();
241+
await expect(terminalInput).toBeVisible({ timeout: 10000 });
242+
243+
const terminalId = await waitForTrackedTerminalId(page, commandStartIndex);
244+
245+
expect(terminalId).toBeTruthy();
246+
247+
await terminalInput.click();
248+
await page.keyboard.type(
249+
"printf 'RECONNECT_E2E_START\\n'; sleep 5; printf 'RECONNECT_E2E_DONE\\n'"
250+
);
251+
await page.keyboard.press("Enter");
252+
253+
const terminalViewport = page.locator(".bottom-terminal .xterm-rows").first();
254+
await expect(terminalViewport).toContainText("RECONNECT_E2E_START", { timeout: 10000 });
255+
256+
const reconnectProbeStart = await page.evaluate(
257+
() => window.__terminalReconnectMessages?.length ?? 0
258+
);
259+
260+
await page.evaluate(() => {
261+
const socket = window.__trackedWebSockets?.at(-1);
262+
socket?.close();
263+
});
264+
265+
await expect(terminalViewport).toContainText("RECONNECT_E2E_DONE", { timeout: 15000 });
266+
267+
await expect
268+
.poll(
269+
async () =>
270+
await page.evaluate(
271+
({ startIndex, trackedTerminalId }) => {
272+
const messages = window.__terminalReconnectMessages ?? [];
273+
return messages.slice(startIndex).filter((message) => {
274+
return (
275+
message.kind === "command" &&
276+
message.args?.terminalId === trackedTerminalId &&
277+
(message.op === "terminal.replay" || message.op === "terminal.snapshot")
278+
);
279+
});
280+
},
281+
{ startIndex: reconnectProbeStart, trackedTerminalId: terminalId }
282+
),
283+
{ timeout: 15000 }
284+
)
285+
.not.toEqual([]);
286+
287+
const reconnectCommands = await page.evaluate(
288+
({ startIndex, trackedTerminalId }) => {
289+
const messages = window.__terminalReconnectMessages ?? [];
290+
return messages.slice(startIndex).filter((message) => {
291+
return (
292+
message.kind === "command" &&
293+
message.args?.terminalId === trackedTerminalId &&
294+
(message.op === "terminal.replay" || message.op === "terminal.snapshot")
295+
);
296+
});
297+
},
298+
{ startIndex: reconnectProbeStart, trackedTerminalId: terminalId }
299+
);
300+
301+
const replayIndex = reconnectCommands.findIndex(
302+
(message) => message.op === "terminal.replay"
303+
);
304+
const snapshotIndex = reconnectCommands.findIndex(
305+
(message) => message.op === "terminal.snapshot"
306+
);
307+
const reconnectReplay = reconnectCommands[replayIndex];
308+
309+
expect(reconnectReplay?.args?.lastSeq).toBeGreaterThan(0);
310+
expect(replayIndex).toBeGreaterThanOrEqual(0);
311+
expect(snapshotIndex === -1 || replayIndex < snapshotIndex).toBe(true);
312+
} finally {
313+
rmSync(workspaceDir, { recursive: true, force: true });
314+
}
315+
});
316+
317+
test("refresh retries initial hydration with snapshot again after websocket reconnect", async ({
318+
page,
319+
}) => {
320+
const workspaceDir = createTempWorkspaceDir();
321+
try {
322+
await openWorkspacePath(page, workspaceDir);
323+
324+
const commandStartIndex = await page.evaluate(
325+
() => window.__terminalReconnectMessages?.length ?? 0
326+
);
327+
328+
await page.getByRole("button", { name: "New Terminal" }).first().click();
329+
330+
const terminalInput = page.locator(".bottom-terminal .xterm textarea").first();
331+
await expect(terminalInput).toBeVisible({ timeout: 10000 });
332+
333+
const terminalId = await waitForTrackedTerminalId(page, commandStartIndex);
334+
expect(terminalId).toBeTruthy();
335+
336+
await terminalInput.click();
337+
await page.keyboard.type("printf 'REFRESH_HYDRATE_SNAPSHOT_RETRY\\n'");
338+
await page.keyboard.press("Enter");
339+
340+
const terminalViewport = page.locator(".bottom-terminal .xterm-rows").first();
341+
await expect(terminalViewport).toContainText("REFRESH_HYDRATE_SNAPSHOT_RETRY", {
342+
timeout: 10000,
343+
});
344+
345+
await page.evaluate(
346+
({ storageKey, trackedTerminalId }) => {
347+
window.localStorage.setItem(storageKey, JSON.stringify(trackedTerminalId));
348+
},
349+
{ storageKey: DISCONNECT_ON_SNAPSHOT_STORAGE_KEY, trackedTerminalId: terminalId }
350+
);
351+
352+
await page.reload();
353+
await waitForWorkspaceReady(page);
354+
355+
await expect
356+
.poll(
357+
async () =>
358+
await page.evaluate((trackedTerminalId) => {
359+
const messages = window.__terminalReconnectMessages ?? [];
360+
return messages
361+
.filter((message) => {
362+
return (
363+
message.kind === "command" &&
364+
message.args?.terminalId === trackedTerminalId &&
365+
(message.op === "terminal.snapshot" || message.op === "terminal.replay")
366+
);
367+
})
368+
.slice(0, 2)
369+
.map((message) => message.op ?? null);
370+
}, terminalId),
371+
{ timeout: 15000 }
372+
)
373+
.toEqual(["terminal.snapshot", "terminal.snapshot"]);
374+
375+
await expect(terminalViewport).toContainText("REFRESH_HYDRATE_SNAPSHOT_RETRY", {
376+
timeout: 20000,
377+
});
378+
} finally {
379+
rmSync(workspaceDir, { recursive: true, force: true });
380+
}
381+
});
382+
});

0 commit comments

Comments
 (0)