1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
|
import { describe, expect, it, vi } from "vitest";
// Mock @dispatch/core to provide only the PermissionService impl this test
// touches — the core barrel transitively pulls in bun:sqlite, which vitest
// running under Node cannot resolve.
vi.mock("@dispatch/core", async () => {
const mod = await import("../../core/src/permission/service.js");
return {
PermissionService: mod.PermissionService,
};
});
const { PermissionManager } = await import("../src/permission-manager.js");
interface PermissionRequest {
permission: string;
patterns: string[];
always: string[];
description: string;
metadata: Record<string, unknown>;
}
function makeRequest(overrides: Partial<PermissionRequest> = {}): PermissionRequest {
return {
permission: "bash",
patterns: ["git *"],
always: ["git status"],
description: "Run git status",
metadata: {},
...overrides,
};
}
describe("PermissionManager.onPromptAdded", () => {
it("fires once per newly-added pending prompt", () => {
const mgr = new PermissionManager();
const seen: Array<{ id: string; permission: string }> = [];
mgr.onPromptAdded((p) => {
seen.push({ id: p.id, permission: p.permission });
});
void mgr.ask(makeRequest(), []);
void mgr.ask(makeRequest({ permission: "read", description: "Read X" }), []);
expect(seen).toHaveLength(2);
expect(seen[0].permission).toBe("bash");
expect(seen[1].permission).toBe("read");
// Distinct ids
expect(seen[0].id).not.toBe(seen[1].id);
});
it("does not re-fire when the pending list is rebroadcast for an unrelated change", async () => {
const mgr = new PermissionManager();
const seen: string[] = [];
mgr.onPromptAdded((p) => seen.push(p.id));
// Two prompts in; should see two notifications.
const p1 = mgr.ask(makeRequest(), []);
void mgr.ask(makeRequest({ permission: "read" }), []);
expect(seen).toHaveLength(2);
// Resolve the first one — broadcastPending fires again, but the
// remaining (already-announced) prompt must NOT re-notify.
const pending = mgr.getPending();
const firstId = pending[0].id;
mgr.reply(firstId, "once");
await p1;
expect(seen).toHaveLength(2);
});
it("unsubscribe stops further notifications", () => {
const mgr = new PermissionManager();
const seen: string[] = [];
const unsub = mgr.onPromptAdded((p) => seen.push(p.id));
void mgr.ask(makeRequest(), []);
unsub();
void mgr.ask(makeRequest({ permission: "read" }), []);
expect(seen).toHaveLength(1);
});
it("listener throws are caught and don't break other listeners", () => {
const mgr = new PermissionManager();
const seen: string[] = [];
mgr.onPromptAdded(() => {
throw new Error("boom");
});
mgr.onPromptAdded((p) => seen.push(p.id));
// Swallow the warn during this test.
const origWarn = console.warn;
console.warn = () => {};
try {
void mgr.ask(makeRequest(), []);
} finally {
console.warn = origWarn;
}
expect(seen).toHaveLength(1);
});
});
|