@zhushanwen/pi-ask-user 0.0.1

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,95 @@
1
+ // src/__tests__/e2e-harness.ts
2
+ // E2E test harness for ask_user. Drives tool.execute() end-to-end with a
3
+ // REAL AskUserComponent and simulated key sequences — no mocking of internal
4
+ // state. Mocks only the Pi surface (registerTool, getAllTools, setActiveTools,
5
+ // ctx.ui.custom) since those are the only host-API touch points.
6
+
7
+ import { AskUserComponent } from "../component";
8
+ import factory from "../index";
9
+ import type { AskUserDetails, Question, Result } from "../types";
10
+ import { mockTui, stubTheme } from "./fixtures";
11
+
12
+ /** Subset of Pi that ask_user's factory touches. */
13
+ interface PiShape {
14
+ activeTools: string[] | null;
15
+ tool: unknown;
16
+ registerTool(t: unknown): void;
17
+ getAllTools(): { name: string }[];
18
+ setActiveTools(names: string[]): void;
19
+ }
20
+
21
+ export interface E2EApi {
22
+ /** Most recent execute() result. Populated after getExecuted() resolves. */
23
+ keys(seq: string[]): void;
24
+ abort(): void;
25
+ getExecuted(): Promise<AskUserDetails>;
26
+ pi: { activeTools: string[] | null };
27
+ }
28
+
29
+ export interface E2EOptions {
30
+ hasUI?: boolean;
31
+ preAborted?: boolean;
32
+ }
33
+
34
+ export function makeE2E(questions: Question[], opts: E2EOptions = {}): E2EApi {
35
+ const { hasUI = true, preAborted = false } = opts;
36
+ const controller = new AbortController();
37
+ if (preAborted) controller.abort();
38
+
39
+ const pi: PiShape = {
40
+ activeTools: null,
41
+ tool: undefined,
42
+ registerTool(t) {
43
+ this.tool = t;
44
+ },
45
+ getAllTools() {
46
+ return [{ name: "ask_user" }, { name: "read" }, { name: "bash" }];
47
+ },
48
+ setActiveTools(names) {
49
+ this.activeTools = names;
50
+ },
51
+ };
52
+
53
+ factory(pi as never);
54
+
55
+ type Tool = {
56
+ execute(
57
+ toolCallId: string,
58
+ params: unknown,
59
+ signal: AbortSignal | undefined,
60
+ onUpdate: unknown,
61
+ ctx: unknown,
62
+ ): Promise<AskUserDetails>;
63
+ };
64
+ const tool = pi.tool as Tool;
65
+ if (!tool) throw new Error("factory did not register a tool");
66
+
67
+ let compRef: AskUserComponent | null = null;
68
+
69
+ const execPromise = tool.execute(
70
+ "id",
71
+ { questions },
72
+ controller.signal,
73
+ undefined,
74
+ {
75
+ hasUI,
76
+ signal: controller.signal,
77
+ ui: {
78
+ custom: <T = Result | null>(factoryFn: (...args: unknown[]) => unknown): Promise<T> =>
79
+ new Promise<T>((resolve) => {
80
+ const done = (r: T): void => resolve(r);
81
+ compRef = factoryFn(mockTui, stubTheme, {}, done) as AskUserComponent;
82
+ }),
83
+ },
84
+ },
85
+ );
86
+
87
+ return {
88
+ keys: (seq) => {
89
+ for (const k of seq) compRef?.handleInput(k);
90
+ },
91
+ abort: () => controller.abort(),
92
+ getExecuted: () => execPromise,
93
+ pi: { get activeTools() { return pi.activeTools; } },
94
+ };
95
+ }
@@ -0,0 +1,184 @@
1
+ // src/__tests__/e2e.test.ts
2
+ // E2E test cases for ask_user. Drives tool.execute() → real AskUserComponent
3
+ // → simulated keypresses → asserts on final execute() result contract.
4
+ // Spec: .xyz-harness/2026-06-15-ask-user/e2e-test-cases.md
5
+
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ import { makeE2E } from "./e2e-harness";
9
+
10
+ // ── E2E-1: 单问题无评论 — 选第二项提交 ─────────────────
11
+ describe("E2E-1: single question, no comment — pick 2nd option", () => {
12
+ const questions = [
13
+ {
14
+ question: "Which DB?",
15
+ options: [{ label: "Postgres" }, { label: "SQLite" }],
16
+ },
17
+ ];
18
+
19
+ it("selects SQLite on ↓ + Enter and returns answers", async () => {
20
+ const e = makeE2E(questions);
21
+ e.keys(["\x1b[B", "\r"]); // ↓ + Enter
22
+ const result = await e.getExecuted();
23
+ const details = result.details;
24
+
25
+ // User-facing summary
26
+ expect(result.content[0].text).toContain("Which DB?");
27
+ expect(result.content[0].text).toContain("SQLite");
28
+ // Data contract
29
+ expect(details.cancelled).toBe(false);
30
+ expect(details.answers["Which DB?"]).toBe("SQLite");
31
+ expect(details.questions.length).toBe(1);
32
+ });
33
+ });
34
+
35
+ // ── E2E-2: 单问题 + allowComment — 选项 + 评论拼接 ─────
36
+ describe("E2E-2: single question + allowComment — option + comment joined", () => {
37
+ const questions = [
38
+ {
39
+ question: "Which DB?",
40
+ allowComment: true,
41
+ options: [{ label: "Postgres" }, { label: "SQLite" }],
42
+ },
43
+ ];
44
+
45
+ it("joins selected option with comment via ' — '", async () => {
46
+ const e = makeE2E(questions);
47
+ // Enter 选 Postgres → 进评论模式 → 输 "fast" → Enter 保存(allowComment 分支)
48
+ e.keys(["\r", "f", "a", "s", "t", "\r"]);
49
+ const result = await e.getExecuted();
50
+ const details = result.details;
51
+
52
+ expect(details.cancelled).toBe(false);
53
+ expect(details.answers["Which DB?"]).toBe("Postgres — fast");
54
+ expect(result.content[0].text).toContain("Postgres — fast");
55
+ });
56
+ });
57
+
58
+ // ── E2E-3: 单问题 + allowComment — Enter 空评论跳过 ─────
59
+ describe("E2E-3: single question + allowComment — Enter in comment skips", () => {
60
+ const questions = [
61
+ {
62
+ question: "Which DB?",
63
+ allowComment: true,
64
+ options: [{ label: "Postgres" }, { label: "SQLite" }],
65
+ },
66
+ ];
67
+
68
+ it("empty Enter in comment mode keeps option without ' — ' suffix", async () => {
69
+ const e = makeE2E(questions);
70
+ // Enter 选 Postgres → 直接 Enter 评论模式跳过(AC-12)
71
+ e.keys(["\r", "\r"]);
72
+ const result = await e.getExecuted();
73
+ const details = result.details;
74
+
75
+ expect(details.cancelled).toBe(false);
76
+ // 不含 " — " 分隔符
77
+ expect(details.answers["Which DB?"]).toBe("Postgres");
78
+ expect(details.answers["Which DB?"]).not.toContain("—");
79
+ });
80
+ });
81
+
82
+ // ── E2E-4: 多问题提交 — 逐题选择后 Submit tab 提交(S-11)──────
83
+ describe("E2E-4: multi-question submit — answer each then Submit tab", () => {
84
+ const questions = [
85
+ { question: "Q1", header: "First", options: [{ label: "A" }, { label: "B" }] },
86
+ { question: "Q2", header: "Second", options: [{ label: "X" }, { label: "Y" }] },
87
+ ];
88
+
89
+ it("answers both via Enter + Enter on Submit tab", async () => {
90
+ const e = makeE2E(questions);
91
+ // Q1: Enter 选 A(auto-confirm → advance 到 Q2)
92
+ // Q2: Enter 选 X(auto-confirm → advance 到 Submit tab)
93
+ // Submit: Enter(allConfirmed)→ 提交
94
+ e.keys(["\r", "\r", "\r"]);
95
+ const result = await e.getExecuted();
96
+ const details = result.details;
97
+
98
+ expect(details.cancelled).toBe(false);
99
+ expect(details.answers["Q1"]).toBe("A");
100
+ expect(details.answers["Q2"]).toBe("X");
101
+ expect(result.content[0].text).toContain("Q1");
102
+ expect(result.content[0].text).toContain("Q2");
103
+ });
104
+ });
105
+
106
+ // ── E2E-5: 多选 — Space 勾选多项后 Enter 提交(S-11)──────────
107
+ describe("E2E-5: multi-select — Space toggle two options then Enter", () => {
108
+ const questions = [
109
+ {
110
+ question: "Which features?",
111
+ multiSelect: true,
112
+ options: [{ label: "Auth" }, { label: "Search" }],
113
+ },
114
+ ];
115
+
116
+ it("toggles Auth + Search via Space and submits on Enter", async () => {
117
+ const e = makeE2E(questions);
118
+ // Space 选 Auth(cursor@0)→ ↓ → Space 选 Search(cursor@1)→ Enter 确认(单问题→submit)
119
+ e.keys([" ", "\x1b[B", " ", "\r"]);
120
+ const result = await e.getExecuted();
121
+ const details = result.details;
122
+
123
+ expect(details.cancelled).toBe(false);
124
+ expect(details.answers["Which features?"]).toBe("Auth, Search");
125
+ });
126
+ });
127
+
128
+ // ── E2E-6: Other 自由文本 — 输入自定义答案(S-11)─────────────
129
+ describe("E2E-6: Other free-text — type custom answer", () => {
130
+ const questions = [
131
+ { question: "Which DB?", options: [{ label: "Postgres" }, { label: "SQLite" }] },
132
+ ];
133
+
134
+ it("navigates to Other, types a custom answer, submits", async () => {
135
+ const e = makeE2E(questions);
136
+ // ↓↓ 到 Other(cursor@2,2 个普通选项 + Other)→ Enter 开 freeform 编辑器
137
+ // → 输 "redis" → Enter 保存(afterConfirm → advance → submit)
138
+ e.keys(["\x1b[B", "\x1b[B", "\r", "r", "e", "d", "i", "s", "\r"]);
139
+ const result = await e.getExecuted();
140
+ const details = result.details;
141
+
142
+ expect(details.cancelled).toBe(false);
143
+ expect(details.answers["Which DB?"]).toBe("redis");
144
+ });
145
+ });
146
+
147
+ // ── E2E-7: 取消 — Esc 进入确认层 → Esc 确认取消(S-11)────────
148
+ describe("E2E-7: cancel — Esc confirm overlay → Esc cancels", () => {
149
+ const questions = [
150
+ { question: "Which DB?", options: [{ label: "Postgres" }, { label: "SQLite" }] },
151
+ ];
152
+
153
+ it("single Esc raises confirm overlay, second Esc confirms cancel", async () => {
154
+ const e = makeE2E(questions);
155
+ // 单问题首个 tab:Esc → pendingCancel 覆盖层;再 Esc → cancel() → done(null)
156
+ e.keys(["\x1b", "\x1b"]);
157
+ const result = await e.getExecuted();
158
+ const details = result.details;
159
+
160
+ expect(details.cancelled).toBe(true);
161
+ expect(result.content[0].text).toContain("cancelled");
162
+ // 取消时 answers 为空对象
163
+ expect(Object.keys(details.answers)).toHaveLength(0);
164
+ });
165
+ });
166
+
167
+ // ── E2E-8: cancel 在多问题场景 — 首个问题 Esc→Esc 取消(S-11 cancel 多问题路径)─
168
+ describe("E2E-8: cancel during multi-question — Esc overlay then confirm", () => {
169
+ const questions = [
170
+ { question: "Q1", header: "First", options: [{ label: "A" }, { label: "B" }] },
171
+ { question: "Q2", header: "Second", options: [{ label: "X" }, { label: "Y" }] },
172
+ ];
173
+
174
+ it("Esc on first question raises overlay, second Esc cancels all", async () => {
175
+ const e = makeE2E(questions);
176
+ e.keys(["\x1b", "\x1b"]);
177
+ const result = await e.getExecuted();
178
+ const details = result.details;
179
+
180
+ expect(details.cancelled).toBe(true);
181
+ // 取消返回 details.answers = {},details.questions 仍回传(renderResult 数据源)
182
+ expect(Object.keys(details.answers)).toHaveLength(0);
183
+ });
184
+ });
@@ -0,0 +1,68 @@
1
+ // src/__tests__/fixtures.ts
2
+ // Shared test fixtures — stub theme, mock TUI, sample questions, key sequences.
3
+ import type { Question, ThemeLike } from "../types";
4
+
5
+ // ── Stub theme (passthrough — no ANSI codes, plain text) ──
6
+ export const stubTheme: ThemeLike = {
7
+ fg: (_t: string, s: string) => s,
8
+ bg: (_t: string, s: string) => s,
9
+ bold: (s: string) => s,
10
+ };
11
+
12
+ // ── Mock TUI (no-op requestRender) ──
13
+ export const mockTui = { requestRender: (): void => {} };
14
+
15
+ // ── Key sequences (real terminal escape codes that matchesKey recognizes) ──
16
+ export const ENTER = "\r";
17
+ export const SPACE = " ";
18
+ export const ESC = "\x1b";
19
+ export const UP = "\x1b[A";
20
+ export const DOWN = "\x1b[B";
21
+ export const RIGHT = "\x1b[C";
22
+ export const LEFT = "\x1b[D";
23
+ export const TAB = "\t";
24
+ export const BKSP = "\x7f";
25
+
26
+ // ── Sample questions ──
27
+ export const singleQ: Question = {
28
+ question: "Which DB?",
29
+ options: [
30
+ { label: "Postgres", description: "Battle-tested" },
31
+ { label: "SQLite", description: "Embedded" },
32
+ ],
33
+ };
34
+
35
+ export const singleQWithComment: Question = {
36
+ question: "Which DB? (with comment)",
37
+ allowComment: true,
38
+ options: [
39
+ { label: "Postgres", description: "Battle-tested" },
40
+ { label: "SQLite", description: "Embedded" },
41
+ ],
42
+ };
43
+
44
+ export const singleQMulti: Question = {
45
+ question: "Which features?",
46
+ multiSelect: true,
47
+ allowComment: true,
48
+ options: [
49
+ { label: "Auth", description: "OAuth + session" },
50
+ { label: "Search", description: "Full-text" },
51
+ ],
52
+ };
53
+
54
+ export const multiQ: Question[] = [
55
+ { question: "Q1", header: "First", options: [{ label: "A" }, { label: "B" }] },
56
+ {
57
+ question: "Q2",
58
+ header: "Second",
59
+ options: [{ label: "X" }, { label: "Y" }],
60
+ multiSelect: true,
61
+ },
62
+ { question: "Q3", header: "Third", options: [{ label: "M" }, { label: "N" }] },
63
+ ];
64
+
65
+ export const multiQWithComment: Question[] = [
66
+ { question: "Q1", header: "First", allowComment: true, options: [{ label: "A" }, { label: "B" }] },
67
+ { question: "Q2", header: "Second", options: [{ label: "X" }, { label: "Y" }] },
68
+ ];