Files
paseo/packages/app/e2e/browser/terminal-find.spec.ts
T
paseo-bot[bot]andsino 2d5b8eb1fd Handle web terminal finger scrolling once (#8776)
* fix(app): scroll web terminal history with a finger swipe

A phone browser claims the vertical drag and drops it, because the canvas covers the scrollable viewport and the page itself does not scroll. Keep the gesture in the touch handler and move one line per cell.

* fix(app): handle terminal finger scrolling once

Keep the vertical gesture in the existing terminal handler so xterm does not also scroll it at document level. Retain only the confirmed propagation correction from the contributor patch and cover the actual phone-sized app with trusted touch input.

Co-authored-by: sino <sino2322@gmail.com>

* fix(app): let xterm own terminal touch input

Remove the duplicate gesture implementation so history scrolls once and terminal applications retain arrow-key and mouse-report input. Cover live application modes with trusted touch gestures and a real raw-stdin process.

---------

Co-authored-by: sino <sino2322@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-04 13:17:32 +00:00

632 lines
26 KiB
TypeScript

import { getServerId } from "../support/helpers/server-id";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { spawnSync } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
import { getTerminalBufferText, buildTerminalWorkspaceUrl } from "../support/helpers/terminal-perf";
function query(page: Page) {
return page.getByRole("textbox", { name: "Find in pane", exact: true }).filter({ visible: true });
}
function status(page: Page) {
return page.getByRole("status", { name: "Find matches" }).filter({ visible: true });
}
function input(page: Page) {
return page
.getByTestId("terminal-surface")
.filter({ visible: true })
.locator(".xterm-helper-textarea");
}
async function openFind(page: Page, text?: string) {
await input(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
if (text !== undefined) await query(page).fill(text);
}
async function expectSelectedQuery(page: Page, text: string) {
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue(text);
await expect
.poll(() =>
query(page).evaluate((el: HTMLInputElement) => [el.selectionStart, el.selectionEnd]),
)
.toEqual([0, text.length]);
}
async function expectUncoveredMatch(page: Page) {
const widget = page.getByLabel("Find", { exact: true });
await expect
.poll(async () => {
const selected = await page.evaluate(() => {
const term = (
window as Window & {
__paseoTerminal?: {
rows: number;
cols: number;
buffer: { active: { viewportY: number } };
getSelectionPosition():
| { start: { x: number; y: number }; end: { x: number; y: number } }
| undefined;
};
}
).__paseoTerminal;
const selection = term?.getSelectionPosition();
return term && selection
? { selection, rows: term.rows, cols: term.cols, firstRow: term.buffer.active.viewportY }
: null;
});
const screen = await page.locator(".xterm-screen").filter({ visible: true }).boundingBox();
const w = await widget.boundingBox();
if (!selected || !screen || !w) return false;
const x = screen.x + (selected.selection.start.x * screen.width) / selected.cols;
const y =
screen.y +
((selected.selection.start.y - selected.firstRow) * screen.height) / selected.rows;
const height = screen.height / selected.rows;
return (
y >= screen.y &&
y + height <= screen.y + screen.height + 1 &&
!(x >= w.x && x < w.x + w.width && y < w.y + w.height && y + height > w.y)
);
})
.toBe(true);
}
async function viewport(page: Page) {
return page.evaluate(
() =>
(window as Window & { __paseoTerminal?: { buffer: { active: { viewportY: number } } } })
.__paseoTerminal?.buffer.active.viewportY,
);
}
async function openControlCharacterTerminal(page: Page) {
await test.step("Run cat -v in a real bash terminal", async () => {
const terminal = await harness.createTerminal({
name: "Control characters",
command: "bash",
// Disable the tty's echo so ^F proves cat received the byte. Noncanonical
// input lets cat print it immediately, without waiting for Enter.
args: ["--noprofile", "--norc", "-c", "stty -echo -icanon; printf 'CAT_READY\\n'; cat -v"],
});
await harness.openTerminal(page, { terminalId: terminal.id });
await expect.poll(() => getTerminalBufferText(page)).toContain("CAT_READY");
});
}
async function pressTerminalShortcut(page: Page, shortcut: string) {
await test.step(`Press ${shortcut} in the terminal`, async () => {
await input(page).press(shortcut);
});
}
async function expectFindClosed(page: Page) {
await expect(query(page)).toBeHidden();
await expect(input(page)).toBeFocused();
}
async function recordTerminalEvidence(name: string, body: string) {
const evidencePath = test.info().outputPath(`${name}.txt`);
await writeFile(evidencePath, body);
await test.info().attach(name, { path: evidencePath, contentType: "text/plain" });
}
async function expectCatControlF(page: Page) {
await test.step("Cat displays ^F while Find stays closed", async () => {
await expect.poll(() => getTerminalBufferText(page)).toContain("^F");
await expectFindClosed(page);
await recordTerminalEvidence("cat-control-f", await getTerminalBufferText(page));
});
}
async function expectVimPageDown(page: Page, before: { topLine: number; lastLine: number }) {
await test.step("Vim pages down while Find stays closed", async () => {
try {
// Vim's Ctrl+F keeps two lines of overlap, so the new page starts before the old page ends.
await expect.poll(() => visibleTopLineNumber(page)).toBeGreaterThan(before.topLine);
await expect.poll(() => visibleTopLineNumber(page)).toBeGreaterThanOrEqual(40);
await expectFindClosed(page);
} finally {
await recordTerminalEvidence("vim-after-control-f", await getTerminalBufferText(page));
await recordTerminalEvidence(
"vim-page-down",
JSON.stringify({ before, after: { topLine: await visibleTopLineNumber(page) } }),
);
}
});
}
async function expectCatRoundTrip(page: Page) {
await test.step("Wait for cat to echo subsequent input from the pty", async () => {
await input(page).pressSequentially("PTY_ROUND_TRIP");
await input(page).press("Enter");
await expect.poll(() => getTerminalBufferText(page)).toContain("PTY_ROUND_TRIP");
});
}
async function openNumberedFileInVim(page: Page) {
return test.step("Open 300 numbered lines in vim without user configuration", async () => {
const terminal = await harness.createTerminal({
name: "Vim page down",
command: "bash",
args: [
"--noprofile",
"--norc",
"-c",
"seq 1 300 > numbered-lines.txt && vim -u NONE -i NONE numbered-lines.txt; printf 'VIM_EXITED\\n'; cat",
],
});
await harness.openTerminal(page, { terminalId: terminal.id });
await expectVimFirstPage(page);
const screen = await readVimScreen(page);
await recordTerminalEvidence("vim-before-control-f", screen.text);
return { topLine: screen.numberedLines[0], lastLine: screen.numberedLines.at(-1)! };
});
}
async function readVimScreen(page: Page) {
const rows = await page.evaluate(
() => (window as Window & { __paseoTerminal: { rows: number } }).__paseoTerminal.rows,
);
const text = await getTerminalBufferText(page);
const numberedLines = text
.split("\n")
.map((line) => line.trim())
.filter((line) => /^\d+$/.test(line))
.map(Number);
return { rows, text, numberedLines };
}
async function expectVimFirstPage(page: Page) {
await expect(async () => {
const screen = await readVimScreen(page);
// The captured screen has one numbered file line per row, except Vim's
// bottom command row. Derive the last visible line from the fitted terminal.
expect(screen.numberedLines).toEqual(Array.from({ length: screen.rows - 1 }, (_, i) => i + 1));
expect(screen.text).not.toContain("300L");
}).toPass({ timeout: 10_000 });
}
async function visibleTopLineNumber(page: Page) {
// Vim uses the alternate screen, so its active buffer starts at the visible top row.
return Number((await getTerminalBufferText(page)).split("\n")[0].trim());
}
async function quitVim(page: Page) {
await test.step("Leave vim with Escape and :q!", async () => {
await input(page).press("Escape");
await input(page).pressSequentially(":q!");
await input(page).press("Enter");
await expect.poll(() => getTerminalBufferText(page)).toContain("VIM_EXITED");
});
}
interface TerminalChord {
key: string;
bytes: string;
}
async function openKeyboardCapture(page: Page) {
await test.step("Open a real terminal process that reports received bytes", async () => {
const terminal = await harness.createTerminal({
name: "Keyboard bytes",
command: process.execPath,
args: [
"-e",
`
process.stdin.setRawMode(true);
let received = Buffer.alloc(0);
process.stdin.on('data', chunk => {
received = Buffer.concat([received, chunk]);
process.stdout.write('\\r\\nBYTES:' + received.toString('hex') + ':END\\r\\n');
});
console.log('KEYBOARD_READY');
`,
],
});
await harness.openTerminal(page, { terminalId: terminal.id });
await expect.poll(() => getTerminalBufferText(page)).toContain("KEYBOARD_READY");
});
}
async function expectTerminalChords(page: Page, chords: TerminalChord[]) {
let received = "";
for (const chord of chords) {
await test.step(`${chord.key} sends ${JSON.stringify(chord.bytes)}`, async () => {
await pressTerminalShortcut(page, chord.key);
// A printable sentinel proves delivery even for chords expected to send nothing.
await input(page).pressSequentially("x");
received += Buffer.from(chord.bytes + "x").toString("hex");
await expect.poll(() => getTerminalBufferText(page)).toContain(`BYTES:${received}:END`);
});
}
await recordTerminalEvidence("keyboard-bytes", await getTerminalBufferText(page));
await page.screenshot({ path: test.info().outputPath("keyboard-chords.png") });
}
let harness: TerminalE2EHarness;
test.beforeEach(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-find-" });
});
test.afterEach(async () => {
await harness.cleanup();
});
test.describe("macOS terminal shortcuts", () => {
test.use({
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
});
test("encodes editing chords without confusing Control and Command", async ({ page }) => {
await openKeyboardCapture(page);
await expectTerminalChords(page, [
{ key: "Meta+ArrowLeft", bytes: "\x01" },
{ key: "Meta+ArrowRight", bytes: "\x05" },
{ key: "Alt+ArrowLeft", bytes: "\x1bb" },
{ key: "Alt+ArrowRight", bytes: "\x1bf" },
{ key: "Meta+Backspace", bytes: "\x15" },
{ key: "Control+c", bytes: "\x03" },
{ key: "Control+a", bytes: "\x01" },
{ key: "Control+e", bytes: "\x05" },
{ key: "Control+v", bytes: "\x16" },
{ key: "Control+f", bytes: "\x06" },
{ key: "ArrowLeft", bytes: "\x1b[D" },
{ key: "ArrowRight", bytes: "\x1b[C" },
{ key: "Alt+Backspace", bytes: "\x1b\x7f" },
{ key: "Control+Alt+ArrowLeft", bytes: "\x1b[1;7D" },
]);
await pressTerminalShortcut(page, "Meta+f");
await expect(query(page)).toBeFocused();
});
test("sends Control+f to cat and opens Find with Meta+f", async ({ page }) => {
await openControlCharacterTerminal(page);
await pressTerminalShortcut(page, "Control+f");
await expectCatControlF(page);
await pressTerminalShortcut(page, "Meta+f");
await expect(query(page)).toBeFocused();
});
test("sends Control+f to vim to page down", async ({ page }) => {
const vim = spawnSync("which", ["vim"], { encoding: "utf8" });
await recordTerminalEvidence("which-vim", `status: ${vim.status}\n${vim.stdout}${vim.stderr}`);
test.skip(vim.status !== 0, "vim is not installed on this runner (which vim failed)");
const before = await openNumberedFileInVim(page);
await pressTerminalShortcut(page, "Control+f");
await expectVimPageDown(page, before);
await quitVim(page);
});
});
async function useLinuxKeyboardPlatform(page: Page) {
// A user agent alone leaves navigator.platform as MacIntel on macOS hosts.
// Emulate both browser identity fields before the app reads its keyboard policy.
const session = await page.context().newCDPSession(page);
await session.send("Emulation.setUserAgentOverride", {
userAgent:
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
platform: "Linux x86_64",
});
}
test("Linux terminal chords keep Control, Alt and Meta distinct", async ({ page }) => {
await useLinuxKeyboardPlatform(page);
await openKeyboardCapture(page);
await expectTerminalChords(page, [
{ key: "Meta+ArrowLeft", bytes: "" },
{ key: "Meta+ArrowRight", bytes: "" },
{ key: "Alt+ArrowLeft", bytes: "\x1b[1;3D" },
{ key: "Alt+ArrowRight", bytes: "\x1b[1;3C" },
{ key: "Meta+Backspace", bytes: "\x7f" },
{ key: "Control+c", bytes: "\x03" },
{ key: "Control+a", bytes: "\x01" },
{ key: "Control+e", bytes: "\x05" },
{ key: "Control+ArrowLeft", bytes: "\x1b[1;5D" },
{ key: "Control+ArrowRight", bytes: "\x1b[1;5C" },
{ key: "Control+Backspace", bytes: "\x08" },
{ key: "Alt+Backspace", bytes: "\x1b\x7f" },
]);
await pressTerminalShortcut(page, "Control+f");
await expect(query(page)).toBeFocused();
});
test("opens Find with Control+f on Linux without sending ^F to cat", async ({ page }) => {
await useLinuxKeyboardPlatform(page);
await openControlCharacterTerminal(page);
await pressTerminalShortcut(page, "Control+f");
await expect(query(page)).toBeFocused();
await test.step("Close Find and return focus to the terminal", async () => {
await query(page).press("Escape");
await expectFindClosed(page);
});
await expectCatRoundTrip(page);
await test.step("Cat received subsequent input without any ^F", async () => {
const output = await getTerminalBufferText(page);
expect(output).not.toContain("^F");
await recordTerminalEvidence("cat-without-control-f", output);
});
});
test("searches retained output without sending Find input to the shell", async ({
page,
}, testInfo) => {
const received = path.join(harness.tempRepo.path, "received.txt");
const trigger = path.join(harness.tempRepo.path, "output.trigger");
const bottomTrigger = path.join(harness.tempRepo.path, "bottom.trigger");
const script = `stty -echo; printf 'first a.b\\n'; for i in $(seq 1 90); do printf 'line %s\\n' "$i"; done; printf 'last A.B\\nREADY\\n'; (while [ ! -f output.trigger ]; do sleep 0.05; done; printf 'new output\\n'; while [ ! -f bottom.trigger ]; do sleep 0.05; done; for i in $(seq 1 30); do printf 'bottom output %s\\n' "$i"; done) & while IFS= read -r line; do printf '%s\\n' "$line" >> received.txt; done`;
await writeFile(received, "");
const terminal = await harness.createTerminal({
name: "Find fixture",
command: "bash",
args: ["--noprofile", "--norc", "-c", script],
});
await harness.openTerminal(page, { terminalId: terminal.id });
await expect.poll(() => getTerminalBufferText(page)).toContain("READY");
await openFind(page, "a.b");
await expect(status(page)).toHaveText("2 of 2");
await query(page).press("Shift+Enter");
await expect(status(page)).toHaveText("1 of 2");
await expectUncoveredMatch(page);
const findBox = await page.getByLabel("Find", { exact: true }).boundingBox();
const surfaceBox = await page.getByTestId("terminal-surface").boundingBox();
expect(findBox!.y).toBeLessThan(surfaceBox!.y + 70);
const inspected = await viewport(page);
await writeFile(trigger, "go");
await expect.poll(() => getTerminalBufferText(page)).toContain("new output");
await expect.poll(() => viewport(page)).toBe(inspected);
await page.screenshot({ path: testInfo.outputPath("scrollback-find.png") });
await query(page).press("Enter");
await expect(status(page)).toHaveText("2 of 2");
const inspectedBottom = await viewport(page);
await writeFile(bottomTrigger, "go");
await expect.poll(() => getTerminalBufferText(page)).toContain("bottom output 30");
await expect.poll(() => viewport(page)).toBe(inspectedBottom);
await expectUncoveredMatch(page);
await query(page).press("Enter");
await expect(status(page)).toHaveText("1 of 2");
await query(page).press("ControlOrMeta+f");
await expectSelectedQuery(page, "a.b");
await query(page).fill("A.B");
await expect(status(page)).toHaveText("1 of 2");
await query(page).fill("absent[.*]");
await expect(status(page)).toHaveText("No matches");
await query(page).fill("a.b");
await query(page).press("Escape");
await expect(query(page)).toBeHidden();
await expect(input(page)).toBeFocused();
await expect(page.locator(".xterm-find-result-decoration")).toHaveCount(0);
await input(page).pressSequentially("only-shell-input");
await input(page).press("Enter");
await expect.poll(() => readFile(received, "utf8")).toBe("only-shell-input\n");
await input(page).pressSequentially("discard");
await input(page).press("Control+u");
await input(page).pressSequentially("ordinary-input");
await input(page).press("Enter");
await expect.poll(() => readFile(received, "utf8")).toBe("only-shell-input\nordinary-input\n");
await openFind(page);
await expectSelectedQuery(page, "a.b");
await expect(status(page)).toHaveText(/of 2/);
await expectUncoveredMatch(page);
await expect(page.getByRole("button", { name: "Toggle replace" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Find", exact: true })).toHaveCount(0);
});
test("keeps terminal Find owned by retained tabs and the focused split pane", async ({
page,
}, testInfo) => {
// This journey selects tabs from the full rails in both split panes.
await page.setViewportSize({ width: 1600, height: 900 });
const first = await harness.createTerminal({
name: "First search",
command: "bash",
args: ["-c", "printf 'first needle\\n'; cat"],
});
const second = await harness.createTerminal({
name: "Second search",
command: "bash",
args: ["-c", "printf 'second needle\\nsecond needle\\n'; cat"],
});
await harness.openTerminal(page, { terminalId: first.id });
await openFind(page, "needle");
await expect(status(page)).toHaveText("1 of 1");
await page.getByTestId(`workspace-tab-terminal_${second.id}`).first().click();
await openFind(page, "needle");
await expect(status(page).filter({ visible: true })).toHaveText("2 of 2");
await query(page).filter({ visible: true }).press("Escape");
await page.getByTestId(`workspace-tab-terminal_${first.id}`).first().click();
await input(page).press("ControlOrMeta+f");
await expectSelectedQuery(page, "needle");
await expect(status(page).filter({ visible: true })).toHaveText("1 of 1");
await query(page).filter({ visible: true }).press("Escape");
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await page.getByTestId(`workspace-tab-terminal_${second.id}`).first().click();
await runWorkspaceActionFromCommandCenter(page, "Move tab right");
const left = page
.getByTestId("split-group-child")
.filter({ has: page.getByTestId(`workspace-tab-terminal_${first.id}`) });
const right = page
.getByTestId("split-group-child")
.filter({ has: page.getByTestId(`workspace-tab-terminal_${second.id}`) });
await expect(left.locator(".xterm-helper-textarea")).toHaveCount(1);
await expect(right.locator(".xterm-helper-textarea")).toHaveCount(1);
await right.getByTestId("terminal-surface").click();
await right.locator(".xterm-helper-textarea").press("ControlOrMeta+f");
await expect(right.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused();
await right.getByRole("textbox", { name: "Find in pane", exact: true }).fill("needle");
await expect(right.getByRole("status", { name: "Find matches" })).toHaveText(/of 2/);
await expect(left.getByRole("textbox", { name: "Find in pane", exact: true })).toHaveCount(0);
await right.getByRole("textbox", { name: "Find in pane", exact: true }).press("Escape");
await left.getByTestId("terminal-surface").click();
await left.locator(".xterm-helper-textarea").press("ControlOrMeta+f");
await expect(left.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused();
await left.getByRole("textbox", { name: "Find in pane", exact: true }).fill("needle");
await expect(left.getByRole("status", { name: "Find matches" })).toHaveText("1 of 1");
await page.screenshot({ path: testInfo.outputPath("split-find.png") });
});
test("keeps the shared Find controls usable in a compact dark browser", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 720 });
await page.emulateMedia({ colorScheme: "dark" });
const terminal = await harness.createTerminal({
name: "Compact search",
command: "bash",
args: ["-c", "printf 'needle\\nneedle\\n'; cat"],
});
await page.goto(buildTerminalWorkspaceUrl(harness.workspaceId, terminal.id));
await expect(page.getByTestId("terminal-surface")).toBeVisible();
await page.getByTestId("terminal-surface").click();
await openFind(page, "needle");
await expect(status(page)).toHaveText("2 of 2");
await page.getByRole("button", { name: "Previous match" }).click();
await expect(status(page)).toHaveText("1 of 2");
await page.getByRole("button", { name: "Next match" }).click();
await expect(status(page)).toHaveText("2 of 2");
const box = await page.getByLabel("Find", { exact: true }).boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(390);
await page.screenshot({ path: testInfo.outputPath("compact-find.png") });
await page.getByRole("button", { name: "Close Find" }).click();
await expect(input(page)).toBeFocused();
});
// A phone browser installs xterm's document-level touch gesture listener too.
// Keep this journey touch-enabled so it catches duplicate scroll handling.
test.describe("terminal history with a finger", () => {
test.use({ isMobile: true, hasTouch: true, viewport: { width: 390, height: 844 } });
test("scrolls history once per finger drag", async ({ page }) => {
await openPhoneTerminalHistory(page);
await expectSingleFingerScroll(page, 24);
await expectSingleFingerScroll(page, 1);
await expectMouseWheelScrollsHistory(page);
});
for (const mode of ["alternate", "mouse"] as const) {
test(`preserves finger navigation in ${mode} input mode`, async ({ page }) => {
await openPhoneInputCapture(page, mode);
await expectFingerNavigationInput(page, mode);
});
}
});
async function expectFingerNavigationInput(page: Page, mode: "alternate" | "mouse") {
await dragTerminalWithFinger(page, 24, async () => {
const prefix = mode === "alternate" ? "TOUCH_BYTES:1b4f41" : "TOUCH_BYTES:1b5b3c3634";
await expect.poll(() => getTerminalBufferText(page)).toContain(prefix);
});
}
async function openPhoneTerminalHistory(page: Page) {
await harness.createTerminal({
name: "Finger scroll history",
command: "bash",
args: [
"--noprofile",
"--norc",
"-c",
"for i in $(seq 1 150); do echo HISTORY_$i; done; exec sleep 300",
],
});
await openPhoneTerminal(page);
await expect.poll(() => getTerminalBufferText(page)).toContain("HISTORY_150");
}
async function openPhoneInputCapture(page: Page, mode: "alternate" | "mouse") {
const escapes = mode === "alternate" ? "\x1b[?1049h\x1b[?1h" : "\x1b[?1000h\x1b[?1006h";
await harness.createTerminal({
name: "Touch application input",
command: process.execPath,
args: [
"-e",
`
process.stdin.setRawMode(true);
process.stdin.once("data", () => {
process.stdin.on("data", chunk => {
process.stdout.write("\\r\\nTOUCH_BYTES:" + chunk.toString("hex") + ":END\\r\\n");
});
process.stdout.write(${JSON.stringify(escapes)} + "TOUCH_INPUT_READY\\r\\n");
});
process.stdout.write("TOUCH_START_READY\\r\\n");
`,
],
});
await openPhoneTerminal(page);
await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_START_READY");
await input(page).press("x");
await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_INPUT_READY");
await expect
.poll(() => page.evaluate(() => window.__paseoTerminal!.buffer.active.type))
.toBe(mode === "alternate" ? "alternate" : "normal");
await expect
.poll(() => page.evaluate(() => window.__paseoTerminal!.modes.mouseTrackingMode))
.toBe(mode === "mouse" ? "vt200" : "none");
}
async function openPhoneTerminal(page: Page) {
await page.goto("/");
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await page.getByTestId(`sidebar-workspace-row-${getServerId()}:${harness.workspaceId}`).click();
await expect(page.getByRole("textbox", { name: "Terminal input" })).toBeVisible();
await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toBeVisible();
}
async function expectMouseWheelScrollsHistory(page: Page) {
const beforeWheel = (await viewport(page))!;
await page.mouse.move(200, 300);
await page.mouse.wheel(0, -120);
await expect.poll(() => viewport(page)).toBeLessThan(beforeWheel);
}
async function expectSingleFingerScroll(page: Page, moves: number) {
await dragTerminalWithFinger(page, moves, async ({ lineHeight, before }) => {
await expect.poll(async () => before - (await viewport(page))!).toBeGreaterThan(0);
const moved = before - (await viewport(page))!;
await test.info().attach("finger-scroll", {
body: JSON.stringify({ moves, distancePx: 120, lineHeight, rowsMoved: moved }),
contentType: "application/json",
});
expect(moved, "one gesture must not scroll twice its physical distance").toBeLessThanOrEqual(
Math.ceil(120 / lineHeight),
);
});
}
async function dragTerminalWithFinger(
page: Page,
moves: number,
inspect: (state: { lineHeight: number; before: number }) => Promise<void>,
) {
await test.step(`Drag history with ${moves} touch moves`, async () => {
const screen = page
.getByTestId("terminal-surface")
.filter({ visible: true })
.locator(".xterm-screen");
const box = await screen.boundingBox();
if (!box) throw new Error("Terminal screen is not visible");
const rows = await page.evaluate(() => window.__paseoTerminal!.rows);
const lineHeight = box.height / rows;
const before = (await viewport(page))!;
const session = await page.context().newCDPSession(page);
try {
const start = { x: box.x + box.width / 2, y: box.y + box.height / 3 };
await session.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ ...start, id: 1 }],
});
for (let step = 1; step <= moves; step++) {
await session.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: start.x, y: start.y + (120 * step) / moves, id: 1 }],
});
}
// Check before lifting the finger to exclude inertial scrolling.
await inspect({ lineHeight, before });
} finally {
await session.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await session.detach();
}
});
}