@blackbelt-technology/pi-agent-dashboard 0.4.1 → 0.4.2
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/AGENTS.md +79 -32
- package/README.md +7 -3
- package/docs/architecture.md +361 -12
- package/package.json +7 -7
- package/packages/extension/package.json +7 -2
- package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +51 -7
- package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
- package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
- package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
- package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
- package/packages/extension/src/ask-user-tool.ts +165 -57
- package/packages/extension/src/bridge.ts +97 -4
- package/packages/extension/src/multiselect-decode.ts +40 -0
- package/packages/extension/src/multiselect-polyfill.ts +38 -8
- package/packages/extension/src/ui-modules.ts +272 -0
- package/packages/server/package.json +9 -3
- package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
- package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
- package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
- package/packages/server/src/__tests__/directory-service.test.ts +174 -0
- package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
- package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
- package/packages/server/src/__tests__/package-routes.test.ts +136 -3
- package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
- package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
- package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
- package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
- package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
- package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
- package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
- package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
- package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
- package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
- package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
- package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
- package/packages/server/src/browse.ts +118 -13
- package/packages/server/src/browser-gateway.ts +19 -0
- package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
- package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
- package/packages/server/src/browser-handlers/handler-context.ts +15 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
- package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
- package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
- package/packages/server/src/cli.ts +5 -6
- package/packages/server/src/directory-service.ts +156 -15
- package/packages/server/src/event-wiring.ts +111 -10
- package/packages/server/src/installed-package-enricher.ts +143 -0
- package/packages/server/src/package-manager-wrapper.ts +305 -8
- package/packages/server/src/package-source-helpers.ts +104 -0
- package/packages/server/src/pending-attach-registry.ts +112 -0
- package/packages/server/src/pending-resume-intent-registry.ts +107 -0
- package/packages/server/src/pi-core-checker.ts +9 -14
- package/packages/server/src/pi-gateway.ts +14 -0
- package/packages/server/src/proposal-attach-naming.ts +47 -0
- package/packages/server/src/routes/file-routes.ts +29 -3
- package/packages/server/src/routes/package-routes.ts +72 -3
- package/packages/server/src/routes/plugin-config-routes.ts +129 -0
- package/packages/server/src/routes/system-routes.ts +2 -0
- package/packages/server/src/server.ts +339 -10
- package/packages/server/src/session-api.ts +30 -5
- package/packages/server/src/session-order-manager.ts +22 -0
- package/packages/server/src/session-scanner.ts +10 -1
- package/packages/shared/package.json +9 -2
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
- package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
- package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
- package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
- package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
- package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
- package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
- package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
- package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
- package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
- package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
- package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
- package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
- package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
- package/packages/shared/src/browser-protocol.ts +110 -4
- package/packages/shared/src/config.ts +45 -0
- package/packages/shared/src/dashboard-plugin/index.ts +11 -0
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
- package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
- package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
- package/packages/shared/src/openspec-activity-detector.ts +18 -22
- package/packages/shared/src/openspec-design-evidence.ts +109 -0
- package/packages/shared/src/openspec-poller.ts +117 -3
- package/packages/shared/src/openspec-specs-evidence.ts +79 -0
- package/packages/shared/src/platform/binary-lookup.ts +96 -1
- package/packages/shared/src/plugin-bridge-register.ts +139 -0
- package/packages/shared/src/protocol.ts +56 -2
- package/packages/shared/src/recommended-extensions.ts +7 -1
- package/packages/shared/src/rest-api.ts +68 -3
- package/packages/shared/src/state-replay.ts +11 -1
- package/packages/shared/src/tool-registry/strategies.ts +17 -3
- package/packages/shared/src/types.ts +160 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { refreshUiModules, subscribeUiInvalidate, type UiModulesBridgeCtx } from "../ui-modules.js";
|
|
3
|
+
import type { DecoratorDescriptor, ExtensionUiModule } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Phase-2 (`add-extension-ui-decorations`) bridge contract:
|
|
7
|
+
*
|
|
8
|
+
* - `refreshUiModules` partitions probe.modules by `kind` — `management-modal`
|
|
9
|
+
* keeps flowing through `ui_modules_list`; the five Phase-2 kinds each
|
|
10
|
+
* forward as one `ext_ui_decorator` message.
|
|
11
|
+
* - Decorators MUST carry a `namespace` matching `/^[a-z0-9-]+$/`; malformed
|
|
12
|
+
* namespaces are dropped with a warning.
|
|
13
|
+
* - `(kind, namespace, id)` collisions within one probe → warning + last-write-wins.
|
|
14
|
+
* - `removed: true` is forwarded verbatim.
|
|
15
|
+
* - `ui:invalidate` re-runs the partitioned probe.
|
|
16
|
+
* - Per-session invalidate rate cap: ≤20 invalidations/second; excess
|
|
17
|
+
* coalesced to a trailing-edge probe with one warning.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function createTestCtx(sessionId = "s1") {
|
|
21
|
+
const listeners = new Map<string, Array<(...args: any[]) => any>>();
|
|
22
|
+
const sent: any[] = [];
|
|
23
|
+
|
|
24
|
+
const ctx: UiModulesBridgeCtx & { _sent: any[]; _listeners: typeof listeners } = {
|
|
25
|
+
pi: {
|
|
26
|
+
events: {
|
|
27
|
+
on: vi.fn((event: string, fn: (...args: any[]) => any) => {
|
|
28
|
+
if (!listeners.has(event)) listeners.set(event, []);
|
|
29
|
+
listeners.get(event)!.push(fn);
|
|
30
|
+
}) as any,
|
|
31
|
+
emit: vi.fn((event: string, ...args: any[]) => {
|
|
32
|
+
const handlers = listeners.get(event) ?? [];
|
|
33
|
+
for (const h of handlers) h(...args);
|
|
34
|
+
}) as any,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
connection: {
|
|
38
|
+
send: vi.fn((msg: unknown) => {
|
|
39
|
+
sent.push(msg);
|
|
40
|
+
}) as any,
|
|
41
|
+
},
|
|
42
|
+
getSessionId: () => sessionId,
|
|
43
|
+
_sent: sent,
|
|
44
|
+
_listeners: listeners,
|
|
45
|
+
};
|
|
46
|
+
return ctx;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const sampleModule = (id: string, command: string): ExtensionUiModule => ({
|
|
50
|
+
kind: "management-modal",
|
|
51
|
+
id,
|
|
52
|
+
command,
|
|
53
|
+
title: id,
|
|
54
|
+
view: { kind: "table", dataEvent: `${id}:rows`, fields: [{ key: "id", label: "ID", kind: "text" }] },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const footerSeg = (namespace: string, id: string, text: string): DecoratorDescriptor => ({
|
|
58
|
+
kind: "footer-segment",
|
|
59
|
+
namespace,
|
|
60
|
+
id,
|
|
61
|
+
payload: { text },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const gate = (namespace: string, id: string, flowId: string, available: boolean, reason?: string): DecoratorDescriptor => ({
|
|
65
|
+
kind: "gate",
|
|
66
|
+
namespace,
|
|
67
|
+
id,
|
|
68
|
+
payload: { flowId, available, reason },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const toast = (namespace: string, id: string, message: string): DecoratorDescriptor => ({
|
|
72
|
+
kind: "toast",
|
|
73
|
+
namespace,
|
|
74
|
+
id,
|
|
75
|
+
payload: { level: "info", message },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("refreshUiModules — Phase-2 partitioning", () => {
|
|
79
|
+
it("partitions a mixed probe into one ui_modules_list (modal-only) plus one ext_ui_decorator per decorator", () => {
|
|
80
|
+
const ctx = createTestCtx("S");
|
|
81
|
+
ctx._listeners.set("ui:list-modules", [
|
|
82
|
+
(probe: { modules: any[] }) => {
|
|
83
|
+
probe.modules.push(sampleModule("judo-status", "/judo:status"));
|
|
84
|
+
probe.modules.push(footerSeg("judo", "model-state", "3 mut"));
|
|
85
|
+
probe.modules.push(gate("judo", "save", "judo:save", false, "Not in workspace"));
|
|
86
|
+
probe.modules.push(toast("flows", "done", "Flow finished"));
|
|
87
|
+
},
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
refreshUiModules(ctx);
|
|
91
|
+
|
|
92
|
+
// Exactly one ui_modules_list, exactly three ext_ui_decorator.
|
|
93
|
+
const moduleMsgs = ctx._sent.filter((m) => m.type === "ui_modules_list");
|
|
94
|
+
const decoratorMsgs = ctx._sent.filter((m) => m.type === "ext_ui_decorator");
|
|
95
|
+
expect(moduleMsgs).toHaveLength(1);
|
|
96
|
+
expect(decoratorMsgs).toHaveLength(3);
|
|
97
|
+
|
|
98
|
+
expect(moduleMsgs[0]).toMatchObject({
|
|
99
|
+
type: "ui_modules_list",
|
|
100
|
+
sessionId: "S",
|
|
101
|
+
modules: [expect.objectContaining({ id: "judo-status", kind: "management-modal" })],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const kinds = decoratorMsgs.map((m) => m.descriptor.kind).sort();
|
|
105
|
+
expect(kinds).toEqual(["footer-segment", "gate", "toast"]);
|
|
106
|
+
for (const m of decoratorMsgs) {
|
|
107
|
+
expect(m.sessionId).toBe("S");
|
|
108
|
+
expect(m.removed).toBeUndefined();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("forwards no decorator messages when only modal modules are pushed", () => {
|
|
113
|
+
const ctx = createTestCtx();
|
|
114
|
+
ctx._listeners.set("ui:list-modules", [
|
|
115
|
+
(probe: { modules: any[] }) => {
|
|
116
|
+
probe.modules.push(sampleModule("a", "/a"));
|
|
117
|
+
},
|
|
118
|
+
]);
|
|
119
|
+
refreshUiModules(ctx);
|
|
120
|
+
expect(ctx._sent.filter((m) => m.type === "ext_ui_decorator")).toHaveLength(0);
|
|
121
|
+
expect(ctx._sent.filter((m) => m.type === "ui_modules_list")).toHaveLength(1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("forwards decorator-only probe with empty modules list", () => {
|
|
125
|
+
const ctx = createTestCtx();
|
|
126
|
+
ctx._listeners.set("ui:list-modules", [
|
|
127
|
+
(probe: { modules: any[] }) => {
|
|
128
|
+
probe.modules.push(footerSeg("judo", "f1", "x"));
|
|
129
|
+
probe.modules.push(footerSeg("judo", "f2", "y"));
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
refreshUiModules(ctx);
|
|
133
|
+
const modulesMsg = ctx._sent.find((m) => m.type === "ui_modules_list");
|
|
134
|
+
expect(modulesMsg).toBeDefined();
|
|
135
|
+
expect(modulesMsg.modules).toEqual([]);
|
|
136
|
+
expect(ctx._sent.filter((m) => m.type === "ext_ui_decorator")).toHaveLength(2);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("rejects decorators with malformed namespace and warns", () => {
|
|
140
|
+
const ctx = createTestCtx();
|
|
141
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
142
|
+
try {
|
|
143
|
+
ctx._listeners.set("ui:list-modules", [
|
|
144
|
+
(probe: { modules: any[] }) => {
|
|
145
|
+
probe.modules.push(footerSeg("", "id1", "bad-empty"));
|
|
146
|
+
probe.modules.push(footerSeg("UPPER", "id2", "bad-case"));
|
|
147
|
+
probe.modules.push(footerSeg("with space", "id3", "bad-space"));
|
|
148
|
+
probe.modules.push(footerSeg("ok-ns", "id4", "good"));
|
|
149
|
+
},
|
|
150
|
+
]);
|
|
151
|
+
refreshUiModules(ctx);
|
|
152
|
+
|
|
153
|
+
const decoratorMsgs = ctx._sent.filter((m) => m.type === "ext_ui_decorator");
|
|
154
|
+
expect(decoratorMsgs).toHaveLength(1);
|
|
155
|
+
expect(decoratorMsgs[0].descriptor.namespace).toBe("ok-ns");
|
|
156
|
+
// Three bad descriptors → at least one warning each (or one combined).
|
|
157
|
+
expect(warn).toHaveBeenCalled();
|
|
158
|
+
const allWarnText = warn.mock.calls.map((c) => String(c[0])).join("\n");
|
|
159
|
+
expect(allWarnText).toMatch(/namespace/i);
|
|
160
|
+
} finally {
|
|
161
|
+
warn.mockRestore();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("collisions on (kind, namespace, id) within one probe warn once and last-write-wins", () => {
|
|
166
|
+
const ctx = createTestCtx();
|
|
167
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
168
|
+
try {
|
|
169
|
+
ctx._listeners.set("ui:list-modules", [
|
|
170
|
+
(probe: { modules: any[] }) => {
|
|
171
|
+
probe.modules.push(footerSeg("judo", "x", "first"));
|
|
172
|
+
probe.modules.push(footerSeg("judo", "x", "second"));
|
|
173
|
+
probe.modules.push(footerSeg("judo", "x", "third"));
|
|
174
|
+
},
|
|
175
|
+
]);
|
|
176
|
+
refreshUiModules(ctx);
|
|
177
|
+
|
|
178
|
+
const decoratorMsgs = ctx._sent.filter((m) => m.type === "ext_ui_decorator");
|
|
179
|
+
expect(decoratorMsgs).toHaveLength(1);
|
|
180
|
+
expect((decoratorMsgs[0].descriptor.payload as any).text).toBe("third");
|
|
181
|
+
// One warning per colliding key, regardless of how many duplicates.
|
|
182
|
+
const collisionWarnings = warn.mock.calls.filter((c) => /footer-segment:judo:x/.test(String(c[0])));
|
|
183
|
+
expect(collisionWarnings.length).toBe(1);
|
|
184
|
+
} finally {
|
|
185
|
+
warn.mockRestore();
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("different namespaces with the same id are NOT a collision", () => {
|
|
190
|
+
const ctx = createTestCtx();
|
|
191
|
+
ctx._listeners.set("ui:list-modules", [
|
|
192
|
+
(probe: { modules: any[] }) => {
|
|
193
|
+
probe.modules.push(footerSeg("judo", "model-state", "judo-text"));
|
|
194
|
+
probe.modules.push(footerSeg("flows", "model-state", "flows-text"));
|
|
195
|
+
},
|
|
196
|
+
]);
|
|
197
|
+
refreshUiModules(ctx);
|
|
198
|
+
const decoratorMsgs = ctx._sent.filter((m) => m.type === "ext_ui_decorator");
|
|
199
|
+
expect(decoratorMsgs).toHaveLength(2);
|
|
200
|
+
const namespaces = decoratorMsgs.map((m) => m.descriptor.namespace).sort();
|
|
201
|
+
expect(namespaces).toEqual(["flows", "judo"]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("forwards `removed: true` verbatim on decorator descriptors", () => {
|
|
205
|
+
const ctx = createTestCtx();
|
|
206
|
+
ctx._listeners.set("ui:list-modules", [
|
|
207
|
+
(probe: { modules: any[] }) => {
|
|
208
|
+
probe.modules.push({ ...gate("judo", "save", "judo:save", true), removed: true });
|
|
209
|
+
},
|
|
210
|
+
]);
|
|
211
|
+
refreshUiModules(ctx);
|
|
212
|
+
const decoratorMsgs = ctx._sent.filter((m) => m.type === "ext_ui_decorator");
|
|
213
|
+
expect(decoratorMsgs).toHaveLength(1);
|
|
214
|
+
expect(decoratorMsgs[0].removed).toBe(true);
|
|
215
|
+
expect(decoratorMsgs[0].descriptor.kind).toBe("gate");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("does not regress Phase-1 module-only probes", () => {
|
|
219
|
+
// Mirrors the Phase-1 test "emits ui:list-modules and forwards collected
|
|
220
|
+
// modules as ui_modules_list" — Phase-2 partitioning MUST be a no-op when
|
|
221
|
+
// no decorators are pushed.
|
|
222
|
+
const ctx = createTestCtx("session-A");
|
|
223
|
+
ctx._listeners.set("ui:list-modules", [
|
|
224
|
+
(probe: { modules: ExtensionUiModule[] }) => {
|
|
225
|
+
probe.modules.push(sampleModule("judo-status", "/judo:status"));
|
|
226
|
+
probe.modules.push(sampleModule("ragger-workspaces", "/ragger:workspaces"));
|
|
227
|
+
},
|
|
228
|
+
]);
|
|
229
|
+
refreshUiModules(ctx);
|
|
230
|
+
const moduleMsgs = ctx._sent.filter((m) => m.type === "ui_modules_list");
|
|
231
|
+
expect(moduleMsgs).toHaveLength(1);
|
|
232
|
+
expect(moduleMsgs[0].modules).toHaveLength(2);
|
|
233
|
+
expect(ctx._sent.filter((m) => m.type === "ext_ui_decorator")).toHaveLength(0);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("subscribeUiInvalidate — Phase-2 re-forwarding", () => {
|
|
238
|
+
it("re-runs the partitioned probe on every ui:invalidate (leading + trailing under throttle)", () => {
|
|
239
|
+
vi.useFakeTimers();
|
|
240
|
+
try {
|
|
241
|
+
const ctx = createTestCtx();
|
|
242
|
+
let counter = 0;
|
|
243
|
+
ctx._listeners.set("ui:list-modules", [
|
|
244
|
+
(probe: { modules: any[] }) => {
|
|
245
|
+
counter++;
|
|
246
|
+
probe.modules.push(footerSeg("judo", "model-state", `count=${counter}`));
|
|
247
|
+
},
|
|
248
|
+
]);
|
|
249
|
+
subscribeUiInvalidate(ctx);
|
|
250
|
+
|
|
251
|
+
// Leading-edge probe.
|
|
252
|
+
ctx.pi.events!.emit("ui:invalidate", { id: "model-state" });
|
|
253
|
+
// Coalesced into trailing-edge probe — flush by advancing timers past
|
|
254
|
+
// the 50ms throttle window.
|
|
255
|
+
ctx.pi.events!.emit("ui:invalidate", { id: "model-state" });
|
|
256
|
+
vi.advanceTimersByTime(100);
|
|
257
|
+
|
|
258
|
+
const decoratorMsgs = ctx._sent.filter((m) => m.type === "ext_ui_decorator");
|
|
259
|
+
expect(decoratorMsgs).toHaveLength(2);
|
|
260
|
+
expect((decoratorMsgs[0].descriptor.payload as any).text).toBe("count=1");
|
|
261
|
+
expect((decoratorMsgs[1].descriptor.payload as any).text).toBe("count=2");
|
|
262
|
+
} finally {
|
|
263
|
+
vi.useRealTimers();
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe("Per-session ui:invalidate rate cap", () => {
|
|
269
|
+
beforeEach(() => {
|
|
270
|
+
vi.useFakeTimers();
|
|
271
|
+
});
|
|
272
|
+
afterEach(() => {
|
|
273
|
+
vi.useRealTimers();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("coalesces a 100-invalidation burst to a small bounded number of probes with exactly one warning", () => {
|
|
277
|
+
const ctx = createTestCtx();
|
|
278
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
279
|
+
try {
|
|
280
|
+
let probeCount = 0;
|
|
281
|
+
ctx._listeners.set("ui:list-modules", [
|
|
282
|
+
(probe: { modules: any[] }) => {
|
|
283
|
+
probeCount++;
|
|
284
|
+
probe.modules.push(footerSeg("judo", "model-state", `p${probeCount}`));
|
|
285
|
+
},
|
|
286
|
+
]);
|
|
287
|
+
|
|
288
|
+
subscribeUiInvalidate(ctx);
|
|
289
|
+
|
|
290
|
+
// Fire 100 invalidations within ~200ms (well over the 20/sec cap).
|
|
291
|
+
for (let i = 0; i < 100; i++) {
|
|
292
|
+
ctx.pi.events!.emit("ui:invalidate", { id: "x" });
|
|
293
|
+
vi.advanceTimersByTime(2);
|
|
294
|
+
}
|
|
295
|
+
// Allow any trailing-edge timer to fire.
|
|
296
|
+
vi.advanceTimersByTime(2000);
|
|
297
|
+
|
|
298
|
+
// Probes are bounded — at minimum 1 (the first), at most a handful, NOT 100.
|
|
299
|
+
expect(probeCount).toBeGreaterThanOrEqual(1);
|
|
300
|
+
expect(probeCount).toBeLessThanOrEqual(10);
|
|
301
|
+
|
|
302
|
+
// Exactly one rate-cap warning per offending burst.
|
|
303
|
+
const rateWarnings = warn.mock.calls.filter((c) => /rate|invalidat/i.test(String(c[0])));
|
|
304
|
+
expect(rateWarnings.length).toBe(1);
|
|
305
|
+
} finally {
|
|
306
|
+
warn.mockRestore();
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
});
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { refreshUiModules, subscribeUiInvalidate, handleUiManagement, type UiModulesBridgeCtx } from "../ui-modules.js";
|
|
3
|
+
import type { ExtensionUiModule } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Minimal bus + ctx harness. The real bridge wires this up via `pi.events`
|
|
7
|
+
* (a real `EventEmitter`); the contract this module relies on is `on` /
|
|
8
|
+
* `emit`, so a hand-rolled bus is sufficient for tests.
|
|
9
|
+
*/
|
|
10
|
+
function createTestCtx(sessionId = "s1") {
|
|
11
|
+
const listeners = new Map<string, Array<(...args: any[]) => any>>();
|
|
12
|
+
const sent: any[] = [];
|
|
13
|
+
|
|
14
|
+
const ctx: UiModulesBridgeCtx & { _sent: any[]; _listeners: typeof listeners } = {
|
|
15
|
+
pi: {
|
|
16
|
+
events: {
|
|
17
|
+
on: vi.fn((event: string, fn: (...args: any[]) => any) => {
|
|
18
|
+
if (!listeners.has(event)) listeners.set(event, []);
|
|
19
|
+
listeners.get(event)!.push(fn);
|
|
20
|
+
}) as any,
|
|
21
|
+
emit: vi.fn((event: string, ...args: any[]) => {
|
|
22
|
+
const handlers = listeners.get(event) ?? [];
|
|
23
|
+
for (const h of handlers) h(...args);
|
|
24
|
+
}) as any,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
connection: {
|
|
28
|
+
send: vi.fn((msg: unknown) => {
|
|
29
|
+
sent.push(msg);
|
|
30
|
+
}) as any,
|
|
31
|
+
},
|
|
32
|
+
getSessionId: () => sessionId,
|
|
33
|
+
_sent: sent,
|
|
34
|
+
_listeners: listeners,
|
|
35
|
+
};
|
|
36
|
+
return ctx;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const sampleModule = (id: string, command: string): ExtensionUiModule => ({
|
|
40
|
+
kind: "management-modal",
|
|
41
|
+
id,
|
|
42
|
+
command,
|
|
43
|
+
title: id,
|
|
44
|
+
view: { kind: "table", dataEvent: `${id}:rows`, fields: [{ key: "id", label: "ID", kind: "text" }] },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("refreshUiModules", () => {
|
|
48
|
+
it("emits ui:list-modules and forwards collected modules as ui_modules_list", () => {
|
|
49
|
+
const ctx = createTestCtx("session-A");
|
|
50
|
+
ctx._listeners.set("ui:list-modules", [
|
|
51
|
+
(probe: { modules: ExtensionUiModule[] }) => {
|
|
52
|
+
probe.modules.push(sampleModule("judo-status", "/judo:status"));
|
|
53
|
+
probe.modules.push(sampleModule("ragger-workspaces", "/ragger:workspaces"));
|
|
54
|
+
},
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
refreshUiModules(ctx);
|
|
58
|
+
|
|
59
|
+
expect(ctx.pi.events!.emit).toHaveBeenCalledWith("ui:list-modules", expect.any(Object));
|
|
60
|
+
expect(ctx._sent).toHaveLength(1);
|
|
61
|
+
expect(ctx._sent[0]).toMatchObject({
|
|
62
|
+
type: "ui_modules_list",
|
|
63
|
+
sessionId: "session-A",
|
|
64
|
+
modules: [
|
|
65
|
+
expect.objectContaining({ id: "judo-status", command: "/judo:status" }),
|
|
66
|
+
expect.objectContaining({ id: "ragger-workspaces", command: "/ragger:workspaces" }),
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("forwards an empty modules list when no listeners push", () => {
|
|
72
|
+
const ctx = createTestCtx();
|
|
73
|
+
refreshUiModules(ctx);
|
|
74
|
+
expect(ctx._sent).toEqual([{ type: "ui_modules_list", sessionId: "s1", modules: [] }]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("last-write-wins on duplicate id and warns once per collision", () => {
|
|
78
|
+
const ctx = createTestCtx();
|
|
79
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
80
|
+
try {
|
|
81
|
+
ctx._listeners.set("ui:list-modules", [
|
|
82
|
+
(probe: { modules: ExtensionUiModule[] }) => {
|
|
83
|
+
// First push wins on insertion order; second push for same id replaces it.
|
|
84
|
+
const a: ExtensionUiModule = { ...sampleModule("dup", "/a"), title: "First" };
|
|
85
|
+
const b: ExtensionUiModule = { ...sampleModule("dup", "/b"), title: "Second" };
|
|
86
|
+
const c: ExtensionUiModule = { ...sampleModule("dup", "/c"), title: "Third" };
|
|
87
|
+
probe.modules.push(a, b, c);
|
|
88
|
+
},
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
refreshUiModules(ctx);
|
|
92
|
+
|
|
93
|
+
const sent = ctx._sent[0] as { modules: ExtensionUiModule[] };
|
|
94
|
+
expect(sent.modules).toHaveLength(1);
|
|
95
|
+
expect(sent.modules[0]).toMatchObject({ id: "dup", title: "Third", command: "/c" });
|
|
96
|
+
// Two collisions reported, but only one warning per id.
|
|
97
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
98
|
+
expect(warn.mock.calls[0]?.[0]).toMatch(/duplicate module id "dup"/);
|
|
99
|
+
} finally {
|
|
100
|
+
warn.mockRestore();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("ignores modules with missing/empty id", () => {
|
|
105
|
+
const ctx = createTestCtx();
|
|
106
|
+
ctx._listeners.set("ui:list-modules", [
|
|
107
|
+
(probe: { modules: any[] }) => {
|
|
108
|
+
probe.modules.push({ kind: "management-modal", id: "", command: "/x", title: "x", view: { kind: "table" } });
|
|
109
|
+
probe.modules.push({ kind: "management-modal", command: "/y", title: "y", view: { kind: "table" } });
|
|
110
|
+
probe.modules.push(sampleModule("ok", "/ok"));
|
|
111
|
+
},
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
refreshUiModules(ctx);
|
|
115
|
+
const sent = ctx._sent[0] as { modules: ExtensionUiModule[] };
|
|
116
|
+
expect(sent.modules.map((m) => m.id)).toEqual(["ok"]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("does not throw or send when pi.events is missing", () => {
|
|
120
|
+
const ctx = createTestCtx();
|
|
121
|
+
ctx.pi = { events: undefined as any };
|
|
122
|
+
expect(() => refreshUiModules(ctx)).not.toThrow();
|
|
123
|
+
expect(ctx._sent).toHaveLength(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("absorbs handler errors without breaking the bridge", () => {
|
|
127
|
+
const ctx = createTestCtx();
|
|
128
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
129
|
+
try {
|
|
130
|
+
ctx._listeners.set("ui:list-modules", [
|
|
131
|
+
() => {
|
|
132
|
+
throw new Error("listener exploded");
|
|
133
|
+
},
|
|
134
|
+
]);
|
|
135
|
+
expect(() => refreshUiModules(ctx)).not.toThrow();
|
|
136
|
+
expect(errSpy).toHaveBeenCalled();
|
|
137
|
+
expect(ctx._sent).toHaveLength(0);
|
|
138
|
+
} finally {
|
|
139
|
+
errSpy.mockRestore();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("subscribeUiInvalidate", () => {
|
|
145
|
+
it("re-runs the probe whenever ui:invalidate fires (leading + trailing throttle, see Phase 2)", () => {
|
|
146
|
+
vi.useFakeTimers();
|
|
147
|
+
try {
|
|
148
|
+
const ctx = createTestCtx();
|
|
149
|
+
ctx._listeners.set("ui:list-modules", [
|
|
150
|
+
(probe: { modules: ExtensionUiModule[] }) => {
|
|
151
|
+
probe.modules.push(sampleModule("a", "/a"));
|
|
152
|
+
},
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
subscribeUiInvalidate(ctx);
|
|
156
|
+
// First emit triggers a leading-edge probe immediately.
|
|
157
|
+
ctx.pi.events!.emit("ui:invalidate", { id: "a" });
|
|
158
|
+
expect(ctx._sent).toHaveLength(1);
|
|
159
|
+
expect((ctx._sent[0] as any).type).toBe("ui_modules_list");
|
|
160
|
+
|
|
161
|
+
// Second emit within the throttle window coalesces into a trailing-edge
|
|
162
|
+
// probe; advance timers past the 50ms window to flush it.
|
|
163
|
+
ctx.pi.events!.emit("ui:invalidate", {});
|
|
164
|
+
vi.advanceTimersByTime(100);
|
|
165
|
+
expect(ctx._sent).toHaveLength(2);
|
|
166
|
+
} finally {
|
|
167
|
+
vi.useRealTimers();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("is a no-op when pi.events.on is missing", () => {
|
|
172
|
+
const ctx = createTestCtx();
|
|
173
|
+
ctx.pi = { events: { emit: vi.fn() as any } as any };
|
|
174
|
+
expect(() => subscribeUiInvalidate(ctx)).not.toThrow();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("handleUiManagement", () => {
|
|
179
|
+
it("re-emits the event on pi.events with action and _reply injected, and forwards synchronous data.items", () => {
|
|
180
|
+
const ctx = createTestCtx("S");
|
|
181
|
+
ctx._listeners.set("judo:status-rows", [
|
|
182
|
+
(data: { action: string; items?: unknown[] }) => {
|
|
183
|
+
expect(data.action).toBe("list");
|
|
184
|
+
data.items = [{ id: 1 }, { id: 2 }];
|
|
185
|
+
},
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
handleUiManagement(ctx, {
|
|
189
|
+
type: "ui_management",
|
|
190
|
+
sessionId: "S",
|
|
191
|
+
action: "list",
|
|
192
|
+
event: "judo:status-rows",
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(ctx.pi.events!.emit).toHaveBeenCalledWith("judo:status-rows", expect.any(Object));
|
|
196
|
+
expect(ctx._sent).toEqual([
|
|
197
|
+
{ type: "ui_data_list", sessionId: "S", event: "judo:status-rows", items: [{ id: 1 }, { id: 2 }] },
|
|
198
|
+
]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("supports async _reply path (extension calls _reply asynchronously)", () => {
|
|
202
|
+
const ctx = createTestCtx();
|
|
203
|
+
ctx._listeners.set("judo:rows", [
|
|
204
|
+
(data: { _reply: (items: unknown[]) => void }) => {
|
|
205
|
+
// Simulate async: call _reply later in this tick.
|
|
206
|
+
setTimeout(() => data._reply([{ id: 7 }]), 0);
|
|
207
|
+
},
|
|
208
|
+
]);
|
|
209
|
+
|
|
210
|
+
handleUiManagement(ctx, { type: "ui_management", sessionId: "s1", action: "list", event: "judo:rows" });
|
|
211
|
+
// Synchronous fast-path didn't fire because data.items wasn't set.
|
|
212
|
+
expect(ctx._sent).toHaveLength(0);
|
|
213
|
+
return new Promise<void>((resolve) => {
|
|
214
|
+
setTimeout(() => {
|
|
215
|
+
expect(ctx._sent).toEqual([
|
|
216
|
+
{ type: "ui_data_list", sessionId: "s1", event: "judo:rows", items: [{ id: 7 }] },
|
|
217
|
+
]);
|
|
218
|
+
resolve();
|
|
219
|
+
}, 5);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("does not double-send if both data.items is set and _reply is called", () => {
|
|
224
|
+
const ctx = createTestCtx();
|
|
225
|
+
ctx._listeners.set("e", [
|
|
226
|
+
(data: { _reply: (items: unknown[]) => void; items?: unknown[] }) => {
|
|
227
|
+
data._reply([1, 2]);
|
|
228
|
+
data.items = [3, 4];
|
|
229
|
+
},
|
|
230
|
+
]);
|
|
231
|
+
handleUiManagement(ctx, { type: "ui_management", sessionId: "s1", action: "list", event: "e" });
|
|
232
|
+
// _reply ran synchronously inside the emit; data.items fast-path is gated by `replied`.
|
|
233
|
+
expect(ctx._sent).toHaveLength(1);
|
|
234
|
+
expect((ctx._sent[0] as any).items).toEqual([1, 2]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("does NOT send a ui_data_list for fire-and-forget actions (no items, no _reply)", () => {
|
|
238
|
+
const ctx = createTestCtx();
|
|
239
|
+
ctx._listeners.set("judo:delete-row", [() => { /* side-effect only */ }]);
|
|
240
|
+
handleUiManagement(ctx, {
|
|
241
|
+
type: "ui_management",
|
|
242
|
+
sessionId: "s1",
|
|
243
|
+
action: "delete",
|
|
244
|
+
event: "judo:delete-row",
|
|
245
|
+
params: { id: 42 },
|
|
246
|
+
});
|
|
247
|
+
expect(ctx._sent).toHaveLength(0);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("forwards the action and params verbatim to listeners", () => {
|
|
251
|
+
const ctx = createTestCtx();
|
|
252
|
+
let captured: any = null;
|
|
253
|
+
ctx._listeners.set("e", [(data: any) => { captured = { ...data }; }]);
|
|
254
|
+
handleUiManagement(ctx, {
|
|
255
|
+
type: "ui_management",
|
|
256
|
+
sessionId: "s1",
|
|
257
|
+
action: "delete",
|
|
258
|
+
event: "e",
|
|
259
|
+
params: { id: 42, force: true },
|
|
260
|
+
});
|
|
261
|
+
expect(captured).toMatchObject({ action: "delete", id: 42, force: true });
|
|
262
|
+
expect(typeof captured._reply).toBe("function");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("absorbs handler errors without sending data", () => {
|
|
266
|
+
const ctx = createTestCtx();
|
|
267
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
268
|
+
try {
|
|
269
|
+
ctx._listeners.set("explode", [() => { throw new Error("boom"); }]);
|
|
270
|
+
expect(() => handleUiManagement(ctx, { type: "ui_management", sessionId: "s1", action: "list", event: "explode" })).not.toThrow();
|
|
271
|
+
expect(errSpy).toHaveBeenCalled();
|
|
272
|
+
expect(ctx._sent).toHaveLength(0);
|
|
273
|
+
} finally {
|
|
274
|
+
errSpy.mockRestore();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("Bridge invariants", () => {
|
|
280
|
+
it("ui-modules.ts does not import pi.newSession / ctx.fork / ctx.switchSession", async () => {
|
|
281
|
+
// Mirrors the contract checked by `no-session-replacement-calls.test.ts`,
|
|
282
|
+
// but localized to the new module so a regression is caught at unit-test
|
|
283
|
+
// granularity in addition to the global lint test.
|
|
284
|
+
const fs = await import("node:fs/promises");
|
|
285
|
+
const url = await import("node:url");
|
|
286
|
+
const path = await import("node:path");
|
|
287
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
288
|
+
const src = await fs.readFile(path.join(here, "..", "ui-modules.ts"), "utf8");
|
|
289
|
+
expect(src).not.toMatch(/pi\.newSession\s*\(/);
|
|
290
|
+
expect(src).not.toMatch(/ctx\.fork\s*\(/);
|
|
291
|
+
expect(src).not.toMatch(/ctx\.switchSession\s*\(/);
|
|
292
|
+
});
|
|
293
|
+
});
|