Files
paseo/packages/app/e2e/browser/terminal-protocol-query.spec.ts
T

439 lines
17 KiB
TypeScript

import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { Locator, Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { holdFileUploadResponse } from "../support/helpers/file-upload-gate";
import { installLocalDesktopHost } from "../support/helpers/local-desktop-host";
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
import { getTerminalBufferText, waitForTerminalContent } from "../support/helpers/terminal-perf";
const OSC11_CAPTURE_SCRIPT = `
let captured = Buffer.alloc(0);
function finish() {
process.stdout.write("PASEO_OSC11_CAPTURE:" + JSON.stringify(captured.toString("latin1")) + "\\n");
process.exit(0);
}
process.stdout.write("\\x1b]11;?\\x07");
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdin.on("data", (chunk) => {
captured = Buffer.concat([captured, chunk]);
if (captured.includes(Buffer.from("rgb:"))) {
finish();
}
});
setTimeout(finish, 700);
`;
// A TUI stand-in: turns on bracketed paste, reads every quoted path in each
// bracketed paste, and echoes typed lines, so the PTY output proves what reached
// the terminal and where the file was read from. Each paste body is also echoed
// as PASTED:<json>, whitespace escaped so it survives the capture's line joins.
const FILE_RECEIVER_SCRIPT = String.raw`
const fs = require("node:fs");
const crypto = require("node:crypto");
let buffer = "";
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdout.write("\x1b[?2004hFILE_RECEIVER_READY\r\n");
process.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer);
if (!paste) break;
const pasted = JSON.stringify(paste[1]).replace(
/\s/g,
(space) => "\\u" + space.charCodeAt(0).toString(16).padStart(4, "0"),
);
process.stdout.write("PASTED:" + pasted + "\r\n");
for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) {
const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex");
const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local";
process.stdout.write("FILE_READ:" + digest + ":" + location + "\r\n");
}
buffer = buffer.slice(0, paste.index) + buffer.slice(paste.index + paste[0].length);
}
if (buffer.includes("\x03")) {
process.stdout.write("INTERRUPTED\r\n");
buffer = buffer.replaceAll("\x03", "");
}
const enter = buffer.indexOf("\r");
if (enter >= 0 && !buffer.includes("'")) {
process.stdout.write("TYPED:" + buffer.slice(0, enter) + "\r\n");
buffer = buffer.slice(enter + 1);
}
});
`;
function pngBytes(seed: number): Buffer {
return Buffer.concat([Buffer.from("iVBORw0KGgo=", "base64"), Buffer.from([seed])]);
}
function fileRead(bytes: Buffer, location: "upload" | "local"): string {
const digest = createHash("sha256").update(bytes).digest("hex");
return `FILE_READ:${digest}:${location}`;
}
// Inactive terminal tabs retain their surfaces, and another page opening a terminal in
// the same workspace adds a tab here too. Only the selected tab's surface is visible.
function terminalSurface(page: Page) {
return page.getByTestId("terminal-surface").filter({ visible: true });
}
function visibleTestId(page: Page, testId: string) {
return page.getByTestId(testId).filter({ visible: true }).first();
}
/** The test ID of the toolbar control rendered immediately before `control`. */
async function controlBefore(control: Locator): Promise<string | null> {
return await control.evaluate((element) => {
let group = element.parentElement;
while (group && group.querySelectorAll('[role="button"]').length < 2) {
group = group.parentElement;
}
const buttons = Array.from(group?.querySelectorAll('[role="button"]') ?? []);
return buttons[buttons.indexOf(element) - 1]?.getAttribute("data-testid") ?? null;
});
}
function terminalInput(page: Page) {
return terminalSurface(page).locator(".xterm-helper-textarea");
}
async function pasteImage(page: Page, bytes: Buffer): Promise<void> {
await terminalInput(page).evaluate(
(element, data) => {
(element as HTMLTextAreaElement).focus();
const clipboardData = new DataTransfer();
clipboardData.items.add(new File([new Uint8Array(data)], "image.png", { type: "image/png" }));
element.dispatchEvent(
new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }),
);
},
[...bytes],
);
}
interface DroppedFile {
name: string;
type: string;
bytes: Buffer;
}
async function dragTransfer(page: Page, files: DroppedFile[]) {
return await page.evaluateHandle(
(entries) => {
const dataTransfer = new DataTransfer();
for (const entry of entries) {
dataTransfer.items.add(
new File([new Uint8Array(entry.bytes)], entry.name, { type: entry.type }),
);
}
return dataTransfer;
},
files.map((file) => ({ ...file, bytes: [...file.bytes] })),
);
}
async function dragFilesOver(page: Page, files: DroppedFile[]): Promise<void> {
const dataTransfer = await dragTransfer(page, files);
await terminalInput(page).dispatchEvent("dragenter", { dataTransfer });
await terminalInput(page).dispatchEvent("dragover", { dataTransfer });
await dataTransfer.dispose();
}
async function dropFiles(page: Page, files: DroppedFile[]): Promise<void> {
const dataTransfer = await dragTransfer(page, files);
await terminalInput(page).dispatchEvent("drop", { dataTransfer });
await dataTransfer.dispose();
}
/** The shared "Drop files here" overlay of the zone around the terminal. */
function terminalDropOverlay(page: Page) {
return terminalSurface(page)
.locator("xpath=ancestor::*[.//*[@data-testid='file-drop-backdrop']][1]")
.getByTestId("file-drop-backdrop");
}
/**
* Makes the app treat the e2e daemon as the desktop app's own daemon, so dropped
* files resolve to paths inside `directory` instead of uploading.
*/
async function typeLine(page: Page, text: string): Promise<void> {
await terminalInput(page).pressSequentially(text);
await terminalInput(page).press("Enter");
}
test.describe("Terminal protocol queries", () => {
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-protocol-query-" });
await writeFile(path.join(harness.tempRepo.path, "osc11-capture.cjs"), OSC11_CAPTURE_SCRIPT);
await writeFile(path.join(harness.tempRepo.path, "file-receiver.cjs"), FILE_RECEIVER_SCRIPT);
});
test.afterAll(async () => {
await harness?.cleanup();
});
test("does not send browser OSC 11 color-query replies back to the PTY", async ({ page }) => {
const terminalInstance = await harness.createTerminal({ name: "osc11-query" });
try {
await harness.openTerminal(page, { terminalId: terminalInstance.id });
await harness.setupPrompt(page);
const terminal = harness.terminalSurface(page);
await terminal.pressSequentially("node osc11-capture.cjs\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes("PASEO_OSC11_CAPTURE:"), 10_000);
await page.waitForTimeout(500);
const text = await getTerminalBufferText(page);
expect(text).toContain("rgb:0b0b/0b0b/0b0b");
expect(text).not.toContain("rgb:ffff/ffff/ffff");
} finally {
await harness.killTerminal(terminalInstance.id);
}
});
async function openFileReceiver(page: Page, name: string) {
const terminal = await harness.createTerminal({
name,
command: process.execPath,
args: ["file-receiver.cjs"],
});
await harness.openTerminal(page, { terminalId: terminal.id });
await expectTerminalOutput(terminal.id, "FILE_RECEIVER_READY");
return terminal;
}
// Reads the PTY itself, so output from one pane cannot satisfy another's check.
async function terminalOutput(terminalId: string): Promise<string> {
const capture = await harness.client.captureTerminal(terminalId);
return capture.lines.join("").replace(/\s/g, "");
}
async function expectTerminalOutput(terminalId: string, text: string): Promise<void> {
await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text);
}
// Every bracketed paste body the receiver consumed, in order and joined: what a
// shell would read as one command line.
async function pastedText(terminalId: string): Promise<string> {
const output = await terminalOutput(terminalId);
return [...output.matchAll(/PASTED:("(?:[^"\\]|\\.)*")/g)]
.map(([, json]) => JSON.parse(json) as string)
.join("");
}
// Exactly one ASCII space after each quoted path and nothing else between them,
// so the paths stay separate shell words.
async function expectSeparatePastedPaths(terminalId: string): Promise<void> {
await expect.poll(() => pastedText(terminalId)).toMatch(/^'[^']+' '[^']+' $/);
}
test("pastes images from two panes into their own terminals as uploaded host files", async ({
page,
}) => {
const secondPage = await page.context().newPage();
const first = await openFileReceiver(page, "image-paste-a");
const second = await openFileReceiver(secondPage, "image-paste-b");
try {
await Promise.all([pasteImage(page, pngBytes(1)), pasteImage(secondPage, pngBytes(2))]);
await expectTerminalOutput(first.id, fileRead(pngBytes(1), "upload"));
await expectTerminalOutput(second.id, fileRead(pngBytes(2), "upload"));
expect(await terminalOutput(first.id)).not.toContain(fileRead(pngBytes(2), "upload"));
expect(await terminalOutput(second.id)).not.toContain(fileRead(pngBytes(1), "upload"));
} finally {
await secondPage.close();
await harness.killTerminal(first.id);
await harness.killTerminal(second.id);
}
});
test("uploads dropped files of any type when the host is another machine", async ({ page }) => {
const terminal = await openFileReceiver(page, "file-drop-remote");
const files = [
{ name: "shot.png", type: "image/png", bytes: pngBytes(3) },
{ name: "notes.txt", type: "text/plain", bytes: Buffer.from("remote notes") },
];
try {
await dragFilesOver(page, files);
await expect(terminalDropOverlay(page)).toHaveCSS("opacity", "1");
await expect(terminalDropOverlay(page)).toHaveText("Drop files here");
await dropFiles(page, files);
await expect(terminalDropOverlay(page)).toHaveCSS("opacity", "0");
await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload"));
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload"));
await expectSeparatePastedPaths(terminal.id);
} finally {
await harness.killTerminal(terminal.id);
}
});
test("keeps the paths of two consecutive drops to another machine apart", async ({ page }) => {
const terminal = await openFileReceiver(page, "file-drop-remote-twice");
const first = { name: "first.png", type: "image/png", bytes: pngBytes(10) };
const second = { name: "second.txt", type: "text/plain", bytes: Buffer.from("second drop") };
try {
await dropFiles(page, [first]);
await expectTerminalOutput(terminal.id, fileRead(first.bytes, "upload"));
await dropFiles(page, [second]);
await expectTerminalOutput(terminal.id, fileRead(second.bytes, "upload"));
await expectSeparatePastedPaths(terminal.id);
} finally {
await harness.killTerminal(terminal.id);
}
});
test("upload button uploads picked files when the host is another machine", async ({ page }) => {
const terminal = await openFileReceiver(page, "file-pick-remote");
const files = [
{ name: "picked.png", mimeType: "image/png", buffer: pngBytes(11) },
{ name: "picked.txt", mimeType: "text/plain", buffer: Buffer.from("picked notes") },
];
try {
const upload = visibleTestId(page, "terminal-toolbar-upload");
await expect(upload).toHaveCount(1);
// The terminal slot sits right before "...": upload, then the mic.
expect(await controlBefore(visibleTestId(page, "workspace-split-pane-menu"))).toBe(
"terminal-toolbar-dictation",
);
expect(await controlBefore(visibleTestId(page, "terminal-toolbar-dictation"))).toBe(
"terminal-toolbar-upload",
);
const chooser = page.waitForEvent("filechooser");
await upload.click();
await (await chooser).setFiles(files);
await expectTerminalOutput(terminal.id, fileRead(files[0].buffer, "upload"));
await expectTerminalOutput(terminal.id, fileRead(files[1].buffer, "upload"));
await expectSeparatePastedPaths(terminal.id);
} finally {
await harness.killTerminal(terminal.id);
}
});
test("shows the upload button only while the pane's active tab is a terminal", async ({
page,
}) => {
const terminal = await openFileReceiver(page, "file-pick-slot");
try {
await expect(visibleTestId(page, "terminal-toolbar-upload")).toHaveCount(1);
await visibleTestId(page, "workspace-new-tab-button").click();
await visibleTestId(page, "workspace-new-tab-menu-diff").click();
await expect(visibleTestId(page, "working-diff-panel")).toBeVisible();
await expect(visibleTestId(page, "terminal-toolbar-upload")).toHaveCount(0);
await visibleTestId(page, `workspace-tab-terminal_${terminal.id}`).click();
await expect(visibleTestId(page, "terminal-toolbar-upload")).toHaveCount(1);
} finally {
await harness.killTerminal(terminal.id);
}
});
test("pastes dropped files' own paths when the host is this machine", async ({ page }) => {
const files = [
{ name: "local-shot.png", type: "image/png", bytes: pngBytes(9) },
{ name: "local-notes.txt", type: "text/plain", bytes: Buffer.from("local notes") },
];
for (const file of files) {
await writeFile(path.join(harness.tempRepo.path, file.name), file.bytes);
}
await installLocalDesktopHost(page, harness.tempRepo.path);
const terminal = await openFileReceiver(page, "file-drop-local");
try {
await dropFiles(page, files);
await expectTerminalOutput(terminal.id, fileRead(pngBytes(9), "local"));
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("local notes"), "local"));
await expectSeparatePastedPaths(terminal.id);
expect(await terminalOutput(terminal.id)).not.toContain(":upload");
await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0);
} finally {
await harness.killTerminal(terminal.id);
}
});
test("lands image pastes in gesture order while typing stays live", async ({ page }) => {
const upload = await holdFileUploadResponse(page);
const terminal = await openFileReceiver(page, "image-order");
try {
await pasteImage(page, pngBytes(4));
await upload.waitUntilHeld();
await pasteImage(page, pngBytes(5));
await expect(page.getByTestId("terminal-file-uploading")).toHaveText("Uploading file…");
await typeLine(page, "describe");
await expectTerminalOutput(terminal.id, "TYPED:describe");
upload.release();
await expectTerminalOutput(terminal.id, fileRead(pngBytes(5), "upload"));
const output = await terminalOutput(terminal.id);
expect(output.indexOf(fileRead(pngBytes(4), "upload"))).toBeGreaterThanOrEqual(0);
expect(output.indexOf(fileRead(pngBytes(4), "upload"))).toBeLessThan(
output.indexOf(fileRead(pngBytes(5), "upload")),
);
await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0);
await expect(page.getByTestId("app-toast-message")).toHaveText("File uploaded");
} finally {
await harness.killTerminal(terminal.id);
}
});
test("Ctrl+C abandons an image paste that is still uploading", async ({ page }) => {
const upload = await holdFileUploadResponse(page);
const terminal = await openFileReceiver(page, "image-cancel");
try {
await pasteImage(page, pngBytes(6));
await upload.waitUntilHeld();
await terminalInput(page).press("Control+c");
await expectTerminalOutput(terminal.id, "INTERRUPTED");
await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0);
upload.release();
await pasteImage(page, pngBytes(7));
await expectTerminalOutput(terminal.id, fileRead(pngBytes(7), "upload"));
expect(await terminalOutput(terminal.id)).not.toContain(fileRead(pngBytes(6), "upload"));
} finally {
await harness.killTerminal(terminal.id);
}
});
test("a disconnect during upload reports one failure and input keeps working", async ({
page,
}) => {
const upload = await holdFileUploadResponse(page);
const terminal = await openFileReceiver(page, "image-disconnect");
try {
await pasteImage(page, pngBytes(8));
await upload.waitUntilHeld();
await upload.disconnect();
await expect(page.getByTestId("app-toast-message")).toHaveText(
"Failed to paste file: Host is not connected",
);
await typeLine(page, "after");
await expectTerminalOutput(terminal.id, "TYPED:after");
expect(await terminalOutput(terminal.id)).not.toContain(fileRead(pngBytes(8), "upload"));
} finally {
await harness.killTerminal(terminal.id);
}
});
});