test: add input-parser parity tests from Ink (+45)
Port all 57 tests from Ink's input-parser test suite (43 named + 14 backspace/delete splitting via test.each). Replaces the previous 12 skeleton tests with full Ink parity covering: CSI parameters, SS3 sequences, meta+CSI/SS3 double escape, escaped printable/supplementary codepoints, legacy ESC[[ sequences, incomplete holding states, flush pending, empty chunks, text + incomplete escape, 14 backspace/delete splitting cases, and 10 bracketed paste tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,76 +1,385 @@
|
||||
import { describe, test, expect } from "vite-plus/test";
|
||||
import type { InputEvent } from "./input-parser.ts";
|
||||
import { createInputParser } from "./input-parser.ts";
|
||||
|
||||
const parseChunks = (chunks: string[]): InputEvent[] => {
|
||||
const parser = createInputParser();
|
||||
const events: InputEvent[] = [];
|
||||
for (const chunk of chunks) {
|
||||
events.push(...parser.push(chunk));
|
||||
}
|
||||
return events;
|
||||
};
|
||||
|
||||
describe("input-parser", () => {
|
||||
test("splits plain text into single event", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("hello")).toEqual(["hello"]);
|
||||
// ── Plain text ──────────────────────────────────────────────────────
|
||||
|
||||
test("passes through plain text chunks", () => {
|
||||
expect(parseChunks(["hello", " ", "world"])).toEqual(["hello", " ", "world"]);
|
||||
});
|
||||
|
||||
test("parses CSI sequence (arrow up)", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1b[A")).toEqual(["\x1b[A"]);
|
||||
test("keeps plain text and control sequences separate", () => {
|
||||
expect(parseChunks(["a\x1b[Ab"])).toEqual(["a", "\x1b[A", "b"]);
|
||||
});
|
||||
|
||||
test("handles incomplete CSI as pending", () => {
|
||||
// ── CSI sequences ──────────────────────────────────────────────────
|
||||
|
||||
test("parses multiple standard CSI keys in one chunk", () => {
|
||||
expect(parseChunks(["\x1b[A\x1b[B\x1b[C\x1b[D"])).toEqual([
|
||||
"\x1b[A",
|
||||
"\x1b[B",
|
||||
"\x1b[C",
|
||||
"\x1b[D",
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses CSI sequences with parameters", () => {
|
||||
expect(parseChunks(["\x1b[1;5A\x1b[5~\x1b[6~"])).toEqual(["\x1b[1;5A", "\x1b[5~", "\x1b[6~"]);
|
||||
});
|
||||
|
||||
test("parses kitty protocol sequence as one key event", () => {
|
||||
expect(parseChunks(["\x1b[97;5u"])).toEqual(["\x1b[97;5u"]);
|
||||
});
|
||||
|
||||
// ── SS3 sequences ─────────────────────────────────────────────────
|
||||
|
||||
test("parses SS3 sequences as one key event", () => {
|
||||
expect(parseChunks(["\x1bOA\x1bOB\x1bOC\x1bOD"])).toEqual([
|
||||
"\x1bOA",
|
||||
"\x1bOB",
|
||||
"\x1bOC",
|
||||
"\x1bOD",
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not consume a following escape as SS3 final byte", () => {
|
||||
expect(parseChunks(["\x1bO\x1b[A"])).toEqual(["\x1bO", "\x1b[A"]);
|
||||
});
|
||||
|
||||
// ── Double-escape (meta) sequences ────────────────────────────────
|
||||
|
||||
test("parses meta+CSI sequence with double escape", () => {
|
||||
expect(parseChunks(["\x1b\x1b[A"])).toEqual(["\x1b\x1b[A"]);
|
||||
});
|
||||
|
||||
test("parses meta+SS3 sequence with double escape", () => {
|
||||
expect(parseChunks(["\x1b\x1bOA"])).toEqual(["\x1b\x1bOA"]);
|
||||
});
|
||||
|
||||
test("emits double escape as single event for non-control character", () => {
|
||||
expect(parseChunks(["\x1b\x1bx"])).toEqual(["\x1b\x1b", "x"]);
|
||||
});
|
||||
|
||||
// ── Escaped printable / supplementary code points ─────────────────
|
||||
|
||||
test("parses escaped printable code points", () => {
|
||||
expect(parseChunks(["\x1bx\x1b1"])).toEqual(["\x1bx", "\x1b1"]);
|
||||
});
|
||||
|
||||
test("parses escaped supplementary code points", () => {
|
||||
expect(parseChunks(["\x1b\u{1F600}"])).toEqual(["\x1b\u{1F600}"]);
|
||||
});
|
||||
|
||||
// ── Legacy ESC[[... sequences ─────────────────────────────────────
|
||||
|
||||
test("preserves legacy ESC[[... sequences in a mixed chunk", () => {
|
||||
expect(parseChunks(["\x1b[[A\x1b[[5~"])).toEqual(["\x1b[[A", "\x1b[[5~"]);
|
||||
});
|
||||
|
||||
test("preserves legacy ESC[[... sequences across chunks", () => {
|
||||
expect(parseChunks(["\x1b[[", "A\x1b[[5~"])).toEqual(["\x1b[[A", "\x1b[[5~"]);
|
||||
});
|
||||
|
||||
test("parses legacy and standard CSI sequences mixed together", () => {
|
||||
expect(parseChunks(["\x1b[[A\x1b[B\x1b[[6~\x1b[1;5D"])).toEqual([
|
||||
"\x1b[[A",
|
||||
"\x1b[B",
|
||||
"\x1b[[6~",
|
||||
"\x1b[1;5D",
|
||||
]);
|
||||
});
|
||||
|
||||
// ── Incomplete / holding states ───────────────────────────────────
|
||||
|
||||
test("holds incomplete CSI sequence until final byte arrives", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1b[")).toEqual([]);
|
||||
expect(parser.hasPendingEscape()).toBe(true);
|
||||
expect(parser.push("1;5")).toEqual([]);
|
||||
expect(parser.push("A")).toEqual(["\x1b[1;5A"]);
|
||||
});
|
||||
|
||||
test("holds incomplete legacy ESC[[... sequence until final byte arrives", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1b[[")).toEqual([]);
|
||||
expect(parser.push("5")).toEqual([]);
|
||||
expect(parser.push("~")).toEqual(["\x1b[[5~"]);
|
||||
});
|
||||
|
||||
test("holds incomplete SS3 sequence until final byte arrives", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1bO")).toEqual([]);
|
||||
expect(parser.push("A")).toEqual(["\x1bOA"]);
|
||||
});
|
||||
|
||||
test("holds incomplete double-escape CSI sequence until final byte arrives", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1b\x1b[")).toEqual([]);
|
||||
expect(parser.push("A")).toEqual(["\x1b\x1b[A"]);
|
||||
});
|
||||
|
||||
test("holds incomplete double-escape SS3 sequence until final byte arrives", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1b\x1bO")).toEqual([]);
|
||||
expect(parser.hasPendingEscape()).toBe(true);
|
||||
expect(parser.push("A")).toEqual(["\x1b\x1bOA"]);
|
||||
});
|
||||
|
||||
test("assembles CSI sequence from single-byte chunks", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1b")).toEqual([]);
|
||||
expect(parser.push("[")).toEqual([]);
|
||||
expect(parser.push("1")).toEqual([]);
|
||||
expect(parser.push(";")).toEqual([]);
|
||||
expect(parser.push("5")).toEqual([]);
|
||||
expect(parser.push("A")).toEqual(["\x1b[1;5A"]);
|
||||
});
|
||||
|
||||
// ── Flush pending ─────────────────────────────────────────────────
|
||||
|
||||
test("keeps pending plain escape and can flush it", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1b")).toEqual([]);
|
||||
expect(parser.hasPendingEscape()).toBe(true);
|
||||
expect(parser.flushPendingEscape()).toBe("\x1b");
|
||||
expect(parser.hasPendingEscape()).toBe(false);
|
||||
});
|
||||
|
||||
test("flushes pending CSI prefix as literal input", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1b[")).toEqual([]);
|
||||
expect(parser.hasPendingEscape()).toBe(true);
|
||||
expect(parser.flushPendingEscape()).toBe("\x1b[");
|
||||
expect(parser.hasPendingEscape()).toBe(false);
|
||||
expect(parser.push("A")).toEqual(["A"]);
|
||||
});
|
||||
|
||||
test("flushes pending SS3 prefix as literal input", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1bO")).toEqual([]);
|
||||
expect(parser.hasPendingEscape()).toBe(true);
|
||||
expect(parser.flushPendingEscape()).toBe("\x1bO");
|
||||
expect(parser.push("x")).toEqual(["x"]);
|
||||
});
|
||||
|
||||
test("flushes pending legacy CSI prefix as literal input", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1b[[")).toEqual([]);
|
||||
expect(parser.hasPendingEscape()).toBe(true);
|
||||
expect(parser.flushPendingEscape()).toBe("\x1b[[");
|
||||
expect(parser.push("x")).toEqual(["x"]);
|
||||
});
|
||||
|
||||
// ── Reset ─────────────────────────────────────────────────────────
|
||||
|
||||
test("reset clears pending input state", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1b[")).toEqual([]);
|
||||
parser.reset();
|
||||
expect(parser.push("A")).toEqual(["A"]);
|
||||
});
|
||||
|
||||
// ── Invalid CSI continuation ──────────────────────────────────────
|
||||
|
||||
test("treats invalid CSI continuation as escaped code point plus plain text", () => {
|
||||
expect(parseChunks(["\x1b[\n"])).toEqual(["\x1b[", "\n"]);
|
||||
});
|
||||
|
||||
// ── Mixed text and key events ─────────────────────────────────────
|
||||
|
||||
test("parses mixed text and many key events in one read", () => {
|
||||
expect(parseChunks(["start\x1b[A mid \x1bOH end\x1b[[5~"])).toEqual([
|
||||
"start",
|
||||
"\x1b[A",
|
||||
" mid ",
|
||||
"\x1bOH",
|
||||
" end",
|
||||
"\x1b[[5~",
|
||||
]);
|
||||
});
|
||||
|
||||
// ── Empty chunks ──────────────────────────────────────────────────
|
||||
|
||||
test("empty chunk produces no events", () => {
|
||||
expect(parseChunks([""])).toEqual([]);
|
||||
});
|
||||
|
||||
test("empty chunk does not disturb pending state", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1b[")).toEqual([]);
|
||||
expect(parser.push("")).toEqual([]);
|
||||
expect(parser.hasPendingEscape()).toBe(true);
|
||||
expect(parser.push("A")).toEqual(["\x1b[A"]);
|
||||
});
|
||||
|
||||
test("parses bracketed paste", () => {
|
||||
const parser = createInputParser();
|
||||
const events = parser.push("\x1b[200~pasted text\x1b[201~");
|
||||
expect(events).toEqual([{ paste: "pasted text" }]);
|
||||
});
|
||||
// ── Text + incomplete escape ──────────────────────────────────────
|
||||
|
||||
test("splits backspace bytes", () => {
|
||||
test("plain text followed by incomplete escape holds escape as pending", () => {
|
||||
const parser = createInputParser();
|
||||
const events = parser.push("ab\x7F\x7Fc");
|
||||
expect(events).toEqual(["ab", "\x7F", "\x7F", "c"]);
|
||||
});
|
||||
|
||||
test("hasPendingEscape is false during paste assembly", () => {
|
||||
const parser = createInputParser();
|
||||
parser.push("\x1b[200");
|
||||
expect(parser.hasPendingEscape()).toBe(false);
|
||||
});
|
||||
|
||||
test("flushPendingEscape returns pending sequence", () => {
|
||||
const parser = createInputParser();
|
||||
parser.push("\x1b");
|
||||
expect(parser.push("hello\x1b")).toEqual(["hello"]);
|
||||
expect(parser.hasPendingEscape()).toBe(true);
|
||||
expect(parser.flushPendingEscape()).toBe("\x1b");
|
||||
});
|
||||
|
||||
test("parses SS3 sequence", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1bOP")).toEqual(["\x1bOP"]);
|
||||
// ── Backspace / delete splitting ──────────────────────────────────
|
||||
|
||||
const deleteAndBackspaceCases = [
|
||||
{
|
||||
title: "splits batched 0x7F backspace characters into individual events",
|
||||
chunks: ["\x7F\x7F\x7F"],
|
||||
events: ["\x7F", "\x7F", "\x7F"],
|
||||
},
|
||||
{
|
||||
title: "splits batched backspace characters into individual events",
|
||||
chunks: ["\x08\x08\x08"],
|
||||
events: ["\x08", "\x08", "\x08"],
|
||||
},
|
||||
{
|
||||
title: "splits mixed 0x7F and 0x08 backspace characters",
|
||||
chunks: ["\x7F\x08\x7F"],
|
||||
events: ["\x7F", "\x08", "\x7F"],
|
||||
},
|
||||
{
|
||||
title: "splits mixed printable text and 0x7F backspace characters",
|
||||
chunks: ["abc\x7F\x7F\x7F"],
|
||||
events: ["abc", "\x7F", "\x7F", "\x7F"],
|
||||
},
|
||||
{
|
||||
title: "single 0x7F backspace character is preserved as individual event",
|
||||
chunks: ["\x7F"],
|
||||
events: ["\x7F"],
|
||||
},
|
||||
{
|
||||
title: "single backspace character is preserved as individual event",
|
||||
chunks: ["\x08"],
|
||||
events: ["\x08"],
|
||||
},
|
||||
{
|
||||
title: "splits trailing 0x7F backspace from text",
|
||||
chunks: ["abc\x7F"],
|
||||
events: ["abc", "\x7F"],
|
||||
},
|
||||
{
|
||||
title: "splits 0x7F backspace characters before escape sequences",
|
||||
chunks: ["\x7F\x7F\x1b[A"],
|
||||
events: ["\x7F", "\x7F", "\x1b[A"],
|
||||
},
|
||||
{
|
||||
title: "splits 0x7F backspace characters after escape sequences",
|
||||
chunks: ["\x1b[A\x7F\x7F"],
|
||||
events: ["\x1b[A", "\x7F", "\x7F"],
|
||||
},
|
||||
{
|
||||
title: "splits 0x7F backspace characters between escape sequences",
|
||||
chunks: ["\x1b[A\x7F\x1b[B"],
|
||||
events: ["\x1b[A", "\x7F", "\x1b[B"],
|
||||
},
|
||||
{
|
||||
title: "splits backspace characters around escape sequences",
|
||||
chunks: ["\x08\x1b[A\x08"],
|
||||
events: ["\x08", "\x1b[A", "\x08"],
|
||||
},
|
||||
{
|
||||
title: "splits interleaved text and 0x7F backspace characters",
|
||||
chunks: ["ab\x7Fcd"],
|
||||
events: ["ab", "\x7F", "cd"],
|
||||
},
|
||||
{
|
||||
title: "does not split pasted carriage return from text",
|
||||
chunks: ["\rtest"],
|
||||
events: ["\rtest"],
|
||||
},
|
||||
{
|
||||
title: "does not split pasted tab from text",
|
||||
chunks: ["\ttest"],
|
||||
events: ["\ttest"],
|
||||
},
|
||||
];
|
||||
|
||||
test.each(deleteAndBackspaceCases)("$title", ({ chunks, events }) => {
|
||||
expect(parseChunks(chunks)).toEqual(events);
|
||||
});
|
||||
|
||||
test("parses double-ESC prefix (meta+arrow)", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1b\x1b[A")).toEqual(["\x1b\x1b[A"]);
|
||||
// ── Bracketed paste ───────────────────────────────────────────────
|
||||
|
||||
test("emits paste event for bracketed paste sequence", () => {
|
||||
expect(parseChunks(["\x1b[200~hello world\x1b[201~"])).toEqual([{ paste: "hello world" }]);
|
||||
});
|
||||
|
||||
test("reset clears pending state", () => {
|
||||
test("emits paste event for multiline bracketed paste", () => {
|
||||
expect(parseChunks(["\x1b[200~line1\nline2\x1b[201~"])).toEqual([{ paste: "line1\nline2" }]);
|
||||
});
|
||||
|
||||
test("paste content with escape sequences is delivered verbatim", () => {
|
||||
expect(parseChunks(["\x1b[200~hello\x1b[Aworld\x1b[201~"])).toEqual([
|
||||
{ paste: "hello\x1b[Aworld" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("emits normal events before and after bracketed paste", () => {
|
||||
expect(parseChunks(["before\x1b[200~pasted\x1b[201~after"])).toEqual([
|
||||
"before",
|
||||
{ paste: "pasted" },
|
||||
"after",
|
||||
]);
|
||||
});
|
||||
|
||||
test("emits multiple paste events in one chunk", () => {
|
||||
expect(parseChunks(["\x1b[200~first\x1b[201~mid\x1b[200~second\x1b[201~"])).toEqual([
|
||||
{ paste: "first" },
|
||||
"mid",
|
||||
{ paste: "second" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("holds incomplete bracketed paste as pending", () => {
|
||||
const parser = createInputParser();
|
||||
parser.push("\x1b[");
|
||||
expect(parser.push("\x1b[200~hello")).toEqual([]);
|
||||
expect(parser.hasPendingEscape()).toBe(false);
|
||||
expect(parser.push(" world\x1b[201~")).toEqual([{ paste: "hello world" }]);
|
||||
});
|
||||
|
||||
test("assembles bracketed paste from chunk-by-chunk delivery", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1b[200~")).toEqual([]);
|
||||
expect(parser.push("hello")).toEqual([]);
|
||||
expect(parser.push("\x1b[201~")).toEqual([{ paste: "hello" }]);
|
||||
});
|
||||
|
||||
test("emits empty paste for adjacent paste markers", () => {
|
||||
expect(parseChunks(["\x1b[200~\x1b[201~"])).toEqual([{ paste: "" }]);
|
||||
});
|
||||
|
||||
test("handles pasteStart split before the tilde (\\x1b[200 without ~)", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1b[200")).toEqual([]);
|
||||
expect(parser.hasPendingEscape()).toBe(false);
|
||||
expect(parser.push("~hello\x1b[201~")).toEqual([{ paste: "hello" }]);
|
||||
});
|
||||
|
||||
test("hasPendingEscape returns true for length-3 pasteStart prefix (\\x1b[2)", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1b[2")).toEqual([]);
|
||||
expect(parser.hasPendingEscape()).toBe(true);
|
||||
parser.reset();
|
||||
expect(parser.hasPendingEscape()).toBe(false);
|
||||
});
|
||||
|
||||
test("incomplete paste start does not trigger flush", () => {
|
||||
test("hasPendingEscape returns true for length-4 pasteStart prefix (\\x1b[20)", () => {
|
||||
const parser = createInputParser();
|
||||
parser.push("\x1b[200");
|
||||
expect(parser.hasPendingEscape()).toBe(false);
|
||||
expect(parser.push("\x1b[20")).toEqual([]);
|
||||
expect(parser.hasPendingEscape()).toBe(true);
|
||||
});
|
||||
|
||||
test("text mixed with escape sequences", () => {
|
||||
const parser = createInputParser();
|
||||
const events = parser.push("a\x1b[Ab\x1b[B");
|
||||
expect(events).toEqual(["a", "\x1b[A", "b", "\x1b[B"]);
|
||||
test("paste event delivers backspace chars verbatim without splitting", () => {
|
||||
expect(parseChunks(["\x1b[200~\x7F\x08\x7F\x1b[201~"])).toEqual([{ paste: "\x7F\x08\x7F" }]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user