@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
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for server-side /reload handling on headless pi sessions.
|
|
3
|
+
*
|
|
4
|
+
* See change: headless-reload-via-respawn.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
7
|
+
|
|
8
|
+
// Mock spawnPiSession BEFORE importing the handler.
|
|
9
|
+
vi.mock("../process-manager.js", () => ({
|
|
10
|
+
spawnPiSession: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
vi.mock("@blackbelt-technology/pi-dashboard-shared/config.js", () => ({
|
|
13
|
+
loadConfig: () => ({ spawnStrategy: "headless" as const }),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
handleHeadlessReload,
|
|
18
|
+
handleSendPrompt,
|
|
19
|
+
} from "../browser-handlers/session-action-handler.js";
|
|
20
|
+
import { spawnPiSession } from "../process-manager.js";
|
|
21
|
+
|
|
22
|
+
type SentMessage = { type: string; [k: string]: unknown };
|
|
23
|
+
type InsertedEvent = {
|
|
24
|
+
sessionId: string;
|
|
25
|
+
event: { eventType: string; data: Record<string, unknown>; timestamp: number };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function makeCtx(
|
|
29
|
+
options: {
|
|
30
|
+
pidBySession?: Record<string, number | undefined>;
|
|
31
|
+
sessions?: Record<string, any>;
|
|
32
|
+
} = {},
|
|
33
|
+
) {
|
|
34
|
+
const broadcasts: SentMessage[] = [];
|
|
35
|
+
const insertedEvents: InsertedEvent[] = [];
|
|
36
|
+
const killCalls: string[] = [];
|
|
37
|
+
const registerCalls: Array<{ pid: number; cwd: string; proc: unknown }> = [];
|
|
38
|
+
const sessionUpdates: Array<{ id: string; updates: any }> = [];
|
|
39
|
+
|
|
40
|
+
const pidBySession: Record<string, number | undefined> = {
|
|
41
|
+
...(options.pidBySession ?? {}),
|
|
42
|
+
};
|
|
43
|
+
const sessions: Record<string, any> = { ...(options.sessions ?? {}) };
|
|
44
|
+
|
|
45
|
+
const ctx = {
|
|
46
|
+
ws: { readyState: 1 } as any,
|
|
47
|
+
sessionManager: {
|
|
48
|
+
get: (sid: string) => sessions[sid],
|
|
49
|
+
update: (sid: string, updates: any) => {
|
|
50
|
+
sessionUpdates.push({ id: sid, updates });
|
|
51
|
+
if (sessions[sid]) Object.assign(sessions[sid], updates);
|
|
52
|
+
},
|
|
53
|
+
unregister: vi.fn(),
|
|
54
|
+
},
|
|
55
|
+
piGateway: {
|
|
56
|
+
sendToSession: vi.fn().mockReturnValue(true),
|
|
57
|
+
},
|
|
58
|
+
headlessPidRegistry: {
|
|
59
|
+
getPid: (sid: string) => pidBySession[sid],
|
|
60
|
+
killBySessionId: (sid: string) => {
|
|
61
|
+
killCalls.push(sid);
|
|
62
|
+
// Simulate immediate removal from registry on kill
|
|
63
|
+
pidBySession[sid] = undefined;
|
|
64
|
+
return true;
|
|
65
|
+
},
|
|
66
|
+
register: (pid: number, cwd: string, proc: unknown) => {
|
|
67
|
+
registerCalls.push({ pid, cwd, proc });
|
|
68
|
+
// Simulate the registry linking the session after re-register
|
|
69
|
+
const existingSessionId = Object.keys(sessions).find(
|
|
70
|
+
(id) => sessions[id]?.cwd === cwd,
|
|
71
|
+
);
|
|
72
|
+
if (existingSessionId) pidBySession[existingSessionId] = pid;
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
pendingResumeRegistry: { record: vi.fn(), consume: vi.fn() },
|
|
76
|
+
pendingDashboardSpawns: new Map<string, number>(),
|
|
77
|
+
eventStore: {
|
|
78
|
+
insertEvent: (sid: string, event: any) => {
|
|
79
|
+
insertedEvents.push({ sessionId: sid, event });
|
|
80
|
+
return insertedEvents.length; // fake seq
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
broadcast: (m: SentMessage) => {
|
|
84
|
+
broadcasts.push(m);
|
|
85
|
+
},
|
|
86
|
+
sendTo: (_ws: unknown, m: SentMessage) => {
|
|
87
|
+
broadcasts.push(m);
|
|
88
|
+
},
|
|
89
|
+
} as any;
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
ctx,
|
|
93
|
+
broadcasts,
|
|
94
|
+
insertedEvents,
|
|
95
|
+
killCalls,
|
|
96
|
+
registerCalls,
|
|
97
|
+
sessionUpdates,
|
|
98
|
+
pidBySession,
|
|
99
|
+
sessions,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function findFeedback(events: InsertedEvent[]) {
|
|
104
|
+
return events
|
|
105
|
+
.filter((e) => e.event.eventType === "command_feedback")
|
|
106
|
+
.map((e) => e.event.data);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe("handleHeadlessReload — happy path", () => {
|
|
110
|
+
beforeEach(() => vi.clearAllMocks());
|
|
111
|
+
afterEach(() => vi.restoreAllMocks());
|
|
112
|
+
|
|
113
|
+
it("kills old pi, spawns new pi, registers new PID, emits started+completed", async () => {
|
|
114
|
+
(spawnPiSession as any).mockResolvedValueOnce({
|
|
115
|
+
success: true,
|
|
116
|
+
pid: 9999,
|
|
117
|
+
process: { _fake: true },
|
|
118
|
+
message: "ok",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const { ctx, killCalls, registerCalls, insertedEvents } = makeCtx({
|
|
122
|
+
pidBySession: { S1: 1234 },
|
|
123
|
+
sessions: {
|
|
124
|
+
S1: {
|
|
125
|
+
id: "S1",
|
|
126
|
+
cwd: "/home/u/proj",
|
|
127
|
+
sessionFile: "/home/u/proj/.pi/sessions/abc.jsonl",
|
|
128
|
+
status: "active",
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await handleHeadlessReload(
|
|
134
|
+
{ type: "send_prompt", sessionId: "S1", text: "/reload" } as any,
|
|
135
|
+
ctx,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Kill came before spawn
|
|
139
|
+
expect(killCalls).toEqual(["S1"]);
|
|
140
|
+
expect(spawnPiSession).toHaveBeenCalledTimes(1);
|
|
141
|
+
expect(spawnPiSession).toHaveBeenCalledWith(
|
|
142
|
+
"/home/u/proj",
|
|
143
|
+
expect.objectContaining({
|
|
144
|
+
sessionFile: "/home/u/proj/.pi/sessions/abc.jsonl",
|
|
145
|
+
mode: "continue",
|
|
146
|
+
strategy: "headless",
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// New PID registered
|
|
151
|
+
expect(registerCalls).toHaveLength(1);
|
|
152
|
+
expect(registerCalls[0].pid).toBe(9999);
|
|
153
|
+
|
|
154
|
+
// Feedback sequence: started → completed
|
|
155
|
+
const feedback = findFeedback(insertedEvents);
|
|
156
|
+
expect(feedback.map((f) => f.status)).toEqual(["started", "completed"]);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("handleHeadlessReload — streaming session", () => {
|
|
161
|
+
beforeEach(() => vi.clearAllMocks());
|
|
162
|
+
afterEach(() => vi.restoreAllMocks());
|
|
163
|
+
|
|
164
|
+
it("rejects reload when session is streaming; no kill, no spawn", async () => {
|
|
165
|
+
const { ctx, killCalls, insertedEvents } = makeCtx({
|
|
166
|
+
pidBySession: { S1: 1234 },
|
|
167
|
+
sessions: {
|
|
168
|
+
S1: {
|
|
169
|
+
id: "S1",
|
|
170
|
+
cwd: "/p",
|
|
171
|
+
sessionFile: "/p/s.jsonl",
|
|
172
|
+
status: "streaming",
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await handleHeadlessReload(
|
|
178
|
+
{ type: "send_prompt", sessionId: "S1", text: "/reload" } as any,
|
|
179
|
+
ctx,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
expect(killCalls).toEqual([]);
|
|
183
|
+
expect(spawnPiSession).not.toHaveBeenCalled();
|
|
184
|
+
|
|
185
|
+
const feedback = findFeedback(insertedEvents);
|
|
186
|
+
expect(feedback).toHaveLength(1);
|
|
187
|
+
expect(feedback[0].status).toBe("error");
|
|
188
|
+
expect(String(feedback[0].message)).toMatch(/response/i);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("handleHeadlessReload — spawn failure", () => {
|
|
193
|
+
beforeEach(() => vi.clearAllMocks());
|
|
194
|
+
afterEach(() => vi.restoreAllMocks());
|
|
195
|
+
|
|
196
|
+
it("broadcasts session_updated{status:ended} and error feedback when spawnPiSession returns failure", async () => {
|
|
197
|
+
(spawnPiSession as any).mockResolvedValueOnce({
|
|
198
|
+
success: false,
|
|
199
|
+
message: "tmux unavailable, headless failed",
|
|
200
|
+
});
|
|
201
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
202
|
+
|
|
203
|
+
const { ctx, broadcasts, sessionUpdates, insertedEvents } = makeCtx({
|
|
204
|
+
pidBySession: { S1: 1234 },
|
|
205
|
+
sessions: {
|
|
206
|
+
S1: {
|
|
207
|
+
id: "S1",
|
|
208
|
+
cwd: "/p",
|
|
209
|
+
sessionFile: "/p/s.jsonl",
|
|
210
|
+
status: "active",
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await handleHeadlessReload(
|
|
216
|
+
{ type: "send_prompt", sessionId: "S1", text: "/reload" } as any,
|
|
217
|
+
ctx,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
expect(errSpy).toHaveBeenCalledWith(
|
|
221
|
+
expect.stringContaining("headless reload spawn failed"),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// Session marked ended
|
|
225
|
+
expect(
|
|
226
|
+
sessionUpdates.some(
|
|
227
|
+
(u) => u.id === "S1" && u.updates.status === "ended",
|
|
228
|
+
),
|
|
229
|
+
).toBe(true);
|
|
230
|
+
// session_updated broadcast
|
|
231
|
+
expect(
|
|
232
|
+
broadcasts.some(
|
|
233
|
+
(m) =>
|
|
234
|
+
m.type === "session_updated" &&
|
|
235
|
+
(m as any).sessionId === "S1" &&
|
|
236
|
+
((m as any).updates as any).status === "ended",
|
|
237
|
+
),
|
|
238
|
+
).toBe(true);
|
|
239
|
+
|
|
240
|
+
// Final feedback is error
|
|
241
|
+
const feedback = findFeedback(insertedEvents);
|
|
242
|
+
expect(feedback[feedback.length - 1].status).toBe("error");
|
|
243
|
+
expect(String(feedback[feedback.length - 1].message)).toContain(
|
|
244
|
+
"tmux unavailable",
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
errSpy.mockRestore();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("handles spawnPiSession throwing", async () => {
|
|
251
|
+
(spawnPiSession as any).mockRejectedValueOnce(new Error("ENOENT: pi not found"));
|
|
252
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
253
|
+
|
|
254
|
+
const { ctx, sessionUpdates, insertedEvents } = makeCtx({
|
|
255
|
+
pidBySession: { S1: 1234 },
|
|
256
|
+
sessions: {
|
|
257
|
+
S1: { id: "S1", cwd: "/p", sessionFile: "/p/s.jsonl", status: "active" },
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
await handleHeadlessReload(
|
|
262
|
+
{ type: "send_prompt", sessionId: "S1", text: "/reload" } as any,
|
|
263
|
+
ctx,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
expect(
|
|
267
|
+
sessionUpdates.some((u) => u.updates.status === "ended"),
|
|
268
|
+
).toBe(true);
|
|
269
|
+
const feedback = findFeedback(insertedEvents);
|
|
270
|
+
expect(feedback[feedback.length - 1].status).toBe("error");
|
|
271
|
+
expect(String(feedback[feedback.length - 1].message)).toMatch(/ENOENT/);
|
|
272
|
+
|
|
273
|
+
errSpy.mockRestore();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("handleHeadlessReload — missing session file", () => {
|
|
278
|
+
beforeEach(() => vi.clearAllMocks());
|
|
279
|
+
afterEach(() => vi.restoreAllMocks());
|
|
280
|
+
|
|
281
|
+
it("errors when the session has no sessionFile", async () => {
|
|
282
|
+
const { ctx, insertedEvents, killCalls } = makeCtx({
|
|
283
|
+
pidBySession: { S1: 1234 },
|
|
284
|
+
sessions: {
|
|
285
|
+
S1: { id: "S1", cwd: "/p", status: "active" },
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
await handleHeadlessReload(
|
|
290
|
+
{ type: "send_prompt", sessionId: "S1", text: "/reload" } as any,
|
|
291
|
+
ctx,
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
expect(killCalls).toEqual([]);
|
|
295
|
+
expect(spawnPiSession).not.toHaveBeenCalled();
|
|
296
|
+
|
|
297
|
+
const feedback = findFeedback(insertedEvents);
|
|
298
|
+
expect(feedback).toHaveLength(1);
|
|
299
|
+
expect(feedback[0].status).toBe("error");
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("handleHeadlessReload — concurrent calls", () => {
|
|
304
|
+
beforeEach(() => vi.clearAllMocks());
|
|
305
|
+
afterEach(() => vi.restoreAllMocks());
|
|
306
|
+
|
|
307
|
+
it("two back-to-back /reload calls still register exactly one live PID (the second)", async () => {
|
|
308
|
+
let nextPid = 7001;
|
|
309
|
+
(spawnPiSession as any).mockImplementation(async () => ({
|
|
310
|
+
success: true,
|
|
311
|
+
pid: nextPid++,
|
|
312
|
+
process: { _fake: true },
|
|
313
|
+
}));
|
|
314
|
+
|
|
315
|
+
const { ctx, killCalls, registerCalls, pidBySession } = makeCtx({
|
|
316
|
+
pidBySession: { S1: 1234 },
|
|
317
|
+
sessions: {
|
|
318
|
+
S1: {
|
|
319
|
+
id: "S1",
|
|
320
|
+
cwd: "/p",
|
|
321
|
+
sessionFile: "/p/s.jsonl",
|
|
322
|
+
status: "active",
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// Fire two concurrent reloads.
|
|
328
|
+
const [r1, r2] = await Promise.all([
|
|
329
|
+
handleHeadlessReload(
|
|
330
|
+
{ type: "send_prompt", sessionId: "S1", text: "/reload" } as any,
|
|
331
|
+
ctx,
|
|
332
|
+
),
|
|
333
|
+
handleHeadlessReload(
|
|
334
|
+
{ type: "send_prompt", sessionId: "S1", text: "/reload" } as any,
|
|
335
|
+
ctx,
|
|
336
|
+
),
|
|
337
|
+
]);
|
|
338
|
+
expect(r1).toBeUndefined();
|
|
339
|
+
expect(r2).toBeUndefined();
|
|
340
|
+
|
|
341
|
+
// First call kills the original; second call observes no PID and kills noop.
|
|
342
|
+
expect(killCalls.length).toBeGreaterThanOrEqual(1);
|
|
343
|
+
expect(killCalls.length).toBeLessThanOrEqual(2);
|
|
344
|
+
|
|
345
|
+
// Both calls spawned, but registry ended with one live PID (the later one).
|
|
346
|
+
expect(spawnPiSession).toHaveBeenCalledTimes(2);
|
|
347
|
+
expect(registerCalls).toHaveLength(2);
|
|
348
|
+
expect(pidBySession.S1).toBe(registerCalls[registerCalls.length - 1].pid);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("handleSendPrompt — interception wiring", () => {
|
|
353
|
+
beforeEach(() => vi.clearAllMocks());
|
|
354
|
+
afterEach(() => vi.restoreAllMocks());
|
|
355
|
+
|
|
356
|
+
it("/reload on a headless session triggers respawn, NOT bridge forward", async () => {
|
|
357
|
+
(spawnPiSession as any).mockResolvedValueOnce({
|
|
358
|
+
success: true,
|
|
359
|
+
pid: 4242,
|
|
360
|
+
process: { _fake: true },
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const { ctx } = makeCtx({
|
|
364
|
+
pidBySession: { S1: 1234 },
|
|
365
|
+
sessions: {
|
|
366
|
+
S1: {
|
|
367
|
+
id: "S1",
|
|
368
|
+
cwd: "/p",
|
|
369
|
+
sessionFile: "/p/s.jsonl",
|
|
370
|
+
status: "active",
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
await handleSendPrompt(
|
|
376
|
+
{ type: "send_prompt", sessionId: "S1", text: "/reload" } as any,
|
|
377
|
+
ctx,
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
expect(spawnPiSession).toHaveBeenCalledTimes(1);
|
|
381
|
+
expect(ctx.piGateway.sendToSession).not.toHaveBeenCalled();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("/reload on a non-headless (tmux) session forwards to the bridge unchanged", async () => {
|
|
385
|
+
const { ctx } = makeCtx({
|
|
386
|
+
pidBySession: { S1: undefined }, // no PID → non-headless
|
|
387
|
+
sessions: {
|
|
388
|
+
S1: {
|
|
389
|
+
id: "S1",
|
|
390
|
+
cwd: "/p",
|
|
391
|
+
sessionFile: "/p/s.jsonl",
|
|
392
|
+
status: "active",
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
await handleSendPrompt(
|
|
398
|
+
{ type: "send_prompt", sessionId: "S1", text: "/reload" } as any,
|
|
399
|
+
ctx,
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
expect(spawnPiSession).not.toHaveBeenCalled();
|
|
403
|
+
expect(ctx.piGateway.sendToSession).toHaveBeenCalledWith(
|
|
404
|
+
"S1",
|
|
405
|
+
expect.objectContaining({
|
|
406
|
+
type: "send_prompt",
|
|
407
|
+
text: "/reload",
|
|
408
|
+
}),
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("non-/reload prompt on a headless session still forwards to the bridge", async () => {
|
|
413
|
+
const { ctx } = makeCtx({
|
|
414
|
+
pidBySession: { S1: 1234 }, // headless
|
|
415
|
+
sessions: {
|
|
416
|
+
S1: {
|
|
417
|
+
id: "S1",
|
|
418
|
+
cwd: "/p",
|
|
419
|
+
sessionFile: "/p/s.jsonl",
|
|
420
|
+
status: "active",
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
await handleSendPrompt(
|
|
426
|
+
{
|
|
427
|
+
type: "send_prompt",
|
|
428
|
+
sessionId: "S1",
|
|
429
|
+
text: "please do the thing",
|
|
430
|
+
} as any,
|
|
431
|
+
ctx,
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
expect(spawnPiSession).not.toHaveBeenCalled();
|
|
435
|
+
expect(ctx.piGateway.sendToSession).toHaveBeenCalledWith(
|
|
436
|
+
"S1",
|
|
437
|
+
expect.objectContaining({ text: "please do the thing" }),
|
|
438
|
+
);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("/reload with images on a headless session is NOT intercepted (falls through to bridge)", async () => {
|
|
442
|
+
const { ctx } = makeCtx({
|
|
443
|
+
pidBySession: { S1: 1234 },
|
|
444
|
+
sessions: {
|
|
445
|
+
S1: {
|
|
446
|
+
id: "S1",
|
|
447
|
+
cwd: "/p",
|
|
448
|
+
sessionFile: "/p/s.jsonl",
|
|
449
|
+
status: "active",
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
await handleSendPrompt(
|
|
455
|
+
{
|
|
456
|
+
type: "send_prompt",
|
|
457
|
+
sessionId: "S1",
|
|
458
|
+
text: "/reload",
|
|
459
|
+
images: [{ type: "image", data: "x" }],
|
|
460
|
+
} as any,
|
|
461
|
+
ctx,
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
expect(spawnPiSession).not.toHaveBeenCalled();
|
|
465
|
+
expect(ctx.piGateway.sendToSession).toHaveBeenCalled();
|
|
466
|
+
});
|
|
467
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { shouldInterceptReload } from "../browser-handlers/session-action-helpers.js";
|
|
3
|
+
|
|
4
|
+
function makeRegistry(pidBySessionId: Record<string, number | undefined>) {
|
|
5
|
+
return {
|
|
6
|
+
getPid(sid: string) {
|
|
7
|
+
return pidBySessionId[sid];
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function msg(overrides: Partial<{ text: string; images: unknown[]; sessionId: string }> = {}) {
|
|
13
|
+
return {
|
|
14
|
+
type: "send_prompt" as const,
|
|
15
|
+
sessionId: overrides.sessionId ?? "S1",
|
|
16
|
+
text: overrides.text ?? "/reload",
|
|
17
|
+
images: overrides.images as any,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("shouldInterceptReload", () => {
|
|
22
|
+
it("returns true for exact '/reload' on a tracked headless session", () => {
|
|
23
|
+
const reg = makeRegistry({ S1: 1234 });
|
|
24
|
+
expect(shouldInterceptReload(msg() as any, reg)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns false for trailing whitespace ' /reload '", () => {
|
|
28
|
+
const reg = makeRegistry({ S1: 1234 });
|
|
29
|
+
expect(shouldInterceptReload(msg({ text: " /reload" }) as any, reg)).toBe(false);
|
|
30
|
+
expect(shouldInterceptReload(msg({ text: "/reload " }) as any, reg)).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns false for '/reload something'", () => {
|
|
34
|
+
const reg = makeRegistry({ S1: 1234 });
|
|
35
|
+
expect(shouldInterceptReload(msg({ text: "/reload arg" }) as any, reg)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns false when images are attached", () => {
|
|
39
|
+
const reg = makeRegistry({ S1: 1234 });
|
|
40
|
+
expect(
|
|
41
|
+
shouldInterceptReload(msg({ images: [{ type: "image", data: "xxx" }] }) as any, reg),
|
|
42
|
+
).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns true when images is an empty array", () => {
|
|
46
|
+
const reg = makeRegistry({ S1: 1234 });
|
|
47
|
+
expect(shouldInterceptReload(msg({ images: [] }) as any, reg)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns false when the session has no tracked PID (non-headless)", () => {
|
|
51
|
+
const reg = makeRegistry({ S1: undefined });
|
|
52
|
+
expect(shouldInterceptReload(msg() as any, reg)).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns false for the wrong session id", () => {
|
|
56
|
+
const reg = makeRegistry({ S1: 1234 });
|
|
57
|
+
expect(shouldInterceptReload(msg({ sessionId: "OTHER" }) as any, reg)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns false for unrelated slash commands", () => {
|
|
61
|
+
const reg = makeRegistry({ S1: 1234 });
|
|
62
|
+
expect(shouldInterceptReload(msg({ text: "/new" }) as any, reg)).toBe(false);
|
|
63
|
+
expect(shouldInterceptReload(msg({ text: "/quit" }) as any, reg)).toBe(false);
|
|
64
|
+
expect(shouldInterceptReload(msg({ text: "hello" }) as any, reg)).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("still returns true even if the tracked PID is stale — liveness is checked later, not here", () => {
|
|
68
|
+
// shouldInterceptReload is a cheap gate. Liveness is the handler's job;
|
|
69
|
+
// killBySessionId is a no-op when the process is already dead.
|
|
70
|
+
const reg = makeRegistry({ S1: 99999999 });
|
|
71
|
+
expect(shouldInterceptReload(msg() as any, reg)).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock spawnPiSession BEFORE importing the handler.
|
|
4
|
+
vi.mock("../process-manager.js", () => ({
|
|
5
|
+
spawnPiSession: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
vi.mock("../../../shared/src/config.js", () => ({
|
|
8
|
+
loadConfig: () => ({ spawnStrategy: "headless" as const }),
|
|
9
|
+
}));
|
|
10
|
+
vi.mock("@blackbelt-technology/pi-dashboard-shared/config.js", () => ({
|
|
11
|
+
loadConfig: () => ({ spawnStrategy: "headless" as const }),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import { handleSpawnSession } from "../browser-handlers/session-action-handler.js";
|
|
15
|
+
import { spawnPiSession } from "../process-manager.js";
|
|
16
|
+
|
|
17
|
+
type SentMessage = { type: string; [k: string]: unknown };
|
|
18
|
+
|
|
19
|
+
function makeCtx() {
|
|
20
|
+
const sent: SentMessage[] = [];
|
|
21
|
+
const ws = { readyState: 1 } as unknown as WebSocket;
|
|
22
|
+
const ctx = {
|
|
23
|
+
ws,
|
|
24
|
+
headlessPidRegistry: { register: vi.fn() },
|
|
25
|
+
pendingDashboardSpawns: new Map<string, number>(),
|
|
26
|
+
sendTo: (_ws: unknown, msg: SentMessage) => { sent.push(msg); },
|
|
27
|
+
} as any;
|
|
28
|
+
return { ctx, sent };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("handleSpawnSession — error propagation", () => {
|
|
32
|
+
beforeEach(() => { vi.clearAllMocks(); });
|
|
33
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
34
|
+
|
|
35
|
+
it("emits spawn_error when spawnPiSession throws", async () => {
|
|
36
|
+
(spawnPiSession as any).mockRejectedValueOnce(new Error("ENOENT: pi not found"));
|
|
37
|
+
const { ctx, sent } = makeCtx();
|
|
38
|
+
await handleSpawnSession({ type: "spawn_session", cwd: "C:\\proj" } as any, ctx);
|
|
39
|
+
const errMsg = sent.find(m => m.type === "spawn_error");
|
|
40
|
+
const resMsg = sent.find(m => m.type === "spawn_result");
|
|
41
|
+
expect(resMsg).toBeDefined();
|
|
42
|
+
expect(resMsg!.success).toBe(false);
|
|
43
|
+
expect(errMsg).toBeDefined();
|
|
44
|
+
expect(errMsg!.cwd).toBe("C:\\proj");
|
|
45
|
+
expect(errMsg!.strategy).toBe("headless");
|
|
46
|
+
expect(errMsg!.message).toMatch(/ENOENT/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("emits spawn_error when spawnPiSession returns { success: false }", async () => {
|
|
50
|
+
(spawnPiSession as any).mockResolvedValueOnce({ success: false, message: "tmux unavailable" });
|
|
51
|
+
const { ctx, sent } = makeCtx();
|
|
52
|
+
await handleSpawnSession({ type: "spawn_session", cwd: "/app" } as any, ctx);
|
|
53
|
+
const errMsg = sent.find(m => m.type === "spawn_error");
|
|
54
|
+
expect(errMsg).toBeDefined();
|
|
55
|
+
expect(errMsg!.message).toBe("tmux unavailable");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("does NOT emit spawn_error on successful spawn", async () => {
|
|
59
|
+
(spawnPiSession as any).mockResolvedValueOnce({ success: true, message: "ok", pid: 1234 });
|
|
60
|
+
const { ctx, sent } = makeCtx();
|
|
61
|
+
await handleSpawnSession({ type: "spawn_session", cwd: "/app" } as any, ctx);
|
|
62
|
+
expect(sent.some(m => m.type === "spawn_error")).toBe(false);
|
|
63
|
+
expect(sent.some(m => m.type === "spawn_result" && m.success === true)).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("includes stderr tail when thrown error carries one", async () => {
|
|
67
|
+
const err = Object.assign(new Error("boom"), { stderr: "line1\nline2\nline3" });
|
|
68
|
+
(spawnPiSession as any).mockRejectedValueOnce(err);
|
|
69
|
+
const { ctx, sent } = makeCtx();
|
|
70
|
+
await handleSpawnSession({ type: "spawn_session", cwd: "/x" } as any, ctx);
|
|
71
|
+
const errMsg = sent.find(m => m.type === "spawn_error");
|
|
72
|
+
expect(errMsg!.stderr).toContain("line3");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* This prevents resuming a stale session from loading the wrong conversation.
|
|
5
5
|
*/
|
|
6
6
|
import { describe, it, expect, afterAll } from "vitest";
|
|
7
|
-
import {
|
|
7
|
+
import { createTestServer, type TestServerHandle } from "../test-support/test-server.js";
|
|
8
|
+
import type { DashboardServer } from "../server.js";
|
|
8
9
|
import { WebSocket } from "ws";
|
|
9
10
|
|
|
10
11
|
function waitForOpen(ws: WebSocket): Promise<void> {
|
|
@@ -26,22 +27,21 @@ function collectMsgs(ws: WebSocket, ms: number): Promise<any[]> {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
29
|
-
|
|
30
|
-
const piPort = 19091;
|
|
30
|
+
let handle: TestServerHandle;
|
|
31
31
|
let server: DashboardServer;
|
|
32
|
+
let piPort: number;
|
|
33
|
+
|
|
32
34
|
|
|
33
35
|
describe("session file deduplication", () => {
|
|
34
36
|
afterAll(async () => {
|
|
35
|
-
if (
|
|
37
|
+
if (handle) await handle.stop();
|
|
36
38
|
});
|
|
37
39
|
|
|
38
40
|
it("clears sessionFile from old session when new session registers with same file", async () => {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
});
|
|
44
|
-
await server.start();
|
|
41
|
+
handle = await createTestServer();
|
|
42
|
+
server = handle.server;
|
|
43
|
+
piPort = handle.piPort;
|
|
44
|
+
const httpPort = handle.httpPort;
|
|
45
45
|
|
|
46
46
|
const sharedFile = "/tmp/sessions/test.jsonl";
|
|
47
47
|
|
|
@@ -88,7 +88,9 @@ describe("Session lifecycle logging", () => {
|
|
|
88
88
|
await delay(SHORT_HB + 300);
|
|
89
89
|
|
|
90
90
|
const logs = errorSpy.mock.calls.map((c: any) => c[0]);
|
|
91
|
-
|
|
91
|
+
// Heartbeat-timeout path now goes through a reconnect grace period first;
|
|
92
|
+
// the terminal log message ends with "(reconnect grace period expired)".
|
|
93
|
+
expect(logs).toContainEqual(expect.stringContaining("[gateway] session timed out: log-timeout (reconnect grace period expired)"));
|
|
92
94
|
}, 10000);
|
|
93
95
|
|
|
94
96
|
it("should log on connection close", async () => {
|
|
@@ -111,7 +113,11 @@ describe("Session lifecycle logging", () => {
|
|
|
111
113
|
expect(logs).toContainEqual(expect.stringContaining("[gateway] connection closed: log-close"));
|
|
112
114
|
}, 10000);
|
|
113
115
|
|
|
114
|
-
|
|
116
|
+
// TODO(fix-failing-tests-followup): pi-gateway ping-timeout now keeps the
|
|
117
|
+
// session alive when the TCP socket is still writable (logs "ping: N misses
|
|
118
|
+
// but TCP alive, keeping session"), so the old "connection dead" path is no
|
|
119
|
+
// longer reachable by pausing the socket in tests. See §7.
|
|
120
|
+
it.skip("should log on ping timeout", async () => {
|
|
115
121
|
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
116
122
|
const sessionManager = createMemorySessionManager();
|
|
117
123
|
gateway = createPiGateway(sessionManager, {
|
|
@@ -73,7 +73,9 @@ describe("Sleep-aware heartbeat", () => {
|
|
|
73
73
|
await delay(100);
|
|
74
74
|
|
|
75
75
|
ws.close();
|
|
76
|
-
|
|
76
|
+
// Heartbeat timeout now has a reconnect grace-period retry (same duration),
|
|
77
|
+
// so the terminal onEmpty fires after ~2× SHORT_HB + slack.
|
|
78
|
+
await delay(SHORT_HB * 2 + 400);
|
|
77
79
|
|
|
78
80
|
expect(emptyCalled).toBe(true);
|
|
79
81
|
}, 10000);
|