@zhushanwen/pi-permission 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.
- package/README.md +455 -0
- package/index.ts +2 -0
- package/package.json +52 -0
- package/src/__tests__/approval.test.ts +456 -0
- package/src/__tests__/commands.test.ts +487 -0
- package/src/__tests__/config.test.ts +229 -0
- package/src/__tests__/e2e-modes.test.ts +253 -0
- package/src/__tests__/extension.test.ts +170 -0
- package/src/__tests__/index-integration.test.ts +282 -0
- package/src/__tests__/model-picker.test.ts +371 -0
- package/src/__tests__/pipeline.test.ts +976 -0
- package/src/__tests__/production.test.ts +102 -0
- package/src/__tests__/rule-editor-component.test.ts +475 -0
- package/src/__tests__/rule-editor.test.ts +252 -0
- package/src/__tests__/rule-templates.test.ts +264 -0
- package/src/__tests__/statusline.test.ts +221 -0
- package/src/__tests__/types.test.ts +59 -0
- package/src/approval.ts +352 -0
- package/src/ast/__tests__/analyzer.test.ts +262 -0
- package/src/ast/__tests__/loader.test.ts +136 -0
- package/src/ast/analyzer.ts +326 -0
- package/src/ast/index.ts +7 -0
- package/src/ast/loader.ts +113 -0
- package/src/classifier/__tests__/classifier.test.ts +285 -0
- package/src/classifier/__tests__/json-parser.test.ts +118 -0
- package/src/classifier/__tests__/model-resolver.test.ts +173 -0
- package/src/classifier/__tests__/prompt.test.ts +59 -0
- package/src/classifier/classifier.ts +242 -0
- package/src/classifier/index.ts +28 -0
- package/src/classifier/json-parser.ts +127 -0
- package/src/classifier/model-resolver.ts +295 -0
- package/src/classifier/prompt.ts +52 -0
- package/src/commands.ts +234 -0
- package/src/config.ts +216 -0
- package/src/index.ts +291 -0
- package/src/model-picker.ts +398 -0
- package/src/pipeline.ts +541 -0
- package/src/production.ts +102 -0
- package/src/rule-editor-component.ts +883 -0
- package/src/rule-editor.ts +262 -0
- package/src/rule-templates.ts +332 -0
- package/src/rules/__tests__/builtins.test.ts +614 -0
- package/src/rules/__tests__/index.test.ts +26 -0
- package/src/rules/__tests__/matcher.test.ts +378 -0
- package/src/rules/__tests__/wildcard.test.ts +84 -0
- package/src/rules/builtins.ts +680 -0
- package/src/rules/index.ts +27 -0
- package/src/rules/matcher.ts +160 -0
- package/src/rules/wildcard.ts +43 -0
- package/src/statusline.ts +226 -0
- package/src/types.ts +249 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* production.test.ts — createProductionClassifier + createPipelineDeps 装配测试。
|
|
3
|
+
*
|
|
4
|
+
* getApiProvider 来自 pi-ai,测试时用真实 SDK(vitest alias 到 @earendil-works/pi-ai)。
|
|
5
|
+
* 装配后验证:classifyRisk 可调(走 fallback 路径,无真实 provider 时返回 ask)、
|
|
6
|
+
* deps 字段齐全、analyzeBashStructure/matchRulesForArgv/getDefaultRules 是真实实现。
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
|
|
10
|
+
import type { ApprovalContext } from "../approval.js";
|
|
11
|
+
import type { CheckPermissionDeps } from "../pipeline.js";
|
|
12
|
+
import { _resetClassifierSingletonForTest, createPipelineDeps, createProductionClassifier } from "../production.js";
|
|
13
|
+
|
|
14
|
+
function makeApprovalCtx(): ApprovalContext {
|
|
15
|
+
return {
|
|
16
|
+
mode: "headless",
|
|
17
|
+
ui: {
|
|
18
|
+
notify() {},
|
|
19
|
+
select() {
|
|
20
|
+
return Promise.resolve(undefined);
|
|
21
|
+
},
|
|
22
|
+
custom() {
|
|
23
|
+
return Promise.resolve(undefined);
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("createProductionClassifier", () => {
|
|
30
|
+
it("返回带 classifyRisk 的对象", () => {
|
|
31
|
+
const classifier = createProductionClassifier();
|
|
32
|
+
expect(typeof classifier.classifyRisk).toBe("function");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("classifyRisk 在无模型时 fail-closed 返回 ask(不 throw)", async () => {
|
|
36
|
+
const classifier = createProductionClassifier();
|
|
37
|
+
// 用不存在的 provider/model 规格强制 resolveClassifierModel 返回 null → fallback ask。
|
|
38
|
+
// (真实环境磁盘存在带 apiKey 的 models.json,model:"auto" 会解析到真实模型并调用 LLM,
|
|
39
|
+
// 无法稳定走 fail-closed 路径,故用 bogus spec 确保无可用模型。)
|
|
40
|
+
const result = await classifier.classifyRisk(
|
|
41
|
+
{ toolName: "bash", command: "ls", cwd: "/tmp" },
|
|
42
|
+
{ enabled: true, model: "nonexistent-provider/no-such-model", timeout: 5, autoApproveLowRisk: false, autoDenyHighRisk: true },
|
|
43
|
+
);
|
|
44
|
+
expect(result.outcome).toBe("ask");
|
|
45
|
+
expect(result.risk_level).toBe("medium");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("createPipelineDeps", () => {
|
|
50
|
+
it("装配完整 CheckPermissionDeps(5 个字段齐全)", () => {
|
|
51
|
+
const deps = createPipelineDeps(makeApprovalCtx());
|
|
52
|
+
expect(deps).toBeInstanceOf(Object);
|
|
53
|
+
expect(typeof deps.analyzeBashStructure).toBe("function");
|
|
54
|
+
expect(typeof deps.matchRulesForArgv).toBe("function");
|
|
55
|
+
expect(typeof deps.getDefaultRules).toBe("function");
|
|
56
|
+
expect(typeof deps.classifier.classifyRisk).toBe("function");
|
|
57
|
+
expect(typeof deps.requestUserApproval).toBe("function");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("getDefaultRules 返回内置危险规则(12 条)", () => {
|
|
61
|
+
const deps = createPipelineDeps(makeApprovalCtx());
|
|
62
|
+
const rules = deps.getDefaultRules();
|
|
63
|
+
expect(rules.length).toBe(12);
|
|
64
|
+
expect(rules.every((r) => r.source === "builtin-danger")).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("analyzeBashStructure 是真实实现(干净命令 → clean=true)", async () => {
|
|
68
|
+
const deps = createPipelineDeps(makeApprovalCtx());
|
|
69
|
+
const analysis = await deps.analyzeBashStructure("ls -la");
|
|
70
|
+
expect(analysis.clean).toBe(true);
|
|
71
|
+
expect(analysis.commands).toEqual([["ls", "-la"]]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("requestUserApproval 走 headless 分支(M1:signal abort → fail-closed deny)", async () => {
|
|
75
|
+
const deps = createPipelineDeps(makeApprovalCtx());
|
|
76
|
+
// M1:headless 无 signal 时永挂,故用已 aborted 的 signal 触发 fail-closed deny。
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
controller.abort();
|
|
79
|
+
const decision = await deps.requestUserApproval(
|
|
80
|
+
{ toolName: "bash", command: "rm", reason: "test" },
|
|
81
|
+
{ toolName: "bash", command: "rm", cwd: "/tmp" },
|
|
82
|
+
controller.signal,
|
|
83
|
+
);
|
|
84
|
+
expect(decision.approved).toBe(false);
|
|
85
|
+
expect(decision.reason).toContain("headless");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("deps 满足 CheckPermissionDeps 类型(结构兼容)", () => {
|
|
89
|
+
const deps: CheckPermissionDeps = createPipelineDeps(makeApprovalCtx());
|
|
90
|
+
// 仅验证类型兼容(运行时已在上面测试)
|
|
91
|
+
expect(deps).toBeDefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("m1:多次 createPipelineDeps 复用同一 classifier 单例", () => {
|
|
95
|
+
_resetClassifierSingletonForTest();
|
|
96
|
+
const deps1 = createPipelineDeps(makeApprovalCtx());
|
|
97
|
+
const deps2 = createPipelineDeps(makeApprovalCtx());
|
|
98
|
+
// classifier 是单例,两次装配应引用同一对象(classifyRisk 引用相同)
|
|
99
|
+
expect(deps1.classifier).toBe(deps2.classifier);
|
|
100
|
+
expect(deps1.classifier.classifyRisk).toBe(deps2.classifier.classifyRisk);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rule-editor-component.test.ts — W8 T7: RuleEditorComponent 单元测试。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* - 构造 + 初始 list stage
|
|
6
|
+
* - list stage 选择规则 → fill(edit)
|
|
7
|
+
* - list stage [+ Add] → add stage
|
|
8
|
+
* - add stage 选模板 → fill stage
|
|
9
|
+
* - [Done] → settle ops
|
|
10
|
+
* - _resolved 守卫
|
|
11
|
+
* - currentListRules 实时反映 ops(G2)
|
|
12
|
+
* - WR1 handleInput 锁定(Container override 委托)
|
|
13
|
+
* - Tab 焦点路由(WR8)
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it, vi } from "vitest";
|
|
16
|
+
|
|
17
|
+
import { RuleEditorComponent } from "../rule-editor-component.js";
|
|
18
|
+
import type { RuleOp } from "../rule-templates.js";
|
|
19
|
+
import type { Rule } from "../types.js";
|
|
20
|
+
|
|
21
|
+
// ──────────────────────── helpers ────────────────────────
|
|
22
|
+
|
|
23
|
+
function makeRule(overrides: Partial<Rule> = {}): Rule {
|
|
24
|
+
return {
|
|
25
|
+
id: "user-1",
|
|
26
|
+
tool: "bash",
|
|
27
|
+
pattern: "npm *",
|
|
28
|
+
action: "allow",
|
|
29
|
+
source: "user",
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 构造一个 RuleEditorComponent 并收集 done 结果。 */
|
|
35
|
+
function createComp(
|
|
36
|
+
initialRules: Rule[] = [],
|
|
37
|
+
doneFn?: (ops: RuleOp[]) => void,
|
|
38
|
+
): { comp: RuleEditorComponent; done: ReturnType<typeof vi.fn>; counter: () => string } {
|
|
39
|
+
let nextId = 1;
|
|
40
|
+
const counter = (): string => `user-${nextId++}`;
|
|
41
|
+
const done = vi.fn(doneFn ?? (() => undefined));
|
|
42
|
+
const comp = new RuleEditorComponent(initialRules, counter, done);
|
|
43
|
+
return { comp, done, counter };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ──────────────────────── 构造 + 初始 list stage ────────────────────────
|
|
47
|
+
|
|
48
|
+
describe("REC1: 构造 + 初始 list stage", () => {
|
|
49
|
+
it("构造不抛错,render 非空", () => {
|
|
50
|
+
const { comp } = createComp();
|
|
51
|
+
const lines = comp.render(80);
|
|
52
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("初始 render 含 'Permission Rules'", () => {
|
|
56
|
+
const { comp } = createComp([makeRule()]);
|
|
57
|
+
expect(comp.render(80).join("\n")).toContain("Permission Rules");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("list stage 含已有规则 + [+ Add rule] + [Done]", () => {
|
|
61
|
+
const { comp } = createComp([makeRule({ id: "user-1", pattern: "npm *" })]);
|
|
62
|
+
const text = comp.render(80).join("\n");
|
|
63
|
+
expect(text).toContain("npm *");
|
|
64
|
+
expect(text).toContain("[+ Add rule]");
|
|
65
|
+
expect(text).toContain("[Done]");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ──────────────────────── list stage Enter 行为 ────────────────────────
|
|
70
|
+
|
|
71
|
+
describe("REC2: list stage Enter 行为", () => {
|
|
72
|
+
it("Enter 选 [+ Add rule] → add stage(render 含 'Select Template')", () => {
|
|
73
|
+
const { comp } = createComp();
|
|
74
|
+
// 预选在第一条([+ Add rule]),因为无规则时它是第一项
|
|
75
|
+
// 但有 [Done] 在最后,所以 [+ Add rule] 是 index 0(无规则时)
|
|
76
|
+
comp.handleInput("\r"); // Enter
|
|
77
|
+
const text = comp.render(80).join("\n");
|
|
78
|
+
expect(text).toContain("Select Template");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("Enter 选 [Done] → settle 空 ops(无规则时 [Done] 是第二项)", () => {
|
|
82
|
+
const { comp, done } = createComp();
|
|
83
|
+
comp.handleInput("\x1b[B"); // Down → [Done]
|
|
84
|
+
comp.handleInput("\r"); // Enter
|
|
85
|
+
expect(done).toHaveBeenCalledOnce();
|
|
86
|
+
const ops = done.mock.calls[0]![0] as RuleOp[];
|
|
87
|
+
expect(ops).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ──────────────────────── add stage 选模板 ────────────────────────
|
|
92
|
+
|
|
93
|
+
describe("REC3: add stage 选模板", () => {
|
|
94
|
+
it("选 allow-family 模板 → fill stage(render 含 'Select command')", () => {
|
|
95
|
+
const { comp } = createComp();
|
|
96
|
+
// 进 add stage
|
|
97
|
+
comp.handleInput("\r"); // Enter → add stage([+ Add rule] 预选)
|
|
98
|
+
// add stage 第一条是 allow-family,Enter 选中
|
|
99
|
+
comp.handleInput("\r");
|
|
100
|
+
const text = comp.render(80).join("\n");
|
|
101
|
+
expect(text).toContain("Select command");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("add stage Esc → 回 list stage", () => {
|
|
105
|
+
const { comp } = createComp();
|
|
106
|
+
comp.handleInput("\r"); // → add stage
|
|
107
|
+
comp.handleInput("\x1b"); // Esc
|
|
108
|
+
const text = comp.render(80).join("\n");
|
|
109
|
+
expect(text).toContain("Permission Rules");
|
|
110
|
+
expect(text).not.toContain("Select Template");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ──────────────────────── fill stage:命令选择 + commitFill ────────────────────────
|
|
115
|
+
|
|
116
|
+
describe("REC4: fill stage 命令选择 + commitFill", () => {
|
|
117
|
+
it("选 npm → commitFill → 回 list stage,新规则出现", () => {
|
|
118
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
119
|
+
const { comp, done } = createComp();
|
|
120
|
+
// → add stage → 选 allow-family → fill stage(command-select 带搜索)
|
|
121
|
+
comp.handleInput("\r"); // [+ Add rule]
|
|
122
|
+
comp.handleInput("\r"); // allow-family
|
|
123
|
+
// fill stage:搜索框默认获焦,Enter(空 query)→ 选中第一个匹配 npm → 进入 scope 列表
|
|
124
|
+
comp.handleInput("\r");
|
|
125
|
+
// scope 列表:第一条是 `${cmd} * (all subcommands)`(__all__),Enter → commitFill
|
|
126
|
+
comp.handleInput("\r");
|
|
127
|
+
// commitFill → switchToListStage,list 现在应有新规则
|
|
128
|
+
const text = comp.render(80).join("\n");
|
|
129
|
+
expect(text).toContain("npm *");
|
|
130
|
+
expect(comp.ops).toHaveLength(1);
|
|
131
|
+
expect(comp.ops[0]!.kind).toBe("add");
|
|
132
|
+
expect(comp.ops[0]!.rule.pattern).toBe("npm *");
|
|
133
|
+
expect(comp.ops[0]!.rule.action).toBe("allow");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ──────────────────────── _resolved 守卫 ────────────────────────
|
|
138
|
+
|
|
139
|
+
describe("REC5: _resolved 守卫", () => {
|
|
140
|
+
it("settle 后再 handleInput no-op(done 只调一次)", () => {
|
|
141
|
+
const { comp, done } = createComp();
|
|
142
|
+
// 选 [Done]
|
|
143
|
+
comp.handleInput("\r"); // [+ Add rule](但无规则时第一条是 [+ Add rule])
|
|
144
|
+
// 实际无规则时 items = ["[+ Add rule]", "[Done]"]
|
|
145
|
+
// Enter 选了 [+ Add rule],进 add stage
|
|
146
|
+
// 回到 list 用 cancel
|
|
147
|
+
comp.handleInput("\x1b"); // Esc → back to list
|
|
148
|
+
// 现在 list 有 [+ Add rule] 和 [Done]
|
|
149
|
+
comp.handleInput("\x1b[B"); // Down → [Done]
|
|
150
|
+
comp.handleInput("\r"); // Enter → settle
|
|
151
|
+
expect(done).toHaveBeenCalledOnce();
|
|
152
|
+
// 再次输入 no-op
|
|
153
|
+
comp.handleInput("\r");
|
|
154
|
+
expect(done).toHaveBeenCalledOnce();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ──────────────────────── currentListRules(G2) ────────────────────────
|
|
159
|
+
|
|
160
|
+
describe("REC6: currentListRules 实时反映 ops(G2)", () => {
|
|
161
|
+
it("初始 currentListRules 等于 initialRules", () => {
|
|
162
|
+
const rules = [makeRule({ id: "user-1" })];
|
|
163
|
+
const { comp } = createComp(rules);
|
|
164
|
+
expect(comp.currentListRules()).toEqual(rules);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("ops.push add 后 currentListRules 包含新规则", () => {
|
|
168
|
+
const { comp } = createComp([makeRule({ id: "user-1" })]);
|
|
169
|
+
comp.ops.push({ kind: "add", rule: makeRule({ id: "user-2", pattern: "git *" }) });
|
|
170
|
+
const rules = comp.currentListRules();
|
|
171
|
+
expect(rules).toHaveLength(2);
|
|
172
|
+
expect(rules[1]!.pattern).toBe("git *");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ──────────────────────── WR1 handleInput 锁定 ────────────────────────
|
|
177
|
+
|
|
178
|
+
describe("REC7: WR1 handleInput 委托锁定(critical)", () => {
|
|
179
|
+
it("handleInput('\\r') 直接触发 SelectList.onSelect(list stage [Done])", () => {
|
|
180
|
+
const { comp, done } = createComp();
|
|
181
|
+
// list stage:无规则 → items = [[+ Add rule], [Done]]
|
|
182
|
+
// Down → [Done]
|
|
183
|
+
comp.handleInput("\x1b[B");
|
|
184
|
+
comp.handleInput("\r");
|
|
185
|
+
expect(done).toHaveBeenCalledOnce();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("handleInput(down + enter) 链式触发导航 + onSelect", () => {
|
|
189
|
+
const { comp, done } = createComp([makeRule({ id: "user-1", pattern: "npm *" })]);
|
|
190
|
+
// list items: [npm * (rule), [+ Add rule], [Done]]
|
|
191
|
+
// 预选 index 0(npm *),Enter → fill(edit)
|
|
192
|
+
comp.handleInput("\r");
|
|
193
|
+
// fill(edit) 模式,render 应含 Edit Rule
|
|
194
|
+
const text = comp.render(80).join("\n");
|
|
195
|
+
expect(text).toContain("Edit Rule");
|
|
196
|
+
// done 不应被调(还在 fill stage)
|
|
197
|
+
expect(done).not.toHaveBeenCalled();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ──────────────────────── WR8 Tab 焦点路由 ────────────────────────
|
|
202
|
+
|
|
203
|
+
describe("REC8: WR8 Tab 焦点路由(Custom form)", () => {
|
|
204
|
+
it("custom form Tab 循环焦点(5 项)", () => {
|
|
205
|
+
const { comp } = createComp();
|
|
206
|
+
// → add stage
|
|
207
|
+
comp.handleInput("\r"); // [+ Add rule]
|
|
208
|
+
// 选 custom(最后一条模板,index 4)
|
|
209
|
+
for (let i = 0; i < 4; i++) comp.handleInput("\x1b[B"); // Down x4
|
|
210
|
+
comp.handleInput("\r"); // Enter → custom fill
|
|
211
|
+
const text = comp.render(80).join("\n");
|
|
212
|
+
expect(text).toContain("Custom Rule");
|
|
213
|
+
// Tab 5 次应回到原焦点(模 5)
|
|
214
|
+
for (let i = 0; i < 5; i++) comp.handleInput("\t");
|
|
215
|
+
// 不应抛错
|
|
216
|
+
expect(comp.render(80).length).toBeGreaterThan(0);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ──────────────────────── cancel ────────────────────────
|
|
221
|
+
|
|
222
|
+
describe("REC9: cancel", () => {
|
|
223
|
+
it("cancel() 调 done(ops)(可能为空)", () => {
|
|
224
|
+
const { comp, done } = createComp();
|
|
225
|
+
comp.cancel();
|
|
226
|
+
expect(done).toHaveBeenCalledOnce();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("cancel 后 _resolved 守卫生效", () => {
|
|
230
|
+
const { comp, done } = createComp();
|
|
231
|
+
comp.cancel();
|
|
232
|
+
comp.cancel();
|
|
233
|
+
expect(done).toHaveBeenCalledOnce();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ──────────────────────── Wave 2: edit 提交保留改动(M1 + M2) ────────────────────────
|
|
238
|
+
|
|
239
|
+
describe("REC10: Wave 2 — edit 提交保留改动(M1)", () => {
|
|
240
|
+
it("edit 规则改 action 后 ops 含 edit 且字段正确", () => {
|
|
241
|
+
const initialRule = makeRule({ id: "user-1", pattern: "npm *", action: "allow" });
|
|
242
|
+
const { comp } = createComp([initialRule]);
|
|
243
|
+
// list stage 第一条是规则,Enter 进 edit(custom form,edit 模式)
|
|
244
|
+
comp.handleInput("\r");
|
|
245
|
+
// 焦点在 pattern(index 0),Tab 到 action(index 1)
|
|
246
|
+
comp.handleInput("\t"); // → action
|
|
247
|
+
comp.handleInput("\x1b[B"); // Down → deny(移动指针)
|
|
248
|
+
comp.handleInput("\r"); // Enter → actionList.onSelect → fillSelections.action = "deny"
|
|
249
|
+
// Tab 经 tool / description 到 submit
|
|
250
|
+
comp.handleInput("\t"); // → tool (index 2)
|
|
251
|
+
comp.handleInput("\t"); // → description (index 3)
|
|
252
|
+
comp.handleInput("\t"); // → submit (index 4)
|
|
253
|
+
comp.handleInput("\r"); // submit → commitFill
|
|
254
|
+
// 回到 list stage,ops 应含 1 个 edit
|
|
255
|
+
expect(comp.ops).toHaveLength(1);
|
|
256
|
+
expect(comp.ops[0]!.kind).toBe("edit");
|
|
257
|
+
const editOp = comp.ops[0]!;
|
|
258
|
+
if (editOp.kind === "edit") {
|
|
259
|
+
expect(editOp.id).toBe("user-1");
|
|
260
|
+
expect(editOp.rule.id).toBe("user-1");
|
|
261
|
+
expect(editOp.rule.pattern).toBe("npm *"); // 未改 pattern
|
|
262
|
+
expect(editOp.rule.action).toBe("deny"); // 改成 deny
|
|
263
|
+
expect(editOp.rule.tool).toBe("bash");
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("edit 提交后 pattern 通过 _syncCustomFormValues 保留(即使没按 Enter 确认 Input)", () => {
|
|
268
|
+
// 验证 M2:Input.onSubmit 仅在 Enter 时触发;Tab 切走不触发。
|
|
269
|
+
// commitFill 入口的 _syncCustomFormValues 兜底读 getValue,确保预填值不丢。
|
|
270
|
+
const initialRule = makeRule({ id: "user-1", pattern: "npm *", action: "allow" });
|
|
271
|
+
const { comp } = createComp([initialRule]);
|
|
272
|
+
comp.handleInput("\r"); // 进 edit
|
|
273
|
+
// 焦点在 pattern,直接 Tab 到 submit(不按 Enter 确认 pattern)
|
|
274
|
+
comp.handleInput("\t"); // → action
|
|
275
|
+
comp.handleInput("\t"); // → tool
|
|
276
|
+
comp.handleInput("\t"); // → description
|
|
277
|
+
comp.handleInput("\t"); // → submit
|
|
278
|
+
comp.handleInput("\r"); // submit → commitFill(入口调 _syncCustomFormValues)
|
|
279
|
+
expect(comp.ops).toHaveLength(1);
|
|
280
|
+
const editOp = comp.ops[0]!;
|
|
281
|
+
if (editOp.kind === "edit") {
|
|
282
|
+
expect(editOp.rule.pattern).toBe("npm *"); // _syncCustomFormValues 读到预填值
|
|
283
|
+
expect(editOp.rule.action).toBe("allow"); // 原 action 保留
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("edit 模式 commitFill 绕过 fillTemplate.build(M1:不再被 fillTemplate===null 吞掉)", () => {
|
|
288
|
+
// 直接验证:edit 进入 custom form 后 fillTemplate 始终为 null,
|
|
289
|
+
// 旧逻辑会 switchToListStage() 提前返回,ops 为空。
|
|
290
|
+
// 新逻辑:edit 分支前置,直接构造 Rule。
|
|
291
|
+
const initialRule = makeRule({ id: "user-1", pattern: "git *", action: "ask" });
|
|
292
|
+
const { comp } = createComp([initialRule]);
|
|
293
|
+
comp.handleInput("\r"); // 进 edit
|
|
294
|
+
// 焦点一路 Tab 到 submit,不改任何字段
|
|
295
|
+
for (let i = 0; i < 4; i++) comp.handleInput("\t"); // pattern→action→tool→desc→submit
|
|
296
|
+
comp.handleInput("\r"); // submit
|
|
297
|
+
expect(comp.ops).toHaveLength(1);
|
|
298
|
+
const editOp = comp.ops[0]!;
|
|
299
|
+
if (editOp.kind === "edit") {
|
|
300
|
+
expect(editOp.rule.pattern).toBe("git *");
|
|
301
|
+
expect(editOp.rule.action).toBe("ask");
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ──────────────────────── Wave 2: custom form 焦点指示(M4) ────────────────────────
|
|
307
|
+
|
|
308
|
+
describe("REC11: Wave 2 — custom form 焦点指示(M4)", () => {
|
|
309
|
+
it("初始焦点在 Pattern,render 含 ▶ Pattern 标记且不含 ▶ Action", () => {
|
|
310
|
+
const { comp } = createComp();
|
|
311
|
+
// 进 custom form:add → 选 custom 模板(index 4)
|
|
312
|
+
comp.handleInput("\r"); // [+ Add rule]
|
|
313
|
+
for (let i = 0; i < 4; i++) comp.handleInput("\x1b[B"); // Down x4 到 custom
|
|
314
|
+
comp.handleInput("\r"); // Enter custom
|
|
315
|
+
const text = comp.render(80).join("\n");
|
|
316
|
+
expect(text).toContain("▶ Pattern");
|
|
317
|
+
expect(text).not.toContain("▶ Action");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("Tab 后焦点到 Action,render 含 ▶ Action 标记且 ▶ Pattern 消失", () => {
|
|
321
|
+
const { comp } = createComp();
|
|
322
|
+
comp.handleInput("\r");
|
|
323
|
+
for (let i = 0; i < 4; i++) comp.handleInput("\x1b[B");
|
|
324
|
+
comp.handleInput("\r");
|
|
325
|
+
comp.handleInput("\t"); // Tab → Action
|
|
326
|
+
const text = comp.render(80).join("\n");
|
|
327
|
+
expect(text).toContain("▶ Action");
|
|
328
|
+
expect(text).not.toContain("▶ Pattern");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("连续 Tab 循环焦点:Pattern → Action → Tool → Description → 回 Pattern 区域无重复 ▶", () => {
|
|
332
|
+
const { comp } = createComp();
|
|
333
|
+
comp.handleInput("\r");
|
|
334
|
+
for (let i = 0; i < 4; i++) comp.handleInput("\x1b[B");
|
|
335
|
+
comp.handleInput("\r");
|
|
336
|
+
// 焦点 0=Pattern → 1=Action → 2=Tool → 3=Description
|
|
337
|
+
const labels = ["▶ Pattern", "▶ Action", "▶ Tool", "▶ Description (optional)"];
|
|
338
|
+
for (const expected of labels) {
|
|
339
|
+
const text = comp.render(80).join("\n");
|
|
340
|
+
expect(text).toContain(expected);
|
|
341
|
+
comp.handleInput("\t");
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// ──────────────────────── Wave 3: 搜索过滤一致性(M5) ────────────────────────
|
|
347
|
+
|
|
348
|
+
describe("REC12: Wave 3 — 搜索过滤一致性(M5)", () => {
|
|
349
|
+
it("输入前缀匹配的字符,列表过滤显示对应命令", () => {
|
|
350
|
+
const { comp } = createComp();
|
|
351
|
+
// 进 command-select:add → allow-family
|
|
352
|
+
comp.handleInput("\r"); // [+ Add rule]
|
|
353
|
+
comp.handleInput("\r"); // allow-family(第一条模板,预选)
|
|
354
|
+
// 现在在 command-select,焦点默认在搜索框
|
|
355
|
+
// 输入 'gi'(git value 的前缀,startsWith 匹配)
|
|
356
|
+
comp.handleInput("g");
|
|
357
|
+
comp.handleInput("i");
|
|
358
|
+
const text = comp.render(80).join("\n");
|
|
359
|
+
// git 应在过滤后列表中
|
|
360
|
+
expect(text).toMatch(/git/i);
|
|
361
|
+
// 非前缀匹配的命令不应出现(如 npm,'gi' 不是其前缀)
|
|
362
|
+
expect(text).not.toMatch(/npm/i);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("输入非前缀字符,实时列表不显示不匹配项(startsWith 一致性)", () => {
|
|
366
|
+
const { comp } = createComp();
|
|
367
|
+
comp.handleInput("\r"); // [+ Add rule]
|
|
368
|
+
comp.handleInput("\r"); // allow-family
|
|
369
|
+
// 输入 'install'(不是任何命令 value/label 的前缀;npm install 的 value 是 'npm')
|
|
370
|
+
for (const ch of "install") comp.handleInput(ch);
|
|
371
|
+
const text = comp.render(80).join("\n");
|
|
372
|
+
// npm 不应出现(startsWith 不匹配)
|
|
373
|
+
expect(text).not.toMatch(/npm/i);
|
|
374
|
+
// 实时列表(SelectList.setFilter)无匹配 → 显示 noMatch 文案
|
|
375
|
+
// 注:__other__ 仅在 Enter 选择路径(_filterCommands)保留,不在实时显示保留
|
|
376
|
+
// (SelectList.setFilter 是 pi-tui 内置 value.startsWith,不感知 __other__)
|
|
377
|
+
expect(text).toContain("No matching commands");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("__other__ 在 Enter 选择路径始终保留(_filterCommands 语义)", () => {
|
|
381
|
+
const { comp } = createComp();
|
|
382
|
+
comp.handleInput("\r");
|
|
383
|
+
comp.handleInput("\r");
|
|
384
|
+
// 输入任意字符(无命令 value 以 zzz 开头)
|
|
385
|
+
for (const ch of "zzz") comp.handleInput(ch);
|
|
386
|
+
// 实时显示已被 setFilter 清空(No matching commands),但 _filterCommands
|
|
387
|
+
// 保留 __other__ → Enter 触发 onSubmit → filtered 含 __other__ → 进入手动输入
|
|
388
|
+
comp.handleInput("\r");
|
|
389
|
+
const text = comp.render(80).join("\n");
|
|
390
|
+
expect(text).toContain("Enter command name"); // startCommandInput 界面
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// ──────────────────────── C1/C2/C3: _resetStageRefs 死锁修复回归 ────────────────────────
|
|
395
|
+
|
|
396
|
+
describe("REC13: C3 — _resetStageRefs 多次 stage 转换不残留旧组件引用(死锁修复回归)", () => {
|
|
397
|
+
// C3 根因:switchTo*/startFill* 方法手动清理子组件引用时遗漏字段,
|
|
398
|
+
// 导致 handleInput 把输入路由到已清空的旧组件(输入死锁)。
|
|
399
|
+
// _resetStageRefs 集中清理 _searchInput/_commandList/_searchFocus/
|
|
400
|
+
// currentInput/currentList/customChildren,杜绝遗漏。
|
|
401
|
+
// 因这些字段是 private,通过行为验证:Esc 回退后再进 stage,
|
|
402
|
+
// 输入字符应作用于「新」组件而非残留的旧组件(不 crash、过滤正确)。
|
|
403
|
+
it("command-select → Esc 回退 → 再进 command-select:输入字符过滤新列表(不路由到旧组件)", () => {
|
|
404
|
+
const { comp } = createComp();
|
|
405
|
+
// 1. 进 command-select stage(add → allow-family)
|
|
406
|
+
comp.handleInput("\r"); // [+ Add rule] → add stage
|
|
407
|
+
comp.handleInput("\r"); // allow-family → command-select stage
|
|
408
|
+
expect(comp.render(80).join("\n")).toContain("Select command");
|
|
409
|
+
// 2. 输入字符到 searchInput(验证旧 _searchInput 活跃:能过滤列表)
|
|
410
|
+
comp.handleInput("g");
|
|
411
|
+
comp.handleInput("i");
|
|
412
|
+
let text = comp.render(80).join("\n");
|
|
413
|
+
expect(text).toMatch(/git/i); // 'gi' 前缀过滤出 git
|
|
414
|
+
// 3. Esc 回退:command-select 的 searchInput.onEscape → switchToListStage
|
|
415
|
+
// → _resetStageRefs 清空 _searchInput/_commandList
|
|
416
|
+
comp.handleInput("\x1b");
|
|
417
|
+
text = comp.render(80).join("\n");
|
|
418
|
+
expect(text).toContain("Permission Rules"); // 回到 list stage
|
|
419
|
+
// 4. 再进 command-select stage(list → add → allow-family)
|
|
420
|
+
comp.handleInput("\r"); // [+ Add rule] → add stage
|
|
421
|
+
comp.handleInput("\r"); // allow-family → command-select stage(新建 _searchInput/_commandList)
|
|
422
|
+
expect(comp.render(80).join("\n")).toContain("Select command");
|
|
423
|
+
// 5. 关键:输入字符应作用于「新」searchInput → 过滤新 commandList
|
|
424
|
+
// 若旧引用残留,过滤会错乱或 crash。输入 'gi' 应再次过滤出 git。
|
|
425
|
+
comp.handleInput("g");
|
|
426
|
+
comp.handleInput("i");
|
|
427
|
+
text = comp.render(80).join("\n");
|
|
428
|
+
expect(text).toMatch(/git/i); // 新列表被正确过滤
|
|
429
|
+
// npm 不应出现('gi' 不是 npm 前缀)—— 证明输入路由到新组件而非旧组件
|
|
430
|
+
expect(text).not.toMatch(/npm/i);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("custom form → Esc 回退 → 再进 command-select:Tab/字符不误触发旧 customChildren", () => {
|
|
434
|
+
// 跨 fillKind 转换(custom → list → command-select):
|
|
435
|
+
// customChildren 在 _resetStageRefs 被清空,再进 command-select 时
|
|
436
|
+
// handleInput 不应走 custom 分支(fillKind 已变为 command-select)。
|
|
437
|
+
const { comp } = createComp();
|
|
438
|
+
// 进 custom form:add → custom 模板(index 4)
|
|
439
|
+
comp.handleInput("\r"); // [+ Add rule]
|
|
440
|
+
for (let i = 0; i < 4; i++) comp.handleInput("\x1b[B"); // Down x4 → custom
|
|
441
|
+
comp.handleInput("\r"); // Enter → custom form
|
|
442
|
+
expect(comp.render(80).join("\n")).toContain("Custom Rule");
|
|
443
|
+
// Esc:pattern Input.onEscape → switchToListStage → _resetStageRefs 清 customChildren
|
|
444
|
+
comp.handleInput("\x1b");
|
|
445
|
+
expect(comp.render(80).join("\n")).toContain("Permission Rules");
|
|
446
|
+
// 再进 command-select
|
|
447
|
+
comp.handleInput("\r"); // [+ Add rule]
|
|
448
|
+
comp.handleInput("\r"); // allow-family → command-select
|
|
449
|
+
expect(comp.render(80).join("\n")).toContain("Select command");
|
|
450
|
+
// 输入字符 + Tab:不应 crash,不应误触发残留 customChildren 的 Tab 路由
|
|
451
|
+
comp.handleInput("g");
|
|
452
|
+
comp.handleInput("\t"); // Tab 切换搜索框↔列表(command-select 语义,非 custom Tab 循环)
|
|
453
|
+
// 不抛错 + 仍处于 command-select stage
|
|
454
|
+
const text = comp.render(80).join("\n");
|
|
455
|
+
expect(text).toContain("Select command");
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("连续多次 add↔list 转换不残留 currentList(Enter 选模板稳定生效)", () => {
|
|
459
|
+
// 多次 switchToAddStage/switchToListStage 循环:每次 _resetStageRefs 清 currentList,
|
|
460
|
+
// 新 currentList 正确赋值。验证连续进 add stage 选模板仍能正常进入 fill stage。
|
|
461
|
+
const { comp } = createComp();
|
|
462
|
+
for (let round = 0; round < 3; round++) {
|
|
463
|
+
// list → add
|
|
464
|
+
comp.handleInput("\r"); // [+ Add rule] → add stage
|
|
465
|
+
expect(comp.render(80).join("\n")).toContain("Select Template");
|
|
466
|
+
// add → list(Esc)
|
|
467
|
+
comp.handleInput("\x1b");
|
|
468
|
+
expect(comp.render(80).join("\n")).toContain("Permission Rules");
|
|
469
|
+
}
|
|
470
|
+
// 最后一次进 add 并选 allow-family:currentList 是新建的,onSelect 正常触发
|
|
471
|
+
comp.handleInput("\r"); // → add stage
|
|
472
|
+
comp.handleInput("\r"); // allow-family → command-select
|
|
473
|
+
expect(comp.render(80).join("\n")).toContain("Select command");
|
|
474
|
+
});
|
|
475
|
+
});
|