Files
paseo/packages/app/e2e/browser/terminal-find.spec.ts

490 lines
21 KiB
TypeScript

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) => {
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();
});