@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,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the post-install OpenSpec + pi-resources force-refresh
|
|
3
|
+
* portion of `runPostInstallRepair`.
|
|
4
|
+
*
|
|
5
|
+
* See change: fix-openspec-buttons-after-bootstrap-install.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
8
|
+
import { runPostInstallRepair } from "../server.js";
|
|
9
|
+
|
|
10
|
+
interface SpyRegistry { rescan: ReturnType<typeof vi.fn>; }
|
|
11
|
+
interface SpyDirSvc {
|
|
12
|
+
knownDirectories: ReturnType<typeof vi.fn>;
|
|
13
|
+
getOpenSpecData: ReturnType<typeof vi.fn>;
|
|
14
|
+
refreshOpenSpec: ReturnType<typeof vi.fn>;
|
|
15
|
+
refreshPiResources: ReturnType<typeof vi.fn>;
|
|
16
|
+
}
|
|
17
|
+
interface SpyGateway { broadcastToAll: ReturnType<typeof vi.fn>; }
|
|
18
|
+
|
|
19
|
+
function makeRegistry(): SpyRegistry { return { rescan: vi.fn() }; }
|
|
20
|
+
function makeGateway(): SpyGateway { return { broadcastToAll: vi.fn() }; }
|
|
21
|
+
|
|
22
|
+
describe("runPostInstallRepair: openspec + pi-resources refresh", () => {
|
|
23
|
+
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("calls refreshOpenSpec(cwd) once for every known directory", async () => {
|
|
30
|
+
const registry = makeRegistry();
|
|
31
|
+
const cwds = ["/p1", "/p2", "/p3"];
|
|
32
|
+
const directoryService: SpyDirSvc = {
|
|
33
|
+
knownDirectories: vi.fn(() => cwds),
|
|
34
|
+
getOpenSpecData: vi.fn(() => ({ initialized: false, changes: [] })),
|
|
35
|
+
refreshOpenSpec: vi.fn(async () => ({ initialized: true, changes: [{ name: "x" } as never] })),
|
|
36
|
+
refreshPiResources: vi.fn(async () => ({})),
|
|
37
|
+
};
|
|
38
|
+
const browserGateway = makeGateway();
|
|
39
|
+
|
|
40
|
+
await runPostInstallRepair({
|
|
41
|
+
registry: registry as never,
|
|
42
|
+
directoryService: directoryService as never,
|
|
43
|
+
browserGateway: browserGateway as never,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(directoryService.refreshOpenSpec).toHaveBeenCalledTimes(3);
|
|
47
|
+
expect(directoryService.refreshOpenSpec).toHaveBeenNthCalledWith(1, "/p1");
|
|
48
|
+
expect(directoryService.refreshOpenSpec).toHaveBeenNthCalledWith(2, "/p2");
|
|
49
|
+
expect(directoryService.refreshOpenSpec).toHaveBeenNthCalledWith(3, "/p3");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("broadcasts openspec_update for each cwd whose prior cache was empty/undefined", async () => {
|
|
53
|
+
const cwds = ["/a", "/b"];
|
|
54
|
+
const fresh = { initialized: true, changes: [{ name: "c1" } as never] };
|
|
55
|
+
const directoryService: SpyDirSvc = {
|
|
56
|
+
knownDirectories: vi.fn(() => cwds),
|
|
57
|
+
// Prior was undefined for /a, empty for /b — both should broadcast.
|
|
58
|
+
getOpenSpecData: vi.fn((cwd: string) =>
|
|
59
|
+
cwd === "/a" ? undefined : { initialized: false, changes: [] }
|
|
60
|
+
),
|
|
61
|
+
refreshOpenSpec: vi.fn(async () => fresh),
|
|
62
|
+
refreshPiResources: vi.fn(async () => ({})),
|
|
63
|
+
};
|
|
64
|
+
const browserGateway = makeGateway();
|
|
65
|
+
|
|
66
|
+
await runPostInstallRepair({
|
|
67
|
+
registry: makeRegistry() as never,
|
|
68
|
+
directoryService: directoryService as never,
|
|
69
|
+
browserGateway: browserGateway as never,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const broadcasts = browserGateway.broadcastToAll.mock.calls
|
|
73
|
+
.map((c: unknown[]) => c[0])
|
|
74
|
+
.filter((m: any) => m?.type === "openspec_update");
|
|
75
|
+
expect(broadcasts).toHaveLength(2);
|
|
76
|
+
expect(broadcasts).toContainEqual({ type: "openspec_update", cwd: "/a", data: fresh });
|
|
77
|
+
expect(broadcasts).toContainEqual({ type: "openspec_update", cwd: "/b", data: fresh });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("does not broadcast openspec_update when refreshed data equals prior data", async () => {
|
|
81
|
+
const same = { initialized: true, changes: [{ name: "stable" } as never] };
|
|
82
|
+
const directoryService: SpyDirSvc = {
|
|
83
|
+
knownDirectories: vi.fn(() => ["/p"]),
|
|
84
|
+
getOpenSpecData: vi.fn(() => same),
|
|
85
|
+
refreshOpenSpec: vi.fn(async () => same),
|
|
86
|
+
refreshPiResources: vi.fn(async () => ({})),
|
|
87
|
+
};
|
|
88
|
+
const browserGateway = makeGateway();
|
|
89
|
+
|
|
90
|
+
await runPostInstallRepair({
|
|
91
|
+
registry: makeRegistry() as never,
|
|
92
|
+
directoryService: directoryService as never,
|
|
93
|
+
browserGateway: browserGateway as never,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const broadcasts = browserGateway.broadcastToAll.mock.calls
|
|
97
|
+
.map((c: unknown[]) => c[0])
|
|
98
|
+
.filter((m: any) => m?.type === "openspec_update");
|
|
99
|
+
expect(broadcasts).toHaveLength(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("isolates a per-cwd refresh failure — the other cwd still refreshes and broadcasts", async () => {
|
|
103
|
+
const cwds = ["/good", "/bad"];
|
|
104
|
+
const fresh = { initialized: true, changes: [{ name: "ok" } as never] };
|
|
105
|
+
const directoryService: SpyDirSvc = {
|
|
106
|
+
knownDirectories: vi.fn(() => cwds),
|
|
107
|
+
getOpenSpecData: vi.fn(() => undefined),
|
|
108
|
+
refreshOpenSpec: vi.fn(async (cwd: string) => {
|
|
109
|
+
if (cwd === "/bad") throw new Error("boom");
|
|
110
|
+
return fresh;
|
|
111
|
+
}),
|
|
112
|
+
refreshPiResources: vi.fn(async () => ({})),
|
|
113
|
+
};
|
|
114
|
+
const browserGateway = makeGateway();
|
|
115
|
+
|
|
116
|
+
await runPostInstallRepair({
|
|
117
|
+
registry: makeRegistry() as never,
|
|
118
|
+
directoryService: directoryService as never,
|
|
119
|
+
browserGateway: browserGateway as never,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Both cwds attempted.
|
|
123
|
+
expect(directoryService.refreshOpenSpec).toHaveBeenCalledTimes(2);
|
|
124
|
+
// Only the good one broadcasts.
|
|
125
|
+
const broadcasts = browserGateway.broadcastToAll.mock.calls
|
|
126
|
+
.map((c: unknown[]) => c[0])
|
|
127
|
+
.filter((m: any) => m?.type === "openspec_update" && m.cwd === "/good");
|
|
128
|
+
expect(broadcasts).toHaveLength(1);
|
|
129
|
+
// Failure was logged, not propagated.
|
|
130
|
+
expect(consoleErrorSpy).toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("calls refreshPiResources(cwd) once for every known directory", async () => {
|
|
134
|
+
const cwds = ["/a", "/b", "/c"];
|
|
135
|
+
const directoryService: SpyDirSvc = {
|
|
136
|
+
knownDirectories: vi.fn(() => cwds),
|
|
137
|
+
getOpenSpecData: vi.fn(() => undefined),
|
|
138
|
+
refreshOpenSpec: vi.fn(async () => ({ initialized: false, changes: [] })),
|
|
139
|
+
refreshPiResources: vi.fn(async () => ({})),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
await runPostInstallRepair({
|
|
143
|
+
registry: makeRegistry() as never,
|
|
144
|
+
directoryService: directoryService as never,
|
|
145
|
+
browserGateway: makeGateway() as never,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(directoryService.refreshPiResources).toHaveBeenCalledTimes(3);
|
|
149
|
+
expect(directoryService.refreshPiResources).toHaveBeenCalledWith("/a");
|
|
150
|
+
expect(directoryService.refreshPiResources).toHaveBeenCalledWith("/b");
|
|
151
|
+
expect(directoryService.refreshPiResources).toHaveBeenCalledWith("/c");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("a refreshPiResources failure does not block other cwds or openspec broadcasts", async () => {
|
|
155
|
+
const cwds = ["/good", "/bad"];
|
|
156
|
+
const fresh = { initialized: true, changes: [{ name: "ok" } as never] };
|
|
157
|
+
const directoryService: SpyDirSvc = {
|
|
158
|
+
knownDirectories: vi.fn(() => cwds),
|
|
159
|
+
getOpenSpecData: vi.fn(() => undefined),
|
|
160
|
+
refreshOpenSpec: vi.fn(async () => fresh),
|
|
161
|
+
refreshPiResources: vi.fn(async (cwd: string) => {
|
|
162
|
+
if (cwd === "/bad") throw new Error("pi-resources blew up");
|
|
163
|
+
return {};
|
|
164
|
+
}),
|
|
165
|
+
};
|
|
166
|
+
const browserGateway = makeGateway();
|
|
167
|
+
|
|
168
|
+
await runPostInstallRepair({
|
|
169
|
+
registry: makeRegistry() as never,
|
|
170
|
+
directoryService: directoryService as never,
|
|
171
|
+
browserGateway: browserGateway as never,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(directoryService.refreshPiResources).toHaveBeenCalledTimes(2);
|
|
175
|
+
const openSpecBroadcasts = browserGateway.broadcastToAll.mock.calls
|
|
176
|
+
.map((c: unknown[]) => c[0])
|
|
177
|
+
.filter((m: any) => m?.type === "openspec_update");
|
|
178
|
+
expect(openSpecBroadcasts).toHaveLength(2);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the centralized post-install rescan hook.
|
|
3
|
+
*
|
|
4
|
+
* Asserts that the helper invoked from the bootstrap-state subscribe
|
|
5
|
+
* callback calls `registry.rescan()` (no arg → full registry invalidate)
|
|
6
|
+
* exactly once on `installing → ready` and never on other transitions.
|
|
7
|
+
*
|
|
8
|
+
* See change: fix-openspec-buttons-after-bootstrap-install.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, vi } from "vitest";
|
|
11
|
+
import { runPostInstallRepair, makeBootstrapTransitionHandler } from "../server.js";
|
|
12
|
+
|
|
13
|
+
interface SpyRegistry {
|
|
14
|
+
rescan: ReturnType<typeof vi.fn>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface SpyDirSvc {
|
|
18
|
+
knownDirectories: ReturnType<typeof vi.fn>;
|
|
19
|
+
getOpenSpecData: ReturnType<typeof vi.fn>;
|
|
20
|
+
refreshOpenSpec: ReturnType<typeof vi.fn>;
|
|
21
|
+
refreshPiResources: ReturnType<typeof vi.fn>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface SpyGateway {
|
|
25
|
+
broadcastToAll: ReturnType<typeof vi.fn>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeRegistry(): SpyRegistry {
|
|
29
|
+
return { rescan: vi.fn() };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeDirSvc(): SpyDirSvc {
|
|
33
|
+
return {
|
|
34
|
+
knownDirectories: vi.fn(() => []),
|
|
35
|
+
getOpenSpecData: vi.fn(() => undefined),
|
|
36
|
+
refreshOpenSpec: vi.fn(async () => ({ initialized: false, changes: [] })),
|
|
37
|
+
refreshPiResources: vi.fn(async () => ({ initialized: false, packages: [] })),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeGateway(): SpyGateway {
|
|
42
|
+
return { broadcastToAll: vi.fn() };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("runPostInstallRepair", () => {
|
|
46
|
+
it("calls registry.rescan() with no argument (full registry)", async () => {
|
|
47
|
+
const registry = makeRegistry();
|
|
48
|
+
const directoryService = makeDirSvc();
|
|
49
|
+
const browserGateway = makeGateway();
|
|
50
|
+
await runPostInstallRepair({
|
|
51
|
+
registry: registry as never,
|
|
52
|
+
directoryService: directoryService as never,
|
|
53
|
+
browserGateway: browserGateway as never,
|
|
54
|
+
});
|
|
55
|
+
expect(registry.rescan).toHaveBeenCalledTimes(1);
|
|
56
|
+
expect(registry.rescan).toHaveBeenCalledWith();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("makeBootstrapTransitionHandler", () => {
|
|
61
|
+
it("invokes the post-install repair exactly once on installing → ready", async () => {
|
|
62
|
+
const repair = vi.fn(async () => undefined);
|
|
63
|
+
const flushAll = vi.fn(async () => undefined);
|
|
64
|
+
const handler = makeBootstrapTransitionHandler({
|
|
65
|
+
onTransitionToReady: repair,
|
|
66
|
+
flushQueue: flushAll,
|
|
67
|
+
});
|
|
68
|
+
handler({ status: "installing" } as never);
|
|
69
|
+
handler({ status: "ready" } as never);
|
|
70
|
+
// Wait one microtask tick for the fire-and-forget to fire.
|
|
71
|
+
await Promise.resolve();
|
|
72
|
+
expect(repair).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(flushAll).toHaveBeenCalledTimes(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("does not call repair on ready → ready", async () => {
|
|
77
|
+
const repair = vi.fn(async () => undefined);
|
|
78
|
+
const flushAll = vi.fn(async () => undefined);
|
|
79
|
+
const handler = makeBootstrapTransitionHandler({
|
|
80
|
+
onTransitionToReady: repair,
|
|
81
|
+
flushQueue: flushAll,
|
|
82
|
+
});
|
|
83
|
+
// Initial state defaults to ready, so first ready-snapshot is a no-op.
|
|
84
|
+
handler({ status: "ready" } as never);
|
|
85
|
+
handler({ status: "ready" } as never);
|
|
86
|
+
await Promise.resolve();
|
|
87
|
+
expect(repair).not.toHaveBeenCalled();
|
|
88
|
+
expect(flushAll).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("does not call repair on installing → failed", async () => {
|
|
92
|
+
const repair = vi.fn(async () => undefined);
|
|
93
|
+
const flushAll = vi.fn(async () => undefined);
|
|
94
|
+
const handler = makeBootstrapTransitionHandler({
|
|
95
|
+
onTransitionToReady: repair,
|
|
96
|
+
flushQueue: flushAll,
|
|
97
|
+
});
|
|
98
|
+
handler({ status: "installing" } as never);
|
|
99
|
+
handler({ status: "failed" } as never);
|
|
100
|
+
await Promise.resolve();
|
|
101
|
+
expect(repair).not.toHaveBeenCalled();
|
|
102
|
+
expect(flushAll).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("does not call repair on the very first subscribe snapshot", async () => {
|
|
106
|
+
// Bootstrap state defaults to "ready"; the first emitted snapshot
|
|
107
|
+
// (e.g. from an immediate broadcast) should NOT trigger the hook.
|
|
108
|
+
const repair = vi.fn(async () => undefined);
|
|
109
|
+
const flushAll = vi.fn(async () => undefined);
|
|
110
|
+
const handler = makeBootstrapTransitionHandler({
|
|
111
|
+
onTransitionToReady: repair,
|
|
112
|
+
flushQueue: flushAll,
|
|
113
|
+
});
|
|
114
|
+
handler({ status: "ready" } as never);
|
|
115
|
+
await Promise.resolve();
|
|
116
|
+
expect(repair).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("calls repair on a second installing → ready cycle (e.g. user retry)", async () => {
|
|
120
|
+
const repair = vi.fn(async () => undefined);
|
|
121
|
+
const flushAll = vi.fn(async () => undefined);
|
|
122
|
+
const handler = makeBootstrapTransitionHandler({
|
|
123
|
+
onTransitionToReady: repair,
|
|
124
|
+
flushQueue: flushAll,
|
|
125
|
+
});
|
|
126
|
+
handler({ status: "installing" } as never);
|
|
127
|
+
handler({ status: "ready" } as never);
|
|
128
|
+
handler({ status: "installing" } as never);
|
|
129
|
+
handler({ status: "ready" } as never);
|
|
130
|
+
await Promise.resolve();
|
|
131
|
+
expect(repair).toHaveBeenCalledTimes(2);
|
|
132
|
+
expect(flushAll).toHaveBeenCalledTimes(2);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure-helper tests for the idempotent attach/detach auto-rename rule.
|
|
3
|
+
* See change: fix-mobile-attach-proposal-display (design.md decision matrix).
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
attachRenameTarget,
|
|
8
|
+
detachShouldClearName,
|
|
9
|
+
isNameAutoSetFromAttachment,
|
|
10
|
+
} from "../proposal-attach-naming.js";
|
|
11
|
+
|
|
12
|
+
type S = { name?: string | null; attachedProposal?: string | null };
|
|
13
|
+
|
|
14
|
+
describe("attachRenameTarget — decision matrix", () => {
|
|
15
|
+
it("(a) empty name + null attached → returns new change name", () => {
|
|
16
|
+
const s: S = { name: undefined, attachedProposal: null };
|
|
17
|
+
expect(attachRenameTarget(s as any, "bar")).toBe("bar");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("(a) whitespace-only name → treated as empty, returns new change name", () => {
|
|
21
|
+
const s: S = { name: " ", attachedProposal: null };
|
|
22
|
+
expect(attachRenameTarget(s as any, "bar")).toBe("bar");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("custom name + null attached → returns undefined (preserve user name)", () => {
|
|
26
|
+
const s: S = { name: "my custom", attachedProposal: null };
|
|
27
|
+
expect(attachRenameTarget(s as any, "bar")).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("(b) name === attached (auto-set) → returns new change name (re-track)", () => {
|
|
31
|
+
const s: S = { name: "foo", attachedProposal: "foo" };
|
|
32
|
+
expect(attachRenameTarget(s as any, "bar")).toBe("bar");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("custom name + non-null attached (user customised after auto) → returns undefined", () => {
|
|
36
|
+
const s: S = { name: "my custom", attachedProposal: "foo" };
|
|
37
|
+
expect(attachRenameTarget(s as any, "bar")).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("undefined session → returns undefined (defensive)", () => {
|
|
41
|
+
expect(attachRenameTarget(undefined, "bar")).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("detachShouldClearName — decision matrix", () => {
|
|
46
|
+
it("name === attached (auto-set) → true", () => {
|
|
47
|
+
expect(detachShouldClearName({ name: "foo", attachedProposal: "foo" } as any)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("custom name + non-null attached → false", () => {
|
|
51
|
+
expect(detachShouldClearName({ name: "my custom", attachedProposal: "foo" } as any)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("empty name + non-null attached → false (nothing to revert)", () => {
|
|
55
|
+
expect(detachShouldClearName({ name: undefined, attachedProposal: "foo" } as any)).toBe(false);
|
|
56
|
+
expect(detachShouldClearName({ name: "", attachedProposal: "foo" } as any)).toBe(false);
|
|
57
|
+
expect(detachShouldClearName({ name: " ", attachedProposal: "foo" } as any)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("name set + null attached → false (defensive: not auto-set)", () => {
|
|
61
|
+
expect(detachShouldClearName({ name: "foo", attachedProposal: null } as any)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("undefined session → false", () => {
|
|
65
|
+
expect(detachShouldClearName(undefined)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("isNameAutoSetFromAttachment", () => {
|
|
70
|
+
it("name === attached → true", () => {
|
|
71
|
+
expect(isNameAutoSetFromAttachment({ name: "foo", attachedProposal: "foo" } as any)).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
it("name !== attached → false", () => {
|
|
74
|
+
expect(isNameAutoSetFromAttachment({ name: "foo", attachedProposal: "bar" } as any)).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
it("trims whitespace before comparing", () => {
|
|
77
|
+
expect(isNameAutoSetFromAttachment({ name: " foo ", attachedProposal: "foo" } as any)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifies that `handleSpawnSession` enqueues a pending-attach intent
|
|
3
|
+
* when the browser sends `attachProposal`, and does NOT enqueue when omitted.
|
|
4
|
+
* See change: add-folder-task-checker-and-spawn-attach.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
7
|
+
|
|
8
|
+
vi.mock("../process-manager.js", () => ({
|
|
9
|
+
spawnPiSession: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
vi.mock("../../../shared/src/config.js", () => ({
|
|
12
|
+
loadConfig: () => ({ spawnStrategy: "headless" as const }),
|
|
13
|
+
}));
|
|
14
|
+
vi.mock("@blackbelt-technology/pi-dashboard-shared/config.js", () => ({
|
|
15
|
+
loadConfig: () => ({ spawnStrategy: "headless" as const }),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import { handleSpawnSession } from "../browser-handlers/session-action-handler.js";
|
|
19
|
+
import { spawnPiSession } from "../process-manager.js";
|
|
20
|
+
|
|
21
|
+
function makeCtx() {
|
|
22
|
+
const enqueue = vi.fn();
|
|
23
|
+
const ctx = {
|
|
24
|
+
ws: { readyState: 1 } as unknown as WebSocket,
|
|
25
|
+
headlessPidRegistry: { register: vi.fn() },
|
|
26
|
+
pendingDashboardSpawns: new Map<string, number>(),
|
|
27
|
+
pendingAttachRegistry: { enqueue, consume: vi.fn(), size: vi.fn() },
|
|
28
|
+
sendTo: () => {},
|
|
29
|
+
} as any;
|
|
30
|
+
return { ctx, enqueue };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("handleSpawnSession — attachProposal", () => {
|
|
34
|
+
beforeEach(() => { vi.clearAllMocks(); });
|
|
35
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
36
|
+
|
|
37
|
+
it("enqueues exactly once when attachProposal is set", async () => {
|
|
38
|
+
(spawnPiSession as any).mockResolvedValueOnce({ success: true });
|
|
39
|
+
const { ctx, enqueue } = makeCtx();
|
|
40
|
+
await handleSpawnSession(
|
|
41
|
+
{ type: "spawn_session", cwd: "/proj", attachProposal: "add-foo" } as any,
|
|
42
|
+
ctx,
|
|
43
|
+
);
|
|
44
|
+
expect(enqueue).toHaveBeenCalledTimes(1);
|
|
45
|
+
expect(enqueue).toHaveBeenCalledWith("/proj", "add-foo");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("does NOT enqueue when attachProposal is absent", async () => {
|
|
49
|
+
(spawnPiSession as any).mockResolvedValueOnce({ success: true });
|
|
50
|
+
const { ctx, enqueue } = makeCtx();
|
|
51
|
+
await handleSpawnSession({ type: "spawn_session", cwd: "/proj" } as any, ctx);
|
|
52
|
+
expect(enqueue).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("does NOT enqueue when attachProposal is empty string", async () => {
|
|
56
|
+
(spawnPiSession as any).mockResolvedValueOnce({ success: true });
|
|
57
|
+
const { ctx, enqueue } = makeCtx();
|
|
58
|
+
await handleSpawnSession(
|
|
59
|
+
{ type: "spawn_session", cwd: "/proj", attachProposal: "" } as any,
|
|
60
|
+
ctx,
|
|
61
|
+
);
|
|
62
|
+
expect(enqueue).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("enqueues BEFORE awaiting spawnPiSession (intent survives a fast register)", async () => {
|
|
66
|
+
let spawnCalled = false;
|
|
67
|
+
const { ctx, enqueue } = makeCtx();
|
|
68
|
+
enqueue.mockImplementation(() => {
|
|
69
|
+
// The spawn must not have started yet at the time we enqueue.
|
|
70
|
+
expect(spawnCalled).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
(spawnPiSession as any).mockImplementation(async () => {
|
|
73
|
+
spawnCalled = true;
|
|
74
|
+
return { success: true };
|
|
75
|
+
});
|
|
76
|
+
await handleSpawnSession(
|
|
77
|
+
{ type: "spawn_session", cwd: "/proj", attachProposal: "add-bar" } as any,
|
|
78
|
+
ctx,
|
|
79
|
+
);
|
|
80
|
+
expect(enqueue).toHaveBeenCalledOnce();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("still enqueues even when spawn throws (spawn failure isn't a reason to lose intent — TTL handles it)", async () => {
|
|
84
|
+
(spawnPiSession as any).mockRejectedValueOnce(new Error("boom"));
|
|
85
|
+
const { ctx, enqueue } = makeCtx();
|
|
86
|
+
await handleSpawnSession(
|
|
87
|
+
{ type: "spawn_session", cwd: "/proj", attachProposal: "add-baz" } as any,
|
|
88
|
+
ctx,
|
|
89
|
+
);
|
|
90
|
+
expect(enqueue).toHaveBeenCalledOnce();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("works when pendingAttachRegistry is undefined (back-compat)", async () => {
|
|
94
|
+
(spawnPiSession as any).mockResolvedValueOnce({ success: true });
|
|
95
|
+
const ctx: any = {
|
|
96
|
+
ws: { readyState: 1 },
|
|
97
|
+
headlessPidRegistry: { register: vi.fn() },
|
|
98
|
+
pendingDashboardSpawns: new Map(),
|
|
99
|
+
sendTo: () => {},
|
|
100
|
+
};
|
|
101
|
+
await expect(
|
|
102
|
+
handleSpawnSession(
|
|
103
|
+
{ type: "spawn_session", cwd: "/proj", attachProposal: "add-foo" } as any,
|
|
104
|
+
ctx,
|
|
105
|
+
),
|
|
106
|
+
).resolves.toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -132,4 +132,59 @@ describe("SessionOrderManager", () => {
|
|
|
132
132
|
expect(orders).toEqual({ "/a": ["s1"], "/b": ["s2", "s3"] });
|
|
133
133
|
});
|
|
134
134
|
});
|
|
135
|
+
|
|
136
|
+
describe("moveToFront", () => {
|
|
137
|
+
it("prepends id when absent (creates entry for new cwd)", () => {
|
|
138
|
+
const mgr = createSessionOrderManager(stateStore);
|
|
139
|
+
mgr.moveToFront("/project", "s1");
|
|
140
|
+
expect(mgr.getOrder("/project")).toEqual(["s1"]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("prepends id when absent from existing order", () => {
|
|
144
|
+
stateStore = createMockPreferencesStore({ "/project": ["s0", "s1", "s2"] });
|
|
145
|
+
const mgr = createSessionOrderManager(stateStore);
|
|
146
|
+
mgr.moveToFront("/project", "s9");
|
|
147
|
+
expect(mgr.getOrder("/project")).toEqual(["s9", "s0", "s1", "s2"]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("moves id from non-front position to index 0", () => {
|
|
151
|
+
stateStore = createMockPreferencesStore({ "/project": ["s0", "s1", "s2"] });
|
|
152
|
+
const mgr = createSessionOrderManager(stateStore);
|
|
153
|
+
mgr.moveToFront("/project", "s2");
|
|
154
|
+
expect(mgr.getOrder("/project")).toEqual(["s2", "s0", "s1"]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("is idempotent: id already at front stays at index 0", () => {
|
|
158
|
+
stateStore = createMockPreferencesStore({ "/project": ["s0", "s1", "s2"] });
|
|
159
|
+
const mgr = createSessionOrderManager(stateStore);
|
|
160
|
+
mgr.moveToFront("/project", "s0");
|
|
161
|
+
expect(mgr.getOrder("/project")).toEqual(["s0", "s1", "s2"]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("persists after moveToFront", () => {
|
|
165
|
+
stateStore = createMockPreferencesStore({ "/project": ["s0", "s1"] });
|
|
166
|
+
const mgr = createSessionOrderManager(stateStore);
|
|
167
|
+
mgr.moveToFront("/project", "s1");
|
|
168
|
+
expect(stateStore.setSessionOrder).toHaveBeenCalled();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("end → resume → end → resume cycle keeps id at index 0", () => {
|
|
172
|
+
stateStore = createMockPreferencesStore({ "/project": ["s0", "s1"] });
|
|
173
|
+
const mgr = createSessionOrderManager(stateStore);
|
|
174
|
+
// First resume cycle: id is in the order list (drag-to-resume case)
|
|
175
|
+
mgr.moveToFront("/project", "s1");
|
|
176
|
+
expect(mgr.getOrder("/project")).toEqual(["s1", "s0"]);
|
|
177
|
+
// End: alive→ended branch removes the id
|
|
178
|
+
mgr.remove("/project", "s1");
|
|
179
|
+
expect(mgr.getOrder("/project")).toEqual(["s0"]);
|
|
180
|
+
// Second resume: moveToFront re-prepends
|
|
181
|
+
mgr.moveToFront("/project", "s1");
|
|
182
|
+
expect(mgr.getOrder("/project")).toEqual(["s1", "s0"]);
|
|
183
|
+
// End again
|
|
184
|
+
mgr.remove("/project", "s1");
|
|
185
|
+
// Third resume
|
|
186
|
+
mgr.moveToFront("/project", "s1");
|
|
187
|
+
expect(mgr.getOrder("/project")).toEqual(["s1", "s0"]);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
135
190
|
});
|