@blackbelt-technology/pi-agent-dashboard 0.3.0 → 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 +67 -116
- package/README.md +93 -7
- package/docs/architecture.md +408 -9
- package/package.json +6 -4
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -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/bridge.ts +69 -2
- 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/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 +16 -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 +17 -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__/crash-recovery.test.ts +88 -0
- package/packages/server/src/__tests__/directory-service.test.ts +234 -8
- package/packages/server/src/__tests__/editor-registry.test.ts +28 -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__/force-kill-handler.test.ts +57 -8
- 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__/package-manager-wrapper-resolve.test.ts +5 -1
- package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
- 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-probe.test.ts +287 -0
- package/packages/server/src/__tests__/provider-test-route.test.ts +149 -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__/terminal-manager.test.ts +41 -1
- 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 +13 -7
- 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 +8 -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 +19 -4
- package/packages/server/src/editor-pid-registry.ts +9 -8
- package/packages/server/src/editor-registry.ts +22 -25
- package/packages/server/src/git-operations.ts +1 -1
- package/packages/server/src/headless-pid-registry.ts +7 -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/package-manager-wrapper.ts +207 -47
- package/packages/server/src/pi-core-checker.ts +1 -1
- package/packages/server/src/pi-core-updater.ts +7 -1
- 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/openspec-routes.ts +25 -1
- package/packages/server/src/routes/pi-core-routes.ts +24 -1
- package/packages/server/src/routes/provider-auth-routes.ts +8 -8
- package/packages/server/src/routes/provider-routes.ts +43 -0
- package/packages/server/src/routes/recommended-routes.ts +10 -12
- package/packages/server/src/routes/system-routes.ts +20 -33
- 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 +211 -10
- 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 +61 -20
- package/packages/server/src/tunnel.ts +42 -28
- 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 +56 -0
- 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__/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 +40 -7
- 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__/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 +71 -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 +63 -46
- 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 +18 -2
- package/packages/shared/src/resolve-jiti.ts +62 -3
- package/packages/shared/src/rest-api.ts +26 -0
- package/packages/shared/src/semaphore.ts +83 -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
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test — concurrent launches.
|
|
3
|
+
*
|
|
4
|
+
* Simulates two dashboard startups racing for the same per-HOME lock.
|
|
5
|
+
* Asserts that exactly one wins (`acquired`) and the other falls back
|
|
6
|
+
* cleanly (`attach` OR `InstanceLockMismatchError`, depending on liveness
|
|
7
|
+
* of the winner's probe).
|
|
8
|
+
*
|
|
9
|
+
* Uses real tmp dirs (not memfs) because proper-lockfile requires real
|
|
10
|
+
* filesystem semantics.
|
|
11
|
+
*
|
|
12
|
+
* See change: single-dashboard-per-home, task 12.1.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { acquireOrAttach, InstanceLockMismatchError } from "../home-lock.js";
|
|
19
|
+
|
|
20
|
+
let tmpHome: string;
|
|
21
|
+
let lockPath: string;
|
|
22
|
+
let metaPath: string;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-concurrent-"));
|
|
26
|
+
lockPath = path.join(tmpHome, ".pi", "dashboard", "server.lock");
|
|
27
|
+
metaPath = `${lockPath}.meta.json`;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("concurrent launch", () => {
|
|
35
|
+
it("exactly one of two parallel acquireOrAttach calls wins the lock", async () => {
|
|
36
|
+
// Both attempts race. Whichever wins first gets `acquired`. The loser
|
|
37
|
+
// sees ELOCKED; because our probe says the winner is "not alive" (we
|
|
38
|
+
// intentionally return dead to avoid racing the probe), the loser
|
|
39
|
+
// steals the stale lock and also acquires. That's not right for
|
|
40
|
+
// same-HOME same-instant races — we need the loser to SEE the winner.
|
|
41
|
+
//
|
|
42
|
+
// To mimic reality: make isProcessAlive true (process IS alive) and
|
|
43
|
+
// have the probe treat the metadata's identity as authoritative.
|
|
44
|
+
const hookFactory = () => ({
|
|
45
|
+
lockPath, metaPath, staleMs: 5_000,
|
|
46
|
+
isProcessAlive: () => true,
|
|
47
|
+
probeHealth: async () => {
|
|
48
|
+
// Read the live metadata file and echo back its identity — this
|
|
49
|
+
// models a working /api/health from the winner.
|
|
50
|
+
try {
|
|
51
|
+
const raw = fs.readFileSync(metaPath, "utf-8");
|
|
52
|
+
const m = JSON.parse(raw) as { identity?: string; pid?: number };
|
|
53
|
+
if (m && typeof m.identity === "string") {
|
|
54
|
+
return { running: true, identity: m.identity, pid: m.pid };
|
|
55
|
+
}
|
|
56
|
+
} catch { /* metadata not yet written */ }
|
|
57
|
+
return { running: true, pid: process.pid };
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const cfg = (id: string) => ({
|
|
62
|
+
httpPort: 8000, piPort: 9999, version: "t",
|
|
63
|
+
identity: id,
|
|
64
|
+
hooks: hookFactory(),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const [a, b] = await Promise.allSettled([
|
|
68
|
+
acquireOrAttach(cfg("racer-A")),
|
|
69
|
+
acquireOrAttach(cfg("racer-B")),
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
// Count outcomes.
|
|
73
|
+
const outcomes = [a, b].map(r => {
|
|
74
|
+
if (r.status === "rejected") return "error";
|
|
75
|
+
return r.value.mode;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Exactly one winner, and the loser is either "attach" or "error"
|
|
79
|
+
// (identity mismatch if the winner's identity appears in metadata
|
|
80
|
+
// before the loser reads it).
|
|
81
|
+
const winners = outcomes.filter(o => o === "acquired");
|
|
82
|
+
expect(winners).toHaveLength(1);
|
|
83
|
+
|
|
84
|
+
const losers = outcomes.filter(o => o !== "acquired");
|
|
85
|
+
expect(losers).toHaveLength(1);
|
|
86
|
+
expect(["attach", "error"]).toContain(losers[0]);
|
|
87
|
+
|
|
88
|
+
// Cleanup: release whichever won.
|
|
89
|
+
for (const r of [a, b]) {
|
|
90
|
+
if (r.status === "fulfilled" && r.value.mode === "acquired") {
|
|
91
|
+
await r.value.release();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("the winning identity is persisted to metadata", async () => {
|
|
97
|
+
const first = await acquireOrAttach({
|
|
98
|
+
httpPort: 8000, piPort: 9999, version: "t",
|
|
99
|
+
identity: "winner",
|
|
100
|
+
hooks: { lockPath, metaPath, staleMs: 5_000 },
|
|
101
|
+
});
|
|
102
|
+
expect(first.mode).toBe("acquired");
|
|
103
|
+
|
|
104
|
+
const raw = fs.readFileSync(metaPath, "utf-8");
|
|
105
|
+
const meta = JSON.parse(raw) as { identity: string };
|
|
106
|
+
expect(meta.identity).toBe("winner");
|
|
107
|
+
|
|
108
|
+
if (first.mode === "acquired") await first.release();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -100,5 +100,73 @@ describe("config-api", () => {
|
|
|
100
100
|
// providers preserved
|
|
101
101
|
expect(written.auth.providers.github.clientId).toBe("x");
|
|
102
102
|
});
|
|
103
|
+
|
|
104
|
+
// ── fix-trusted-networks-no-oauth regression tests ─────────────────
|
|
105
|
+
// These assert that auth.bypassHosts and auth.bypassUrls are persisted
|
|
106
|
+
// through PUT /api/config. Before the fix, the auth-merge block only
|
|
107
|
+
// copied secret / providers / allowedUsers, silently dropping bypass*
|
|
108
|
+
// on every save.
|
|
109
|
+
|
|
110
|
+
it("should persist auth.bypassHosts with no pre-existing auth (task 1.5)", () => {
|
|
111
|
+
fs.writeFileSync(configFile, JSON.stringify({ port: 8000 }));
|
|
112
|
+
const result = writeConfigPartial({
|
|
113
|
+
auth: { providers: {}, bypassHosts: ["192.168.1.0/24"] },
|
|
114
|
+
});
|
|
115
|
+
expect(result.success).toBe(true);
|
|
116
|
+
const written = JSON.parse(fs.readFileSync(configFile, "utf-8"));
|
|
117
|
+
expect(written.auth.bypassHosts).toEqual(["192.168.1.0/24"]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("should persist auth.bypassHosts alongside existing providers (task 1.6)", () => {
|
|
121
|
+
fs.writeFileSync(configFile, JSON.stringify({
|
|
122
|
+
auth: {
|
|
123
|
+
secret: "s",
|
|
124
|
+
providers: { github: { clientId: "abc", clientSecret: "xyz" } },
|
|
125
|
+
},
|
|
126
|
+
}));
|
|
127
|
+
const result = writeConfigPartial({
|
|
128
|
+
auth: { bypassHosts: ["10.0.0.0/8"] },
|
|
129
|
+
});
|
|
130
|
+
expect(result.success).toBe(true);
|
|
131
|
+
const written = JSON.parse(fs.readFileSync(configFile, "utf-8"));
|
|
132
|
+
expect(written.auth.providers.github.clientId).toBe("abc");
|
|
133
|
+
expect(written.auth.providers.github.clientSecret).toBe("xyz");
|
|
134
|
+
expect(written.auth.bypassHosts).toEqual(["10.0.0.0/8"]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("should clear auth.bypassHosts via empty array (task 1.7)", () => {
|
|
138
|
+
fs.writeFileSync(configFile, JSON.stringify({
|
|
139
|
+
auth: { providers: {}, bypassHosts: ["192.168.1.0/24"] },
|
|
140
|
+
}));
|
|
141
|
+
const result = writeConfigPartial({
|
|
142
|
+
auth: { bypassHosts: [] },
|
|
143
|
+
});
|
|
144
|
+
expect(result.success).toBe(true);
|
|
145
|
+
const written = JSON.parse(fs.readFileSync(configFile, "utf-8"));
|
|
146
|
+
expect(written.auth.bypassHosts).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("should preserve existing auth.bypassHosts when partial omits the key (task 1.8)", () => {
|
|
150
|
+
fs.writeFileSync(configFile, JSON.stringify({
|
|
151
|
+
auth: { providers: {}, bypassHosts: ["192.168.1.0/24"] },
|
|
152
|
+
}));
|
|
153
|
+
const result = writeConfigPartial({
|
|
154
|
+
auth: { allowedUsers: ["alice"] },
|
|
155
|
+
});
|
|
156
|
+
expect(result.success).toBe(true);
|
|
157
|
+
const written = JSON.parse(fs.readFileSync(configFile, "utf-8"));
|
|
158
|
+
expect(written.auth.bypassHosts).toEqual(["192.168.1.0/24"]);
|
|
159
|
+
expect(written.auth.allowedUsers).toEqual(["alice"]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("should persist auth.bypassUrls symmetrically (task 1.9)", () => {
|
|
163
|
+
fs.writeFileSync(configFile, JSON.stringify({ port: 8000 }));
|
|
164
|
+
const result = writeConfigPartial({
|
|
165
|
+
auth: { providers: {}, bypassUrls: ["/webhooks/", "/metrics"] },
|
|
166
|
+
});
|
|
167
|
+
expect(result.success).toBe(true);
|
|
168
|
+
const written = JSON.parse(fs.readFileSync(configFile, "utf-8"));
|
|
169
|
+
expect(written.auth.bypassUrls).toEqual(["/webhooks/", "/metrics"]);
|
|
170
|
+
});
|
|
103
171
|
});
|
|
104
172
|
});
|
|
@@ -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
|
});
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
2
|
|
|
3
|
-
const { mockedExecSync } = vi.hoisted(() => ({
|
|
3
|
+
const { mockedExecSync, mockedSpawnSync } = vi.hoisted(() => ({
|
|
4
4
|
mockedExecSync: vi.fn(),
|
|
5
|
+
mockedSpawnSync: vi.fn(),
|
|
5
6
|
}));
|
|
6
7
|
|
|
8
|
+
// platform/process.ts and platform/tools.ts now use spawnSync via whereAllLines
|
|
9
|
+
// and isProcessRunning; both must be mocked to exercise the detection path in
|
|
10
|
+
// isolation. Default return is status:1 (not found) so each test explicitly
|
|
11
|
+
// overrides what it needs.
|
|
7
12
|
vi.mock("node:child_process", () => ({
|
|
8
|
-
default: { execSync: mockedExecSync },
|
|
13
|
+
default: { execSync: mockedExecSync, spawnSync: mockedSpawnSync },
|
|
9
14
|
execSync: mockedExecSync,
|
|
15
|
+
spawnSync: mockedSpawnSync,
|
|
10
16
|
}));
|
|
11
17
|
|
|
12
18
|
import { detectEditors, isProcessRunning, isProcessRunningWin32, EDITORS, type DetectedEditor } from "../editor-registry.js";
|
|
@@ -14,6 +20,9 @@ import { detectEditors, isProcessRunning, isProcessRunningWin32, EDITORS, type D
|
|
|
14
20
|
describe("editor-registry", () => {
|
|
15
21
|
beforeEach(() => {
|
|
16
22
|
vi.resetAllMocks();
|
|
23
|
+
// Default: spawnSync reports not-found so any un-overridden test still
|
|
24
|
+
// sees clean state rather than stale returns from previous tests.
|
|
25
|
+
mockedSpawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
17
26
|
});
|
|
18
27
|
|
|
19
28
|
describe("EDITORS", () => {
|
|
@@ -74,20 +83,22 @@ describe("editor-registry", () => {
|
|
|
74
83
|
|
|
75
84
|
describe("detectEditors", () => {
|
|
76
85
|
it("should return editor when process is running AND CLI is available", () => {
|
|
86
|
+
// platform/process.ts isProcessRunning uses execSync (pgrep) internally.
|
|
77
87
|
mockedExecSync.mockImplementation((cmd) => {
|
|
78
88
|
const s = String(cmd);
|
|
79
89
|
if (s.includes("pgrep")) {
|
|
80
|
-
// Only Zed is running — match on both the macOS pattern
|
|
81
|
-
// ("/Applications/Zed.app") and the Linux pattern ("zed").
|
|
82
90
|
if (s.includes("Zed") || s.includes("zed")) return Buffer.from("12345\n");
|
|
83
91
|
throw new Error("not found");
|
|
84
92
|
}
|
|
85
|
-
if (s.includes("which")) {
|
|
86
|
-
if (s.includes("zed")) return Buffer.from("/usr/local/bin/zed\n");
|
|
87
|
-
throw new Error("not found");
|
|
88
|
-
}
|
|
89
93
|
throw new Error("unexpected command");
|
|
90
94
|
});
|
|
95
|
+
// ToolResolver.which uses spawnSync for `which`/`where` lookup.
|
|
96
|
+
mockedSpawnSync.mockImplementation((cmd, args) => {
|
|
97
|
+
if ((cmd === "which" || cmd === "where") && args?.[0] === "zed") {
|
|
98
|
+
return { status: 0, stdout: "/usr/local/bin/zed\n", stderr: "" };
|
|
99
|
+
}
|
|
100
|
+
return { status: 1, stdout: "", stderr: "" };
|
|
101
|
+
});
|
|
91
102
|
|
|
92
103
|
const result = detectEditors("/some/project");
|
|
93
104
|
expect(result).toEqual([{ id: "zed", name: "Zed" }]);
|
|
@@ -97,9 +108,9 @@ describe("editor-registry", () => {
|
|
|
97
108
|
mockedExecSync.mockImplementation((cmd) => {
|
|
98
109
|
const s = String(cmd);
|
|
99
110
|
if (s.includes("pgrep")) throw new Error("not found");
|
|
100
|
-
if (s.includes("which")) return Buffer.from("/usr/local/bin/zed\n");
|
|
101
111
|
throw new Error("unexpected command");
|
|
102
112
|
});
|
|
113
|
+
mockedSpawnSync.mockImplementation(() => ({ status: 0, stdout: "/usr/local/bin/zed\n", stderr: "" }));
|
|
103
114
|
|
|
104
115
|
const result = detectEditors("/some/project");
|
|
105
116
|
expect(result).toEqual([]);
|
|
@@ -109,9 +120,9 @@ describe("editor-registry", () => {
|
|
|
109
120
|
mockedExecSync.mockImplementation((cmd) => {
|
|
110
121
|
const s = String(cmd);
|
|
111
122
|
if (s.includes("pgrep")) return Buffer.from("12345\n");
|
|
112
|
-
if (s.includes("which")) throw new Error("not found");
|
|
113
123
|
throw new Error("unexpected command");
|
|
114
124
|
});
|
|
125
|
+
mockedSpawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
115
126
|
|
|
116
127
|
const result = detectEditors("/some/project");
|
|
117
128
|
expect(result).toEqual([]);
|
|
@@ -125,13 +136,15 @@ describe("editor-registry", () => {
|
|
|
125
136
|
if (s.includes("Visual Studio Code") || s.includes("code")) return Buffer.from("67890\n");
|
|
126
137
|
throw new Error("not found");
|
|
127
138
|
}
|
|
128
|
-
if (s.includes("which")) {
|
|
129
|
-
if (s.includes("zed")) return Buffer.from("/usr/local/bin/zed\n");
|
|
130
|
-
if (s.includes("code")) return Buffer.from("/usr/local/bin/code\n");
|
|
131
|
-
throw new Error("not found");
|
|
132
|
-
}
|
|
133
139
|
throw new Error("unexpected command");
|
|
134
140
|
});
|
|
141
|
+
mockedSpawnSync.mockImplementation((cmd, args) => {
|
|
142
|
+
if (cmd === "which" || cmd === "where") {
|
|
143
|
+
if (args?.[0] === "zed") return { status: 0, stdout: "/usr/local/bin/zed\n", stderr: "" };
|
|
144
|
+
if (args?.[0] === "code") return { status: 0, stdout: "/usr/local/bin/code\n", stderr: "" };
|
|
145
|
+
}
|
|
146
|
+
return { status: 1, stdout: "", stderr: "" };
|
|
147
|
+
});
|
|
135
148
|
|
|
136
149
|
const result = detectEditors("/some/project");
|
|
137
150
|
expect(result).toEqual([
|
|
@@ -30,7 +30,11 @@ describe("findBundledExtension - AppImage guard", () => {
|
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
it("returns null when extension does not exist", () => {
|
|
33
|
-
|
|
33
|
+
// Disable Strategy 2 (node-resolver fallback) so this test exercises
|
|
34
|
+
// the AppImage guard path in isolation.
|
|
35
|
+
expect(
|
|
36
|
+
findBundledExtension(tmpDir, { resolvePackage: () => null }),
|
|
37
|
+
).toBeNull();
|
|
34
38
|
});
|
|
35
39
|
|
|
36
40
|
// Note: We can't easily test the /tmp/.mount_ guard with real paths
|
|
@@ -26,7 +26,9 @@ describe("bridge extension registration (server context)", () => {
|
|
|
26
26
|
});
|
|
27
27
|
|
|
28
28
|
it("findBundledExtension returns null when extension dir does not exist", () => {
|
|
29
|
-
|
|
29
|
+
// Strategy 2 (require.resolve) would find the monorepo extension;
|
|
30
|
+
// disable it for this test so we exercise Strategy 1 in isolation.
|
|
31
|
+
const result = findBundledExtension(tmpDir, { resolvePackage: () => null });
|
|
30
32
|
expect(result).toBeNull();
|
|
31
33
|
});
|
|
32
34
|
|