@blackbelt-technology/pi-agent-dashboard 0.4.0 → 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 +104 -35
- package/README.md +390 -494
- package/docs/architecture.md +423 -20
- package/package.json +11 -8
- package/packages/extension/package.json +11 -4
- package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +91 -15
- package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
- package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
- package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
- package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
- package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
- package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -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 +170 -61
- package/packages/extension/src/bridge.ts +199 -19
- package/packages/extension/src/multiselect-decode.ts +40 -0
- package/packages/extension/src/multiselect-list.ts +146 -0
- package/packages/extension/src/multiselect-polyfill.ts +73 -0
- package/packages/extension/src/server-launcher.ts +15 -3
- package/packages/extension/src/ui-modules.ts +272 -0
- package/packages/server/package.json +11 -5
- 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__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
- package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -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__/pi-version-skew.test.ts +72 -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__/restart-helper.test.ts +34 -6
- 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 +61 -15
- 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/pi-version-skew.ts +12 -1
- package/packages/server/src/proposal-attach-naming.ts +47 -0
- package/packages/server/src/restart-helper.ts +13 -2
- 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-hardcoded-node-modules-paths.test.ts +176 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
- package/packages/shared/src/__tests__/node-spawn.test.ts +210 -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__/resolve-tool-cli.test.ts +105 -0
- package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
- package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -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/platform/index.ts +1 -0
- package/packages/shared/src/platform/node-spawn.ts +154 -0
- package/packages/shared/src/plugin-bridge-register.ts +139 -0
- package/packages/shared/src/protocol.ts +79 -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 +20 -1
- package/packages/shared/src/tool-registry/definitions.ts +92 -0
- package/packages/shared/src/tool-registry/strategies.ts +17 -3
- package/packages/shared/src/types.ts +160 -0
|
@@ -1,100 +1,88 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for
|
|
2
|
+
* Tests for bridge entryId stamping on message_end events.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* HISTORY: Originally this file modelled pi <0.69's synchronous emit pattern,
|
|
5
|
+
* where the bridge's `queueMicrotask` deferral ran BEFORE
|
|
6
|
+
* sessionManager.appendMessage. That design no longer reflects pi 0.70.x:
|
|
7
|
+
* pi awaits extension handlers inside _emitExtensionEvent, so the microtask
|
|
8
|
+
* resolves *inside* the awaited dispatcher, before persistence. The fix
|
|
9
|
+
* (see change: fix-per-message-fork) is `setTimeout(0)` (a macrotask)
|
|
10
|
+
* combined with reading `event.message.id` (which pi mutates in place
|
|
11
|
+
* during appendMessage) or a WeakMap populated by the wrapped appendMessage.
|
|
7
12
|
*
|
|
8
|
-
* The
|
|
9
|
-
*
|
|
10
|
-
* The
|
|
11
|
-
*
|
|
13
|
+
* The previous test "message_start should still capture entryId immediately
|
|
14
|
+
* (no deferral)" codified the off-by-one bug as expected behavior — it has
|
|
15
|
+
* been REMOVED. The current test suite below models pi 0.70.x semantics
|
|
16
|
+
* directly. Detailed pi-0.70-specific scenarios live in
|
|
17
|
+
* `bridge-entry-id-pi-070.test.ts`.
|
|
12
18
|
*/
|
|
13
|
-
import { describe, it, expect
|
|
19
|
+
import { describe, it, expect } from "vitest";
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
* 1. _emit(event) — bridge handler called (async, not awaited)
|
|
20
|
-
* 2. appendMessage(msg) — updates leafId synchronously
|
|
21
|
-
*
|
|
22
|
-
* The bridge handler (async) should yield via queueMicrotask before reading
|
|
23
|
-
* getLeafId(), so that appendMessage has already run.
|
|
24
|
-
*/
|
|
25
|
-
describe("message_end entryId timing", () => {
|
|
26
|
-
it("deferred getLeafId() captures the post-persist entry ID", async () => {
|
|
27
|
-
// Simulate sessionManager with mutable leafId
|
|
28
|
-
let leafId = "user-entry-100"; // stale leaf before appendMessage
|
|
21
|
+
describe("message_end entryId timing on pi 0.70.x", () => {
|
|
22
|
+
it("setTimeout(0) deferral captures the post-persist entry ID", async () => {
|
|
23
|
+
// Simulate pi 0.70.x: bridge handler runs awaited, THEN appendMessage runs.
|
|
24
|
+
let leafId = "user-entry-100";
|
|
29
25
|
const sessionManager = {
|
|
30
26
|
getLeafId: () => leafId,
|
|
27
|
+
appendMessage: (msg: any) => {
|
|
28
|
+
msg.id = "assistant-entry-101";
|
|
29
|
+
leafId = msg.id;
|
|
30
|
+
return msg.id;
|
|
31
|
+
},
|
|
31
32
|
};
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
const event = { type: "message_end", message: { role: "assistant" } as any };
|
|
35
|
+
let captured: string | undefined;
|
|
36
|
+
let sendDone!: () => void;
|
|
37
|
+
const sentP = new Promise<void>((r) => { sendDone = r; });
|
|
38
|
+
|
|
39
|
+
// Bridge handler: schedules a setTimeout(0) and returns synchronously.
|
|
40
|
+
// The awaited dispatcher then unwinds, appendMessage runs, AND finally
|
|
41
|
+
// the timeout fires.
|
|
42
|
+
const bridgeHandler = (ev: any) => {
|
|
43
|
+
setTimeout(() => {
|
|
44
|
+
captured = ev.message.id ?? sessionManager.getLeafId();
|
|
45
|
+
sendDone();
|
|
46
|
+
}, 0);
|
|
40
47
|
};
|
|
41
48
|
|
|
42
|
-
// Simulate
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
leafId = "assistant-entry-101";
|
|
47
|
-
|
|
48
|
-
// Wait for the deferred handler to complete
|
|
49
|
-
await handlerPromise;
|
|
49
|
+
// Simulate the dispatcher: await handler, then call appendMessage.
|
|
50
|
+
await bridgeHandler(event);
|
|
51
|
+
sessionManager.appendMessage(event.message);
|
|
52
|
+
await sentP;
|
|
50
53
|
|
|
51
|
-
expect(
|
|
54
|
+
expect(captured).toBe("assistant-entry-101");
|
|
52
55
|
});
|
|
53
56
|
|
|
54
|
-
it("
|
|
57
|
+
it("queueMicrotask deferral would NOT work on pi 0.70.x (regression demonstration)", async () => {
|
|
58
|
+
// Reproduces why we abandoned queueMicrotask: it resolves inside the
|
|
59
|
+
// awaited dispatcher, before appendMessage runs.
|
|
55
60
|
let leafId = "user-entry-100";
|
|
56
|
-
|
|
57
|
-
getLeafId: () => leafId,
|
|
58
|
-
};
|
|
61
|
+
let captured: string | undefined;
|
|
59
62
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const buggyBridgeHandler = async () => {
|
|
64
|
-
// No deferral — reads leafId before appendMessage runs
|
|
65
|
-
capturedEntryId = sessionManager.getLeafId();
|
|
63
|
+
const buggyBridge = async () => {
|
|
64
|
+
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
|
65
|
+
captured = leafId;
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
-
//
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
await handlerPromise;
|
|
68
|
+
// Pi 0.70.x: await the bridge, THEN persist. Mirroring the real ordering:
|
|
69
|
+
const handlerP = buggyBridge();
|
|
70
|
+
await handlerP;
|
|
71
|
+
leafId = "assistant-entry-101"; // appendMessage runs after await
|
|
73
72
|
|
|
74
|
-
|
|
75
|
-
expect(
|
|
73
|
+
expect(captured).toBe("user-entry-100");
|
|
74
|
+
expect(captured).not.toBe("assistant-entry-101");
|
|
76
75
|
});
|
|
77
76
|
|
|
78
|
-
it("
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// Simulate bridge handler for message_start (immediate, no deferral)
|
|
87
|
-
const messageStartHandler = async () => {
|
|
88
|
-
capturedEntryId = sessionManager.getLeafId();
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
const handlerPromise = messageStartHandler();
|
|
92
|
-
// User entry gets written after message_start
|
|
93
|
-
leafId = "user-entry-100";
|
|
94
|
-
|
|
95
|
-
await handlerPromise;
|
|
77
|
+
it("entry_persisted is the back-fill mechanism for user message_start (where event.message.id is unavailable)", () => {
|
|
78
|
+
// Behavioural assertion (pure data shape): when the bridge sends a
|
|
79
|
+
// user message_start, it stamps a nonce; later when pi persists the
|
|
80
|
+
// user entry, the bridge sends entry_persisted { nonce, entryId }.
|
|
81
|
+
// The reducer pairs them by nonce. See change: fix-per-message-fork.
|
|
82
|
+
const start = { type: "message_start", message: { role: "user" }, nonce: "n-1" };
|
|
83
|
+
const persisted = { type: "entry_persisted", entryId: "user-200", nonce: "n-1" };
|
|
96
84
|
|
|
97
|
-
|
|
98
|
-
expect(
|
|
85
|
+
expect(start.nonce).toBe(persisted.nonce);
|
|
86
|
+
expect(persisted.entryId).toBe("user-200");
|
|
99
87
|
});
|
|
100
88
|
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the bridge's `ctx.ui.multiselect` PromptBus patch and the TUI
|
|
3
|
+
* adapter's multiselect handling. The patch lives in `bridge.ts` but is
|
|
4
|
+
* exercised here through a small reproduction harness so we don't pull in
|
|
5
|
+
* the full session lifecycle.
|
|
6
|
+
*
|
|
7
|
+
* See change: fix-multiselect-auto-cancel-on-dashboard.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
10
|
+
import { decodeMultiselectAnswer } from "../multiselect-decode.js";
|
|
11
|
+
|
|
12
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
13
|
+
// `decodeMultiselectAnswer` — pure helper used by both the runtime patch
|
|
14
|
+
// and the TUI adapter's response encoding round-trip.
|
|
15
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
describe("decodeMultiselectAnswer", () => {
|
|
18
|
+
it("resolves cancellation as undefined", () => {
|
|
19
|
+
expect(decodeMultiselectAnswer({ cancelled: true })).toBeUndefined();
|
|
20
|
+
expect(decodeMultiselectAnswer({ cancelled: true, answer: '["x"]' })).toBeUndefined();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("resolves successful selection from JSON-encoded array", () => {
|
|
24
|
+
expect(decodeMultiselectAnswer({ cancelled: false, answer: '["a","c"]' })).toEqual(["a", "c"]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("resolves empty selection as []", () => {
|
|
28
|
+
expect(decodeMultiselectAnswer({ cancelled: false, answer: "[]" })).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("resolves null / undefined / empty answer as [] (not undefined)", () => {
|
|
32
|
+
expect(decodeMultiselectAnswer({ cancelled: false, answer: undefined })).toEqual([]);
|
|
33
|
+
expect(decodeMultiselectAnswer({ cancelled: false, answer: "" })).toEqual([]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("resolves unparseable JSON as [] without throwing", () => {
|
|
37
|
+
expect(decodeMultiselectAnswer({ cancelled: false, answer: "not-json" })).toEqual([]);
|
|
38
|
+
expect(decodeMultiselectAnswer({ cancelled: false, answer: "{not:array}" })).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("resolves valid JSON that is not an array as []", () => {
|
|
42
|
+
expect(decodeMultiselectAnswer({ cancelled: false, answer: '"just-a-string"' })).toEqual([]);
|
|
43
|
+
expect(decodeMultiselectAnswer({ cancelled: false, answer: '{"k":"v"}' })).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
48
|
+
// Bridge patch reproduction — emulate the assignment block in
|
|
49
|
+
// `bridge.ts:935-948` plus the new multiselect arm and assert behavior.
|
|
50
|
+
// We cannot import `bridge.ts` directly (it has heavy session-lifecycle
|
|
51
|
+
// imports), so we reproduce the assignment closure here. The closure
|
|
52
|
+
// shape MUST match `bridge.ts` exactly; if `bridge.ts` drifts, the
|
|
53
|
+
// regression guard test (see "ctx.ui.multiselect is assigned by the
|
|
54
|
+
// bridge patch block" below) re-loads the bridge source and asserts
|
|
55
|
+
// the assignment exists.
|
|
56
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
interface BusRequestArgs {
|
|
59
|
+
pipeline: string;
|
|
60
|
+
type: string;
|
|
61
|
+
question: string;
|
|
62
|
+
options?: string[];
|
|
63
|
+
metadata?: Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface FakeBus {
|
|
67
|
+
request: (args: BusRequestArgs) => Promise<{ cancelled?: boolean; answer?: string }>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function applyMultiselectPatch(
|
|
71
|
+
ctx: { ui: Record<string, any> },
|
|
72
|
+
bus: FakeBus,
|
|
73
|
+
): void {
|
|
74
|
+
const existing = (ctx.ui as any).multiselect;
|
|
75
|
+
if (typeof existing === "function") {
|
|
76
|
+
// eslint-disable-next-line no-console
|
|
77
|
+
console.warn("[bridge] ctx.ui.multiselect already exists — overriding for PromptBus routing");
|
|
78
|
+
}
|
|
79
|
+
(ctx.ui as any).multiselect = (title: string, options: string[], opts?: { message?: string }) =>
|
|
80
|
+
bus.request({
|
|
81
|
+
pipeline: "command",
|
|
82
|
+
type: "multiselect",
|
|
83
|
+
question: title,
|
|
84
|
+
options,
|
|
85
|
+
metadata: opts?.message ? { message: opts.message } : undefined,
|
|
86
|
+
}).then((r) => decodeMultiselectAnswer(r));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("bridge ctx.ui.multiselect patch", () => {
|
|
90
|
+
let ctx: { ui: Record<string, any> };
|
|
91
|
+
let bus: FakeBus;
|
|
92
|
+
let requestSpy: ReturnType<typeof vi.fn>;
|
|
93
|
+
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
requestSpy = vi.fn();
|
|
96
|
+
bus = { request: requestSpy as any };
|
|
97
|
+
ctx = { ui: {} };
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("assigns ctx.ui.multiselect as a function after the patch runs", () => {
|
|
101
|
+
expect(typeof ctx.ui.multiselect).toBe("undefined");
|
|
102
|
+
applyMultiselectPatch(ctx, bus);
|
|
103
|
+
expect(typeof ctx.ui.multiselect).toBe("function");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("dispatches bus.request with the right shape on call", async () => {
|
|
107
|
+
applyMultiselectPatch(ctx, bus);
|
|
108
|
+
requestSpy.mockResolvedValue({ cancelled: false, answer: '["a","c"]' });
|
|
109
|
+
|
|
110
|
+
const result = await ctx.ui.multiselect("Pick", ["a", "b", "c"], { message: "ctx" });
|
|
111
|
+
|
|
112
|
+
expect(requestSpy).toHaveBeenCalledTimes(1);
|
|
113
|
+
expect(requestSpy).toHaveBeenCalledWith({
|
|
114
|
+
pipeline: "command",
|
|
115
|
+
type: "multiselect",
|
|
116
|
+
question: "Pick",
|
|
117
|
+
options: ["a", "b", "c"],
|
|
118
|
+
metadata: { message: "ctx" },
|
|
119
|
+
});
|
|
120
|
+
expect(result).toEqual(["a", "c"]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("omits metadata when no message is provided", async () => {
|
|
124
|
+
applyMultiselectPatch(ctx, bus);
|
|
125
|
+
requestSpy.mockResolvedValue({ cancelled: false, answer: "[]" });
|
|
126
|
+
|
|
127
|
+
await ctx.ui.multiselect("Pick", ["a", "b"]);
|
|
128
|
+
|
|
129
|
+
expect(requestSpy).toHaveBeenCalledWith(
|
|
130
|
+
expect.objectContaining({ metadata: undefined }),
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("resolves successful selection through the decoder", async () => {
|
|
135
|
+
applyMultiselectPatch(ctx, bus);
|
|
136
|
+
requestSpy.mockResolvedValue({ cancelled: false, answer: '["a","c"]' });
|
|
137
|
+
await expect(ctx.ui.multiselect("t", ["a", "b", "c"])).resolves.toEqual(["a", "c"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("resolves empty selection as []", async () => {
|
|
141
|
+
applyMultiselectPatch(ctx, bus);
|
|
142
|
+
requestSpy.mockResolvedValue({ cancelled: false, answer: "[]" });
|
|
143
|
+
await expect(ctx.ui.multiselect("t", ["a"])).resolves.toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("resolves cancellation as undefined", async () => {
|
|
147
|
+
applyMultiselectPatch(ctx, bus);
|
|
148
|
+
requestSpy.mockResolvedValue({ cancelled: true });
|
|
149
|
+
await expect(ctx.ui.multiselect("t", ["a"])).resolves.toBeUndefined();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("resolves unparseable answer as [] without throwing", async () => {
|
|
153
|
+
applyMultiselectPatch(ctx, bus);
|
|
154
|
+
requestSpy.mockResolvedValue({ cancelled: false, answer: "not-json" });
|
|
155
|
+
await expect(ctx.ui.multiselect("t", ["a"])).resolves.toEqual([]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("warns when ctx.ui.multiselect was already a function before the patch", () => {
|
|
159
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
160
|
+
ctx.ui.multiselect = () => Promise.resolve(["pre-existing"]);
|
|
161
|
+
|
|
162
|
+
applyMultiselectPatch(ctx, bus);
|
|
163
|
+
|
|
164
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
165
|
+
expect.stringContaining("already exists"),
|
|
166
|
+
);
|
|
167
|
+
// The patch still wins — subsequent calls go through bus.request.
|
|
168
|
+
requestSpy.mockResolvedValue({ cancelled: false, answer: '["winner"]' });
|
|
169
|
+
return ctx.ui.multiselect("t", ["a"]).then((r: any) => {
|
|
170
|
+
expect(r).toEqual(["winner"]);
|
|
171
|
+
expect(requestSpy).toHaveBeenCalled();
|
|
172
|
+
warnSpy.mockRestore();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
178
|
+
// Source regression guard — re-load bridge.ts as text and assert the
|
|
179
|
+
// multiselect patch line exists. Mirrors `no-direct-process-kill.test.ts`
|
|
180
|
+
// pattern: textual scan over a single file, fail with file:line if the
|
|
181
|
+
// expected snippet is missing or moved.
|
|
182
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
describe("bridge.ts source regression guard", () => {
|
|
185
|
+
it("contains the ctx.ui.multiselect PromptBus assignment", async () => {
|
|
186
|
+
const fs = await import("node:fs");
|
|
187
|
+
const path = await import("node:path");
|
|
188
|
+
const url = await import("node:url");
|
|
189
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
190
|
+
const bridgePath = path.resolve(here, "../bridge.ts");
|
|
191
|
+
const src = fs.readFileSync(bridgePath, "utf8");
|
|
192
|
+
|
|
193
|
+
// The assignment must mention `multiselect` as a key on ctx.ui AND
|
|
194
|
+
// dispatch through bus.request with type:"multiselect".
|
|
195
|
+
expect(src).toMatch(/\(ctx\.ui as any\)\.multiselect\s*=/);
|
|
196
|
+
expect(src).toMatch(/type:\s*"multiselect"/);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Note: the previous-change assertions about `custom: ctx.ui.custom?.bind(...)`
|
|
200
|
+
// capture and the TUI `prompt.type === "multiselect"` arm were removed by
|
|
201
|
+
// change `fix-multiselect-tui-arm-self-cancel`. The inverse-guard for that
|
|
202
|
+
// removal lives in `no-tui-multiselect-arm-regression.test.ts`.
|
|
203
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { MultiSelectList } from "../multiselect-list.js";
|
|
3
|
+
|
|
4
|
+
function make(options: string[] = ["A", "B", "C"], title = "Pick", message?: string) {
|
|
5
|
+
const list = new MultiSelectList(title, options, message);
|
|
6
|
+
const onConfirm = vi.fn();
|
|
7
|
+
const onCancel = vi.fn();
|
|
8
|
+
list.onConfirm = onConfirm;
|
|
9
|
+
list.onCancel = onCancel;
|
|
10
|
+
return { list, onConfirm, onCancel };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("MultiSelectList", () => {
|
|
14
|
+
describe("keybindings", () => {
|
|
15
|
+
it("space toggles the checked state of the current item and nothing else", () => {
|
|
16
|
+
const { list, onConfirm } = make();
|
|
17
|
+
list.handleInput(" "); // toggle index 0 → A checked
|
|
18
|
+
list.handleInput("\r");
|
|
19
|
+
expect(onConfirm).toHaveBeenCalledWith(["A"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("arrow down moves cursor and space toggles the new current item", () => {
|
|
23
|
+
const { list, onConfirm } = make();
|
|
24
|
+
list.handleInput("\u001b[B"); // cursor → 1 (B)
|
|
25
|
+
list.handleInput(" "); // B checked
|
|
26
|
+
list.handleInput("\r");
|
|
27
|
+
expect(onConfirm).toHaveBeenCalledWith(["B"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("j / k navigation works like arrows", () => {
|
|
31
|
+
const { list, onConfirm } = make();
|
|
32
|
+
list.handleInput("j"); // cursor → 1
|
|
33
|
+
list.handleInput("j"); // cursor → 2
|
|
34
|
+
list.handleInput(" "); // C checked
|
|
35
|
+
list.handleInput("k"); // cursor → 1
|
|
36
|
+
list.handleInput(" "); // B checked
|
|
37
|
+
list.handleInput("\r");
|
|
38
|
+
// selected values returned in ORIGINAL order
|
|
39
|
+
expect(onConfirm).toHaveBeenCalledWith(["B", "C"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("enter with nothing checked confirms with []", () => {
|
|
43
|
+
const { list, onConfirm } = make();
|
|
44
|
+
list.handleInput("\r");
|
|
45
|
+
expect(onConfirm).toHaveBeenCalledWith([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("escape cancels; no confirm is fired", () => {
|
|
49
|
+
const { list, onConfirm, onCancel } = make();
|
|
50
|
+
list.handleInput(" "); // check A
|
|
51
|
+
list.handleInput("\u001b");
|
|
52
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
53
|
+
expect(onConfirm).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("pressing 'a' does NOT bulk-toggle (no select-all in TUI)", () => {
|
|
57
|
+
const { list, onConfirm } = make();
|
|
58
|
+
list.handleInput("a");
|
|
59
|
+
list.handleInput("\r");
|
|
60
|
+
expect(onConfirm).toHaveBeenCalledWith([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("cursor does not go below 0", () => {
|
|
64
|
+
const { list } = make();
|
|
65
|
+
list.handleInput("k");
|
|
66
|
+
list.handleInput("k");
|
|
67
|
+
expect(list.getCursor()).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("cursor does not go past last item", () => {
|
|
71
|
+
const { list } = make(["A", "B"]);
|
|
72
|
+
list.handleInput("j");
|
|
73
|
+
list.handleInput("j");
|
|
74
|
+
list.handleInput("j");
|
|
75
|
+
expect(list.getCursor()).toBe(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("toggling twice returns item to unchecked", () => {
|
|
79
|
+
const { list, onConfirm } = make();
|
|
80
|
+
list.handleInput(" ");
|
|
81
|
+
list.handleInput(" ");
|
|
82
|
+
list.handleInput("\r");
|
|
83
|
+
expect(onConfirm).toHaveBeenCalledWith([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("selected order follows original option order, not toggle order", () => {
|
|
87
|
+
const { list, onConfirm } = make(["A", "B", "C", "D"]);
|
|
88
|
+
// toggle D first, then A, then C
|
|
89
|
+
list.handleInput("j");
|
|
90
|
+
list.handleInput("j");
|
|
91
|
+
list.handleInput("j");
|
|
92
|
+
list.handleInput(" "); // D
|
|
93
|
+
list.handleInput("k");
|
|
94
|
+
list.handleInput("k");
|
|
95
|
+
list.handleInput("k");
|
|
96
|
+
list.handleInput(" "); // A
|
|
97
|
+
list.handleInput("j");
|
|
98
|
+
list.handleInput("j");
|
|
99
|
+
list.handleInput(" "); // C
|
|
100
|
+
list.handleInput("\r");
|
|
101
|
+
expect(onConfirm).toHaveBeenCalledWith(["A", "C", "D"]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("render", () => {
|
|
106
|
+
it("includes footer hint", () => {
|
|
107
|
+
const { list } = make();
|
|
108
|
+
const lines = list.render(80);
|
|
109
|
+
expect(lines.some((l) => l.includes("space toggle"))).toBe(true);
|
|
110
|
+
expect(lines.some((l) => l.includes("enter confirm"))).toBe(true);
|
|
111
|
+
expect(lines.some((l) => l.includes("esc cancel"))).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("renders [ ] for unchecked and [x] for checked items", () => {
|
|
115
|
+
const { list } = make();
|
|
116
|
+
list.handleInput(" "); // check A
|
|
117
|
+
const lines = list.render(80);
|
|
118
|
+
expect(lines.some((l) => l.includes("[x] A"))).toBe(true);
|
|
119
|
+
expect(lines.some((l) => l.includes("[ ] B"))).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("renders cursor marker on current item", () => {
|
|
123
|
+
const { list } = make();
|
|
124
|
+
list.handleInput("j"); // cursor → 1
|
|
125
|
+
const lines = list.render(80);
|
|
126
|
+
// Cursor line should start with "▸ " somewhere
|
|
127
|
+
expect(lines.some((l) => l.startsWith("▸ ") && l.includes("B"))).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("includes title and message when provided", () => {
|
|
131
|
+
const { list } = make(["A", "B"], "Pick one or more", "Some context");
|
|
132
|
+
const lines = list.render(80);
|
|
133
|
+
expect(lines.some((l) => l.includes("Pick one or more"))).toBe(true);
|
|
134
|
+
expect(lines.some((l) => l.includes("Some context"))).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `polyfillMultiselect`'s fallback chain. After the
|
|
3
|
+
* fix-multiselect-auto-cancel-on-dashboard change, the polyfill prefers a
|
|
4
|
+
* bridge-patched `ctx.ui.multiselect` (the PromptBus path that surfaces in
|
|
5
|
+
* the dashboard browser) and falls back to the legacy `ctx.ui.custom` +
|
|
6
|
+
* `MultiSelectList` overlay only when the patch is absent (older pi
|
|
7
|
+
* versions or non-bridge embeddings).
|
|
8
|
+
*
|
|
9
|
+
* See change: fix-multiselect-auto-cancel-on-dashboard.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, vi } from "vitest";
|
|
12
|
+
import { polyfillMultiselect } from "../multiselect-polyfill.js";
|
|
13
|
+
|
|
14
|
+
describe("polyfillMultiselect — fallback chain", () => {
|
|
15
|
+
it("delegates to ctx.ui.multiselect when present (primary, bus-routed path)", async () => {
|
|
16
|
+
const multiselectFn = vi.fn().mockResolvedValue(["a"]);
|
|
17
|
+
const customFn = vi.fn();
|
|
18
|
+
const ctx = {
|
|
19
|
+
ui: {
|
|
20
|
+
multiselect: multiselectFn,
|
|
21
|
+
custom: customFn,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const result = await polyfillMultiselect(ctx as any, "Pick", ["a", "b"]);
|
|
26
|
+
|
|
27
|
+
expect(result).toEqual(["a"]);
|
|
28
|
+
expect(multiselectFn).toHaveBeenCalledTimes(1);
|
|
29
|
+
expect(multiselectFn).toHaveBeenCalledWith("Pick", ["a", "b"], undefined);
|
|
30
|
+
expect(customFn).not.toHaveBeenCalled();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("forwards opts.message to ctx.ui.multiselect when present", async () => {
|
|
34
|
+
const multiselectFn = vi.fn().mockResolvedValue([]);
|
|
35
|
+
const ctx = { ui: { multiselect: multiselectFn, custom: vi.fn() } };
|
|
36
|
+
|
|
37
|
+
await polyfillMultiselect(ctx as any, "Pick", ["a"], { message: "ctx" });
|
|
38
|
+
|
|
39
|
+
expect(multiselectFn).toHaveBeenCalledWith("Pick", ["a"], { message: "ctx" });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("propagates undefined (cancellation) through the primary path", async () => {
|
|
43
|
+
const multiselectFn = vi.fn().mockResolvedValue(undefined);
|
|
44
|
+
const ctx = { ui: { multiselect: multiselectFn, custom: vi.fn() } };
|
|
45
|
+
|
|
46
|
+
await expect(polyfillMultiselect(ctx as any, "t", ["a"])).resolves.toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("falls back to ctx.ui.custom when ctx.ui.multiselect is absent (TUI path)", async () => {
|
|
50
|
+
let capturedDone: ((r: string[] | undefined) => void) | undefined;
|
|
51
|
+
const customFn = vi.fn().mockImplementation((factory: any) => {
|
|
52
|
+
return new Promise<string[] | undefined>((resolve) => {
|
|
53
|
+
const done = (r: string[] | undefined) => resolve(r);
|
|
54
|
+
capturedDone = done;
|
|
55
|
+
const component = factory({}, {}, {}, done);
|
|
56
|
+
// Simulate user confirming after the factory wires the component.
|
|
57
|
+
component?.onConfirm?.(["b"]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
const ctx = { ui: { custom: customFn } };
|
|
61
|
+
|
|
62
|
+
const result = await polyfillMultiselect(ctx as any, "Pick", ["a", "b"]);
|
|
63
|
+
|
|
64
|
+
expect(result).toEqual(["b"]);
|
|
65
|
+
expect(customFn).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(capturedDone).toBeTypeOf("function");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("falls back to ctx.ui.custom and resolves undefined on cancel", async () => {
|
|
70
|
+
const customFn = vi.fn().mockImplementation((factory: any) => {
|
|
71
|
+
return new Promise<string[] | undefined>((resolve) => {
|
|
72
|
+
const component = factory({}, {}, {}, (r: any) => resolve(r));
|
|
73
|
+
component?.onCancel?.();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
const ctx = { ui: { custom: customFn } };
|
|
77
|
+
|
|
78
|
+
await expect(polyfillMultiselect(ctx as any, "Pick", ["a"])).resolves.toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("does NOT fall back to ctx.ui.custom when ctx.ui.multiselect resolves to []", async () => {
|
|
82
|
+
// Empty selection is a valid answer — must NOT trigger the legacy fallback.
|
|
83
|
+
const multiselectFn = vi.fn().mockResolvedValue([]);
|
|
84
|
+
const customFn = vi.fn();
|
|
85
|
+
const ctx = { ui: { multiselect: multiselectFn, custom: customFn } };
|
|
86
|
+
|
|
87
|
+
const result = await polyfillMultiselect(ctx as any, "t", ["a"]);
|
|
88
|
+
|
|
89
|
+
expect(result).toEqual([]);
|
|
90
|
+
expect(customFn).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
});
|