@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
|
@@ -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
|
+
});
|
|
@@ -34,14 +34,18 @@ describe("buildOrchestratorScript", () => {
|
|
|
34
34
|
const script = buildOrchestratorScript(baseParams);
|
|
35
35
|
// ARGS should be a JSON array containing --import and the loader
|
|
36
36
|
expect(script).toMatch(/const ARGS = \[.*"--import".*"file:\/\/\/tmp\/jiti-register\.mjs"/);
|
|
37
|
+
// On POSIX, cliPath stays RAW — jiti's resolver misbehaves on file:// URL entries.
|
|
37
38
|
expect(script).toMatch(/"\/tmp\/cli\.ts"/);
|
|
39
|
+
expect(script).not.toContain(JSON.stringify("file:///tmp/cli.ts"));
|
|
38
40
|
expect(script).toMatch(/"start"/);
|
|
39
41
|
});
|
|
40
42
|
|
|
41
43
|
it("omits --import when loader is empty", () => {
|
|
42
44
|
const script = buildOrchestratorScript({ ...baseParams, loader: "" });
|
|
43
45
|
expect(script).not.toMatch(/"--import"/);
|
|
46
|
+
// No loader + POSIX host → raw entry.
|
|
44
47
|
expect(script).toMatch(/"\/tmp\/cli\.ts"/);
|
|
48
|
+
expect(script).not.toContain(JSON.stringify("file:///tmp/cli.ts"));
|
|
45
49
|
expect(script).toMatch(/"start"/);
|
|
46
50
|
});
|
|
47
51
|
|
|
@@ -51,7 +55,12 @@ describe("buildOrchestratorScript", () => {
|
|
|
51
55
|
expect(script).toMatch(/"start","--dev"/);
|
|
52
56
|
});
|
|
53
57
|
|
|
54
|
-
it("
|
|
58
|
+
it("wraps Windows cliPath as file:// URL when loader is jiti AND host is Windows (Node parses drive letters as URL schemes)", () => {
|
|
59
|
+
// NOTE: shouldUrlWrapEntry consults process.platform. This test runs on
|
|
60
|
+
// Linux CI, so the wrap branch isn't directly exercised here — but the
|
|
61
|
+
// UNIT test for shouldUrlWrapEntry itself covers the win32 contract.
|
|
62
|
+
// Here we verify the tree of what buildOrchestratorScript emits on the
|
|
63
|
+
// host platform (Linux): raw entry even with a Windows-styled path.
|
|
55
64
|
const winParams = {
|
|
56
65
|
...baseParams,
|
|
57
66
|
cliPath: "B:\\Dev\\BB\\pi-agent-dashboard\\packages\\server\\src\\cli.ts",
|
|
@@ -59,13 +68,32 @@ describe("buildOrchestratorScript", () => {
|
|
|
59
68
|
execPath: "C:\\Program Files\\nodejs\\node.exe",
|
|
60
69
|
};
|
|
61
70
|
const script = buildOrchestratorScript(winParams);
|
|
62
|
-
// Must be embedded via JSON.stringify (backslashes escaped, quotes preserved)
|
|
63
71
|
expect(script).toContain(JSON.stringify(winParams.execPath));
|
|
64
|
-
expect(script).toContain(JSON.stringify(winParams.cliPath));
|
|
65
72
|
expect(script).toContain(JSON.stringify(winParams.loader));
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
// Host is Linux → entry stays raw (tested branch here).
|
|
74
|
+
expect(script).toContain(JSON.stringify(winParams.cliPath));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("keeps cliPath as RAW path when loader is tsx (tsx rejects file:// URL entries)", () => {
|
|
78
|
+
// Regression: tsx's ESM hook treats the entry as a user-typed specifier
|
|
79
|
+
// and attempts bare/relative resolution. A file:// URL becomes "<cwd>/file:/..."
|
|
80
|
+
// and crashes with ERR_MODULE_NOT_FOUND. This is the Linux dev-loop case
|
|
81
|
+
// (jiti not in repo node_modules, tsx fallback picked up).
|
|
82
|
+
const tsxParams = {
|
|
83
|
+
cliPath: "/home/u/repo/packages/server/src/cli.ts",
|
|
84
|
+
loader: "file:///home/u/repo/node_modules/tsx/dist/esm/index.mjs",
|
|
85
|
+
port: 8000,
|
|
86
|
+
extraArgs: [] as string[],
|
|
87
|
+
execPath: "/usr/bin/node",
|
|
88
|
+
};
|
|
89
|
+
const script = buildOrchestratorScript(tsxParams);
|
|
90
|
+
// Loader is still URL-wrapped (Node's --import requires file://)
|
|
91
|
+
expect(script).toContain(JSON.stringify(tsxParams.loader));
|
|
92
|
+
// Entry is the RAW path, NOT a file:// URL
|
|
93
|
+
expect(script).toContain(JSON.stringify(tsxParams.cliPath));
|
|
94
|
+
// Negative: must NOT contain the file:// URL form of the entry
|
|
95
|
+
const urlForm = "file://" + tsxParams.cliPath;
|
|
96
|
+
expect(script).not.toContain(JSON.stringify(urlForm));
|
|
69
97
|
});
|
|
70
98
|
|
|
71
99
|
it("references ~/.pi/dashboard/restart.log for failure logging", () => {
|
|
@@ -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
|
});
|