@blackbelt-technology/pi-agent-dashboard 0.2.9 → 0.4.0
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 +64 -8
- package/README.md +308 -101
- package/docs/architecture.md +515 -16
- package/package.json +14 -7
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
- package/packages/extension/src/__tests__/git-info.test.ts +67 -55
- package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
- package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
- package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
- package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
- package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
- package/packages/extension/src/ask-user-tool.ts +289 -20
- package/packages/extension/src/bridge.ts +107 -6
- package/packages/extension/src/command-handler.ts +34 -39
- package/packages/extension/src/dev-build.ts +1 -1
- package/packages/extension/src/git-info.ts +9 -19
- package/packages/extension/src/pi-env.d.ts +1 -0
- package/packages/extension/src/process-scanner.ts +72 -38
- package/packages/extension/src/prompt-expander.ts +25 -4
- package/packages/extension/src/provider-register.ts +304 -16
- package/packages/extension/src/server-auto-start.ts +27 -1
- package/packages/extension/src/server-launcher.ts +71 -27
- package/packages/server/package.json +17 -2
- package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
- package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
- package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
- package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
- package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
- package/packages/server/src/__tests__/browse-endpoint.test.ts +246 -10
- package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
- package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
- package/packages/server/src/__tests__/config-api.test.ts +68 -0
- package/packages/server/src/__tests__/cors.test.ts +34 -2
- package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
- package/packages/server/src/__tests__/directory-service.test.ts +234 -8
- package/packages/server/src/__tests__/editor-manager-pid-registry.test.ts +168 -0
- package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
- package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
- package/packages/server/src/__tests__/editor-registry.test.ts +29 -15
- package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
- package/packages/server/src/__tests__/extension-register.test.ts +3 -1
- package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
- package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
- package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
- package/packages/server/src/__tests__/git-operations.test.ts +9 -7
- package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
- package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
- package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
- package/packages/server/src/__tests__/home-lock.test.ts +308 -0
- package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
- package/packages/server/src/__tests__/node-guard.test.ts +85 -0
- package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
- package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +126 -0
- package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
- package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
- package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
- package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
- package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
- package/packages/server/src/__tests__/process-manager.test.ts +45 -18
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
- package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
- package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
- package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
- package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
- package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
- package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
- package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
- package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
- package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
- package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
- package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
- package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
- package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
- package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
- package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
- package/packages/server/src/__tests__/tunnel.test.ts +103 -6
- package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
- package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
- package/packages/server/src/bootstrap-queue.ts +130 -0
- package/packages/server/src/bootstrap-state.ts +131 -0
- package/packages/server/src/browse.ts +108 -9
- package/packages/server/src/browser-gateway.ts +16 -3
- package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
- package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
- package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
- package/packages/server/src/cli.ts +256 -32
- package/packages/server/src/config-api.ts +16 -0
- package/packages/server/src/directory-service.ts +270 -39
- package/packages/server/src/editor-detection.ts +12 -9
- package/packages/server/src/editor-manager.ts +39 -5
- package/packages/server/src/editor-pid-registry.ts +199 -0
- package/packages/server/src/editor-registry.ts +22 -25
- package/packages/server/src/fix-pty-permissions.ts +44 -0
- package/packages/server/src/git-operations.ts +1 -1
- package/packages/server/src/headless-pid-registry.ts +16 -20
- package/packages/server/src/home-lock-release.ts +72 -0
- package/packages/server/src/home-lock.ts +389 -0
- package/packages/server/src/node-guard.ts +52 -0
- package/packages/server/src/npm-search-proxy.ts +71 -0
- package/packages/server/src/openspec-tasks.ts +158 -0
- package/packages/server/src/package-manager-wrapper.ts +225 -34
- package/packages/server/src/pi-core-checker.ts +290 -0
- package/packages/server/src/pi-core-updater.ts +172 -0
- package/packages/server/src/pi-gateway.ts +7 -0
- package/packages/server/src/pi-resource-scanner.ts +5 -8
- package/packages/server/src/pi-version-skew.ts +196 -0
- package/packages/server/src/preferences-store.ts +17 -3
- package/packages/server/src/process-manager.ts +403 -222
- package/packages/server/src/provider-probe.ts +234 -0
- package/packages/server/src/restart-helper.ts +130 -0
- package/packages/server/src/routes/bootstrap-routes.ts +88 -0
- package/packages/server/src/routes/file-routes.ts +30 -3
- package/packages/server/src/routes/openspec-routes.ts +107 -1
- package/packages/server/src/routes/pi-core-routes.ts +140 -0
- package/packages/server/src/routes/provider-auth-routes.ts +12 -10
- package/packages/server/src/routes/provider-routes.ts +55 -2
- package/packages/server/src/routes/recommended-routes.ts +225 -0
- package/packages/server/src/routes/system-routes.ts +30 -34
- package/packages/server/src/routes/tool-routes.ts +153 -0
- package/packages/server/src/server-pid.ts +5 -9
- package/packages/server/src/server.ts +363 -26
- package/packages/server/src/session-api.ts +77 -8
- package/packages/server/src/session-bootstrap.ts +17 -3
- package/packages/server/src/session-diff.ts +21 -21
- package/packages/server/src/terminal-manager.ts +65 -20
- package/packages/server/src/test-env-guard.ts +26 -0
- package/packages/server/src/test-support/test-server.ts +63 -0
- package/packages/server/src/tunnel.ts +172 -34
- package/packages/shared/package.json +10 -3
- package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
- package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
- package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
- package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
- package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
- package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
- package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
- package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
- package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
- package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
- package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
- package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
- package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
- package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
- package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
- package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
- package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
- package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
- package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
- package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
- package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
- package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
- package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
- package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
- package/packages/shared/src/__tests__/config.test.ts +59 -3
- package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
- package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
- package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
- package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
- package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
- package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
- package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
- package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
- package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
- package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
- package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
- package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
- package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
- package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
- package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
- package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
- package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +156 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
- package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
- package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
- package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
- package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
- package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
- package/packages/shared/src/bootstrap-install.ts +212 -0
- package/packages/shared/src/bridge-register.ts +87 -20
- package/packages/shared/src/browser-protocol.ts +93 -1
- package/packages/shared/src/config.ts +87 -15
- package/packages/shared/src/managed-paths.ts +31 -4
- package/packages/shared/src/openspec-poller.ts +71 -49
- package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
- package/packages/shared/src/platform/commands.ts +100 -0
- package/packages/shared/src/platform/detached-spawn.ts +305 -0
- package/packages/shared/src/platform/exec.ts +220 -0
- package/packages/shared/src/platform/git.ts +155 -0
- package/packages/shared/src/platform/index.ts +15 -0
- package/packages/shared/src/platform/npm.ts +162 -0
- package/packages/shared/src/platform/openspec.ts +91 -0
- package/packages/shared/src/platform/paths.ts +276 -0
- package/packages/shared/src/platform/process-identify.ts +126 -0
- package/packages/shared/src/platform/process-scan.ts +94 -0
- package/packages/shared/src/platform/process.ts +168 -0
- package/packages/shared/src/platform/runner.ts +369 -0
- package/packages/shared/src/platform/shell.ts +44 -0
- package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
- package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
- package/packages/shared/src/recommended-extensions.ts +196 -0
- package/packages/shared/src/resolve-jiti.ts +62 -3
- package/packages/shared/src/rest-api.ts +97 -0
- package/packages/shared/src/semaphore.ts +83 -0
- package/packages/shared/src/source-matching.ts +126 -0
- package/packages/shared/src/test-support/setup-home.ts +74 -0
- package/packages/shared/src/tool-registry/definitions.ts +342 -0
- package/packages/shared/src/tool-registry/index.ts +56 -0
- package/packages/shared/src/tool-registry/overrides.ts +118 -0
- package/packages/shared/src/tool-registry/registry.ts +262 -0
- package/packages/shared/src/tool-registry/strategies.ts +198 -0
- package/packages/shared/src/tool-registry/types.ts +180 -0
- package/packages/shared/src/types.ts +7 -0
|
@@ -2,10 +2,16 @@ import { describe, it, expect } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Unit tests for CORS origin validation logic.
|
|
5
|
-
*
|
|
5
|
+
* Mirrors the callback used in server.ts — kept in sync by hand. The tunnel
|
|
6
|
+
* URL is injected via a thunk so tests can simulate an active tunnel without
|
|
7
|
+
* importing the full server.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
|
-
function isAllowedOrigin(
|
|
10
|
+
function isAllowedOrigin(
|
|
11
|
+
origin: string | undefined,
|
|
12
|
+
configuredOrigins: string[],
|
|
13
|
+
getTunnelUrl: () => string | null = () => null,
|
|
14
|
+
): boolean {
|
|
9
15
|
if (!origin) return true;
|
|
10
16
|
try {
|
|
11
17
|
const u = new URL(origin);
|
|
@@ -13,6 +19,9 @@ function isAllowedOrigin(origin: string | undefined, configuredOrigins: string[]
|
|
|
13
19
|
if (host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1") {
|
|
14
20
|
return true;
|
|
15
21
|
}
|
|
22
|
+
const tunnelUrl = getTunnelUrl();
|
|
23
|
+
if (tunnelUrl && origin === tunnelUrl) return true;
|
|
24
|
+
if (host.endsWith(".share.zrok.io")) return true;
|
|
16
25
|
} catch { /* ignore */ }
|
|
17
26
|
return configuredOrigins.includes(origin);
|
|
18
27
|
}
|
|
@@ -45,4 +54,27 @@ describe("CORS origin validation", () => {
|
|
|
45
54
|
it("rejects non-localhost remote origins without config", () => {
|
|
46
55
|
expect(isAllowedOrigin("http://192.168.1.100:3000", [])).toBe(false);
|
|
47
56
|
});
|
|
57
|
+
|
|
58
|
+
// Regression: Vite emits `<script type="module" crossorigin>` which makes
|
|
59
|
+
// browsers send CORS-mode requests even same-origin. When the dashboard is
|
|
60
|
+
// served through a zrok tunnel the Origin header is the tunnel URL, which
|
|
61
|
+
// previously wasn't in the allow list — the server then threw inside the
|
|
62
|
+
// CORS callback, surfacing as HTTP 500 on every asset. These tests pin the
|
|
63
|
+
// fix so that behavior cannot regress.
|
|
64
|
+
describe("zrok tunnel origins (browser module-script regression)", () => {
|
|
65
|
+
it("allows the currently-active tunnel URL", () => {
|
|
66
|
+
const tunnelUrl = "https://cwanni9wce66.share.zrok.io";
|
|
67
|
+
expect(isAllowedOrigin(tunnelUrl, [], () => tunnelUrl)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("allows any *.share.zrok.io origin (URL rotation, stale tabs)", () => {
|
|
71
|
+
expect(isAllowedOrigin("https://tgbdzzvlar6b.share.zrok.io", [])).toBe(true);
|
|
72
|
+
expect(isAllowedOrigin("https://anyothershare123.share.zrok.io", [])).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("does not allow non-zrok sibling hosts", () => {
|
|
76
|
+
expect(isAllowedOrigin("https://share.zrok.io.attacker.com", [])).toBe(false);
|
|
77
|
+
expect(isAllowedOrigin("https://evil.io", [])).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
48
80
|
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test — crash recovery.
|
|
3
|
+
*
|
|
4
|
+
* Simulates the "SIGKILL, no cleanup" case:
|
|
5
|
+
* 1. Acquire lock
|
|
6
|
+
* 2. Skip release() (simulated crash)
|
|
7
|
+
* 3. Attempt to acquire again from a different caller
|
|
8
|
+
* 4. Assert: stale detection fires, new caller acquires cleanly
|
|
9
|
+
*
|
|
10
|
+
* See change: single-dashboard-per-home, task 12.2.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { acquireOrAttach } from "../home-lock.js";
|
|
17
|
+
|
|
18
|
+
let tmpHome: string;
|
|
19
|
+
let lockPath: string;
|
|
20
|
+
let metaPath: string;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-crash-"));
|
|
24
|
+
lockPath = path.join(tmpHome, ".pi", "dashboard", "server.lock");
|
|
25
|
+
metaPath = `${lockPath}.meta.json`;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("crash recovery", () => {
|
|
33
|
+
it("steals a stale lock when the previous holder's process is dead", async () => {
|
|
34
|
+
// First acquire — simulate a dead process.
|
|
35
|
+
const first = await acquireOrAttach({
|
|
36
|
+
httpPort: 8000, piPort: 9999, version: "t",
|
|
37
|
+
identity: "dead-holder",
|
|
38
|
+
hooks: { lockPath, metaPath, staleMs: 1 },
|
|
39
|
+
});
|
|
40
|
+
expect(first.mode).toBe("acquired");
|
|
41
|
+
// INTENTIONALLY don't release.
|
|
42
|
+
|
|
43
|
+
// Allow stale threshold to elapse.
|
|
44
|
+
await new Promise(r => setTimeout(r, 50));
|
|
45
|
+
|
|
46
|
+
const second = await acquireOrAttach({
|
|
47
|
+
httpPort: 8000, piPort: 9999, version: "t",
|
|
48
|
+
identity: "recovery",
|
|
49
|
+
hooks: {
|
|
50
|
+
lockPath, metaPath, staleMs: 1,
|
|
51
|
+
isProcessAlive: () => false, // previous holder is dead
|
|
52
|
+
probeHealth: async () => ({ running: false }),
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
expect(second.mode).toBe("acquired");
|
|
56
|
+
|
|
57
|
+
// Metadata now reflects the new holder.
|
|
58
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8")) as { identity: string };
|
|
59
|
+
expect(meta.identity).toBe("recovery");
|
|
60
|
+
|
|
61
|
+
if (second.mode === "acquired") await second.release();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("cleans up the metadata sidecar on graceful release", async () => {
|
|
65
|
+
const r = await acquireOrAttach({
|
|
66
|
+
httpPort: 8000, piPort: 9999, version: "t",
|
|
67
|
+
hooks: { lockPath, metaPath, staleMs: 1_000 },
|
|
68
|
+
});
|
|
69
|
+
expect(r.mode).toBe("acquired");
|
|
70
|
+
expect(fs.existsSync(metaPath)).toBe(true);
|
|
71
|
+
|
|
72
|
+
if (r.mode === "acquired") {
|
|
73
|
+
await r.release();
|
|
74
|
+
expect(fs.existsSync(metaPath)).toBe(false);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("leaves metadata in place on crash (no release called)", async () => {
|
|
79
|
+
const r = await acquireOrAttach({
|
|
80
|
+
httpPort: 8000, piPort: 9999, version: "t",
|
|
81
|
+
hooks: { lockPath, metaPath, staleMs: 1_000 },
|
|
82
|
+
});
|
|
83
|
+
expect(r.mode).toBe("acquired");
|
|
84
|
+
// Don't release; metadata should persist until the next successful
|
|
85
|
+
// acquire clears it as part of steal.
|
|
86
|
+
expect(fs.existsSync(metaPath)).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -7,9 +7,23 @@ import type { PreferencesStore } from "../preferences-store.js";
|
|
|
7
7
|
import type { SessionManager } from "../memory-session-manager.js";
|
|
8
8
|
import type { DashboardSession } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
9
9
|
|
|
10
|
-
// Mock the shared openspec poller
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
// Mock the shared openspec poller. We expose three entry points now:
|
|
11
|
+
// - pollOpenSpecAsync: legacy monolithic (still used as fallback where no mtime gate applies)
|
|
12
|
+
// - runOpenSpecList: new granular list call
|
|
13
|
+
// - runOpenSpecStatus: new granular status call
|
|
14
|
+
vi.mock("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js", async (importOriginal) => {
|
|
15
|
+
const actual = await importOriginal<typeof import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js")>();
|
|
16
|
+
return {
|
|
17
|
+
...actual,
|
|
18
|
+
pollOpenSpecAsync: vi.fn(async () => ({ initialized: false, changes: [] })),
|
|
19
|
+
runOpenSpecList: vi.fn(async () => null),
|
|
20
|
+
runOpenSpecStatus: vi.fn(async () => null),
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Mock pi-resource-scanner so polling ticks don't hit the filesystem.
|
|
25
|
+
vi.mock("../pi-resource-scanner.js", () => ({
|
|
26
|
+
scanPiResources: vi.fn(async () => ({ local: { extensions: [], skills: [], prompts: [] }, global: { extensions: [], skills: [], prompts: [] }, packages: [] })),
|
|
13
27
|
}));
|
|
14
28
|
|
|
15
29
|
// Mock the shared state replay
|
|
@@ -184,20 +198,33 @@ describe("DirectoryService", () => {
|
|
|
184
198
|
|
|
185
199
|
describe("getOpenSpecData / refreshOpenSpec", () => {
|
|
186
200
|
it("returns cached data after polling", async () => {
|
|
187
|
-
|
|
188
|
-
|
|
201
|
+
// Use the granular mocks; fs.stat on the bogus path will return undefined so
|
|
202
|
+
// the new gated poll short-circuits. To exercise the happy path we set up a
|
|
203
|
+
// real tmp dir with an openspec/changes folder.
|
|
204
|
+
const fs = await import("node:fs");
|
|
205
|
+
const os = await import("node:os");
|
|
206
|
+
const path = await import("node:path");
|
|
207
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ds-cache-"));
|
|
208
|
+
fs.mkdirSync(path.join(tmp, "openspec", "changes", "change-1"), { recursive: true });
|
|
209
|
+
|
|
210
|
+
const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
|
|
211
|
+
(runOpenSpecList as any).mockResolvedValue({ changes: [
|
|
212
|
+
{ name: "change-1", status: "in-progress", completedTasks: 0, totalTasks: 1 },
|
|
213
|
+
] });
|
|
214
|
+
(runOpenSpecStatus as any).mockResolvedValue({ artifacts: [], isComplete: false });
|
|
189
215
|
|
|
190
216
|
const stateStore = createMockPreferencesStore();
|
|
191
217
|
const sessionManager = createMockSessionManager();
|
|
192
218
|
service = createDirectoryService(stateStore, sessionManager);
|
|
193
219
|
|
|
194
|
-
const data = await service.refreshOpenSpec(
|
|
220
|
+
const data = await service.refreshOpenSpec(tmp);
|
|
195
221
|
expect(data.initialized).toBe(true);
|
|
196
222
|
expect(data.changes[0].name).toBe("change-1");
|
|
197
223
|
|
|
198
|
-
|
|
199
|
-
const cached = service.getOpenSpecData("/project");
|
|
224
|
+
const cached = service.getOpenSpecData(tmp);
|
|
200
225
|
expect(cached).toEqual(data);
|
|
226
|
+
|
|
227
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
201
228
|
});
|
|
202
229
|
});
|
|
203
230
|
|
|
@@ -237,4 +264,203 @@ describe("DirectoryService", () => {
|
|
|
237
264
|
vi.useRealTimers();
|
|
238
265
|
});
|
|
239
266
|
});
|
|
267
|
+
|
|
268
|
+
describe("mtime gate", () => {
|
|
269
|
+
const fs = require("node:fs") as typeof import("node:fs");
|
|
270
|
+
const os = require("node:os") as typeof import("node:os");
|
|
271
|
+
const path = require("node:path") as typeof import("node:path");
|
|
272
|
+
|
|
273
|
+
let tmpDir: string;
|
|
274
|
+
let cwd: string;
|
|
275
|
+
let changesDir: string;
|
|
276
|
+
|
|
277
|
+
beforeEach(async () => {
|
|
278
|
+
vi.clearAllMocks();
|
|
279
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ds-mtime-"));
|
|
280
|
+
cwd = tmpDir;
|
|
281
|
+
changesDir = path.join(cwd, "openspec", "changes");
|
|
282
|
+
fs.mkdirSync(changesDir, { recursive: true });
|
|
283
|
+
fs.mkdirSync(path.join(changesDir, "change-a"));
|
|
284
|
+
fs.mkdirSync(path.join(changesDir, "change-b"));
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
afterEach(() => {
|
|
288
|
+
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("first poll invokes list and status for each change", async () => {
|
|
292
|
+
const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
|
|
293
|
+
(runOpenSpecList as any).mockResolvedValue({ changes: [
|
|
294
|
+
{ name: "change-a", status: "in-progress", completedTasks: 1, totalTasks: 2 },
|
|
295
|
+
{ name: "change-b", status: "in-progress", completedTasks: 0, totalTasks: 3 },
|
|
296
|
+
] });
|
|
297
|
+
(runOpenSpecStatus as any).mockResolvedValue({ artifacts: [], isComplete: false });
|
|
298
|
+
|
|
299
|
+
const stateStore = createMockPreferencesStore();
|
|
300
|
+
const sessionManager = createMockSessionManager();
|
|
301
|
+
service = createDirectoryService(stateStore, sessionManager);
|
|
302
|
+
|
|
303
|
+
const data = await service.refreshOpenSpec(cwd);
|
|
304
|
+
expect(data.initialized).toBe(true);
|
|
305
|
+
expect(data.changes).toHaveLength(2);
|
|
306
|
+
expect(runOpenSpecList).toHaveBeenCalledTimes(1);
|
|
307
|
+
expect(runOpenSpecStatus).toHaveBeenCalledTimes(2);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("second poll with unchanged mtimes makes zero CLI calls", async () => {
|
|
311
|
+
const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
|
|
312
|
+
(runOpenSpecList as any).mockResolvedValue({ changes: [
|
|
313
|
+
{ name: "change-a", status: "in-progress", completedTasks: 1, totalTasks: 2 },
|
|
314
|
+
] });
|
|
315
|
+
(runOpenSpecStatus as any).mockResolvedValue({ artifacts: [], isComplete: false });
|
|
316
|
+
|
|
317
|
+
const stateStore = createMockPreferencesStore();
|
|
318
|
+
const sessionManager = createMockSessionManager();
|
|
319
|
+
service = createDirectoryService(stateStore, sessionManager);
|
|
320
|
+
|
|
321
|
+
// First poll (force = true bypasses gate, populates cache).
|
|
322
|
+
await service.refreshOpenSpec(cwd);
|
|
323
|
+
(runOpenSpecList as any).mockClear();
|
|
324
|
+
(runOpenSpecStatus as any).mockClear();
|
|
325
|
+
|
|
326
|
+
// Second poll via the internal gated path.
|
|
327
|
+
await service.pollDirectoryGated(cwd);
|
|
328
|
+
expect(runOpenSpecList).not.toHaveBeenCalled();
|
|
329
|
+
expect(runOpenSpecStatus).not.toHaveBeenCalled();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("mtime advance on one change runs status only for that change", async () => {
|
|
333
|
+
const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
|
|
334
|
+
(runOpenSpecList as any).mockResolvedValue({ changes: [
|
|
335
|
+
{ name: "change-a", status: "in-progress", completedTasks: 1, totalTasks: 2 },
|
|
336
|
+
{ name: "change-b", status: "in-progress", completedTasks: 0, totalTasks: 3 },
|
|
337
|
+
] });
|
|
338
|
+
(runOpenSpecStatus as any).mockResolvedValue({ artifacts: [], isComplete: false });
|
|
339
|
+
|
|
340
|
+
const stateStore = createMockPreferencesStore();
|
|
341
|
+
const sessionManager = createMockSessionManager();
|
|
342
|
+
service = createDirectoryService(stateStore, sessionManager);
|
|
343
|
+
await service.refreshOpenSpec(cwd);
|
|
344
|
+
(runOpenSpecList as any).mockClear();
|
|
345
|
+
(runOpenSpecStatus as any).mockClear();
|
|
346
|
+
|
|
347
|
+
// Bump mtime of change-a only by touching a file inside it.
|
|
348
|
+
const future = new Date(Date.now() + 10_000);
|
|
349
|
+
fs.utimesSync(path.join(changesDir, "change-a"), future, future);
|
|
350
|
+
|
|
351
|
+
await service.pollDirectoryGated(cwd);
|
|
352
|
+
// List is gated by top-level mtime (unchanged) so it's skipped.
|
|
353
|
+
expect(runOpenSpecList).not.toHaveBeenCalled();
|
|
354
|
+
// Status re-run exactly once, for change-a.
|
|
355
|
+
expect(runOpenSpecStatus).toHaveBeenCalledTimes(1);
|
|
356
|
+
expect((runOpenSpecStatus as any).mock.calls[0][1]).toBe("change-a");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("changeDetection: 'always' bypasses the gate", async () => {
|
|
360
|
+
const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
|
|
361
|
+
(runOpenSpecList as any).mockResolvedValue({ changes: [
|
|
362
|
+
{ name: "change-a", status: "in-progress", completedTasks: 0, totalTasks: 1 },
|
|
363
|
+
] });
|
|
364
|
+
(runOpenSpecStatus as any).mockResolvedValue({ artifacts: [], isComplete: false });
|
|
365
|
+
|
|
366
|
+
const stateStore = createMockPreferencesStore();
|
|
367
|
+
const sessionManager = createMockSessionManager();
|
|
368
|
+
service = createDirectoryService(stateStore, sessionManager, { changeDetection: "always" });
|
|
369
|
+
await service.refreshOpenSpec(cwd);
|
|
370
|
+
(runOpenSpecList as any).mockClear();
|
|
371
|
+
(runOpenSpecStatus as any).mockClear();
|
|
372
|
+
|
|
373
|
+
await service.pollDirectoryGated(cwd);
|
|
374
|
+
expect(runOpenSpecList).toHaveBeenCalledTimes(1);
|
|
375
|
+
expect(runOpenSpecStatus).toHaveBeenCalledTimes(1);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("removed change is pruned from cache", async () => {
|
|
379
|
+
const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
|
|
380
|
+
(runOpenSpecList as any).mockResolvedValueOnce({ changes: [
|
|
381
|
+
{ name: "change-a", status: "in-progress", completedTasks: 0, totalTasks: 1 },
|
|
382
|
+
{ name: "change-b", status: "in-progress", completedTasks: 0, totalTasks: 1 },
|
|
383
|
+
] });
|
|
384
|
+
(runOpenSpecStatus as any).mockResolvedValue({ artifacts: [], isComplete: false });
|
|
385
|
+
|
|
386
|
+
const stateStore = createMockPreferencesStore();
|
|
387
|
+
const sessionManager = createMockSessionManager();
|
|
388
|
+
service = createDirectoryService(stateStore, sessionManager);
|
|
389
|
+
await service.refreshOpenSpec(cwd);
|
|
390
|
+
|
|
391
|
+
// Remove change-b and bump top-level mtime.
|
|
392
|
+
fs.rmSync(path.join(changesDir, "change-b"), { recursive: true });
|
|
393
|
+
const future = new Date(Date.now() + 20_000);
|
|
394
|
+
fs.utimesSync(changesDir, future, future);
|
|
395
|
+
(runOpenSpecList as any).mockResolvedValueOnce({ changes: [
|
|
396
|
+
{ name: "change-a", status: "in-progress", completedTasks: 0, totalTasks: 1 },
|
|
397
|
+
] });
|
|
398
|
+
(runOpenSpecStatus as any).mockClear();
|
|
399
|
+
|
|
400
|
+
await service.pollDirectoryGated(cwd);
|
|
401
|
+
const data = service.getOpenSpecData(cwd);
|
|
402
|
+
expect(data?.changes).toHaveLength(1);
|
|
403
|
+
expect(data?.changes[0].name).toBe("change-a");
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
describe("semaphore + refresh", () => {
|
|
408
|
+
it("caps concurrent CLI spawns during refresh storms", async () => {
|
|
409
|
+
const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
|
|
410
|
+
(runOpenSpecList as any).mockImplementation(async () => ({ changes: [
|
|
411
|
+
{ name: "c1", status: "in-progress", completedTasks: 0, totalTasks: 1 },
|
|
412
|
+
] }));
|
|
413
|
+
|
|
414
|
+
let active = 0;
|
|
415
|
+
let peak = 0;
|
|
416
|
+
(runOpenSpecStatus as any).mockImplementation(async () => {
|
|
417
|
+
active++;
|
|
418
|
+
peak = Math.max(peak, active);
|
|
419
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
420
|
+
active--;
|
|
421
|
+
return { artifacts: [], isComplete: false };
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const stateStore = createMockPreferencesStore();
|
|
425
|
+
const sessionManager = createMockSessionManager();
|
|
426
|
+
service = createDirectoryService(stateStore, sessionManager, { maxConcurrentSpawns: 2, changeDetection: "always" });
|
|
427
|
+
|
|
428
|
+
// 10 concurrent refreshes across 5 dirs with list returning 1 change each.
|
|
429
|
+
await Promise.all(Array.from({ length: 10 }, (_, i) => service.refreshOpenSpec(`/project-${i}`)));
|
|
430
|
+
expect(peak).toBeLessThanOrEqual(2);
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
describe("jitter", () => {
|
|
435
|
+
it("produces deterministic per-cwd offsets within jitterSeconds", async () => {
|
|
436
|
+
const { phaseOffsetMs } = await import("../directory-service.js");
|
|
437
|
+
const a1 = phaseOffsetMs("/project/a", 5);
|
|
438
|
+
const a2 = phaseOffsetMs("/project/a", 5);
|
|
439
|
+
const b = phaseOffsetMs("/project/b", 5);
|
|
440
|
+
expect(a1).toBe(a2); // stable
|
|
441
|
+
expect(a1).toBeLessThan(5000);
|
|
442
|
+
expect(a1).toBeGreaterThanOrEqual(0);
|
|
443
|
+
expect(b).toBeLessThan(5000);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("returns 0 when jitterSeconds is 0", async () => {
|
|
447
|
+
const { phaseOffsetMs } = await import("../directory-service.js");
|
|
448
|
+
expect(phaseOffsetMs("/any", 0)).toBe(0);
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
describe("reconfigurePolling", () => {
|
|
453
|
+
it("accepts a new interval without losing cached data", async () => {
|
|
454
|
+
const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
|
|
455
|
+
(runOpenSpecList as any).mockResolvedValue({ changes: [] });
|
|
456
|
+
(runOpenSpecStatus as any).mockResolvedValue(null);
|
|
457
|
+
|
|
458
|
+
const stateStore = createMockPreferencesStore();
|
|
459
|
+
const sessionManager = createMockSessionManager();
|
|
460
|
+
service = createDirectoryService(stateStore, sessionManager);
|
|
461
|
+
await service.refreshOpenSpec("/x");
|
|
462
|
+
service.reconfigurePolling({ pollIntervalSeconds: 60, maxConcurrentSpawns: 5, changeDetection: "mtime", jitterSeconds: 0 });
|
|
463
|
+
expect(service.getOpenSpecData("/x")).toBeDefined();
|
|
464
|
+
});
|
|
465
|
+
});
|
|
240
466
|
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test: verify pidRegistry.register/remove fires during an
|
|
3
|
+
* actual EditorManager start()/stop() cycle.
|
|
4
|
+
*
|
|
5
|
+
* Stubs `node:child_process#spawn` to return a fake ChildProcess that binds
|
|
6
|
+
* a real TCP listener on the port parsed from --bind-addr. This lets the
|
|
7
|
+
* real `waitForPort` resolve true, so the production start() code path
|
|
8
|
+
* (including the pidRegistry.register call with child.pid) executes as in prod.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
11
|
+
import { EventEmitter } from "node:events";
|
|
12
|
+
import { createServer as createNetServer, type Server as NetServer } from "node:net";
|
|
13
|
+
import type { EditorConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
14
|
+
import type { EditorDetectionResult } from "@blackbelt-technology/pi-dashboard-shared/editor-types.js";
|
|
15
|
+
|
|
16
|
+
// Track every fake child spawned so we can tear them down.
|
|
17
|
+
const spawnedChildren: FakeChild[] = [];
|
|
18
|
+
|
|
19
|
+
class FakeChild extends EventEmitter {
|
|
20
|
+
pid: number;
|
|
21
|
+
killed = false;
|
|
22
|
+
private server: NetServer | null = null;
|
|
23
|
+
|
|
24
|
+
constructor(port: number) {
|
|
25
|
+
super();
|
|
26
|
+
// Use a fake but plausible PID; real PID would be this process's tree.
|
|
27
|
+
// Using Math.random keeps tests from colliding.
|
|
28
|
+
this.pid = 100000 + Math.floor(Math.random() * 900000);
|
|
29
|
+
|
|
30
|
+
// Bind a TCP listener on the requested port so waitForPort's probe
|
|
31
|
+
// succeeds. This is the key trick that lets the real start() path run.
|
|
32
|
+
this.server = createNetServer();
|
|
33
|
+
this.server.listen(port, "127.0.0.1");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
kill(_signal?: NodeJS.Signals): boolean {
|
|
37
|
+
if (this.killed) return false;
|
|
38
|
+
this.killed = true;
|
|
39
|
+
if (this.server) {
|
|
40
|
+
this.server.close();
|
|
41
|
+
this.server = null;
|
|
42
|
+
}
|
|
43
|
+
// Emit exit asynchronously, like the real ChildProcess.
|
|
44
|
+
setImmediate(() => this.emit("exit", 0, null));
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
vi.mock("node:child_process", async (importOriginal) => {
|
|
50
|
+
const real = await importOriginal<typeof import("node:child_process")>();
|
|
51
|
+
return {
|
|
52
|
+
...real,
|
|
53
|
+
spawn: (_cmd: string, args: readonly string[] = []) => {
|
|
54
|
+
// Parse --bind-addr 127.0.0.1:<port>
|
|
55
|
+
const idx = args.indexOf("--bind-addr");
|
|
56
|
+
const bind = idx >= 0 ? args[idx + 1] : "";
|
|
57
|
+
const port = Number(bind.split(":")[1] ?? 0);
|
|
58
|
+
const child = new FakeChild(port);
|
|
59
|
+
spawnedChildren.push(child);
|
|
60
|
+
return child as unknown as import("node:child_process").ChildProcess;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// IMPORTANT: import AFTER vi.mock so the mocked child_process is picked up.
|
|
66
|
+
const { createEditorManager } = await import("../editor-manager.js");
|
|
67
|
+
|
|
68
|
+
const DEFAULT_CONFIG: EditorConfig = { idleTimeoutMinutes: 10, maxInstances: 3 };
|
|
69
|
+
const DETECTED: EditorDetectionResult = { available: true, binary: "/fake/code-server" };
|
|
70
|
+
|
|
71
|
+
function makeRegistryStub() {
|
|
72
|
+
const calls: Array<{ op: "register" | "remove"; payload: unknown }> = [];
|
|
73
|
+
return {
|
|
74
|
+
calls,
|
|
75
|
+
register: vi.fn((entry: unknown) => { calls.push({ op: "register", payload: entry }); }),
|
|
76
|
+
remove: vi.fn((id: unknown) => { calls.push({ op: "remove", payload: id }); }),
|
|
77
|
+
size: () => 0,
|
|
78
|
+
cleanupOrphans: async () => {},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("EditorManager + pidRegistry integration", () => {
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
spawnedChildren.length = 0;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
// Ensure any dangling fake children release their TCP servers.
|
|
89
|
+
for (const c of spawnedChildren) {
|
|
90
|
+
if (!c.killed) c.kill("SIGTERM");
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("calls pidRegistry.register after start() resolves to ready", async () => {
|
|
95
|
+
const registry = makeRegistryStub();
|
|
96
|
+
const mgr = createEditorManager({
|
|
97
|
+
config: DEFAULT_CONFIG,
|
|
98
|
+
detection: DETECTED,
|
|
99
|
+
pidRegistry: registry,
|
|
100
|
+
});
|
|
101
|
+
const info = await mgr.start("/tmp/fake-project-a");
|
|
102
|
+
expect(info.status).toBe("ready");
|
|
103
|
+
expect(registry.register).toHaveBeenCalledTimes(1);
|
|
104
|
+
const payload = (registry.register.mock.calls[0][0] ?? {}) as Record<string, unknown>;
|
|
105
|
+
expect(payload.id).toBe(info.id);
|
|
106
|
+
expect(payload.cwd).toBe("/tmp/fake-project-a");
|
|
107
|
+
expect(payload.port).toBe(info.port);
|
|
108
|
+
expect(typeof payload.pid).toBe("number");
|
|
109
|
+
expect(typeof payload.dataDir).toBe("string");
|
|
110
|
+
mgr.stop(info.id);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("calls pidRegistry.remove when stop(id) is invoked", async () => {
|
|
114
|
+
const registry = makeRegistryStub();
|
|
115
|
+
const mgr = createEditorManager({
|
|
116
|
+
config: DEFAULT_CONFIG,
|
|
117
|
+
detection: DETECTED,
|
|
118
|
+
pidRegistry: registry,
|
|
119
|
+
});
|
|
120
|
+
const info = await mgr.start("/tmp/fake-project-b");
|
|
121
|
+
registry.remove.mockClear();
|
|
122
|
+
mgr.stop(info.id);
|
|
123
|
+
// stop() calls remove() synchronously BEFORE SIGTERM.
|
|
124
|
+
expect(registry.remove).toHaveBeenCalledWith(info.id);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("calls pidRegistry.remove when the child emits exit", async () => {
|
|
128
|
+
const registry = makeRegistryStub();
|
|
129
|
+
const mgr = createEditorManager({
|
|
130
|
+
config: DEFAULT_CONFIG,
|
|
131
|
+
detection: DETECTED,
|
|
132
|
+
pidRegistry: registry,
|
|
133
|
+
});
|
|
134
|
+
const info = await mgr.start("/tmp/fake-project-c");
|
|
135
|
+
registry.remove.mockClear();
|
|
136
|
+
// Simulate the child exiting on its own (e.g., crashed, not via stop()).
|
|
137
|
+
const child = spawnedChildren[spawnedChildren.length - 1];
|
|
138
|
+
child.kill("SIGTERM");
|
|
139
|
+
// exit is emitted on next tick
|
|
140
|
+
await new Promise((r) => setImmediate(r));
|
|
141
|
+
await new Promise((r) => setImmediate(r));
|
|
142
|
+
// Either stop() or the exit handler may call remove — both are fine.
|
|
143
|
+
expect(registry.remove).toHaveBeenCalledWith(info.id);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("does not call pidRegistry.register if spawn fails (no detection)", async () => {
|
|
147
|
+
const registry = makeRegistryStub();
|
|
148
|
+
const mgr = createEditorManager({
|
|
149
|
+
config: DEFAULT_CONFIG,
|
|
150
|
+
detection: { available: false },
|
|
151
|
+
allowRedetection: false,
|
|
152
|
+
pidRegistry: registry,
|
|
153
|
+
});
|
|
154
|
+
await expect(mgr.start("/tmp/fake-project-d")).rejects.toThrow("binary_not_found");
|
|
155
|
+
expect(registry.register).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("operates normally when pidRegistry is undefined (back-compat)", async () => {
|
|
159
|
+
const mgr = createEditorManager({
|
|
160
|
+
config: DEFAULT_CONFIG,
|
|
161
|
+
detection: DETECTED,
|
|
162
|
+
// no pidRegistry
|
|
163
|
+
});
|
|
164
|
+
const info = await mgr.start("/tmp/fake-project-e");
|
|
165
|
+
expect(info.status).toBe("ready");
|
|
166
|
+
expect(() => mgr.stop(info.id)).not.toThrow();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -58,6 +58,39 @@ describe("createEditorManager", () => {
|
|
|
58
58
|
await expect(mgr.start("/tmp/test")).rejects.toThrow("max_instances_reached");
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
+
it("accepts an injected pidRegistry without affecting back-compat behavior", () => {
|
|
62
|
+
const calls: string[] = [];
|
|
63
|
+
const stubRegistry = {
|
|
64
|
+
register: () => calls.push("register"),
|
|
65
|
+
remove: () => calls.push("remove"),
|
|
66
|
+
size: () => 0,
|
|
67
|
+
cleanupOrphans: async () => {},
|
|
68
|
+
};
|
|
69
|
+
const mgr = createEditorManager({ config: DEFAULT_CONFIG, detection: DETECTED, pidRegistry: stubRegistry });
|
|
70
|
+
expect(mgr.list()).toEqual([]);
|
|
71
|
+
// stop on unknown id is a no-op and must not call registry.remove
|
|
72
|
+
expect(() => mgr.stop("nonexistent")).not.toThrow();
|
|
73
|
+
expect(calls).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("start failure path does not call pidRegistry.register", async () => {
|
|
77
|
+
const calls: string[] = [];
|
|
78
|
+
const stubRegistry = {
|
|
79
|
+
register: () => calls.push("register"),
|
|
80
|
+
remove: () => calls.push("remove"),
|
|
81
|
+
size: () => 0,
|
|
82
|
+
cleanupOrphans: async () => {},
|
|
83
|
+
};
|
|
84
|
+
const mgr = createEditorManager({
|
|
85
|
+
config: DEFAULT_CONFIG,
|
|
86
|
+
detection: NOT_DETECTED,
|
|
87
|
+
allowRedetection: false,
|
|
88
|
+
pidRegistry: stubRegistry,
|
|
89
|
+
});
|
|
90
|
+
await expect(mgr.start("/tmp/test")).rejects.toThrow("binary_not_found");
|
|
91
|
+
expect(calls).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
61
94
|
it("calls onStatusChange callback", async () => {
|
|
62
95
|
const statusChanges: Array<{ cwd: string; id: string; status: string }> = [];
|
|
63
96
|
const mgr = createEditorManager({
|