@zhushanwen/pi-ask-user 0.0.4 → 0.2.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.
@@ -0,0 +1,522 @@
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
+
478
+ // 🐛 = U+1F41B,UTF-16 surrogate pair(占 2 个 code unit,index 1=高代理 / 2=低代理)。
479
+ // 验证光标移动/Backspace 按整个 code point 跳跃,不会停在代理中间(index 2)拆散 emoji。
480
+ describe("C-SURROGATE: cursor movement across surrogate pairs", () => {
481
+ it("C-SUR-BS: backspace at end deletes a full surrogate pair (deleteCount=2)", () => {
482
+ const c = openFreeform([singleQ]);
483
+ c.handleInput("ab🐛"); // 🐛 在末尾,cursorIndex=4(a b 高 低)
484
+ c.handleInput(BKSP); // 删整个 🐛(非半个低代理),cursorIndex 4→2
485
+ const lines = c.render(60);
486
+ const editorLine = lines.find((l) => l.includes("\x1b[7m"));
487
+ expect(editorLine).toBeDefined();
488
+ const stripped = editorLine!.replace(/\x1b\[[0-9;]*m/g, "");
489
+ expect(stripped).toContain("ab");
490
+ expect(stripped).not.toContain("🐛");
491
+ });
492
+
493
+ it("C-SUR-RIGHT: Right skips surrogate middle (cursor 1→3, not 2)", () => {
494
+ const c = openFreeform([singleQ]);
495
+ c.handleInput("a🐛b"); // cursorIndex=4
496
+ c.handleInput(HOME); // cursor 4→0
497
+ c.handleInput(RIGHT); // cursor 0→1(a 后;isHighSurrogate(0)='a'→ false)
498
+ c.handleInput(RIGHT); // cursor 1→3(isHighSurrogate(1)=高代理 → 跳 2,不停在代理中间 2)
499
+ c.handleInput("X"); // 在 cursor 3 插入 → "a🐛Xb"(X 在 🐛 后、b 前)
500
+ const lines = c.render(60);
501
+ const editorLine = lines.find((l) => l.includes("\x1b[7m"));
502
+ expect(editorLine).toBeDefined();
503
+ const stripped = editorLine!.replace(/\x1b\[[0-9;]*m/g, "");
504
+ // X 落在 🐛 之后证明 cursor 跳到了 3;若停在代理中间 2 会拆散 🐛,不含完整 "a🐛Xb"
505
+ expect(stripped).toContain("a🐛Xb");
506
+ });
507
+
508
+ it("C-SUR-LEFT: Left skips surrogate middle (cursor 4→1, not 2)", () => {
509
+ const c = openFreeform([singleQ]);
510
+ c.handleInput("a🐛b"); // cursorIndex=4
511
+ c.handleInput(END); // cursor 4(保险,已在末尾)
512
+ c.handleInput(LEFT); // cursor 4→3(🐛 与 b 之间)
513
+ c.handleInput(LEFT); // cursor 3→1(newLeft-1=高代理 → 跳 2,不停在代理中间 2)
514
+ c.handleInput("Y"); // 在 cursor 1 插入 → "aY🐛b"(Y 在 🐛 前)
515
+ const lines = c.render(60);
516
+ const editorLine = lines.find((l) => l.includes("\x1b[7m"));
517
+ expect(editorLine).toBeDefined();
518
+ const stripped = editorLine!.replace(/\x1b\[[0-9;]*m/g, "");
519
+ // Y 落在 🐛 之前证明 cursor 跳到了 1;若停在代理中间 2 会拆散 🐛,不含完整 "aY🐛b"
520
+ expect(stripped).toContain("aY🐛b");
521
+ });
522
+ });
@@ -288,7 +288,7 @@ describe("AskUserComponent — Other free-text editor", () => {
288
288
  c.handleInput(ENTER);
289
289
  const lines = c.render(60);
290
290
  // freeform 模式:光标行(█)出现,editor 已在 Other 行原地渲染
291
- expect(lines.some((l) => l.includes(""))).toBe(true);
291
+ expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(true);
292
292
  // 不再独立 "Your answer" 提示块
293
293
  expect(lines.some((l) => l.includes("Your answer"))).toBe(false);
294
294
  });
@@ -300,7 +300,7 @@ describe("AskUserComponent — Other free-text editor", () => {
300
300
  c.handleInput(TAB);
301
301
  const lines = c.render(60);
302
302
  // Tab 不再打开 Other 编辑器(仅 Enter);单问题下 Tab 是 no-op
303
- expect(lines.some((l) => l.includes(""))).toBe(false);
303
+ expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(false);
304
304
  });
305
305
 
306
306
  it("C-27: editor accepts printable characters", () => {
@@ -334,7 +334,7 @@ describe("AskUserComponent — Other free-text editor", () => {
334
334
  c.handleInput(ESC);
335
335
  const lines = c.render(60);
336
336
  // Back in options mode — no cursor block (freeform inactive)
337
- expect(lines.some((l) => l.includes(""))).toBe(false);
337
+ expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(false);
338
338
  });
339
339
 
340
340
  it("C-29: editor Enter with text saves and submits (single)", () => {
@@ -358,7 +358,7 @@ describe("AskUserComponent — Other free-text editor", () => {
358
358
  expect(result.val).toBeUndefined();
359
359
  // Back in options mode — no cursor block (freeform inactive)
360
360
  const lines = c.render(60);
361
- expect(lines.some((l) => l.includes(""))).toBe(false);
361
+ expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(false);
362
362
  });
363
363
  });
364
364
 
@@ -374,7 +374,7 @@ describe("AskUserComponent — multi-char paste in editor", () => {
374
374
  c.handleInput(ENTER); // 打开 freeform 编辑器
375
375
  c.handleInput("hello world"); // 一次粘贴多字符
376
376
  const lines = c.render(60);
377
- const editorLine = lines.find((l) => l.includes(""));
377
+ const editorLine = lines.find((l) => l.includes("\x1b[7m"));
378
378
  expect(editorLine).toBeDefined();
379
379
  // 完整文本保留(非仅首字符 "h")
380
380
  expect(editorLine).toContain("hello world");
@@ -388,7 +388,7 @@ describe("AskUserComponent — multi-char paste in editor", () => {
388
388
  c.handleInput(ENTER);
389
389
  c.handleInput("fix the 🐛 bug");
390
390
  const lines = c.render(60);
391
- const editorLine = lines.find((l) => l.includes(""));
391
+ const editorLine = lines.find((l) => l.includes("\x1b[7m"));
392
392
  expect(editorLine).toBeDefined();
393
393
  // emoji 完整保留,不丢失
394
394
  expect(editorLine).toContain("fix the 🐛 bug");
@@ -403,7 +403,7 @@ describe("AskUserComponent — multi-char paste in editor", () => {
403
403
  c.handleInput(ENTER);
404
404
  c.handleInput("ab\tcd");
405
405
  const lines = c.render(60);
406
- const editorLine = lines.find((l) => l.includes(""));
406
+ const editorLine = lines.find((l) => l.includes("\x1b[7m"));
407
407
  expect(editorLine).toBeDefined();
408
408
  // 可打印字符保留,控制字符 \t 被过滤
409
409
  expect(editorLine).toContain("abcd");
@@ -422,7 +422,7 @@ describe("AskUserComponent — multi-char paste in editor", () => {
422
422
  expect(result.val).toBeUndefined();
423
423
  expect(after).toEqual(before);
424
424
  // 编辑器仍在(光标在),editorText 仍为空
425
- expect(after.some((l) => l.includes(""))).toBe(true);
425
+ expect(after.some((l) => l.includes("\x1b[7m"))).toBe(true);
426
426
  });
427
427
 
428
428
  it("C-PASTE-5: single printable char still works (backward-compat)", () => {
@@ -433,7 +433,7 @@ describe("AskUserComponent — multi-char paste in editor", () => {
433
433
  c.handleInput(ENTER);
434
434
  c.handleInput("x"); // 单字符
435
435
  const lines = c.render(60);
436
- const editorLine = lines.find((l) => l.includes(""));
436
+ const editorLine = lines.find((l) => l.includes("\x1b[7m"));
437
437
  expect(editorLine).toBeDefined();
438
438
  expect(editorLine).toContain("x");
439
439
  });
@@ -448,7 +448,7 @@ describe("AskUserComponent — multi-char paste in editor", () => {
448
448
  c.handleInput(ENTER); // 打开 freeform
449
449
  c.handleInput("\x1b[200~hello\x1b[201~");
450
450
  const lines = c.render(60);
451
- const editorLine = lines.find((l) => l.includes(""));
451
+ const editorLine = lines.find((l) => l.includes("\x1b[7m"));
452
452
  expect(editorLine).toBeDefined();
453
453
  expect(editorLine).toContain("hello");
454
454
  expect(editorLine).not.toContain("[200~");
@@ -465,7 +465,7 @@ describe("AskUserComponent — multi-char paste in editor", () => {
465
465
  c.handleInput("\x1b[200~foo ");
466
466
  c.handleInput(" bar\x1b[201~");
467
467
  const lines = c.render(60);
468
- const editorLine = lines.find((l) => l.includes(""));
468
+ const editorLine = lines.find((l) => l.includes("\x1b[7m"));
469
469
  expect(editorLine).toContain("foo bar");
470
470
  expect(editorLine).not.toContain("[200~");
471
471
  expect(editorLine).not.toContain("[201~");
@@ -592,6 +592,29 @@ describe("AskUserComponent — re-entry guard", () => {
592
592
  c.handleInput(ENTER); // ignored
593
593
  expect(result.val).toBeNull();
594
594
  });
595
+
596
+ // FR-12 竞态:signal abort 可能在用户已 submit 后才触发,此时 cancel() 必须是
597
+ // no-op,避免二次调 done(用户已拿到 submit 结果,不应被 abort 覆盖为 null)。
598
+ it("C-FR12 (FR-12): cancel() after submit does not re-invoke done", () => {
599
+ const { c, result } = make([singleQ]);
600
+ c.handleInput(ENTER); // submit → done(result), _resolved=true
601
+ expect(result.val).toBeDefined();
602
+ expect(result.val!.cancelled).toBe(false);
603
+ const submitVal = result.val;
604
+ // 模拟 signal abort 在 submit 后触发 → cancel() 应被 _resolved 守卫拦截
605
+ c.cancel();
606
+ expect(result.val).toBe(submitVal); // 不变 —— done 未被二次调用
607
+ });
608
+
609
+ it("C-FR12b (FR-12): cancel() after cancel does not re-invoke done", () => {
610
+ const { c, result } = make([singleQ]);
611
+ c.handleInput(ESC); // overlay
612
+ c.handleInput(ESC); // cancel → done(null), _resolved=true
613
+ expect(result.val).toBeNull();
614
+ // 再次 cancel(模拟 abort 触发)应 no-op
615
+ c.cancel();
616
+ expect(result.val).toBeNull(); // 仍为 null,未触发异常
617
+ });
595
618
  });
596
619
 
597
620
  // ── 5h. 渲染缓存 ───────────────────────────────────────
@@ -798,7 +821,7 @@ describe("AskUserComponent — new behavior (post-refactor)", () => {
798
821
  // 独立 "Your answer" 提示行已消失
799
822
  expect(lines.some((l) => l.includes("Your answer"))).toBe(false);
800
823
  // freeform cursor 出现
801
- expect(lines.some((l) => l.includes(""))).toBe(true);
824
+ expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(true);
802
825
  // 依然能看见 "Auth" "Search"(普通选项不变)
803
826
  expect(lines.some((l) => l.includes("Auth"))).toBe(true);
804
827
  expect(lines.some((l) => l.includes("Search"))).toBe(true);
@@ -892,4 +915,19 @@ describe("AskUserComponent — new behavior (post-refactor)", () => {
892
915
  c.handleInput(ENTER);
893
916
  expect(result.val).toBeUndefined();
894
917
  });
918
+
919
+ it("C-TAB-NOLEAK: Tab in options mode with single question is no-op", () => {
920
+ const c = new AskUserComponent(
921
+ [{ question: "Pick one", options: [{ label: "A" }, { label: "B" }] }],
922
+ mockTui,
923
+ stubTheme,
924
+ () => {},
925
+ );
926
+ c.handleInput(DOWN);
927
+ c.handleInput(TAB);
928
+ // 不应 crash,选项仍可见
929
+ const lines = c.render(60);
930
+ expect(lines.some((l: string) => l.includes("A"))).toBe(true);
931
+ expect(lines.some((l: string) => l.includes("B"))).toBe(true);
932
+ });
895
933
  });
@@ -72,6 +72,7 @@ export function makeE2E(questions: Question[], opts: E2EOptions = {}): E2EApi {
72
72
  controller.signal,
73
73
  undefined,
74
74
  {
75
+ mode: hasUI ? "tui" : "print",
75
76
  hasUI,
76
77
  signal: controller.signal,
77
78
  ui: {