Files
Codeman/test/terminal-tile-scroll.browser.test.ts
T
Saqeb Akhter dbb41c70cf chore: merge upstream master and retain tracked scrolling fixes
Resolve Copilot forwarding and registry/documentation overlaps against upstream e63bb65b. Reuse upstream always policy for measured version-independent forwarders and test slow clicks for Claude, Codex and Copilot.
2026-10-18 14:07:36 +04:00

430 lines
18 KiB
TypeScript

/**
* @fileoverview Real Chromium + real xterm coverage for a TerminalTile's
* hollow-buffer paging (#555 parity), the parts a fake xterm cannot prove:
*
* - the capture-phase wheel listener on the tile's mount really keeps the
* paged wheel away from xterm (its viewport does not move into the stale
* rows above the screen), while a wheel the tile does not page still
* reaches xterm and scrolls it, and
* - real xterm puts a one-screen capture taken at a taller size above the
* screen, and a row-shrinking fit pushes more rows up, which is what the
* tile's overflow discount (`_localRows`) counts. Output that scrolls real
* lines is history, and the tile stops paging.
* - a viewport left up in those discounted rows gets its wheel back: a real
* wheel-down scrolls xterm home instead of being paged, and paging resumes
* from the live screen.
*
* - a fullscreen Claude tile sends the wheel to the CLI as SGR wheel reports
* at the pointer's cell in the TILE (Claude scrolls its own transcript on
* them), and xterm's viewport stays on the live screen instead of
* scrolling the stale replayed frames above it. Shift+wheel scrolls the
* local scrollback, which xterm alone turns into a horizontal no-op.
*
* The wheel is a real one (`page.mouse.wheel()`), and the last step proves it
* reaches xterm: a wheel the tile does not page scrolls xterm's viewport, so
* "xterm did not scroll" on a paged wheel means something. What keeps xterm
* still there is the tile's preventDefault (xterm 6's scrollable element skips
* a wheel whose default was prevented); its stopPropagation, the primary
* pane's other half, keeps xterm's own handlers from seeing the event at all.
* Dropping both makes this test fail.
*
* The tile's load and socket are stubbed in the page (fetch answers its one
* capture, WebSocket never opens), so nothing here needs a PTY; the page keys
* are read from a spy on `app._sendInputEphemeral`. The gates themselves are
* the real ones in terminal-ui.js, asked for the tile's own session.
*
* Port: ephemeral (`new WebServer(0, …)`, read back from `boundPort`).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PROBE_ID = 'tile-scroll-probe';
type Snap = { rows: number; baseY: number; viewportY: number; localRows: number; sent: Array<[string, string]> };
describe('TerminalTile wheel paging in a real browser', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
beforeAll(async () => {
server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 });
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal && (window as any).TerminalTile, null, {
timeout: 30000,
});
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
/** The tile's state plus every page key sent so far. */
const snap = () =>
page.evaluate(() => {
const probe = (window as any).__tileProbe;
const term = probe.tile.terminal;
return {
rows: term.rows,
baseY: term.buffer.active.baseY,
viewportY: term.buffer.active.viewportY,
localRows: probe.tile._localRows(),
sent: probe.sent.slice(),
} as Snap;
});
/** A real wheel of `rows` lines over the tile (negative = up), then time for the 40 ms flush and a frame. */
async function wheelBy(rows: number) {
await page.mouse.move(200, 60);
await page.mouse.wheel(0, rows * 25);
await page.waitForTimeout(150);
}
const wheelUp = (rows: number) => wheelBy(-rows);
it('pages a hollow tile, keeps xterm still, survives a shrink, returns an off-bottom wheel, and stops at real lines', async () => {
await page.evaluate(async (id) => {
const w = window as any;
const app = w.app;
const CAPTURE_ROWS = 40;
const capture = Array.from({ length: CAPTURE_ROWS }, (_, i) => `row ${i}`).join('\r\n');
// The tile's session, as the app knows it: opencode, which draws in place.
app.sessions.set(id, { id, mode: 'opencode' });
const sent: Array<[string, string]> = [];
const realEphemeral = app._sendInputEphemeral;
app._sendInputEphemeral = (sessionId: string, data: string) => sent.push([sessionId, data]);
const realFetch = w.fetch;
w.fetch = async (url: string, init?: unknown) =>
String(url).includes(`/api/sessions/${id}/terminal`)
? new Response(JSON.stringify({ data: { terminalBuffer: capture, captureRows: CAPTURE_ROWS } }))
: realFetch(url, init);
const RealWebSocket = w.WebSocket;
w.WebSocket = class {
static OPEN = 1;
readyState = 0;
send() {}
close() {}
};
const mount = document.createElement('div');
mount.style.cssText = 'position:fixed;left:0;top:0;width:640px;height:300px;z-index:99999;background:#000';
document.body.appendChild(mount);
const tile = new w.TerminalTile(id, mount, { mode: 'opencode' });
try {
await tile.connect();
} finally {
w.fetch = realFetch;
w.WebSocket = RealWebSocket;
}
w.__tileProbe = { tile, mount, sent, realEphemeral };
}, PROBE_ID);
try {
// A 40-row capture in a shorter xterm: the extra rows sit above the
// screen, and the tile counts every one of them as its own overflow.
const afterLoad = await snap();
expect(afterLoad.rows).toBeLessThan(40);
expect(afterLoad.baseY).toBe(40 - afterLoad.rows);
expect(afterLoad.viewportY).toBe(afterLoad.baseY);
expect(afterLoad.localRows).toBe(0);
// The wheel is consumed and paged: xterm never saw it, so its viewport
// stayed at the bottom instead of scrolling into the stale rows.
await wheelUp(afterLoad.rows);
const afterWheel = await snap();
expect(afterWheel.viewportY).toBe(afterLoad.baseY);
expect(afterWheel.sent.length).toBeGreaterThanOrEqual(1);
expect(afterWheel.sent.every(([id]) => id === PROBE_ID)).toBe(true);
expect(afterWheel.sent.map(([, data]) => data).join('')).toMatch(/^(?:\x1b\[5~)+$/);
// A row-shrinking fit pushes more rows up; still not history, still paged.
await page.evaluate(() => {
const probe = (window as any).__tileProbe;
probe.mount.style.height = '200px';
probe.tile.localFit();
});
const afterShrink = await snap();
expect(afterShrink.rows).toBeLessThan(afterLoad.rows);
expect(afterShrink.baseY).toBeGreaterThan(afterLoad.baseY);
expect(afterShrink.localRows).toBe(0);
await wheelUp(afterShrink.rows);
const afterSecondWheel = await snap();
expect(afterSecondWheel.sent.length).toBeGreaterThan(afterWheel.sent.length);
expect(afterSecondWheel.viewportY).toBe(afterShrink.baseY);
// A viewport left up in those rows (Shift+PageUp, a scrollbar drag): the
// wheel is xterm's again, so a wheel-down really scrolls it home and sends
// no page key, and from the live screen the wheel pages once more.
await page.evaluate(() => (window as any).__tileProbe.tile.terminal.scrollLines(-5));
const scrolledUp = await snap();
expect(scrolledUp.viewportY).toBe(afterShrink.baseY - 5);
expect(scrolledUp.localRows).toBe(0);
// xterm scrolls a few lines per real wheel event, so wheel down until home
// (bounded), each step really moving it and none of them paged.
let backHome = scrolledUp;
for (let i = 0; i < 10 && backHome.viewportY < afterShrink.baseY; i++) {
const before = backHome.viewportY;
await wheelBy(afterShrink.rows);
backHome = await snap();
expect(backHome.viewportY).toBeGreaterThan(before);
}
expect(backHome.viewportY).toBe(afterShrink.baseY);
expect(backHome.sent.length).toBe(afterSecondWheel.sent.length);
await wheelUp(afterShrink.rows);
const pagedAgain = await snap();
expect(pagedAgain.sent.length).toBeGreaterThan(backHome.sent.length);
expect(pagedAgain.viewportY).toBe(afterShrink.baseY);
// Output that scrolled real lines is history: the wheel goes back to
// xterm, which scrolls its own buffer, and no page key is sent.
await page.evaluate(
() =>
new Promise((resolve) =>
(window as any).__tileProbe.tile.terminal.write('real 1\r\nreal 2\r\nreal 3\r\n', resolve)
)
);
const afterOutput = await snap();
expect(afterOutput.localRows).toBe(3);
await wheelUp(afterOutput.rows);
const afterThirdWheel = await snap();
expect(afterThirdWheel.sent.length).toBe(pagedAgain.sent.length);
expect(afterThirdWheel.viewportY).toBeLessThan(afterOutput.baseY);
} finally {
await page.evaluate((id) => {
const w = window as any;
const probe = w.__tileProbe;
probe.tile.destroy();
probe.mount.remove();
w.app.sessions.delete(id);
w.app._sendInputEphemeral = probe.realEphemeral;
delete w.__tileProbe;
}, PROBE_ID);
}
});
it("forwards a fullscreen Claude tile's wheel as SGR reports from its own cells, and keeps xterm on the live screen", async () => {
await page.evaluate(async (id) => {
const w = window as any;
const app = w.app;
const CAPTURE_ROWS = 40;
const capture = Array.from({ length: CAPTURE_ROWS }, (_, i) => `frame ${i}`).join('\r\n');
// The tile's session: Claude in its fullscreen renderer (mouse tracking on).
app.sessions.set(id, { id, mode: 'claude', cliVersion: '2.1.295', cliMouseTracking: true });
const sent: Array<[string, string]> = [];
const realEphemeral = app._sendInputEphemeral;
app._sendInputEphemeral = (sessionId: string, data: string) => sent.push([sessionId, data]);
const realFetch = w.fetch;
w.fetch = async (url: string, init?: unknown) =>
String(url).includes(`/api/sessions/${id}/terminal`)
? new Response(JSON.stringify({ data: { terminalBuffer: capture, captureRows: CAPTURE_ROWS } }))
: realFetch(url, init);
const RealWebSocket = w.WebSocket;
w.WebSocket = class {
static OPEN = 1;
readyState = 0;
send() {}
close() {}
};
// Offset from the page corner, so a cell computed against the wrong
// element (the primary pane's) would come out different.
const mount = document.createElement('div');
mount.style.cssText =
'position:fixed;left:100px;top:120px;width:640px;height:300px;z-index:99999;background:#000';
document.body.appendChild(mount);
const tile = new w.TerminalTile(id, mount, { mode: 'claude' });
try {
await tile.connect();
} finally {
w.fetch = realFetch;
w.WebSocket = RealWebSocket;
}
w.__tileProbe = { tile, mount, sent, realEphemeral };
}, PROBE_ID);
try {
// Where the pointer will be, as the tile's own screen maps it (1-based).
const expected = await page.evaluate(() => {
const term = (window as any).__tileProbe.tile.terminal;
const rect = term.element.querySelector('.xterm-screen').getBoundingClientRect();
const cell = term._core._renderService.dimensions.css.cell;
const x = rect.left + cell.width * 7.5;
const y = rect.top + cell.height * 3.5;
return { x, y, col: 8, row: 4 };
});
// The stale frames sit above the live screen: something xterm COULD scroll.
const before = await snap();
expect(before.baseY).toBeGreaterThan(0);
expect(before.viewportY).toBe(before.baseY);
await page.mouse.move(expected.x, expected.y);
await page.mouse.wheel(0, -100); // one Chrome-on-Windows notch up
await page.waitForTimeout(150);
await page.mouse.wheel(0, 50); // half a notch down
await page.waitForTimeout(150);
const after = await snap();
expect(after.viewportY).toBe(before.baseY); // xterm never scrolled the stale frames
expect(after.sent.every(([sid]) => sid === PROBE_ID)).toBe(true);
const up = `\x1b[<64;${expected.col};${expected.row}M`;
const down = `\x1b[<65;${expected.col};${expected.row}M`;
expect(after.sent.map(([, data]) => data)).toEqual([up.repeat(4), down.repeat(2)]);
// A slow macOS mouse click is only ~4px. Each isolated click must report,
// not lose its fraction during the 300ms pause between clicks.
await page.waitForTimeout(300);
for (let click = 0; click < 5; click++) {
await page.mouse.wheel(0, -4);
await page.waitForTimeout(300);
}
const afterSlow = await snap();
expect(afterSlow.sent.slice(after.sent.length).map(([, data]) => data)).toEqual(Array(5).fill(up));
// Shift+wheel is the explicit "local scrollback" gesture: scrolled locally
// by the tile (xterm alone turns it into a horizontal no-op), unforwarded.
await page.keyboard.down('Shift');
await page.mouse.wheel(0, -100);
await page.keyboard.up('Shift');
await page.waitForTimeout(150);
const shifted = await snap();
expect(shifted.sent.length).toBe(afterSlow.sent.length);
expect(shifted.viewportY).toBeLessThan(before.baseY);
} finally {
await page.evaluate((id) => {
const w = window as any;
const probe = w.__tileProbe;
probe.tile.destroy();
probe.mount.remove();
w.app.sessions.delete(id);
w.app._sendInputEphemeral = probe.realEphemeral;
delete w.__tileProbe;
}, PROBE_ID);
}
});
it('preserves local Ctrl/sideways scrolling and forwards small native clicks without amplifying touchpad streams', async () => {
const id = 'primary-wheel-probe';
await page.evaluate(async (id) => {
const a = (window as any).app;
const t = a.terminal;
(window as any).__primaryWheelProbe = {
sent: [],
activeId: a.activeSessionId,
realEphemeral: a._sendInputEphemeral,
point: a._clientPointToCell,
};
a._sendInputEphemeral = (sid: string, data: string) => (window as any).__primaryWheelProbe.sent.push([sid, data]);
a._clientPointToCell = () => ({ col: 1, row: 1 });
a.sessions.set(id, { id, mode: 'shell' });
a.activeSessionId = id;
t.reset();
await new Promise<void>((resolve) =>
t.write(Array.from({ length: 100 }, (_, i) => `LOCAL_${i}\r\n`).join(''), resolve)
);
t.scrollToBottom();
}, id);
const fire = async (options: { deltaY: number; deltaX?: number; ctrlKey?: boolean }) => {
await page.evaluate((options) => {
const t = (window as any).app.terminal;
t.element.dispatchEvent(new WheelEvent('wheel', { ...options, deltaMode: 0, bubbles: true, cancelable: true }));
}, options);
await page.waitForTimeout(80); // actual 40ms batch and animation frames
};
try {
for (const options of [
{ deltaY: -25, ctrlKey: true },
{ deltaY: -25, deltaX: 100 },
]) {
const before = await page.evaluate(() => {
const t = (window as any).app.terminal;
t.scrollToBottom();
return t.buffer.active.viewportY;
});
expect(before).toBeGreaterThan(0);
await fire(options);
const after = await page.evaluate(() => (window as any).app.terminal.buffer.active.viewportY);
expect(after).toBe(before - 1); // local shell behavior matches master
}
for (const mode of ['claude', 'codex', 'copilot']) {
await page.evaluate(
({ id, mode }) => {
const a = (window as any).app;
a.sessions.set(id, {
id,
mode,
cliVersion: mode === 'claude' ? '2.1.300' : undefined,
cliMouseTracking: true,
});
a._wheelTickGesture = null;
a._wheelTickSession = null;
a._selectGeneration++;
(window as any).__primaryWheelProbe.sent.length = 0;
},
{ id, mode }
);
for (const options of [
{ deltaY: -25, ctrlKey: true },
{ deltaY: -25, deltaX: 100 },
])
await fire(options);
expect(await page.evaluate(() => (window as any).__primaryWheelProbe.sent.length)).toBe(0);
for (let click = 0; click < 5; click++) {
await fire({ deltaY: -4 });
await page.waitForTimeout(220);
}
expect(await page.evaluate(() => (window as any).__primaryWheelProbe.sent)).toEqual(
Array.from({ length: 5 }, () => [id, '\x1b[<64;1;1M'])
);
await page.waitForTimeout(200);
await page.evaluate(() => {
(window as any).__primaryWheelProbe.sent.length = 0;
});
await page.evaluate(() => {
const t = (window as any).app.terminal;
for (let event = 0; event < 10; event++)
t.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -2.5,
deltaMode: 0,
bubbles: true,
cancelable: true,
})
);
});
await page.waitForTimeout(80);
expect(await page.evaluate(() => (window as any).__primaryWheelProbe.sent)).toEqual([[id, '\x1b[<64;1;1M']]);
}
await page.evaluate((id) => {
const a = (window as any).app;
a.sessions.set(id, { id, mode: 'codex', cliMouseTracking: false });
a.terminal.clear();
a.terminal.scrollToBottom();
(window as any).__primaryWheelProbe.sent.length = 0;
}, id);
expect(await page.evaluate(() => (window as any).app.terminal.buffer.active.baseY)).toBe(0);
for (const options of [
{ deltaY: -25, ctrlKey: true },
{ deltaY: -25, deltaX: 100 },
])
await fire(options);
expect(await page.evaluate(() => (window as any).__primaryWheelProbe.sent.length)).toBe(0);
} finally {
await page.evaluate((id) => {
const a = (window as any).app,
p = (window as any).__primaryWheelProbe;
a.activeSessionId = p.activeId;
a._sendInputEphemeral = p.realEphemeral;
a._clientPointToCell = p.point;
a.sessions.delete(id);
delete (window as any).__primaryWheelProbe;
}, id);
}
});
});