@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.
- package/ARCHITECTURE.md +171 -0
- package/README.md +135 -9
- package/package.json +6 -2
- package/src/__tests__/answer-format.test.ts +107 -0
- package/src/__tests__/component-keymap.test.ts +522 -0
- package/src/__tests__/component.test.ts +50 -12
- package/src/__tests__/e2e-harness.ts +1 -0
- package/src/__tests__/editor-ops.test.ts +179 -0
- package/src/__tests__/fixtures.ts +56 -2
- package/src/__tests__/index.test.ts +329 -25
- package/src/__tests__/question-view.test.ts +71 -53
- package/src/__tests__/sdk-contract.test.ts +111 -0
- package/src/__tests__/validate.test.ts +19 -4
- package/src/__tests__/w2-draft-hint.test.ts +157 -0
- package/src/__tests__/w3-regression.test.ts +128 -0
- package/src/answer-format.ts +51 -0
- package/src/component.ts +132 -129
- package/src/editor-ops.ts +75 -0
- package/src/index.ts +198 -72
- package/src/question-view.ts +81 -65
- package/src/submit-view.ts +19 -11
- package/src/types.ts +24 -2
- package/src/validate.ts +20 -1
|
@@ -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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
});
|