@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,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for cross-platform port-holder detection.
|
|
3
|
+
* See change: fix-windows-server-parity.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, vi } from "vitest";
|
|
6
|
+
import { findPortHolders, parseNetstatListeners } from "../cli.js";
|
|
7
|
+
|
|
8
|
+
describe("parseNetstatListeners", () => {
|
|
9
|
+
const selfPid = 99999;
|
|
10
|
+
|
|
11
|
+
it("parses Windows netstat -ano output for a listening PID", () => {
|
|
12
|
+
const output = [
|
|
13
|
+
"Active Connections",
|
|
14
|
+
"",
|
|
15
|
+
" Proto Local Address Foreign Address State PID",
|
|
16
|
+
" TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING 12345",
|
|
17
|
+
" TCP 0.0.0.0:445 0.0.0.0:0 LISTENING 4",
|
|
18
|
+
" TCP 127.0.0.1:8000 127.0.0.1:54321 ESTABLISHED 23456",
|
|
19
|
+
].join("\r\n");
|
|
20
|
+
|
|
21
|
+
expect(parseNetstatListeners(output, 8000, selfPid)).toEqual([12345]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("excludes non-LISTENING rows (ESTABLISHED, TIME_WAIT)", () => {
|
|
25
|
+
const output = [
|
|
26
|
+
" TCP 0.0.0.0:9999 0.0.0.0:0 ESTABLISHED 11111",
|
|
27
|
+
" TCP 0.0.0.0:9999 0.0.0.0:0 TIME_WAIT 22222",
|
|
28
|
+
].join("\n");
|
|
29
|
+
expect(parseNetstatListeners(output, 9999, selfPid)).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("excludes the current process PID", () => {
|
|
33
|
+
const output = ` TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING ${selfPid}`;
|
|
34
|
+
expect(parseNetstatListeners(output, 8000, selfPid)).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("only matches the requested port (suffix-based)", () => {
|
|
38
|
+
const output = [
|
|
39
|
+
" TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING 1111",
|
|
40
|
+
" TCP 0.0.0.0:18000 0.0.0.0:0 LISTENING 2222",
|
|
41
|
+
" TCP 0.0.0.0:80 0.0.0.0:0 LISTENING 3333",
|
|
42
|
+
].join("\n");
|
|
43
|
+
expect(parseNetstatListeners(output, 8000, selfPid).sort()).toEqual([1111]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns empty array for empty or garbage input", () => {
|
|
47
|
+
expect(parseNetstatListeners("", 8000, selfPid)).toEqual([]);
|
|
48
|
+
expect(parseNetstatListeners("not a netstat output\nblah", 8000, selfPid)).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("handles IPv6 listening addresses", () => {
|
|
52
|
+
const output = " TCP [::]:8000 [::]:0 LISTENING 7777";
|
|
53
|
+
expect(parseNetstatListeners(output, 8000, selfPid)).toEqual([7777]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("findPortHolders", () => {
|
|
58
|
+
it("uses netstat on Windows (via injected exec)", () => {
|
|
59
|
+
const originalPlatform = process.platform;
|
|
60
|
+
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
|
61
|
+
try {
|
|
62
|
+
const exec = vi.fn().mockReturnValue(
|
|
63
|
+
" TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING 12345\n",
|
|
64
|
+
);
|
|
65
|
+
const result = findPortHolders(8000, exec as any);
|
|
66
|
+
expect(exec).toHaveBeenCalledTimes(1);
|
|
67
|
+
expect(exec.mock.calls[0][0]).toMatch(/netstat/i);
|
|
68
|
+
expect(exec.mock.calls[0][0]).not.toMatch(/lsof/i);
|
|
69
|
+
expect(result).toEqual([12345]);
|
|
70
|
+
} finally {
|
|
71
|
+
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("uses lsof on Unix (via injected exec)", () => {
|
|
76
|
+
const originalPlatform = process.platform;
|
|
77
|
+
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
|
|
78
|
+
try {
|
|
79
|
+
const exec = vi.fn().mockReturnValue("12345\n67890\n");
|
|
80
|
+
const result = findPortHolders(8000, exec as any);
|
|
81
|
+
expect(exec).toHaveBeenCalledTimes(1);
|
|
82
|
+
expect(exec.mock.calls[0][0]).toMatch(/lsof/);
|
|
83
|
+
expect(exec.mock.calls[0][0]).toContain(":8000");
|
|
84
|
+
expect(result.sort()).toEqual([12345, 67890].sort());
|
|
85
|
+
} finally {
|
|
86
|
+
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns empty array on exec failure (best-effort)", () => {
|
|
91
|
+
const exec = vi.fn().mockImplementation(() => { throw new Error("boom"); });
|
|
92
|
+
expect(findPortHolders(8000, exec as any)).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for handleForceKill in session-action-handler.
|
|
3
|
+
*
|
|
4
|
+
* Kill-path routing (see change: route-kill-paths-through-platform):
|
|
5
|
+
* we verify that the handler delegates to the platform `killProcess`
|
|
6
|
+
* helper rather than calling `process.kill(...)` directly. Cross-OS
|
|
7
|
+
* behavior of `killProcess` itself is covered in
|
|
8
|
+
* `packages/shared/src/__tests__/platform-process.test.ts`.
|
|
3
9
|
*/
|
|
4
10
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
5
|
-
|
|
6
|
-
|
|
11
|
+
|
|
12
|
+
// Spy on the platform module so we can assert the handler routes through it.
|
|
13
|
+
const killProcessSpy = vi.fn(async (_pid: number, _opts?: any) => ({ ok: true, forced: false }));
|
|
14
|
+
const isProcessAliveSpy = vi.fn((_pid: number) => false);
|
|
15
|
+
vi.mock("@blackbelt-technology/pi-dashboard-shared/platform/process.js", async () => {
|
|
16
|
+
const actual = await vi.importActual<typeof import("@blackbelt-technology/pi-dashboard-shared/platform/process.js")>(
|
|
17
|
+
"@blackbelt-technology/pi-dashboard-shared/platform/process.js",
|
|
18
|
+
);
|
|
19
|
+
return {
|
|
20
|
+
...actual,
|
|
21
|
+
killProcess: (pid: number, opts?: any) => killProcessSpy(pid, opts),
|
|
22
|
+
isProcessAlive: (pid: number) => isProcessAliveSpy(pid),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const { handleForceKill } = await import("../browser-handlers/session-action-handler.js");
|
|
27
|
+
type BrowserHandlerContext = import("../browser-handlers/handler-context.js").BrowserHandlerContext;
|
|
7
28
|
|
|
8
29
|
function createMockContext(sessionOverrides?: Record<string, any>): BrowserHandlerContext & { sent: any[]; broadcasts: any[] } {
|
|
9
30
|
const sent: any[] = [];
|
|
@@ -44,7 +65,10 @@ function createMockContext(sessionOverrides?: Record<string, any>): BrowserHandl
|
|
|
44
65
|
|
|
45
66
|
describe("handleForceKill", () => {
|
|
46
67
|
beforeEach(() => {
|
|
47
|
-
|
|
68
|
+
killProcessSpy.mockClear();
|
|
69
|
+
killProcessSpy.mockImplementation(async () => ({ ok: true, forced: false }));
|
|
70
|
+
isProcessAliveSpy.mockClear();
|
|
71
|
+
isProcessAliveSpy.mockReturnValue(false);
|
|
48
72
|
});
|
|
49
73
|
|
|
50
74
|
it("should close bridge WebSocket and mark session ended when no PID", async () => {
|
|
@@ -61,19 +85,44 @@ describe("handleForceKill", () => {
|
|
|
61
85
|
expect(result.message).toContain("no PID");
|
|
62
86
|
});
|
|
63
87
|
|
|
64
|
-
it("should
|
|
65
|
-
|
|
66
|
-
const ctx = createMockContext({ pid: 2147483647 });
|
|
88
|
+
it("should delegate termination to platform killProcess with 2s timeout", async () => {
|
|
89
|
+
const ctx = createMockContext({ pid: 12345 });
|
|
67
90
|
|
|
68
91
|
await handleForceKill({ type: "force_kill", sessionId: "sess-1" }, ctx);
|
|
69
92
|
|
|
93
|
+
expect(killProcessSpy).toHaveBeenCalledTimes(1);
|
|
94
|
+
expect(killProcessSpy).toHaveBeenCalledWith(12345, expect.objectContaining({ timeoutMs: 2000 }));
|
|
95
|
+
|
|
70
96
|
expect(ctx.piGateway.closeSession).toHaveBeenCalledWith("sess-1");
|
|
71
97
|
expect(ctx.sessionManager.update).toHaveBeenCalledWith("sess-1", expect.objectContaining({ status: "ended" }));
|
|
72
|
-
|
|
98
|
+
|
|
99
|
+
const result = ctx.sent.find((m: any) => m.type === "force_kill_result");
|
|
100
|
+
expect(result).toBeDefined();
|
|
101
|
+
expect(result.success).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should report already-exited when killProcess reports pid not alive", async () => {
|
|
105
|
+
killProcessSpy.mockResolvedValueOnce({ ok: false, forced: false });
|
|
106
|
+
const ctx = createMockContext({ pid: 2147483647 });
|
|
107
|
+
|
|
108
|
+
await handleForceKill({ type: "force_kill", sessionId: "sess-1" }, ctx);
|
|
109
|
+
|
|
73
110
|
const result = ctx.sent.find((m: any) => m.type === "force_kill_result");
|
|
74
111
|
expect(result).toBeDefined();
|
|
75
112
|
expect(result.success).toBe(true);
|
|
76
|
-
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should not call process.kill directly (must route through platform)", async () => {
|
|
116
|
+
const processKillSpy = vi.spyOn(process, "kill");
|
|
117
|
+
const ctx = createMockContext({ pid: 12345 });
|
|
118
|
+
|
|
119
|
+
await handleForceKill({ type: "force_kill", sessionId: "sess-1" }, ctx);
|
|
120
|
+
|
|
121
|
+
// handleForceKill must NOT invoke process.kill; all termination goes
|
|
122
|
+
// through killProcess from the platform module.
|
|
123
|
+
expect(processKillSpy).not.toHaveBeenCalled();
|
|
124
|
+
expect(killProcessSpy).toHaveBeenCalledOnce();
|
|
125
|
+
processKillSpy.mockRestore();
|
|
77
126
|
});
|
|
78
127
|
|
|
79
128
|
it("should broadcast session_updated with ended status", async () => {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the PI_DASHBOARD_ALLOW_MULTIPLE escape hatch.
|
|
3
|
+
*
|
|
4
|
+
* The escape hatch is evaluated in `cli.ts::runForeground`; here we test
|
|
5
|
+
* the pure predicate `isLockDisabled` plus a behavioral test that confirms
|
|
6
|
+
* NO metadata is written when the lock is skipped.
|
|
7
|
+
*
|
|
8
|
+
* See change: single-dashboard-per-home, task 14.3.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { isLockDisabled, acquireOrAttach } from "../home-lock.js";
|
|
15
|
+
|
|
16
|
+
let tmpHome: string;
|
|
17
|
+
let lockPath: string;
|
|
18
|
+
let metaPath: string;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-escape-hatch-"));
|
|
22
|
+
lockPath = path.join(tmpHome, ".pi", "dashboard", "server.lock");
|
|
23
|
+
metaPath = `${lockPath}.meta.json`;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("escape hatch", () => {
|
|
31
|
+
it("isLockDisabled true for =1 and =true", () => {
|
|
32
|
+
expect(isLockDisabled({ PI_DASHBOARD_ALLOW_MULTIPLE: "1" })).toBe(true);
|
|
33
|
+
expect(isLockDisabled({ PI_DASHBOARD_ALLOW_MULTIPLE: "true" })).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("isLockDisabled false when unset or other values", () => {
|
|
37
|
+
expect(isLockDisabled({})).toBe(false);
|
|
38
|
+
expect(isLockDisabled({ PI_DASHBOARD_ALLOW_MULTIPLE: "" })).toBe(false);
|
|
39
|
+
expect(isLockDisabled({ PI_DASHBOARD_ALLOW_MULTIPLE: "yes" })).toBe(false);
|
|
40
|
+
expect(isLockDisabled({ PI_DASHBOARD_ALLOW_MULTIPLE: "0" })).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("when caller skips acquireOrAttach (escape hatch on), no metadata sidecar exists", () => {
|
|
44
|
+
// The CLI-level behavior when PI_DASHBOARD_ALLOW_MULTIPLE is set is to
|
|
45
|
+
// NOT call acquireOrAttach at all. We simulate that: the fact that we
|
|
46
|
+
// never called acquireOrAttach means the sidecar was never written.
|
|
47
|
+
expect(fs.existsSync(metaPath)).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("when lock IS acquired, metadata is written (control)", async () => {
|
|
51
|
+
const r = await acquireOrAttach({
|
|
52
|
+
httpPort: 8000, piPort: 9999, version: "t",
|
|
53
|
+
hooks: { lockPath, metaPath, staleMs: 500 },
|
|
54
|
+
});
|
|
55
|
+
expect(r.mode).toBe("acquired");
|
|
56
|
+
expect(fs.existsSync(metaPath)).toBe(true);
|
|
57
|
+
if (r.mode === "acquired") await r.release();
|
|
58
|
+
expect(fs.existsSync(metaPath)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for signal-handler installation. See change: single-dashboard-per-home.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi } from "vitest";
|
|
5
|
+
import { EventEmitter } from "node:events";
|
|
6
|
+
import { installReleaseHandlers } from "../home-lock-release.js";
|
|
7
|
+
|
|
8
|
+
function fakeProcess() {
|
|
9
|
+
const ee = new EventEmitter() as unknown as NodeJS.Process;
|
|
10
|
+
(ee as unknown as { exit: (code: number) => void }).exit = vi.fn();
|
|
11
|
+
return ee;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("installReleaseHandlers", () => {
|
|
15
|
+
it("registers SIGINT, SIGTERM, SIGHUP, SIGBREAK, and exit handlers", () => {
|
|
16
|
+
const proc = fakeProcess();
|
|
17
|
+
const onSpy = vi.spyOn(proc, "on");
|
|
18
|
+
installReleaseHandlers(async () => {}, { proc });
|
|
19
|
+
const registered = onSpy.mock.calls.map(c => c[0]);
|
|
20
|
+
expect(registered).toEqual(expect.arrayContaining(["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK", "exit"]));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("calls release() on SIGTERM", async () => {
|
|
24
|
+
const proc = fakeProcess();
|
|
25
|
+
const release = vi.fn(async () => {});
|
|
26
|
+
installReleaseHandlers(release, { proc });
|
|
27
|
+
proc.emit("SIGTERM");
|
|
28
|
+
// Handler is async — let microtasks flush.
|
|
29
|
+
await new Promise(r => setImmediate(r));
|
|
30
|
+
expect(release).toHaveBeenCalledTimes(1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("calls release() on SIGBREAK (Windows Ctrl+Break)", async () => {
|
|
34
|
+
// On POSIX Node never emits SIGBREAK, but the handler must still be
|
|
35
|
+
// wired so Windows Ctrl+Break triggers lock release. Exercising via a
|
|
36
|
+
// fake process guarantees the registration + dispatch path works.
|
|
37
|
+
const proc = fakeProcess();
|
|
38
|
+
const release = vi.fn(async () => {});
|
|
39
|
+
installReleaseHandlers(release, { proc });
|
|
40
|
+
proc.emit("SIGBREAK" as NodeJS.Signals);
|
|
41
|
+
await new Promise(r => setImmediate(r));
|
|
42
|
+
expect(release).toHaveBeenCalledTimes(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("calls release() on SIGHUP", async () => {
|
|
46
|
+
const proc = fakeProcess();
|
|
47
|
+
const release = vi.fn(async () => {});
|
|
48
|
+
installReleaseHandlers(release, { proc });
|
|
49
|
+
proc.emit("SIGHUP");
|
|
50
|
+
await new Promise(r => setImmediate(r));
|
|
51
|
+
expect(release).toHaveBeenCalledTimes(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("does not double-release on repeated signals", async () => {
|
|
55
|
+
const proc = fakeProcess();
|
|
56
|
+
const release = vi.fn(async () => {});
|
|
57
|
+
installReleaseHandlers(release, { proc });
|
|
58
|
+
proc.emit("SIGTERM");
|
|
59
|
+
proc.emit("SIGTERM");
|
|
60
|
+
proc.emit("SIGINT");
|
|
61
|
+
await new Promise(r => setImmediate(r));
|
|
62
|
+
expect(release).toHaveBeenCalledTimes(1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns a dispose function that removes handlers", () => {
|
|
66
|
+
const proc = fakeProcess();
|
|
67
|
+
const release = vi.fn(async () => {});
|
|
68
|
+
const dispose = installReleaseHandlers(release, { proc });
|
|
69
|
+
dispose();
|
|
70
|
+
proc.emit("SIGTERM");
|
|
71
|
+
// After dispose, the release must not fire.
|
|
72
|
+
expect(release).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("swallows release errors but logs them", async () => {
|
|
76
|
+
const proc = fakeProcess();
|
|
77
|
+
const logs: string[] = [];
|
|
78
|
+
const release = vi.fn(async () => { throw new Error("boom"); });
|
|
79
|
+
installReleaseHandlers(release, { proc, log: (m) => logs.push(m) });
|
|
80
|
+
proc.emit("SIGTERM");
|
|
81
|
+
await new Promise(r => setImmediate(r));
|
|
82
|
+
await new Promise(r => setImmediate(r));
|
|
83
|
+
expect(logs.join("\n")).toContain("boom");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the per-HOME advisory lock.
|
|
3
|
+
* See change: single-dashboard-per-home.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import {
|
|
10
|
+
canonicalHomedir,
|
|
11
|
+
getLockPath,
|
|
12
|
+
getMetaPath,
|
|
13
|
+
readMetadata,
|
|
14
|
+
writeMetadataAtomic,
|
|
15
|
+
removeMetadata,
|
|
16
|
+
acquireOrAttach,
|
|
17
|
+
isLockHolderResponsive,
|
|
18
|
+
isLockDisabled,
|
|
19
|
+
InstanceLockMismatchError,
|
|
20
|
+
type LockMetadata,
|
|
21
|
+
} from "../home-lock.js";
|
|
22
|
+
|
|
23
|
+
// Fresh tmp dir per test → real FS (proper-lockfile needs real FS semantics).
|
|
24
|
+
let tmpHome: string;
|
|
25
|
+
let lockPath: string;
|
|
26
|
+
let metaPath: string;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-home-lock-test-"));
|
|
30
|
+
lockPath = path.join(tmpHome, ".pi", "dashboard", "server.lock");
|
|
31
|
+
metaPath = `${lockPath}.meta.json`;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
try {
|
|
36
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
37
|
+
} catch {
|
|
38
|
+
/* ignore */
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function baseConfig(overrides: Partial<Parameters<typeof acquireOrAttach>[0]> = {}) {
|
|
43
|
+
return {
|
|
44
|
+
httpPort: 8000,
|
|
45
|
+
piPort: 9999,
|
|
46
|
+
version: "0.0.0-test",
|
|
47
|
+
hooks: {
|
|
48
|
+
lockPath,
|
|
49
|
+
metaPath,
|
|
50
|
+
staleMs: 500,
|
|
51
|
+
probeHealth: async () => ({ running: false }),
|
|
52
|
+
isProcessAlive: () => false,
|
|
53
|
+
...(overrides.hooks ?? {}),
|
|
54
|
+
},
|
|
55
|
+
...overrides,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("canonicalHomedir + paths", () => {
|
|
60
|
+
it("returns a path containing .pi/dashboard/server.lock", () => {
|
|
61
|
+
const p = getLockPath();
|
|
62
|
+
expect(p.endsWith(path.join(".pi", "dashboard", "server.lock"))).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("getMetaPath appends .meta.json", () => {
|
|
66
|
+
expect(getMetaPath("/x/y/server.lock")).toBe("/x/y/server.lock.meta.json");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("canonicalHomedir survives even when homedir is unreadable (tolerant)", () => {
|
|
70
|
+
expect(typeof canonicalHomedir()).toBe("string");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("ignores $HOME env override — lock path always derives from os.homedir()", () => {
|
|
74
|
+
// The design (§4) explicitly states $HOME must NOT influence the lock
|
|
75
|
+
// path: Git Bash sets $HOME=/c/Users/R while os.homedir()=C:\Users\R,
|
|
76
|
+
// which would otherwise produce two divergent canonical locks. Here we
|
|
77
|
+
// prove the invariant by construction: mutate process.env.HOME and
|
|
78
|
+
// verify getLockPath() doesn't change.
|
|
79
|
+
const original = process.env.HOME;
|
|
80
|
+
const before = getLockPath();
|
|
81
|
+
try {
|
|
82
|
+
process.env.HOME = "/garbage/not/a/real/path/" + Math.random();
|
|
83
|
+
const after = getLockPath();
|
|
84
|
+
expect(after).toBe(before);
|
|
85
|
+
} finally {
|
|
86
|
+
if (original === undefined) delete process.env.HOME;
|
|
87
|
+
else process.env.HOME = original;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("symlinked homedir canonicalizes to the same lock path on repeated calls", () => {
|
|
92
|
+
const real = fs.mkdtempSync(path.join(os.tmpdir(), "pi-real-"));
|
|
93
|
+
const link = path.join(os.tmpdir(), `pi-link-${Date.now()}-${Math.random()}`);
|
|
94
|
+
fs.symlinkSync(real, link);
|
|
95
|
+
try {
|
|
96
|
+
const a = fs.realpathSync(link);
|
|
97
|
+
const b = fs.realpathSync(link);
|
|
98
|
+
expect(a).toBe(b);
|
|
99
|
+
expect(a).toBe(fs.realpathSync(real));
|
|
100
|
+
} finally {
|
|
101
|
+
try { fs.unlinkSync(link); } catch { /* ignore */ }
|
|
102
|
+
fs.rmSync(real, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("writeMetadataAtomic + readMetadata", () => {
|
|
108
|
+
it("round-trips a metadata object", () => {
|
|
109
|
+
const meta: LockMetadata = {
|
|
110
|
+
pid: 1, ppid: 0, httpPort: 8000, piPort: 9999,
|
|
111
|
+
startedAt: 1, identity: "i", version: "v", url: "http://localhost:8000", hostname: "h",
|
|
112
|
+
};
|
|
113
|
+
writeMetadataAtomic(meta, metaPath);
|
|
114
|
+
expect(readMetadata(metaPath)).toEqual(meta);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("readMetadata returns null when file is missing", () => {
|
|
118
|
+
expect(readMetadata(metaPath)).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("readMetadata returns null when JSON is corrupt", () => {
|
|
122
|
+
fs.mkdirSync(path.dirname(metaPath), { recursive: true });
|
|
123
|
+
fs.writeFileSync(metaPath, "{not json");
|
|
124
|
+
expect(readMetadata(metaPath)).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("readMetadata returns null for shape-mismatched JSON", () => {
|
|
128
|
+
fs.mkdirSync(path.dirname(metaPath), { recursive: true });
|
|
129
|
+
fs.writeFileSync(metaPath, JSON.stringify({ foo: "bar" }));
|
|
130
|
+
expect(readMetadata(metaPath)).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("removeMetadata is silent on missing file", () => {
|
|
134
|
+
expect(() => removeMetadata(metaPath)).not.toThrow();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("isLockHolderResponsive", () => {
|
|
139
|
+
const meta: LockMetadata = {
|
|
140
|
+
pid: 12345, ppid: 0, httpPort: 8000, piPort: 9999,
|
|
141
|
+
startedAt: 0, identity: "id-A", version: "v", url: "http://localhost:8000", hostname: "h",
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
it("returns 'dead' when PID is gone", async () => {
|
|
145
|
+
const result = await isLockHolderResponsive(meta, { isProcessAlive: () => false });
|
|
146
|
+
expect(result).toBe("dead");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("returns 'dead' when port is not responding", async () => {
|
|
150
|
+
const result = await isLockHolderResponsive(meta, {
|
|
151
|
+
isProcessAlive: () => true,
|
|
152
|
+
probeHealth: async () => ({ running: false }),
|
|
153
|
+
});
|
|
154
|
+
expect(result).toBe("dead");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("returns 'alive-match' when identity matches", async () => {
|
|
158
|
+
const result = await isLockHolderResponsive(meta, {
|
|
159
|
+
isProcessAlive: () => true,
|
|
160
|
+
probeHealth: async () => ({ running: true, identity: "id-A", pid: 12345 }),
|
|
161
|
+
});
|
|
162
|
+
expect(result).toBe("alive-match");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("returns 'alive-mismatch' when identity differs", async () => {
|
|
166
|
+
const result = await isLockHolderResponsive(meta, {
|
|
167
|
+
isProcessAlive: () => true,
|
|
168
|
+
probeHealth: async () => ({ running: true, identity: "id-B", pid: 99999 }),
|
|
169
|
+
});
|
|
170
|
+
expect(result).toBe("alive-mismatch");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("falls back to PID match when identity missing", async () => {
|
|
174
|
+
const matchByPid = await isLockHolderResponsive(meta, {
|
|
175
|
+
isProcessAlive: () => true,
|
|
176
|
+
probeHealth: async () => ({ running: true, pid: 12345 }),
|
|
177
|
+
});
|
|
178
|
+
expect(matchByPid).toBe("alive-match");
|
|
179
|
+
|
|
180
|
+
const misMatchByPid = await isLockHolderResponsive(meta, {
|
|
181
|
+
isProcessAlive: () => true,
|
|
182
|
+
probeHealth: async () => ({ running: true, pid: 99999 }),
|
|
183
|
+
});
|
|
184
|
+
expect(misMatchByPid).toBe("alive-mismatch");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("acquireOrAttach", () => {
|
|
189
|
+
it("acquires a fresh lock and writes metadata", async () => {
|
|
190
|
+
const result = await acquireOrAttach(baseConfig());
|
|
191
|
+
expect(result.mode).toBe("acquired");
|
|
192
|
+
const meta = readMetadata(metaPath);
|
|
193
|
+
expect(meta).not.toBeNull();
|
|
194
|
+
expect(meta?.pid).toBe(process.pid);
|
|
195
|
+
expect(meta?.httpPort).toBe(8000);
|
|
196
|
+
if (result.mode === "acquired") await result.release();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("release() removes the metadata sidecar", async () => {
|
|
200
|
+
const result = await acquireOrAttach(baseConfig());
|
|
201
|
+
expect(result.mode).toBe("acquired");
|
|
202
|
+
if (result.mode === "acquired") {
|
|
203
|
+
await result.release();
|
|
204
|
+
expect(readMetadata(metaPath)).toBeNull();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("release() is idempotent", async () => {
|
|
209
|
+
const result = await acquireOrAttach(baseConfig());
|
|
210
|
+
if (result.mode === "acquired") {
|
|
211
|
+
await result.release();
|
|
212
|
+
await expect(result.release()).resolves.toBeUndefined();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("attaches when a live dashboard already holds the lock", async () => {
|
|
217
|
+
// Acquire as "another process" first.
|
|
218
|
+
const first = await acquireOrAttach(baseConfig({
|
|
219
|
+
identity: "first-instance",
|
|
220
|
+
}));
|
|
221
|
+
expect(first.mode).toBe("acquired");
|
|
222
|
+
|
|
223
|
+
// Now mount a probe that says the first is alive + matches.
|
|
224
|
+
const second = await acquireOrAttach(baseConfig({
|
|
225
|
+
hooks: {
|
|
226
|
+
lockPath, metaPath, staleMs: 500,
|
|
227
|
+
isProcessAlive: () => true,
|
|
228
|
+
probeHealth: async () => ({ running: true, identity: "first-instance", pid: process.pid }),
|
|
229
|
+
},
|
|
230
|
+
}));
|
|
231
|
+
expect(second.mode).toBe("attach");
|
|
232
|
+
if (second.mode === "attach") {
|
|
233
|
+
expect(second.meta.identity).toBe("first-instance");
|
|
234
|
+
}
|
|
235
|
+
if (first.mode === "acquired") await first.release();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("throws InstanceLockMismatchError on identity mismatch", async () => {
|
|
239
|
+
const first = await acquireOrAttach(baseConfig({ identity: "mine" }));
|
|
240
|
+
expect(first.mode).toBe("acquired");
|
|
241
|
+
|
|
242
|
+
await expect(
|
|
243
|
+
acquireOrAttach(baseConfig({
|
|
244
|
+
hooks: {
|
|
245
|
+
lockPath, metaPath, staleMs: 500,
|
|
246
|
+
isProcessAlive: () => true,
|
|
247
|
+
probeHealth: async () => ({ running: true, identity: "someone-else", pid: 99999 }),
|
|
248
|
+
},
|
|
249
|
+
})),
|
|
250
|
+
).rejects.toBeInstanceOf(InstanceLockMismatchError);
|
|
251
|
+
|
|
252
|
+
if (first.mode === "acquired") await first.release();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("steals a stale lock (process dead)", async () => {
|
|
256
|
+
const first = await acquireOrAttach(baseConfig({ identity: "stale-holder" }));
|
|
257
|
+
expect(first.mode).toBe("acquired");
|
|
258
|
+
// Don't release — simulate a crash. Then attempt to reacquire with
|
|
259
|
+
// isProcessAlive=false → steal path.
|
|
260
|
+
|
|
261
|
+
// proper-lockfile's `stale` option needs the staleMs to have elapsed.
|
|
262
|
+
// We pass a 1ms stale threshold in baseConfig via the hooks override.
|
|
263
|
+
await new Promise(r => setTimeout(r, 50));
|
|
264
|
+
const second = await acquireOrAttach(baseConfig({
|
|
265
|
+
hooks: {
|
|
266
|
+
lockPath, metaPath, staleMs: 1,
|
|
267
|
+
isProcessAlive: () => false,
|
|
268
|
+
probeHealth: async () => ({ running: false }),
|
|
269
|
+
},
|
|
270
|
+
}));
|
|
271
|
+
expect(second.mode).toBe("acquired");
|
|
272
|
+
if (second.mode === "acquired") await second.release();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("steals lock when metadata is corrupt", async () => {
|
|
276
|
+
const first = await acquireOrAttach(baseConfig());
|
|
277
|
+
expect(first.mode).toBe("acquired");
|
|
278
|
+
// Corrupt metadata but leave proper-lockfile in place.
|
|
279
|
+
fs.writeFileSync(metaPath, "{not json");
|
|
280
|
+
await new Promise(r => setTimeout(r, 50));
|
|
281
|
+
|
|
282
|
+
const second = await acquireOrAttach(baseConfig({
|
|
283
|
+
hooks: {
|
|
284
|
+
lockPath, metaPath, staleMs: 1,
|
|
285
|
+
isProcessAlive: () => false,
|
|
286
|
+
probeHealth: async () => ({ running: false }),
|
|
287
|
+
},
|
|
288
|
+
}));
|
|
289
|
+
expect(second.mode).toBe("acquired");
|
|
290
|
+
if (second.mode === "acquired") await second.release();
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("isLockDisabled", () => {
|
|
295
|
+
it("returns true for PI_DASHBOARD_ALLOW_MULTIPLE=1", () => {
|
|
296
|
+
expect(isLockDisabled({ PI_DASHBOARD_ALLOW_MULTIPLE: "1" })).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
it("returns true for PI_DASHBOARD_ALLOW_MULTIPLE=true", () => {
|
|
299
|
+
expect(isLockDisabled({ PI_DASHBOARD_ALLOW_MULTIPLE: "true" })).toBe(true);
|
|
300
|
+
});
|
|
301
|
+
it("returns false when unset", () => {
|
|
302
|
+
expect(isLockDisabled({})).toBe(false);
|
|
303
|
+
});
|
|
304
|
+
it("returns false for other values", () => {
|
|
305
|
+
expect(isLockDisabled({ PI_DASHBOARD_ALLOW_MULTIPLE: "0" })).toBe(false);
|
|
306
|
+
expect(isLockDisabled({ PI_DASHBOARD_ALLOW_MULTIPLE: "yes" })).toBe(false);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for isPiCommandLine (pure predicate used by isPiProcess).
|
|
3
|
+
* See change: fix-windows-server-parity.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import { isPiCommandLine } from "../browser-handlers/session-action-handler.js";
|
|
7
|
+
|
|
8
|
+
describe("isPiCommandLine", () => {
|
|
9
|
+
it("matches a typical pi cli invocation", () => {
|
|
10
|
+
expect(isPiCommandLine("/usr/bin/node /usr/local/lib/node_modules/@mariozechner/pi-coding-agent/dist/cli.js")).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("matches when only 'pi' appears as a word", () => {
|
|
14
|
+
expect(isPiCommandLine("pi --mode rpc")).toBe(true);
|
|
15
|
+
expect(isPiCommandLine("/opt/pi/bin/pi")).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("matches when only 'node' appears as a word", () => {
|
|
19
|
+
expect(isPiCommandLine("node server.js")).toBe(true);
|
|
20
|
+
expect(isPiCommandLine("/usr/bin/node --import tsx /app.ts")).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("does not match unrelated commands", () => {
|
|
24
|
+
expect(isPiCommandLine("/bin/bash -c sleep 10")).toBe(false);
|
|
25
|
+
expect(isPiCommandLine("python3 script.py")).toBe(false);
|
|
26
|
+
expect(isPiCommandLine("")).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("does not match substrings of other words", () => {
|
|
30
|
+
// \b word-boundary: 'api', 'epic', 'snode' must NOT match 'pi'/'node'
|
|
31
|
+
expect(isPiCommandLine("api-server --port 8000")).toBe(false);
|
|
32
|
+
expect(isPiCommandLine("epic-game.exe")).toBe(false);
|
|
33
|
+
// 'snode' is actually a whole word containing "node" at the end; \bnode\b requires word boundary
|
|
34
|
+
expect(isPiCommandLine("running snode-worker")).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|