799 lines
23 KiB
TypeScript
799 lines
23 KiB
TypeScript
import { describe, it, expect, afterEach } from "vitest";
|
|
import {
|
|
createTerminal,
|
|
ensureNodePtySpawnHelperExecutableForCurrentPlatform,
|
|
type TerminalSession,
|
|
} from "./terminal.js";
|
|
import { chmodSync, mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
|
|
// Extract text from a single row
|
|
function getRowText(state: ReturnType<TerminalSession["getState"]>, rowIndex: number): string {
|
|
return state.grid[rowIndex]
|
|
.map((cell) => cell.char)
|
|
.join("")
|
|
.trimEnd();
|
|
}
|
|
|
|
// Extract all visible lines as array (trimmed, empty lines included)
|
|
function getLines(state: ReturnType<TerminalSession["getState"]>): string[] {
|
|
return state.grid.map((row) =>
|
|
row
|
|
.map((cell) => cell.char)
|
|
.join("")
|
|
.trimEnd(),
|
|
);
|
|
}
|
|
|
|
// Wait for terminal state to match expected lines
|
|
async function waitForLines(
|
|
session: TerminalSession,
|
|
expectedLines: string[],
|
|
timeoutMs = 5000,
|
|
): Promise<void> {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
const lines = getLines(session.getState());
|
|
let matches = true;
|
|
for (let i = 0; i < expectedLines.length; i++) {
|
|
if (lines[i] !== expectedLines[i]) {
|
|
matches = false;
|
|
break;
|
|
}
|
|
}
|
|
if (matches) {
|
|
return;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
const actual = getLines(session.getState()).slice(0, expectedLines.length);
|
|
throw new Error(
|
|
`Timeout waiting for expected lines.\nExpected:\n${JSON.stringify(expectedLines, null, 2)}\nActual:\n${JSON.stringify(actual, null, 2)}`,
|
|
);
|
|
}
|
|
|
|
async function waitForState(
|
|
session: TerminalSession,
|
|
predicate: (state: ReturnType<TerminalSession["getState"]>) => boolean,
|
|
timeoutMs = 5000,
|
|
): Promise<ReturnType<TerminalSession["getState"]>> {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
const state = session.getState();
|
|
if (predicate(state)) {
|
|
return state;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
|
|
throw new Error("Timeout waiting for terminal state predicate to match");
|
|
}
|
|
|
|
describe("Terminal", () => {
|
|
const sessions: TerminalSession[] = [];
|
|
const temporaryDirs: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
for (const session of sessions) {
|
|
session.kill();
|
|
}
|
|
sessions.length = 0;
|
|
while (temporaryDirs.length > 0) {
|
|
const dir = temporaryDirs.pop();
|
|
if (dir) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
function trackSession(session: TerminalSession): TerminalSession {
|
|
sessions.push(session);
|
|
return session;
|
|
}
|
|
|
|
describe("createTerminal", () => {
|
|
it("ensures darwin prebuild spawn-helper is executable", () => {
|
|
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-node-pty-helper-"));
|
|
temporaryDirs.push(packageRoot);
|
|
const prebuildDir = join(packageRoot, "prebuilds", `darwin-${process.arch}`);
|
|
mkdirSync(prebuildDir, { recursive: true });
|
|
const helperPath = join(prebuildDir, "spawn-helper");
|
|
writeFileSync(helperPath, "#!/bin/sh\necho helper\n");
|
|
chmodSync(helperPath, 0o644);
|
|
|
|
ensureNodePtySpawnHelperExecutableForCurrentPlatform({
|
|
packageRoot,
|
|
platform: "darwin",
|
|
force: true,
|
|
});
|
|
|
|
expect(statSync(helperPath).mode & 0o111).toBe(0o111);
|
|
});
|
|
|
|
it("creates a terminal session with an id, name, and cwd", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
}),
|
|
);
|
|
|
|
expect(session.id).toBeDefined();
|
|
expect(typeof session.id).toBe("string");
|
|
expect(session.id.length).toBeGreaterThan(0);
|
|
expect(session.name).toBe("Terminal");
|
|
expect(session.cwd).toBe("/tmp");
|
|
});
|
|
|
|
it("uses custom name when provided", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
name: "Dev Server",
|
|
}),
|
|
);
|
|
|
|
expect(session.name).toBe("Dev Server");
|
|
});
|
|
|
|
it("uses default shell if not specified", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
}),
|
|
);
|
|
|
|
expect(session.id).toBeDefined();
|
|
});
|
|
|
|
it("uses default rows and cols", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
}),
|
|
);
|
|
|
|
const state = session.getState();
|
|
expect(state.rows).toBe(24);
|
|
expect(state.cols).toBe(80);
|
|
});
|
|
|
|
it("respects custom rows and cols", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
rows: 40,
|
|
cols: 120,
|
|
}),
|
|
);
|
|
|
|
const state = session.getState();
|
|
expect(state.rows).toBe(40);
|
|
expect(state.cols).toBe(120);
|
|
});
|
|
});
|
|
|
|
describe("send input", () => {
|
|
it("executes a simple echo command", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
}),
|
|
);
|
|
|
|
// Wait for initial prompt, then send command
|
|
await waitForLines(session, ["$"]);
|
|
|
|
session.send({ type: "input", data: "echo hello\r" });
|
|
|
|
// After running "echo hello", terminal should show:
|
|
// Line 0: "$ echo hello"
|
|
// Line 1: "hello"
|
|
// Line 2: "$"
|
|
await waitForLines(session, ["$ echo hello", "hello", "$"]);
|
|
|
|
const state = session.getState();
|
|
expect(getRowText(state, 0)).toBe("$ echo hello");
|
|
expect(getRowText(state, 1)).toBe("hello");
|
|
expect(getRowText(state, 2)).toBe("$");
|
|
});
|
|
|
|
it("executes multiple commands sequentially", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
}),
|
|
);
|
|
|
|
await waitForLines(session, ["$"]);
|
|
|
|
session.send({ type: "input", data: "echo first\r" });
|
|
await waitForLines(session, ["$ echo first", "first", "$"]);
|
|
|
|
session.send({ type: "input", data: "echo second\r" });
|
|
await waitForLines(session, ["$ echo first", "first", "$ echo second", "second", "$"]);
|
|
|
|
const state = session.getState();
|
|
expect(getRowText(state, 0)).toBe("$ echo first");
|
|
expect(getRowText(state, 1)).toBe("first");
|
|
expect(getRowText(state, 2)).toBe("$ echo second");
|
|
expect(getRowText(state, 3)).toBe("second");
|
|
expect(getRowText(state, 4)).toBe("$");
|
|
});
|
|
|
|
it("captures output from pwd in specified cwd", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
}),
|
|
);
|
|
|
|
await waitForLines(session, ["$"]);
|
|
|
|
session.send({ type: "input", data: "pwd\r" });
|
|
|
|
await waitForLines(session, ["$ pwd", "/tmp", "$"]);
|
|
|
|
const state = session.getState();
|
|
expect(getRowText(state, 0)).toBe("$ pwd");
|
|
expect(getRowText(state, 1)).toBe("/tmp");
|
|
expect(getRowText(state, 2)).toBe("$");
|
|
});
|
|
});
|
|
|
|
describe("colors", () => {
|
|
it("captures ANSI 16 color codes (mode 1)", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ ", TERM: "xterm-256color" },
|
|
}),
|
|
);
|
|
|
|
await waitForLines(session, ["$"]);
|
|
|
|
// \033[31m = ANSI red (color 1)
|
|
session.send({ type: "input", data: "printf '\\033[31mRED\\033[0m'\r" });
|
|
|
|
await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]);
|
|
|
|
const state = session.getState();
|
|
const outputRow = state.grid[1];
|
|
|
|
expect(outputRow[0].char).toBe("R");
|
|
expect(outputRow[0].fg).toBe(1); // ANSI red = 1
|
|
expect(outputRow[0].fgMode).toBe(1); // Mode 1 = 16 ANSI colors
|
|
|
|
// The "$" after RED should have default color
|
|
expect(outputRow[3].char).toBe("$");
|
|
expect(outputRow[3].fg).toBe(undefined);
|
|
expect(outputRow[3].fgMode).toBe(undefined);
|
|
});
|
|
|
|
it("captures 256 color codes (mode 2)", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ ", TERM: "xterm-256color" },
|
|
}),
|
|
);
|
|
|
|
await waitForLines(session, ["$"]);
|
|
|
|
// \033[38;5;208m = 256-color orange (color 208)
|
|
session.send({ type: "input", data: "printf '\\033[38;5;208mORG\\033[0m'\r" });
|
|
|
|
await waitForLines(session, ["$ printf '\\033[38;5;208mORG\\033[0m'", "ORG$"]);
|
|
|
|
const state = session.getState();
|
|
const outputRow = state.grid[1];
|
|
|
|
// Check O cell
|
|
expect(outputRow[0].char).toBe("O");
|
|
expect(outputRow[0].fg).toBe(208); // 256-color index
|
|
expect(outputRow[0].fgMode).toBe(2); // Mode 2 = 256 colors
|
|
});
|
|
|
|
it("captures true color RGB (mode 3)", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ ", TERM: "xterm-256color" },
|
|
}),
|
|
);
|
|
|
|
await waitForLines(session, ["$"]);
|
|
|
|
// \033[38;2;255;128;64m = true color RGB(255, 128, 64)
|
|
session.send({ type: "input", data: "printf '\\033[38;2;255;128;64mRGB\\033[0m'\r" });
|
|
|
|
await waitForLines(session, ["$ printf '\\033[38;2;255;128;64mRGB\\033[0m'", "RGB$"]);
|
|
|
|
const state = session.getState();
|
|
const outputRow = state.grid[1];
|
|
|
|
// Check R cell
|
|
expect(outputRow[0].char).toBe("R");
|
|
expect(outputRow[0].fgMode).toBe(3); // Mode 3 = true color
|
|
|
|
// The color value should be packed RGB: (255 << 16) | (128 << 8) | 64
|
|
const expectedPacked = (255 << 16) | (128 << 8) | 64;
|
|
expect(outputRow[0].fg).toBe(expectedPacked);
|
|
});
|
|
|
|
it("captures background colors", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ ", TERM: "xterm-256color" },
|
|
}),
|
|
);
|
|
|
|
await waitForLines(session, ["$"]);
|
|
|
|
// \033[41m = ANSI red background
|
|
session.send({ type: "input", data: "printf '\\033[41mBG\\033[0m'\r" });
|
|
|
|
await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]);
|
|
|
|
const state = session.getState();
|
|
const outputRow = state.grid[1];
|
|
|
|
expect(outputRow[0].char).toBe("B");
|
|
expect(outputRow[0].bg).toBe(1); // ANSI red = 1
|
|
expect(outputRow[0].bgMode).toBe(1); // Mode 1 = 16 ANSI colors
|
|
});
|
|
});
|
|
|
|
describe("resize", () => {
|
|
it("updates terminal dimensions on resize", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
rows: 24,
|
|
cols: 80,
|
|
}),
|
|
);
|
|
|
|
session.send({ type: "resize", rows: 40, cols: 120 });
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
const state = session.getState();
|
|
expect(state.rows).toBe(40);
|
|
expect(state.cols).toBe(120);
|
|
});
|
|
|
|
it("grid reflects new dimensions after resize", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
rows: 24,
|
|
cols: 80,
|
|
}),
|
|
);
|
|
|
|
session.send({ type: "resize", rows: 10, cols: 40 });
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
const state = session.getState();
|
|
expect(state.grid.length).toBe(10);
|
|
expect(state.grid[0].length).toBe(40);
|
|
});
|
|
|
|
it("exposes the current size without extracting full state", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
rows: 24,
|
|
cols: 80,
|
|
}),
|
|
);
|
|
|
|
expect(session.getSize()).toEqual({ rows: 24, cols: 80 });
|
|
|
|
session.send({ type: "resize", rows: 10, cols: 40 });
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
expect(session.getSize()).toEqual({ rows: 10, cols: 40 });
|
|
});
|
|
});
|
|
|
|
describe("subscribe", () => {
|
|
it("receives a snapshot on initial subscription", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
}),
|
|
);
|
|
|
|
const messages: Array<{ type: string }> = [];
|
|
const unsubscribe = session.subscribe((msg) => {
|
|
messages.push(msg);
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
expect(messages.length).toBeGreaterThan(0);
|
|
expect(messages[0].type).toBe("snapshot");
|
|
|
|
unsubscribe();
|
|
});
|
|
|
|
it("receives output messages on updates without replay snapshots", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
}),
|
|
);
|
|
|
|
await waitForLines(session, ["$"]);
|
|
|
|
const messages: Array<{ type: string }> = [];
|
|
const unsubscribe = session.subscribe((msg) => {
|
|
messages.push(msg);
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
messages.length = 0;
|
|
|
|
session.send({ type: "input", data: "echo test\r" });
|
|
|
|
await waitForLines(session, ["$ echo test", "test", "$"]);
|
|
|
|
expect(messages.some((message) => message.type === "output")).toBe(true);
|
|
expect(messages.some((message) => message.type === "snapshot")).toBe(false);
|
|
|
|
unsubscribe();
|
|
});
|
|
|
|
it("does not emit snapshot messages for resize-only updates", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
rows: 24,
|
|
cols: 80,
|
|
}),
|
|
);
|
|
|
|
const messages: Array<{ type: string }> = [];
|
|
const unsubscribe = session.subscribe((msg) => {
|
|
messages.push(msg);
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
messages.length = 0;
|
|
|
|
session.send({ type: "resize", rows: 30, cols: 100 });
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
|
|
expect(messages.some((message) => message.type === "snapshot")).toBe(false);
|
|
expect(session.getSize()).toEqual({ rows: 30, cols: 100 });
|
|
|
|
unsubscribe();
|
|
});
|
|
|
|
it("emits output only after getState reflects the new data", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
}),
|
|
);
|
|
|
|
await waitForLines(session, ["$"]);
|
|
const outputSeenInState = new Promise<boolean>((resolve) => {
|
|
const unsubscribe = session.subscribe((message) => {
|
|
if (message.type !== "output" || !message.data.includes("state-after-output")) {
|
|
return;
|
|
}
|
|
unsubscribe();
|
|
const stateText = getLines(session.getState()).join("\n");
|
|
resolve(stateText.includes("state-after-output"));
|
|
});
|
|
});
|
|
|
|
session.send({ type: "input", data: "echo state-after-output\r" });
|
|
expect(await outputSeenInState).toBe(true);
|
|
});
|
|
|
|
it("unsubscribe stops receiving messages", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
}),
|
|
);
|
|
|
|
await waitForLines(session, ["$"]);
|
|
|
|
const messages: Array<{ type: string }> = [];
|
|
const unsubscribe = session.subscribe((msg) => {
|
|
messages.push(msg);
|
|
});
|
|
|
|
unsubscribe();
|
|
messages.length = 0;
|
|
|
|
session.send({ type: "input", data: "echo after\r" });
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
|
|
expect(messages.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("stream snapshots", () => {
|
|
it("streams raw output messages without replay metadata", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
}),
|
|
);
|
|
|
|
await waitForLines(session, ["$"]);
|
|
|
|
const outputMessages: string[] = [];
|
|
const unsubscribe = session.subscribe((message) => {
|
|
if (message.type !== "output") {
|
|
return;
|
|
}
|
|
outputMessages.push(message.data);
|
|
});
|
|
|
|
session.send({ type: "input", data: "echo raw-stream\r" });
|
|
await waitForLines(session, ["$ echo raw-stream", "raw-stream", "$"]);
|
|
|
|
expect(outputMessages.join("")).toContain("raw-stream");
|
|
|
|
unsubscribe();
|
|
});
|
|
|
|
it("sends the current snapshot to a new subscriber instead of replaying raw output", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
}),
|
|
);
|
|
|
|
await waitForLines(session, ["$"]);
|
|
|
|
session.send({ type: "input", data: "echo before-detach\r" });
|
|
await waitForLines(session, ["$ echo before-detach", "before-detach", "$"]);
|
|
|
|
session.send({ type: "input", data: "echo after-detach\r" });
|
|
await waitForLines(session, [
|
|
"$ echo before-detach",
|
|
"before-detach",
|
|
"$ echo after-detach",
|
|
"after-detach",
|
|
"$",
|
|
]);
|
|
|
|
let snapshotText = "";
|
|
const unsubscribe = session.subscribe((message) => {
|
|
if (message.type !== "snapshot") {
|
|
return;
|
|
}
|
|
snapshotText = [...message.state.scrollback, ...message.state.grid]
|
|
.map((row) =>
|
|
row
|
|
.map((cell) => cell.char)
|
|
.join("")
|
|
.trimEnd(),
|
|
)
|
|
.join("\n");
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
expect(snapshotText).toContain("before-detach");
|
|
expect(snapshotText).toContain("after-detach");
|
|
unsubscribe();
|
|
});
|
|
});
|
|
|
|
describe("getState", () => {
|
|
it("returns current terminal state with grid", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
rows: 24,
|
|
cols: 80,
|
|
}),
|
|
);
|
|
|
|
const state = session.getState();
|
|
|
|
expect(state.rows).toBe(24);
|
|
expect(state.cols).toBe(80);
|
|
expect(state.grid).toBeDefined();
|
|
expect(state.grid.length).toBe(24);
|
|
expect(state.grid[0].length).toBe(80);
|
|
expect(state.cursor).toBeDefined();
|
|
expect(typeof state.cursor.row).toBe("number");
|
|
expect(typeof state.cursor.col).toBe("number");
|
|
});
|
|
|
|
it("captures cursor presentation modes emitted by terminal apps", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
rows: 24,
|
|
cols: 80,
|
|
}),
|
|
);
|
|
|
|
await waitForLines(session, ["$"]);
|
|
session.send({ type: "input", data: "printf '\\033[2 q\\033[?25l'\r" });
|
|
|
|
const state = await waitForState(
|
|
session,
|
|
(current) =>
|
|
current.cursor.style === "block" &&
|
|
current.cursor.blink === false &&
|
|
current.cursor.hidden === true,
|
|
);
|
|
|
|
expect(state.cursor).toMatchObject({
|
|
style: "block",
|
|
blink: false,
|
|
hidden: true,
|
|
});
|
|
});
|
|
|
|
it("grid cells have char and color attributes", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
}),
|
|
);
|
|
|
|
await waitForLines(session, ["$"]);
|
|
|
|
const state = session.getState();
|
|
// First cell should be "$"
|
|
expect(state.grid[0][0].char).toBe("$");
|
|
expect(state.grid[0][0]).toHaveProperty("fg");
|
|
expect(state.grid[0][0]).toHaveProperty("bg");
|
|
});
|
|
});
|
|
|
|
describe("scrollback", () => {
|
|
it("preserves scrollback buffer", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
rows: 5,
|
|
cols: 80,
|
|
}),
|
|
);
|
|
|
|
await waitForLines(session, ["$"]);
|
|
|
|
// seq 1 20 produces 20 lines of output
|
|
// With 5 rows, we expect lines to scroll into scrollback
|
|
session.send({ type: "input", data: "seq 1 20\r" });
|
|
|
|
// Wait for command to finish - final prompt appears after "20"
|
|
// In a 5-row terminal, we'll see the last lines plus prompt
|
|
// The visible area will show something like: 17, 18, 19, 20, $
|
|
await waitForLines(session, ["17", "18", "19", "20", "$"]);
|
|
|
|
const state = session.getState();
|
|
|
|
// Scrollback should contain the earlier output
|
|
expect(state.scrollback.length).toBeGreaterThan(0);
|
|
|
|
const scrollbackText = state.scrollback
|
|
.map((row) =>
|
|
row
|
|
.map((cell) => cell.char)
|
|
.join("")
|
|
.trimEnd(),
|
|
)
|
|
.filter((line) => line.length > 0);
|
|
|
|
// The scrollback should contain the command and early numbers
|
|
expect(scrollbackText).toContain("$ seq 1 20");
|
|
expect(scrollbackText).toContain("1");
|
|
expect(scrollbackText).toContain("2");
|
|
expect(scrollbackText).toContain("3");
|
|
});
|
|
});
|
|
|
|
describe("kill", () => {
|
|
it("terminates the shell process", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
}),
|
|
);
|
|
|
|
await waitForLines(session, ["$"]);
|
|
|
|
session.kill();
|
|
|
|
// Should not throw when trying to get state after kill
|
|
const state = session.getState();
|
|
expect(state).toBeDefined();
|
|
});
|
|
|
|
it("send after kill is a no-op", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
}),
|
|
);
|
|
|
|
session.kill();
|
|
|
|
// Should not throw
|
|
session.send({ type: "input", data: "echo test\r" });
|
|
});
|
|
});
|
|
|
|
describe("mouse events", () => {
|
|
it("accepts mouse events without throwing", async () => {
|
|
const session = trackSession(
|
|
await createTerminal({
|
|
cwd: "/tmp",
|
|
shell: "/bin/sh",
|
|
env: { PS1: "$ " },
|
|
}),
|
|
);
|
|
|
|
// Should not throw
|
|
session.send({ type: "mouse", row: 0, col: 0, button: 0, action: "down" });
|
|
session.send({ type: "mouse", row: 0, col: 0, button: 0, action: "up" });
|
|
});
|
|
});
|
|
});
|