Skip to content

Commit 9ce7da3

Browse files
committed
test: cover bidirectional terminal resizing
1 parent f940a59 commit 9ce7da3

3 files changed

Lines changed: 142 additions & 10 deletions

File tree

e2e/remote-terminal-convergence.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ async function exposeDesktopOnLan(mainWindow: Page): Promise<string> {
5252
}
5353

5454
async function connectBrowser(page: Page, pairingUrl: string): Promise<void> {
55+
await page.setViewportSize({ width: 640, height: 900 });
5556
await page.goto(pairingUrl);
5657
await expect(
5758
page.getByRole('dialog', { name: 'Enroll browser device' }),
@@ -64,6 +65,33 @@ async function connectBrowser(page: Page, pairingUrl: string): Promise<void> {
6465
});
6566
}
6667

68+
async function readTerminalColumns(page: Page, panel: Locator, marker: string): Promise<number> {
69+
const input = panel.locator('.xterm-helper-textarea');
70+
await input.focus();
71+
await page.keyboard.type(`printf '${marker}%s__\\n' "$(tput cols)"`);
72+
await page.keyboard.press('Enter');
73+
const outputPattern = new RegExp(`${marker}(\\d+)__`, 'gu');
74+
let text = '';
75+
await expect.poll(async () => {
76+
text = await panel.locator('.xterm-rows').innerText();
77+
outputPattern.lastIndex = 0;
78+
return outputPattern.test(text);
79+
}).toBe(true);
80+
outputPattern.lastIndex = 0;
81+
const matches = [...text.matchAll(outputPattern)];
82+
const value = Number(matches.at(-1)?.[1]);
83+
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`Unable to read terminal columns for ${marker}`);
84+
return value;
85+
}
86+
87+
async function expectMatchingLogicalGrid(first: Locator, second: Locator): Promise<void> {
88+
const [firstWidth, secondWidth] = await Promise.all([
89+
first.locator('.xterm-screen').evaluate((element) => (element as HTMLElement).offsetWidth),
90+
second.locator('.xterm-screen').evaluate((element) => (element as HTMLElement).offsetWidth),
91+
]);
92+
expect(Math.abs(firstWidth - secondWidth)).toBeLessThanOrEqual(2);
93+
}
94+
6795
async function stopExposure(mainWindow: Page): Promise<void> {
6896
await mainWindow.evaluate(async () => {
6997
const status = await window.terminayRemoteAccessStatusHost.getStatus();
@@ -153,6 +181,11 @@ test('Desktop and browser converge on terminal tabs and one shared PTY output st
153181
screenWidth: panel.querySelector('.xterm-screen')?.getBoundingClientRect().width ?? 0,
154182
}));
155183
expect(desktopGeometry.screenWidth).toBeGreaterThan(desktopGeometry.panelWidth * 0.9);
184+
const desktopColumnsBeforeTakeover = await readTerminalColumns(
185+
mainWindow,
186+
desktopPanel,
187+
'__TD0__',
188+
);
156189
await expect(
157190
browserPanel.getByText('Another device is controlling this terminal.', { exact: true }),
158191
).toBeVisible();
@@ -219,10 +252,15 @@ test('Desktop and browser converge on terminal tabs and one shared PTY output st
219252
.getByRole('button', { name: 'Take back control of terminal' })
220253
.click();
221254
await expect(browserPanel.locator('.terminal-presentation-control')).toHaveCount(0);
255+
await expect(browserPanel).not.toHaveClass(/terminal-panel--remote-size-override/u);
256+
await expect(desktopPanel).toHaveClass(/terminal-panel--remote-size-override/u);
222257
await expect(
223258
desktopPanel.getByText('Another device is controlling this terminal.', { exact: true }),
224259
).toBeVisible();
225260
await expectControlBarLayout(desktopPanel);
261+
const browserColumns = await readTerminalColumns(page, browserPanel, '__TB1__');
262+
expect(browserColumns).toBeLessThan(desktopColumnsBeforeTakeover);
263+
await expectMatchingLogicalGrid(browserPanel, desktopPanel);
226264

227265
const takeoverProof = '__TERMINAY_BROWSER_TAKEOVER_INPUT__';
228266
await browserPanel.locator('.xterm-helper-textarea').focus();
@@ -231,6 +269,20 @@ test('Desktop and browser converge on terminal tabs and one shared PTY output st
231269
await expect(browserPanel).toContainText(takeoverProof);
232270
await expect(desktopPanel).toContainText(takeoverProof);
233271

272+
await desktopPanel
273+
.getByRole('button', { name: 'Take back control of terminal' })
274+
.click();
275+
await expect(desktopPanel.locator('.terminal-presentation-control')).toHaveCount(0);
276+
await expect(desktopPanel).not.toHaveClass(/terminal-panel--remote-size-override/u);
277+
await expect(browserPanel).toHaveClass(/terminal-panel--remote-size-override/u);
278+
const desktopColumnsAfterTakeback = await readTerminalColumns(
279+
mainWindow,
280+
desktopPanel,
281+
'__TD2__',
282+
);
283+
expect(desktopColumnsAfterTakeback).toBeGreaterThan(browserColumns);
284+
await expectMatchingLogicalGrid(desktopPanel, browserPanel);
285+
234286
const remoteConnections = await mainWindow.evaluate(() =>
235287
window.terminayTest.listRemoteProtocolConnections(),
236288
);

packages/client-core/test/terminal-panel.test.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,17 +160,21 @@ test("terminal panel forwards raw replay and filters output, exit, and resync li
160160
const outputs = [];
161161
const exits = [];
162162
const resyncs = [];
163+
const events = [];
164+
panel.onEvent((event) => events.push(event));
163165
panel.onOutput((event) => outputs.push({ bytes: [...event.bytes], position: event.position }));
164166
panel.onExit((event) => exits.push({ exitCode: event.exitCode, signal: event.signal }));
165167
panel.onResync((event) => resyncs.push({ replayFrom: event.replayFrom, outputPosition: event.outputPosition }));
166168

167169
source.emit(output(3, new Uint8Array([0x00, 0xc3, 0xa9])));
168170
source.emit({ ...identity, type: "exit", exitCode: 7, signal: 15 });
169171
source.emit({ ...identity, type: "resync_required", fromPosition: 6, replayFrom: 9, outputPosition: 12 });
172+
source.emit({ ...identity, type: "dimensions", cols: 44, rows: 16 });
170173

171174
assert.deepEqual(outputs, [{ bytes: [0x00, 0xc3, 0xa9], position: 3 }]);
172175
assert.deepEqual(exits, [{ exitCode: 7, signal: 15 }]);
173176
assert.deepEqual(resyncs, [{ replayFrom: 9, outputPosition: 12 }]);
177+
assert.deepEqual(events.at(-1), { ...identity, type: "dimensions", cols: 44, rows: 16 });
174178
await panel.detach();
175179
});
176180

packages/server-core/test/terminal-protocol.test.mjs

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -172,10 +172,17 @@ test("terminal attach refuses an arbitrary replay suffix when the complete prese
172172

173173
assert.equal(attached.ok, true);
174174
assert.equal(attached.result.fromPosition, 6);
175-
assert.equal(attached.result.events.length, 1);
176-
assert.equal(attached.result.events[0].type, "presentation_unavailable");
177-
assert.equal(attached.result.events[0].requestedFromPosition, 0);
178-
assert.equal(attached.result.events[0].outputPosition, 6);
175+
assert.deepEqual(attached.result.events[0], {
176+
...identity,
177+
attachmentId: attached.result.attachmentId,
178+
clientId: "client-a",
179+
type: "dimensions",
180+
cols: 80,
181+
rows: 24,
182+
});
183+
const unavailable = attached.result.events.find((event) => event.type === "presentation_unavailable");
184+
assert.equal(unavailable.requestedFromPosition, 0);
185+
assert.equal(unavailable.outputPosition, 6);
179186
} finally {
180187
await service.shutdown();
181188
}
@@ -211,6 +218,70 @@ test("only the explicit presentation holder can forward emulator replies", async
211218
await service.shutdown();
212219
});
213220

221+
test("the presentation holder publishes canonical dimensions to every exact attachment", async () => {
222+
const pty = createPtyFactory();
223+
const service = new TerminalService({ serverId: "server-dimensions", ptyFactory: pty, generateSessionId: () => "session-dimensions" });
224+
const session = await service.createSession({ projectId: "project-dimensions", cols: 80, rows: 24 });
225+
const journal = new OrderedEventJournal();
226+
const registry = createTerminalOperationRegistry({ service, eventJournal: journal, allowUnresolvedTestSessions: true });
227+
const dispatcher = createOperationDispatcher(registry.operations);
228+
const identity = { serverId: service.serverId, projectId: "project-dimensions", sessionId: session.sessionId };
229+
const attach = async (clientId) => (await dispatcher.command(request(
230+
"terminal.attach",
231+
{ clientId, identity, fromPosition: 0 },
232+
`attach-${clientId}`,
233+
"write",
234+
clientId,
235+
))).result;
236+
const resize = (attachment, clientId, cols, rows, id) => dispatcher.command(request(
237+
"terminal.resize",
238+
{ clientId, identity, attachmentId: attachment.attachmentId, cols, rows },
239+
id,
240+
"write",
241+
clientId,
242+
));
243+
244+
try {
245+
const desktop = await attach("desktop");
246+
const browser = await attach("browser");
247+
assert.deepEqual(desktop.events[0], {
248+
...identity,
249+
attachmentId: desktop.attachmentId,
250+
clientId: "desktop",
251+
type: "dimensions",
252+
cols: 80,
253+
rows: 24,
254+
});
255+
256+
assert.equal((await resize(desktop, "desktop", 120, 40, "desktop-resize")).ok, true);
257+
assert.equal((await resize(browser, "browser", 40, 16, "observer-resize")).ok, false);
258+
259+
const takeover = await dispatcher.command(request(
260+
"terminal.presentation",
261+
{ clientId: "browser", identity, attachmentId: browser.attachmentId, mode: "takeover" },
262+
"browser-takeover",
263+
"write",
264+
"browser",
265+
));
266+
assert.equal(takeover.ok, true);
267+
assert.equal((await resize(desktop, "desktop", 100, 30, "stale-desktop-resize")).ok, false);
268+
assert.equal((await resize(browser, "browser", 40, 16, "browser-resize")).ok, true);
269+
270+
assert.deepEqual(pty.processes[0].resizes, [{ cols: 120, rows: 40 }, { cols: 40, rows: 16 }]);
271+
const dimensions = journal.replay(0).events
272+
.map((event) => event.payload)
273+
.filter((event) => event.type === "dimensions");
274+
assert.deepEqual(dimensions.map((event) => [event.clientId, event.attachmentId, event.cols, event.rows]), [
275+
["desktop", desktop.attachmentId, 120, 40],
276+
["browser", browser.attachmentId, 120, 40],
277+
["desktop", desktop.attachmentId, 40, 16],
278+
["browser", browser.attachmentId, 40, 16],
279+
]);
280+
} finally {
281+
await service.shutdown();
282+
}
283+
});
284+
214285
test("fresh presentation replay preserves complete hostile control sequences at every emitted byte boundary", async () => {
215286
const pty = createPtyFactory();
216287
const service = new TerminalService({ serverId: "server-boundaries", ptyFactory: pty, generateSessionId: () => "session-boundaries", maxReplayBytes: 64 * 1024 });
@@ -222,9 +293,9 @@ test("fresh presentation replay preserves complete hostile control sequences at
222293
for (const byte of transcript) pty.processes[0].emitData(new Uint8Array([byte]));
223294
const attached = await dispatcher.command(request("terminal.attach", { clientId: "fresh", identity, fromPosition: 0, freshPresentation: true, maxInitialReplayBytes: 32 * 1024 }, "attach-boundaries", "write", "fresh"));
224295
assert.equal(attached.ok, true);
225-
assert.equal(attached.result.events.length, 1);
226-
assert.equal(attached.result.events[0].position, 0);
227-
assert.deepEqual(Buffer.from(attached.result.events[0].bytes, "base64"), Buffer.from(transcript));
296+
const replay = attached.result.events.find((event) => event.type === "output");
297+
assert.equal(replay.position, 0);
298+
assert.deepEqual(Buffer.from(replay.bytes, "base64"), Buffer.from(transcript));
228299
await service.shutdown();
229300
});
230301

@@ -271,9 +342,14 @@ test("framed terminal attach keeps an overstated fragmented replay inside the pr
271342
maxInitialReplayBytes: 128 * 1024,
272343
});
273344

274-
assert.equal(attached.initialEvents.length, 1);
275-
assert.equal(attached.initialEvents[0].type, "presentation_unavailable");
276-
assert.equal(attached.initialEvents[0].requestedFromPosition, 0);
345+
assert.equal(attached.initialEvents[0].type, "dimensions");
346+
assert.equal(attached.initialEvents[0].serverId, "server-protocol-budget");
347+
assert.equal(attached.initialEvents[0].projectId, "project-protocol-budget");
348+
assert.equal(attached.initialEvents[0].sessionId, session.sessionId);
349+
assert.equal(attached.initialEvents[0].cols, 80);
350+
assert.equal(attached.initialEvents[0].rows, 24);
351+
const unavailable = attached.initialEvents.find((event) => event.type === "presentation_unavailable");
352+
assert.equal(unavailable.requestedFromPosition, 0);
277353
assert.equal(attached.position, 40_000);
278354
} finally {
279355
await protocolClient.close();

0 commit comments

Comments
 (0)