@zhushanwen/pi-ask-user 0.0.4 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +171 -0
- package/README.md +135 -9
- package/package.json +3 -2
- package/src/__tests__/component-keymap.test.ts +553 -0
- package/src/__tests__/component.test.ts +50 -12
- package/src/__tests__/fixtures.ts +56 -2
- package/src/__tests__/index.test.ts +24 -1
- package/src/__tests__/question-view.test.ts +21 -18
- package/src/__tests__/sdk-contract.test.ts +111 -0
- package/src/__tests__/validate.test.ts +14 -1
- package/src/__tests__/w2-draft-hint.test.ts +246 -0
- package/src/__tests__/w3-regression.test.ts +310 -0
- package/src/component.ts +159 -97
- package/src/index.ts +6 -4
- package/src/question-view.ts +64 -33
- package/src/submit-view.ts +17 -7
- package/src/types.ts +24 -2
- package/src/validate.ts +9 -1
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
// src/__tests__/component-keymap.test.ts
|
|
2
|
+
// Key leak regression tests (#1 parseKey whitelist interception)
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { AskUserComponent } from "../component";
|
|
6
|
+
import type { Question } from "../types";
|
|
7
|
+
import {
|
|
8
|
+
ALT_DOWN,
|
|
9
|
+
ALT_LEFT,
|
|
10
|
+
ALT_RIGHT,
|
|
11
|
+
ALT_UP,
|
|
12
|
+
APC,
|
|
13
|
+
BKSP,
|
|
14
|
+
CTRL_DOWN,
|
|
15
|
+
CTRL_LEFT,
|
|
16
|
+
CTRL_RIGHT,
|
|
17
|
+
CTRL_SHIFT_DOWN,
|
|
18
|
+
CTRL_SHIFT_UP,
|
|
19
|
+
CTRL_UP,
|
|
20
|
+
DA1,
|
|
21
|
+
DA2,
|
|
22
|
+
DCS,
|
|
23
|
+
DELETE,
|
|
24
|
+
DOWN,
|
|
25
|
+
END,
|
|
26
|
+
ENTER,
|
|
27
|
+
ESC,
|
|
28
|
+
F1,
|
|
29
|
+
HOME,
|
|
30
|
+
INSERT,
|
|
31
|
+
LEFT,
|
|
32
|
+
make,
|
|
33
|
+
multiQWithComment,
|
|
34
|
+
OSC_BEL,
|
|
35
|
+
OSC_ST,
|
|
36
|
+
PAGE_DOWN,
|
|
37
|
+
PAGE_UP,
|
|
38
|
+
RIGHT,
|
|
39
|
+
SHIFT_DOWN,
|
|
40
|
+
SHIFT_LEFT,
|
|
41
|
+
SHIFT_RIGHT,
|
|
42
|
+
SHIFT_UP,
|
|
43
|
+
singleQ,
|
|
44
|
+
SUPER_DOWN,
|
|
45
|
+
SUPER_LEFT,
|
|
46
|
+
SUPER_RIGHT,
|
|
47
|
+
SUPER_UP,
|
|
48
|
+
TAB,
|
|
49
|
+
UNKNOWN_CSI,
|
|
50
|
+
UNKNOWN_SS3,
|
|
51
|
+
UP,
|
|
52
|
+
} from "./fixtures";
|
|
53
|
+
|
|
54
|
+
/** Helper: 打开 comment 编辑器并返回 component(多问题避免单问题 auto-submit) */
|
|
55
|
+
function openComment(): AskUserComponent {
|
|
56
|
+
const { c } = make(multiQWithComment);
|
|
57
|
+
// Q1 (allowComment): select A → enters comment mode
|
|
58
|
+
c.handleInput(ENTER);
|
|
59
|
+
return c;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Helper: 打开 freeform 编辑器并返回 component(不渲染,避免缓存) */
|
|
63
|
+
function openFreeform(q: Question[]): AskUserComponent {
|
|
64
|
+
const { c } = make(q);
|
|
65
|
+
// Navigate to Other (last option) and open editor
|
|
66
|
+
for (let i = 0; i < 10; i++) c.handleInput(DOWN); // go to last
|
|
67
|
+
c.handleInput(ENTER); // open freeform
|
|
68
|
+
return c;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("AskUserComponent — key leak fix (C-ARROW / C-KEYMAP)", () => {
|
|
72
|
+
// ── C-ARROW-1: 连按 3 次右箭头,editorText 不含 [ 或 C ──
|
|
73
|
+
it("C-ARROW-1: arrow keys do not leak escape sequences into editorText", () => {
|
|
74
|
+
const c = openFreeform([singleQ]);
|
|
75
|
+
c.handleInput(RIGHT); // \x1b[C — should be no-op
|
|
76
|
+
c.handleInput(RIGHT);
|
|
77
|
+
c.handleInput(RIGHT);
|
|
78
|
+
const lines = c.render(60);
|
|
79
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
80
|
+
expect(editorLine).toBeDefined();
|
|
81
|
+
// BUG: before fix, [C[C[C would appear. After fix: editorText stays empty.
|
|
82
|
+
expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── C-ARROW-2: direction keys + text input → correct cursor behavior ──
|
|
86
|
+
it("C-ARROW-2: mixed arrow keys and text input with cursor movement", () => {
|
|
87
|
+
const c = openFreeform([singleQ]);
|
|
88
|
+
c.handleInput(DOWN); // no-op
|
|
89
|
+
c.handleInput(RIGHT); // no-op (empty text)
|
|
90
|
+
c.handleInput("a"); // insert at 0 → "a", cursor=1
|
|
91
|
+
c.handleInput(UP); // no-op
|
|
92
|
+
c.handleInput(LEFT); // cursor 1→0
|
|
93
|
+
c.handleInput("b"); // insert at 0 → "ba", cursor=1
|
|
94
|
+
const lines = c.render(60);
|
|
95
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
96
|
+
expect(editorLine).toBeDefined();
|
|
97
|
+
// cursor at 1: "b█a" — 去 ANSI 后文本为 "ba"(精确顺序证明 insert 位置:b 在 a 前)
|
|
98
|
+
const stripped = editorLine!.replace(/\x1b\[[0-9;]*m/g, "");
|
|
99
|
+
expect(stripped).toContain("ba");
|
|
100
|
+
// Ensure no leaked sequences
|
|
101
|
+
expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ── C-KEYMAP-UP: up key no-op in editor ──
|
|
105
|
+
it("C-KEYMAP-UP: up arrow is no-op in freeform editor", () => {
|
|
106
|
+
const c = openFreeform([singleQ]);
|
|
107
|
+
c.handleInput("a");
|
|
108
|
+
c.handleInput(UP);
|
|
109
|
+
c.handleInput("b");
|
|
110
|
+
const lines = c.render(60);
|
|
111
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
112
|
+
expect(editorLine).toContain("ab");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ── C-KEYMAP-DOWN: down key no-op in editor ──
|
|
116
|
+
it("C-KEYMAP-DOWN: down arrow is no-op in freeform editor", () => {
|
|
117
|
+
const c = openFreeform([singleQ]);
|
|
118
|
+
c.handleInput("a");
|
|
119
|
+
c.handleInput(DOWN);
|
|
120
|
+
c.handleInput("b");
|
|
121
|
+
const lines = c.render(60);
|
|
122
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
123
|
+
expect(editorLine).toContain("ab");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ── C-KEYMAP-LEFT: left arrow moves cursor ──
|
|
127
|
+
it("C-KEYMAP-LEFT: left arrow moves cursor left in freeform editor", () => {
|
|
128
|
+
const c = openFreeform([singleQ]);
|
|
129
|
+
c.handleInput("ab"); // "ab", cursor=2
|
|
130
|
+
c.handleInput(LEFT); // cursor 2→1
|
|
131
|
+
c.handleInput("c"); // insert at 1 → "acb", cursor=2
|
|
132
|
+
const lines = c.render(60);
|
|
133
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
134
|
+
// cursor at 2: "ac█b" — 去 ANSI 后精确文本 "acb"(证明 c 插在 a 与 b 之间)
|
|
135
|
+
const stripped = editorLine!.replace(/\x1b\[[0-9;]*m/g, "");
|
|
136
|
+
expect(stripped).toContain("acb");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ── C-KEYMAP-HOME: home key moves cursor to start ──
|
|
140
|
+
it("C-KEYMAP-HOME: home key moves cursor to start in freeform editor", () => {
|
|
141
|
+
const c = openFreeform([singleQ]);
|
|
142
|
+
c.handleInput("abc"); // cursor=3
|
|
143
|
+
c.handleInput(HOME); // cursor 3→0
|
|
144
|
+
c.handleInput("d"); // insert at 0 → "dabc", cursor=1
|
|
145
|
+
const lines = c.render(60);
|
|
146
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
147
|
+
// cursor at 1: "d\x1b[7ma\x1b[27mbc" — 去 ANSI 后精确文本 "dabc"(证明 d 插在开头)
|
|
148
|
+
const stripped = editorLine!.replace(/\x1b\[[0-9;]*m/g, "");
|
|
149
|
+
expect(stripped).toContain("dabc");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ── C-KEYMAP-END: end key moves cursor to end ──
|
|
153
|
+
it("C-KEYMAP-END: end key moves cursor to end in freeform editor", () => {
|
|
154
|
+
const c = openFreeform([singleQ]);
|
|
155
|
+
c.handleInput("abc"); // cursor=3
|
|
156
|
+
c.handleInput(HOME); // cursor 3→0
|
|
157
|
+
c.handleInput(END); // cursor 0→3
|
|
158
|
+
c.handleInput("d"); // insert at 3 → "abcd", cursor=4
|
|
159
|
+
const lines = c.render(60);
|
|
160
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
161
|
+
// cursor at 4 (end): "abcd█"
|
|
162
|
+
expect(editorLine).toContain("abcd");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── C-KEYMAP-INSERT: insert key no-op (cursor preserved) ──
|
|
166
|
+
it("C-KEYMAP-INSERT: insert key is no-op in freeform editor", () => {
|
|
167
|
+
const c = openFreeform([singleQ]);
|
|
168
|
+
c.handleInput("abc"); // cursor=3
|
|
169
|
+
c.handleInput(INSERT); // no-op, cursor stays at 3
|
|
170
|
+
c.handleInput("d"); // insert at 3 → "abcd", cursor=4
|
|
171
|
+
const lines = c.render(60);
|
|
172
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
173
|
+
expect(editorLine).toContain("abcd");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ── C-KEYMAP-PGUP: page up no-op (cursor preserved) ──
|
|
177
|
+
it("C-KEYMAP-PGUP: page up is no-op in freeform editor", () => {
|
|
178
|
+
const c = openFreeform([singleQ]);
|
|
179
|
+
c.handleInput("abc"); // cursor=3
|
|
180
|
+
c.handleInput(PAGE_UP); // no-op, cursor stays at 3
|
|
181
|
+
c.handleInput("d"); // insert at 3 → "abcd", cursor=4
|
|
182
|
+
const lines = c.render(60);
|
|
183
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
184
|
+
expect(editorLine).toContain("abcd");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ── C-KEYMAP-PGDN: page down no-op (cursor preserved) ──
|
|
188
|
+
it("C-KEYMAP-PGDN: page down is no-op in freeform editor", () => {
|
|
189
|
+
const c = openFreeform([singleQ]);
|
|
190
|
+
c.handleInput("abc"); // cursor=3
|
|
191
|
+
c.handleInput(PAGE_DOWN); // no-op, cursor stays at 3
|
|
192
|
+
c.handleInput("d"); // insert at 3 → "abcd", cursor=4
|
|
193
|
+
const lines = c.render(60);
|
|
194
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
195
|
+
expect(editorLine).toContain("abcd");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ── C-KEYMAP-F1: f1 key no-op (cursor preserved) ──
|
|
199
|
+
it("C-KEYMAP-F1: F1 key is no-op in freeform editor", () => {
|
|
200
|
+
const c = openFreeform([singleQ]);
|
|
201
|
+
c.handleInput("abc"); // cursor=3
|
|
202
|
+
c.handleInput(F1); // no-op, cursor stays at 3
|
|
203
|
+
c.handleInput("d"); // insert at 3 → "abcd", cursor=4
|
|
204
|
+
const lines = c.render(60);
|
|
205
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
206
|
+
expect(editorLine).toContain("abcd");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ── C-KEYMAP-DELETE: delete key no-op (not backspace) ──
|
|
210
|
+
it("C-KEYMAP-DELETE: delete key is no-op in freeform editor (not backspace)", () => {
|
|
211
|
+
const c = openFreeform([singleQ]);
|
|
212
|
+
c.handleInput("abc"); // cursor=3
|
|
213
|
+
c.handleInput(DELETE); // no-op, cursor stays at 3
|
|
214
|
+
c.handleInput("d"); // insert at 3 → "abcd", cursor=4
|
|
215
|
+
const lines = c.render(60);
|
|
216
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
217
|
+
expect(editorLine).toContain("abcd");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ── C-KEYMAP-TAB: Tab 在编辑器内 no-op(不切 tab、不泄漏)──
|
|
221
|
+
it("C-KEYMAP-TAB: Tab is no-op in freeform editor (no tab switch, no leak)", () => {
|
|
222
|
+
const c = openFreeform([singleQ]);
|
|
223
|
+
c.handleInput("a");
|
|
224
|
+
c.handleInput(TAB); // parseKey 命中 "tab" 但编辑器无对应分支 → no-op
|
|
225
|
+
c.handleInput("b");
|
|
226
|
+
const lines = c.render(60);
|
|
227
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
228
|
+
expect(editorLine).toBeDefined();
|
|
229
|
+
const stripped = editorLine!.replace(/\x1b\[[0-9;]*m/g, "");
|
|
230
|
+
expect(stripped).toContain("ab");
|
|
231
|
+
expect(stripped).not.toContain("\t");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ── C-KEYMAP-SPACE: space character appends correctly ──
|
|
235
|
+
it("C-KEYMAP-SPACE: space character is appended (parseKey special case)", () => {
|
|
236
|
+
const c = openFreeform([singleQ]);
|
|
237
|
+
c.handleInput("a");
|
|
238
|
+
c.handleInput(" "); // space — parseKey(" ") returns "space" not a printable char
|
|
239
|
+
c.handleInput("b");
|
|
240
|
+
const lines = c.render(60);
|
|
241
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
242
|
+
expect(editorLine).toBeDefined();
|
|
243
|
+
expect(editorLine).toContain("a b");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ── C-KEYMAP-ESC: Esc in editor still works (semantic key) ──
|
|
247
|
+
it("C-KEYMAP-ESC: Esc in freeform still discards and returns to options", () => {
|
|
248
|
+
const c = openFreeform([singleQ]);
|
|
249
|
+
c.handleInput("abc");
|
|
250
|
+
c.handleInput(ESC); // should discard and go back to options
|
|
251
|
+
const lines = c.render(60);
|
|
252
|
+
// No more editor (back in options mode)
|
|
253
|
+
expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(false);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ── C-KEYMAP-BKSP: backspace still works (semantic key) ──
|
|
257
|
+
it("C-KEYMAP-BKSP: backspace still deletes last char", () => {
|
|
258
|
+
const c = openFreeform([singleQ]);
|
|
259
|
+
c.handleInput("abc");
|
|
260
|
+
c.handleInput(BKSP); // delete 'c'
|
|
261
|
+
const lines = c.render(60);
|
|
262
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
263
|
+
expect(editorLine).toContain("ab");
|
|
264
|
+
expect(editorLine).not.toContain("abc");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ── C-KEYMAP-COMMENT-UP: arrow key no-op in comment editor too ──
|
|
268
|
+
it("C-KEYMAP-COMMENT-UP: arrow keys are no-op in comment editor", () => {
|
|
269
|
+
const c = openComment();
|
|
270
|
+
c.handleInput("a");
|
|
271
|
+
c.handleInput(UP);
|
|
272
|
+
c.handleInput(RIGHT);
|
|
273
|
+
c.handleInput("b");
|
|
274
|
+
// comment editor renders text and cursor on separate lines;
|
|
275
|
+
// check the text line (before cursor) contains "ab"
|
|
276
|
+
const lines = c.render(60);
|
|
277
|
+
const textLine = lines.find((l) => l.includes("ab"));
|
|
278
|
+
expect(textLine).toBeDefined();
|
|
279
|
+
expect(textLine).toContain("ab");
|
|
280
|
+
// Ensure no leaked bracket characters from arrow escape sequences
|
|
281
|
+
const allText = lines.join("\n");
|
|
282
|
+
expect(allText).not.toMatch(/\[A/); // no leaked UP sequence
|
|
283
|
+
expect(allText).not.toMatch(/\[C/); // no leaked RIGHT sequence
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ── C-KEYMAP-MOD: modifier key combinations matrix (18 cases) ──
|
|
287
|
+
const modifierCases: Array<{ name: string; seq: string }> = [
|
|
288
|
+
{ name: "ctrl+up", seq: CTRL_UP },
|
|
289
|
+
{ name: "ctrl+down", seq: CTRL_DOWN },
|
|
290
|
+
{ name: "ctrl+left", seq: CTRL_LEFT },
|
|
291
|
+
{ name: "ctrl+right", seq: CTRL_RIGHT },
|
|
292
|
+
{ name: "alt+up", seq: ALT_UP },
|
|
293
|
+
{ name: "alt+down", seq: ALT_DOWN },
|
|
294
|
+
{ name: "alt+left", seq: ALT_LEFT },
|
|
295
|
+
{ name: "alt+right", seq: ALT_RIGHT },
|
|
296
|
+
{ name: "shift+up", seq: SHIFT_UP },
|
|
297
|
+
{ name: "shift+down", seq: SHIFT_DOWN },
|
|
298
|
+
{ name: "shift+left", seq: SHIFT_LEFT },
|
|
299
|
+
{ name: "shift+right", seq: SHIFT_RIGHT },
|
|
300
|
+
{ name: "super+up", seq: SUPER_UP },
|
|
301
|
+
{ name: "super+down", seq: SUPER_DOWN },
|
|
302
|
+
{ name: "super+left", seq: SUPER_LEFT },
|
|
303
|
+
{ name: "super+right", seq: SUPER_RIGHT },
|
|
304
|
+
{ name: "ctrl+shift+up", seq: CTRL_SHIFT_UP },
|
|
305
|
+
{ name: "ctrl+shift+down", seq: CTRL_SHIFT_DOWN },
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
for (const { name, seq } of modifierCases) {
|
|
309
|
+
it(`C-KEYMAP-MOD-${name}: ${name} modifier combo is no-op in editor`, () => {
|
|
310
|
+
const c = openFreeform([singleQ]);
|
|
311
|
+
c.handleInput("x");
|
|
312
|
+
c.handleInput(seq); // modifier combo → should be no-op
|
|
313
|
+
c.handleInput("y");
|
|
314
|
+
const lines = c.render(60);
|
|
315
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
316
|
+
expect(editorLine).toBeDefined();
|
|
317
|
+
expect(editorLine).toContain("xy");
|
|
318
|
+
// No leaked characters from the escape sequence
|
|
319
|
+
expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
|
|
320
|
+
expect(editorLine).not.toContain(";");
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("AskUserComponent — unknown control sequence leak fix (C-CSI)", () => {
|
|
326
|
+
it("C-CSI-1: unknown CSI sequence does not leak into editorText", () => {
|
|
327
|
+
const c = openFreeform([singleQ]);
|
|
328
|
+
c.handleInput(UNKNOWN_CSI);
|
|
329
|
+
c.handleInput("a");
|
|
330
|
+
c.handleInput("b");
|
|
331
|
+
const lines = c.render(60);
|
|
332
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
333
|
+
expect(editorLine).toBeDefined();
|
|
334
|
+
expect(editorLine).toContain("ab");
|
|
335
|
+
expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
|
|
336
|
+
expect(editorLine).not.toContain("9");
|
|
337
|
+
expect(editorLine).not.toContain("~");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("C-CSI-2: OSC11 background color response (BEL) does not leak", () => {
|
|
341
|
+
const c = openFreeform([singleQ]);
|
|
342
|
+
c.handleInput(OSC_BEL);
|
|
343
|
+
c.handleInput("x");
|
|
344
|
+
const lines = c.render(60);
|
|
345
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
346
|
+
expect(editorLine).toBeDefined();
|
|
347
|
+
expect(editorLine).toContain("x");
|
|
348
|
+
expect(editorLine).not.toContain("]");
|
|
349
|
+
expect(editorLine).not.toContain("rgb");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("C-CSI-3: OSC11 background color response (ST) does not leak", () => {
|
|
353
|
+
const c = openFreeform([singleQ]);
|
|
354
|
+
c.handleInput(OSC_ST);
|
|
355
|
+
c.handleInput("y");
|
|
356
|
+
const lines = c.render(60);
|
|
357
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
358
|
+
expect(editorLine).toBeDefined();
|
|
359
|
+
expect(editorLine).toContain("y");
|
|
360
|
+
expect(editorLine).not.toContain("]");
|
|
361
|
+
expect(editorLine).not.toContain("rgb");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("C-CSI-4: DA2 device attribute response does not leak", () => {
|
|
365
|
+
const c = openFreeform([singleQ]);
|
|
366
|
+
c.handleInput(DA2);
|
|
367
|
+
c.handleInput("m");
|
|
368
|
+
const lines = c.render(60);
|
|
369
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
370
|
+
expect(editorLine).toBeDefined();
|
|
371
|
+
expect(editorLine).toContain("m");
|
|
372
|
+
expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("C-CSI-5: DA1 device attribute response does not leak", () => {
|
|
376
|
+
const c = openFreeform([singleQ]);
|
|
377
|
+
c.handleInput(DA1);
|
|
378
|
+
c.handleInput("n");
|
|
379
|
+
const lines = c.render(60);
|
|
380
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
381
|
+
expect(editorLine).toBeDefined();
|
|
382
|
+
expect(editorLine).toContain("n");
|
|
383
|
+
expect(editorLine).not.toContain("?");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("C-CSI-6: DCS XTVersion response does not leak", () => {
|
|
387
|
+
const c = openFreeform([singleQ]);
|
|
388
|
+
c.handleInput(DCS);
|
|
389
|
+
c.handleInput("d");
|
|
390
|
+
const lines = c.render(60);
|
|
391
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
392
|
+
expect(editorLine).toBeDefined();
|
|
393
|
+
expect(editorLine).toContain("d");
|
|
394
|
+
expect(editorLine).not.toContain("tmux");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("C-CSI-7: APC Kitty graphics response does not leak", () => {
|
|
398
|
+
const c = openFreeform([singleQ]);
|
|
399
|
+
c.handleInput(APC);
|
|
400
|
+
c.handleInput("e");
|
|
401
|
+
const lines = c.render(60);
|
|
402
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
403
|
+
expect(editorLine).toBeDefined();
|
|
404
|
+
expect(editorLine).toContain("e");
|
|
405
|
+
expect(editorLine).not.toContain("G");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("C-CSI-8: unknown SS3 sequence does not leak", () => {
|
|
409
|
+
const c = openFreeform([singleQ]);
|
|
410
|
+
c.handleInput(UNKNOWN_SS3);
|
|
411
|
+
c.handleInput("f");
|
|
412
|
+
const lines = c.render(60);
|
|
413
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
414
|
+
expect(editorLine).toBeDefined();
|
|
415
|
+
expect(editorLine).toContain("f");
|
|
416
|
+
expect(editorLine).not.toContain("O");
|
|
417
|
+
expect(editorLine).not.toContain("Z");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("C-CSI-9: multiple unknown sequences in a row do not leak", () => {
|
|
421
|
+
const c = openFreeform([singleQ]);
|
|
422
|
+
c.handleInput(UNKNOWN_CSI);
|
|
423
|
+
c.handleInput(DA2);
|
|
424
|
+
c.handleInput("ok");
|
|
425
|
+
const lines = c.render(60);
|
|
426
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
427
|
+
expect(editorLine).toBeDefined();
|
|
428
|
+
expect(editorLine).toContain("ok");
|
|
429
|
+
expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("C-CSI-10: unknown CSI does not leak in comment editor", () => {
|
|
433
|
+
const c = openComment();
|
|
434
|
+
c.handleInput(UNKNOWN_CSI);
|
|
435
|
+
c.handleInput("ab");
|
|
436
|
+
const lines = c.render(60);
|
|
437
|
+
const textLine = lines.find((l) => l.includes("ab"));
|
|
438
|
+
expect(textLine).toBeDefined();
|
|
439
|
+
expect(textLine).toContain("ab");
|
|
440
|
+
const allText = lines.join("\n");
|
|
441
|
+
expect(allText).not.toMatch(/\[9/);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("C-CSI-R1: plain text still appended correctly", () => {
|
|
445
|
+
const c = openFreeform([singleQ]);
|
|
446
|
+
c.handleInput("hello");
|
|
447
|
+
const lines = c.render(60);
|
|
448
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
449
|
+
expect(editorLine).toContain("hello");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("C-CSI-R2: emoji still appended correctly", () => {
|
|
453
|
+
const c = openFreeform([singleQ]);
|
|
454
|
+
c.handleInput("fix the 🐛 bug");
|
|
455
|
+
const lines = c.render(60);
|
|
456
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
457
|
+
expect(editorLine).toContain("fix the 🐛 bug");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("C-CSI-R3: Chinese text still appended correctly", () => {
|
|
461
|
+
const c = openFreeform([singleQ]);
|
|
462
|
+
c.handleInput("你好");
|
|
463
|
+
const lines = c.render(60);
|
|
464
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
465
|
+
expect(editorLine).toContain("你好");
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("C-CSI-R4: bracketed paste still works", () => {
|
|
469
|
+
const c = openFreeform([singleQ]);
|
|
470
|
+
c.handleInput("\x1b[200~hello\x1b[201~");
|
|
471
|
+
const lines = c.render(60);
|
|
472
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
473
|
+
expect(editorLine).toContain("hello");
|
|
474
|
+
expect(editorLine).not.toContain("[200~");
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("C-CSI-R5: arrow keys still no-op", () => {
|
|
478
|
+
const c = openFreeform([singleQ]);
|
|
479
|
+
c.handleInput(RIGHT);
|
|
480
|
+
c.handleInput(RIGHT);
|
|
481
|
+
c.handleInput(RIGHT);
|
|
482
|
+
c.handleInput("a");
|
|
483
|
+
c.handleInput("b");
|
|
484
|
+
const lines = c.render(60);
|
|
485
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
486
|
+
expect(editorLine).toContain("ab");
|
|
487
|
+
expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("C-CSI-R6: backspace still works", () => {
|
|
491
|
+
const c = openFreeform([singleQ]);
|
|
492
|
+
c.handleInput("abc");
|
|
493
|
+
c.handleInput(BKSP);
|
|
494
|
+
const lines = c.render(60);
|
|
495
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
496
|
+
expect(editorLine).toContain("ab");
|
|
497
|
+
expect(editorLine).not.toContain("abc");
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("C-CSI-R7: Esc still exits editor", () => {
|
|
501
|
+
const c = openFreeform([singleQ]);
|
|
502
|
+
c.handleInput("abc");
|
|
503
|
+
c.handleInput(ESC);
|
|
504
|
+
const lines = c.render(60);
|
|
505
|
+
expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(false);
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// 🐛 = U+1F41B,UTF-16 surrogate pair(占 2 个 code unit,index 1=高代理 / 2=低代理)。
|
|
510
|
+
// 验证光标移动/Backspace 按整个 code point 跳跃,不会停在代理中间(index 2)拆散 emoji。
|
|
511
|
+
describe("C-SURROGATE: cursor movement across surrogate pairs", () => {
|
|
512
|
+
it("C-SUR-BS: backspace at end deletes a full surrogate pair (deleteCount=2)", () => {
|
|
513
|
+
const c = openFreeform([singleQ]);
|
|
514
|
+
c.handleInput("ab🐛"); // 🐛 在末尾,cursorIndex=4(a b 高 低)
|
|
515
|
+
c.handleInput(BKSP); // 删整个 🐛(非半个低代理),cursorIndex 4→2
|
|
516
|
+
const lines = c.render(60);
|
|
517
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
518
|
+
expect(editorLine).toBeDefined();
|
|
519
|
+
const stripped = editorLine!.replace(/\x1b\[[0-9;]*m/g, "");
|
|
520
|
+
expect(stripped).toContain("ab");
|
|
521
|
+
expect(stripped).not.toContain("🐛");
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("C-SUR-RIGHT: Right skips surrogate middle (cursor 1→3, not 2)", () => {
|
|
525
|
+
const c = openFreeform([singleQ]);
|
|
526
|
+
c.handleInput("a🐛b"); // cursorIndex=4
|
|
527
|
+
c.handleInput(HOME); // cursor 4→0
|
|
528
|
+
c.handleInput(RIGHT); // cursor 0→1(a 后;isHighSurrogate(0)='a'→ false)
|
|
529
|
+
c.handleInput(RIGHT); // cursor 1→3(isHighSurrogate(1)=高代理 → 跳 2,不停在代理中间 2)
|
|
530
|
+
c.handleInput("X"); // 在 cursor 3 插入 → "a🐛Xb"(X 在 🐛 后、b 前)
|
|
531
|
+
const lines = c.render(60);
|
|
532
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
533
|
+
expect(editorLine).toBeDefined();
|
|
534
|
+
const stripped = editorLine!.replace(/\x1b\[[0-9;]*m/g, "");
|
|
535
|
+
// X 落在 🐛 之后证明 cursor 跳到了 3;若停在代理中间 2 会拆散 🐛,不含完整 "a🐛Xb"
|
|
536
|
+
expect(stripped).toContain("a🐛Xb");
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("C-SUR-LEFT: Left skips surrogate middle (cursor 4→1, not 2)", () => {
|
|
540
|
+
const c = openFreeform([singleQ]);
|
|
541
|
+
c.handleInput("a🐛b"); // cursorIndex=4
|
|
542
|
+
c.handleInput(END); // cursor 4(保险,已在末尾)
|
|
543
|
+
c.handleInput(LEFT); // cursor 4→3(🐛 与 b 之间)
|
|
544
|
+
c.handleInput(LEFT); // cursor 3→1(newLeft-1=高代理 → 跳 2,不停在代理中间 2)
|
|
545
|
+
c.handleInput("Y"); // 在 cursor 1 插入 → "aY🐛b"(Y 在 🐛 前)
|
|
546
|
+
const lines = c.render(60);
|
|
547
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
548
|
+
expect(editorLine).toBeDefined();
|
|
549
|
+
const stripped = editorLine!.replace(/\x1b\[[0-9;]*m/g, "");
|
|
550
|
+
// Y 落在 🐛 之前证明 cursor 跳到了 1;若停在代理中间 2 会拆散 🐛,不含完整 "aY🐛b"
|
|
551
|
+
expect(stripped).toContain("aY🐛b");
|
|
552
|
+
});
|
|
553
|
+
});
|