@blackbelt-technology/pi-agent-dashboard 0.2.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 +342 -0
- package/README.md +619 -0
- package/docs/architecture.md +646 -0
- package/package.json +92 -0
- package/packages/extension/package.json +33 -0
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +85 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +712 -0
- package/packages/extension/src/__tests__/connection.test.ts +344 -0
- package/packages/extension/src/__tests__/credentials-updated.test.ts +26 -0
- package/packages/extension/src/__tests__/dev-build.test.ts +79 -0
- package/packages/extension/src/__tests__/event-forwarder.test.ts +89 -0
- package/packages/extension/src/__tests__/git-info.test.ts +112 -0
- package/packages/extension/src/__tests__/git-link-builder.test.ts +102 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +232 -0
- package/packages/extension/src/__tests__/openspec-poller.test.ts +119 -0
- package/packages/extension/src/__tests__/process-metrics.test.ts +47 -0
- package/packages/extension/src/__tests__/process-scanner.test.ts +202 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +54 -0
- package/packages/extension/src/__tests__/server-auto-start.test.ts +167 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +44 -0
- package/packages/extension/src/__tests__/server-probe.test.ts +25 -0
- package/packages/extension/src/__tests__/session-switch.test.ts +139 -0
- package/packages/extension/src/__tests__/session-sync.test.ts +55 -0
- package/packages/extension/src/__tests__/source-detector.test.ts +73 -0
- package/packages/extension/src/__tests__/stats-extractor.test.ts +92 -0
- package/packages/extension/src/__tests__/ui-proxy.test.ts +583 -0
- package/packages/extension/src/__tests__/watchdog.test.ts +161 -0
- package/packages/extension/src/ask-user-tool.ts +63 -0
- package/packages/extension/src/bridge-context.ts +64 -0
- package/packages/extension/src/bridge.ts +926 -0
- package/packages/extension/src/command-handler.ts +538 -0
- package/packages/extension/src/connection.ts +204 -0
- package/packages/extension/src/dev-build.ts +39 -0
- package/packages/extension/src/event-forwarder.ts +40 -0
- package/packages/extension/src/flow-event-wiring.ts +102 -0
- package/packages/extension/src/git-info.ts +65 -0
- package/packages/extension/src/git-link-builder.ts +112 -0
- package/packages/extension/src/model-tracker.ts +56 -0
- package/packages/extension/src/pi-env.d.ts +23 -0
- package/packages/extension/src/process-metrics.ts +70 -0
- package/packages/extension/src/process-scanner.ts +396 -0
- package/packages/extension/src/prompt-expander.ts +87 -0
- package/packages/extension/src/provider-register.ts +276 -0
- package/packages/extension/src/server-auto-start.ts +87 -0
- package/packages/extension/src/server-launcher.ts +82 -0
- package/packages/extension/src/server-probe.ts +33 -0
- package/packages/extension/src/session-sync.ts +154 -0
- package/packages/extension/src/source-detector.ts +26 -0
- package/packages/extension/src/ui-proxy.ts +269 -0
- package/packages/extension/tsconfig.json +11 -0
- package/packages/server/package.json +37 -0
- package/packages/server/src/__tests__/auth-plugin.test.ts +117 -0
- package/packages/server/src/__tests__/auth.test.ts +224 -0
- package/packages/server/src/__tests__/auto-attach.test.ts +246 -0
- package/packages/server/src/__tests__/auto-resume.test.ts +135 -0
- package/packages/server/src/__tests__/auto-shutdown.test.ts +136 -0
- package/packages/server/src/__tests__/browse-endpoint.test.ts +104 -0
- package/packages/server/src/__tests__/bulk-archive-handler.test.ts +15 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +73 -0
- package/packages/server/src/__tests__/client-discovery.test.ts +39 -0
- package/packages/server/src/__tests__/config-api.test.ts +104 -0
- package/packages/server/src/__tests__/cors.test.ts +48 -0
- package/packages/server/src/__tests__/directory-service.test.ts +240 -0
- package/packages/server/src/__tests__/editor-detection.test.ts +60 -0
- package/packages/server/src/__tests__/editor-endpoints.test.ts +26 -0
- package/packages/server/src/__tests__/editor-manager.test.ts +73 -0
- package/packages/server/src/__tests__/editor-registry.test.ts +151 -0
- package/packages/server/src/__tests__/event-status-extraction-flow.test.ts +55 -0
- package/packages/server/src/__tests__/event-status-extraction.test.ts +58 -0
- package/packages/server/src/__tests__/extension-register.test.ts +61 -0
- package/packages/server/src/__tests__/file-endpoint.test.ts +49 -0
- package/packages/server/src/__tests__/force-kill-handler.test.ts +109 -0
- package/packages/server/src/__tests__/git-operations.test.ts +251 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
- package/packages/server/src/__tests__/headless-shutdown-fallback.test.ts +109 -0
- package/packages/server/src/__tests__/health-endpoint.test.ts +35 -0
- package/packages/server/src/__tests__/heartbeat-ack.test.ts +63 -0
- package/packages/server/src/__tests__/json-store.test.ts +70 -0
- package/packages/server/src/__tests__/localhost-guard.test.ts +149 -0
- package/packages/server/src/__tests__/memory-event-store.test.ts +260 -0
- package/packages/server/src/__tests__/memory-session-manager.test.ts +80 -0
- package/packages/server/src/__tests__/meta-persistence.test.ts +107 -0
- package/packages/server/src/__tests__/migrate-persistence.test.ts +180 -0
- package/packages/server/src/__tests__/npm-search-proxy.test.ts +153 -0
- package/packages/server/src/__tests__/oauth-callback-server.test.ts +165 -0
- package/packages/server/src/__tests__/openspec-archive.test.ts +87 -0
- package/packages/server/src/__tests__/package-manager-wrapper.test.ts +163 -0
- package/packages/server/src/__tests__/package-routes.test.ts +172 -0
- package/packages/server/src/__tests__/pending-fork-registry.test.ts +69 -0
- package/packages/server/src/__tests__/pending-load-manager.test.ts +144 -0
- package/packages/server/src/__tests__/pending-resume-registry.test.ts +130 -0
- package/packages/server/src/__tests__/pi-resource-scanner.test.ts +235 -0
- package/packages/server/src/__tests__/preferences-store.test.ts +108 -0
- package/packages/server/src/__tests__/process-manager.test.ts +184 -0
- package/packages/server/src/__tests__/provider-auth-handlers.test.ts +93 -0
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +143 -0
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +114 -0
- package/packages/server/src/__tests__/resolve-path.test.ts +38 -0
- package/packages/server/src/__tests__/ring-buffer.test.ts +45 -0
- package/packages/server/src/__tests__/server-pid.test.ts +89 -0
- package/packages/server/src/__tests__/session-api.test.ts +244 -0
- package/packages/server/src/__tests__/session-diff.test.ts +138 -0
- package/packages/server/src/__tests__/session-file-dedup.test.ts +102 -0
- package/packages/server/src/__tests__/session-file-reader.test.ts +85 -0
- package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +138 -0
- package/packages/server/src/__tests__/session-order-manager.test.ts +135 -0
- package/packages/server/src/__tests__/session-ordering-integration.test.ts +102 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +199 -0
- package/packages/server/src/__tests__/shutdown-endpoint.test.ts +42 -0
- package/packages/server/src/__tests__/skip-wipe.test.ts +123 -0
- package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +126 -0
- package/packages/server/src/__tests__/smoke-integration.test.ts +175 -0
- package/packages/server/src/__tests__/spa-fallback.test.ts +68 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +155 -0
- package/packages/server/src/__tests__/terminal-gateway.test.ts +61 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +257 -0
- package/packages/server/src/__tests__/trusted-networks-config.test.ts +84 -0
- package/packages/server/src/__tests__/tunnel.test.ts +206 -0
- package/packages/server/src/__tests__/ws-ping-pong.test.ts +112 -0
- package/packages/server/src/auth-plugin.ts +302 -0
- package/packages/server/src/auth.ts +323 -0
- package/packages/server/src/browse.ts +55 -0
- package/packages/server/src/browser-gateway.ts +495 -0
- package/packages/server/src/browser-handlers/directory-handler.ts +137 -0
- package/packages/server/src/browser-handlers/handler-context.ts +45 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +271 -0
- package/packages/server/src/browser-handlers/session-meta-handler.ts +95 -0
- package/packages/server/src/browser-handlers/subscription-handler.ts +154 -0
- package/packages/server/src/browser-handlers/terminal-handler.ts +37 -0
- package/packages/server/src/cli.ts +347 -0
- package/packages/server/src/config-api.ts +130 -0
- package/packages/server/src/directory-service.ts +162 -0
- package/packages/server/src/editor-detection.ts +60 -0
- package/packages/server/src/editor-manager.ts +352 -0
- package/packages/server/src/editor-proxy.ts +134 -0
- package/packages/server/src/editor-registry.ts +108 -0
- package/packages/server/src/event-status-extraction.ts +131 -0
- package/packages/server/src/event-wiring.ts +589 -0
- package/packages/server/src/extension-register.ts +92 -0
- package/packages/server/src/git-operations.ts +200 -0
- package/packages/server/src/headless-pid-registry.ts +207 -0
- package/packages/server/src/idle-timer.ts +61 -0
- package/packages/server/src/json-store.ts +32 -0
- package/packages/server/src/localhost-guard.ts +117 -0
- package/packages/server/src/memory-event-store.ts +193 -0
- package/packages/server/src/memory-session-manager.ts +123 -0
- package/packages/server/src/meta-persistence.ts +64 -0
- package/packages/server/src/migrate-persistence.ts +195 -0
- package/packages/server/src/npm-search-proxy.ts +143 -0
- package/packages/server/src/oauth-callback-server.ts +177 -0
- package/packages/server/src/openspec-archive.ts +60 -0
- package/packages/server/src/package-manager-wrapper.ts +200 -0
- package/packages/server/src/pending-fork-registry.ts +53 -0
- package/packages/server/src/pending-load-manager.ts +110 -0
- package/packages/server/src/pending-resume-registry.ts +69 -0
- package/packages/server/src/pi-gateway.ts +419 -0
- package/packages/server/src/pi-resource-scanner.ts +369 -0
- package/packages/server/src/preferences-store.ts +116 -0
- package/packages/server/src/process-manager.ts +311 -0
- package/packages/server/src/provider-auth-handlers.ts +438 -0
- package/packages/server/src/provider-auth-storage.ts +200 -0
- package/packages/server/src/resolve-path.ts +12 -0
- package/packages/server/src/routes/editor-routes.ts +86 -0
- package/packages/server/src/routes/file-routes.ts +116 -0
- package/packages/server/src/routes/git-routes.ts +89 -0
- package/packages/server/src/routes/openspec-routes.ts +99 -0
- package/packages/server/src/routes/package-routes.ts +172 -0
- package/packages/server/src/routes/provider-auth-routes.ts +244 -0
- package/packages/server/src/routes/provider-routes.ts +101 -0
- package/packages/server/src/routes/route-deps.ts +23 -0
- package/packages/server/src/routes/session-routes.ts +91 -0
- package/packages/server/src/routes/system-routes.ts +271 -0
- package/packages/server/src/server-pid.ts +84 -0
- package/packages/server/src/server.ts +554 -0
- package/packages/server/src/session-api.ts +330 -0
- package/packages/server/src/session-bootstrap.ts +80 -0
- package/packages/server/src/session-diff.ts +178 -0
- package/packages/server/src/session-discovery.ts +134 -0
- package/packages/server/src/session-file-reader.ts +135 -0
- package/packages/server/src/session-order-manager.ts +73 -0
- package/packages/server/src/session-scanner.ts +233 -0
- package/packages/server/src/session-stats-reader.ts +99 -0
- package/packages/server/src/terminal-gateway.ts +51 -0
- package/packages/server/src/terminal-manager.ts +241 -0
- package/packages/server/src/tunnel.ts +329 -0
- package/packages/server/tsconfig.json +11 -0
- package/packages/shared/package.json +15 -0
- package/packages/shared/src/__tests__/config.test.ts +358 -0
- package/packages/shared/src/__tests__/deriveChangeState.test.ts +95 -0
- package/packages/shared/src/__tests__/mdns-discovery.test.ts +80 -0
- package/packages/shared/src/__tests__/protocol.test.ts +243 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +17 -0
- package/packages/shared/src/__tests__/server-identity.test.ts +73 -0
- package/packages/shared/src/__tests__/session-meta.test.ts +125 -0
- package/packages/shared/src/archive-types.ts +11 -0
- package/packages/shared/src/browser-protocol.ts +534 -0
- package/packages/shared/src/config.ts +245 -0
- package/packages/shared/src/diff-types.ts +41 -0
- package/packages/shared/src/editor-types.ts +18 -0
- package/packages/shared/src/mdns-discovery.ts +248 -0
- package/packages/shared/src/openspec-activity-detector.ts +109 -0
- package/packages/shared/src/openspec-poller.ts +96 -0
- package/packages/shared/src/protocol.ts +369 -0
- package/packages/shared/src/resolve-jiti.ts +43 -0
- package/packages/shared/src/rest-api.ts +255 -0
- package/packages/shared/src/server-identity.ts +51 -0
- package/packages/shared/src/session-meta.ts +86 -0
- package/packages/shared/src/state-replay.ts +174 -0
- package/packages/shared/src/stats-extractor.ts +54 -0
- package/packages/shared/src/terminal-types.ts +18 -0
- package/packages/shared/src/types.ts +351 -0
- package/packages/shared/tsconfig.json +8 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import {
|
|
7
|
+
isGitRepo,
|
|
8
|
+
getDirtyFiles,
|
|
9
|
+
listBranches,
|
|
10
|
+
checkoutBranch,
|
|
11
|
+
gitInit,
|
|
12
|
+
stashPop,
|
|
13
|
+
} from "../git-operations.js";
|
|
14
|
+
|
|
15
|
+
function git(cmd: string, cwd: string) {
|
|
16
|
+
execSync(`git ${cmd}`, { cwd, stdio: "pipe" });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makeRepo(): string {
|
|
20
|
+
const dir = mkdtempSync(join(tmpdir(), "git-ops-test-"));
|
|
21
|
+
git("init", dir);
|
|
22
|
+
git("config user.email test@test.com", dir);
|
|
23
|
+
git("config user.name Test", dir);
|
|
24
|
+
// Initial commit so we have a branch
|
|
25
|
+
writeFileSync(join(dir, "README.md"), "init");
|
|
26
|
+
git("add .", dir);
|
|
27
|
+
git("commit -m init", dir);
|
|
28
|
+
return dir;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("git-operations", () => {
|
|
32
|
+
let repo: string;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
repo = makeRepo();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
rmSync(repo, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("isGitRepo", () => {
|
|
43
|
+
it("returns true for a git repo", () => {
|
|
44
|
+
expect(isGitRepo(repo)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns false for a non-git directory", () => {
|
|
48
|
+
const plain = mkdtempSync(join(tmpdir(), "no-git-"));
|
|
49
|
+
try {
|
|
50
|
+
expect(isGitRepo(plain)).toBe(false);
|
|
51
|
+
} finally {
|
|
52
|
+
rmSync(plain, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("getDirtyFiles", () => {
|
|
58
|
+
it("returns empty for a clean repo", () => {
|
|
59
|
+
expect(getDirtyFiles(repo)).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns modified files", () => {
|
|
63
|
+
writeFileSync(join(repo, "README.md"), "changed");
|
|
64
|
+
const files = getDirtyFiles(repo);
|
|
65
|
+
expect(files).toContain("README.md");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns untracked files", () => {
|
|
69
|
+
writeFileSync(join(repo, "new.txt"), "hello");
|
|
70
|
+
const files = getDirtyFiles(repo);
|
|
71
|
+
expect(files).toContain("new.txt");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("listBranches", () => {
|
|
76
|
+
it("lists the current branch", () => {
|
|
77
|
+
const info = listBranches(repo);
|
|
78
|
+
expect(info.detached).toBe(false);
|
|
79
|
+
expect(info.branches.length).toBeGreaterThanOrEqual(1);
|
|
80
|
+
const current = info.branches.find((b) => b.isCurrent);
|
|
81
|
+
expect(current).toBeDefined();
|
|
82
|
+
expect(current!.name).toBe(info.current);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("lists multiple local branches", () => {
|
|
86
|
+
git("checkout -b feature-a", repo);
|
|
87
|
+
git("checkout -b feature-b", repo);
|
|
88
|
+
const info = listBranches(repo);
|
|
89
|
+
const names = info.branches.map((b) => b.name);
|
|
90
|
+
expect(names).toContain("feature-a");
|
|
91
|
+
expect(names).toContain("feature-b");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("handles empty repo (no commits)", () => {
|
|
95
|
+
const emptyRepo = mkdtempSync(join(tmpdir(), "git-empty-"));
|
|
96
|
+
try {
|
|
97
|
+
git("init", emptyRepo);
|
|
98
|
+
const info = listBranches(emptyRepo);
|
|
99
|
+
expect(info.detached).toBe(false);
|
|
100
|
+
expect(info.branches).toEqual([]);
|
|
101
|
+
expect(info.current).toBeTruthy(); // default branch name
|
|
102
|
+
} finally {
|
|
103
|
+
rmSync(emptyRepo, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("detects detached HEAD", () => {
|
|
108
|
+
const sha = execSync("git rev-parse HEAD", { cwd: repo, encoding: "utf-8" }).trim();
|
|
109
|
+
git(`checkout ${sha}`, repo);
|
|
110
|
+
const info = listBranches(repo);
|
|
111
|
+
expect(info.detached).toBe(true);
|
|
112
|
+
expect(info.current).toMatch(/^[0-9a-f]+$/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("lists remote branches", () => {
|
|
116
|
+
// Create a "remote" by cloning
|
|
117
|
+
const clone = mkdtempSync(join(tmpdir(), "git-ops-clone-"));
|
|
118
|
+
try {
|
|
119
|
+
execSync(`git clone ${repo} ${clone}`, { stdio: "pipe" });
|
|
120
|
+
// Create a branch in origin that doesn't exist locally
|
|
121
|
+
git("checkout -b remote-only", repo);
|
|
122
|
+
writeFileSync(join(repo, "remote.txt"), "data");
|
|
123
|
+
git("add .", repo);
|
|
124
|
+
git("commit -m remote-only", repo);
|
|
125
|
+
git("checkout master", repo);
|
|
126
|
+
|
|
127
|
+
// Fetch in clone
|
|
128
|
+
git("fetch origin", clone);
|
|
129
|
+
const info = listBranches(clone);
|
|
130
|
+
const remotes = info.branches.filter((b) => b.isRemote);
|
|
131
|
+
const remoteNames = remotes.map((b) => b.name);
|
|
132
|
+
expect(remoteNames.some((n) => n.includes("remote-only"))).toBe(true);
|
|
133
|
+
} finally {
|
|
134
|
+
rmSync(clone, { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("excludes origin/HEAD from remote branches", () => {
|
|
139
|
+
const clone = mkdtempSync(join(tmpdir(), "git-ops-clone-"));
|
|
140
|
+
try {
|
|
141
|
+
execSync(`git clone ${repo} ${clone}`, { stdio: "pipe" });
|
|
142
|
+
const info = listBranches(clone);
|
|
143
|
+
const remoteNames = info.branches.filter((b) => b.isRemote).map((b) => b.name);
|
|
144
|
+
expect(remoteNames.every((n) => !n.endsWith("/HEAD"))).toBe(true);
|
|
145
|
+
} finally {
|
|
146
|
+
rmSync(clone, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("checkoutBranch", () => {
|
|
152
|
+
it("checks out a local branch on clean repo", () => {
|
|
153
|
+
git("checkout -b feature-x", repo);
|
|
154
|
+
git("checkout master", repo);
|
|
155
|
+
const result = checkoutBranch(repo, "feature-x", false);
|
|
156
|
+
expect(result.success).toBe(true);
|
|
157
|
+
const head = execSync("git rev-parse --abbrev-ref HEAD", { cwd: repo, encoding: "utf-8" }).trim();
|
|
158
|
+
expect(head).toBe("feature-x");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("returns dirty when working tree is dirty and stash=false", () => {
|
|
162
|
+
git("checkout -b feature-y", repo);
|
|
163
|
+
git("checkout master", repo);
|
|
164
|
+
writeFileSync(join(repo, "README.md"), "dirty");
|
|
165
|
+
const result = checkoutBranch(repo, "feature-y", false);
|
|
166
|
+
expect(result.success).toBe(false);
|
|
167
|
+
if (!result.success) {
|
|
168
|
+
expect(result.dirty).toBe(true);
|
|
169
|
+
expect(result.files.length).toBeGreaterThan(0);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("stashes and checks out when stash=true", () => {
|
|
174
|
+
git("checkout -b feature-z", repo);
|
|
175
|
+
git("checkout master", repo);
|
|
176
|
+
writeFileSync(join(repo, "README.md"), "dirty");
|
|
177
|
+
const result = checkoutBranch(repo, "feature-z", true);
|
|
178
|
+
expect(result.success).toBe(true);
|
|
179
|
+
if (result.success) {
|
|
180
|
+
expect(result.stashed).toBe(true);
|
|
181
|
+
}
|
|
182
|
+
const head = execSync("git rev-parse --abbrev-ref HEAD", { cwd: repo, encoding: "utf-8" }).trim();
|
|
183
|
+
expect(head).toBe("feature-z");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("returns success when already on target branch", () => {
|
|
187
|
+
const result = checkoutBranch(repo, "master", false);
|
|
188
|
+
expect(result.success).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("creates local tracking branch for remote branch", () => {
|
|
192
|
+
// Create remote-only branch
|
|
193
|
+
git("checkout -b only-remote", repo);
|
|
194
|
+
writeFileSync(join(repo, "r.txt"), "data");
|
|
195
|
+
git("add .", repo);
|
|
196
|
+
git("commit -m r", repo);
|
|
197
|
+
git("checkout master", repo);
|
|
198
|
+
|
|
199
|
+
const clone = mkdtempSync(join(tmpdir(), "git-ops-clone-"));
|
|
200
|
+
try {
|
|
201
|
+
execSync(`git clone ${repo} ${clone}`, { stdio: "pipe" });
|
|
202
|
+
const result = checkoutBranch(clone, "origin/only-remote", false);
|
|
203
|
+
expect(result.success).toBe(true);
|
|
204
|
+
const head = execSync("git rev-parse --abbrev-ref HEAD", { cwd: clone, encoding: "utf-8" }).trim();
|
|
205
|
+
expect(head).toBe("only-remote");
|
|
206
|
+
} finally {
|
|
207
|
+
rmSync(clone, { recursive: true, force: true });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("gitInit", () => {
|
|
213
|
+
it("initializes a new git repo", () => {
|
|
214
|
+
const dir = mkdtempSync(join(tmpdir(), "git-init-"));
|
|
215
|
+
try {
|
|
216
|
+
gitInit(dir);
|
|
217
|
+
expect(isGitRepo(dir)).toBe(true);
|
|
218
|
+
} finally {
|
|
219
|
+
rmSync(dir, { recursive: true, force: true });
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("throws if already a git repo", () => {
|
|
224
|
+
expect(() => gitInit(repo)).toThrow("already a git repository");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("stashPop", () => {
|
|
229
|
+
it("pops stash cleanly", () => {
|
|
230
|
+
writeFileSync(join(repo, "README.md"), "stashed-content");
|
|
231
|
+
git("stash push -u", repo);
|
|
232
|
+
const result = stashPop(repo);
|
|
233
|
+
expect(result.conflicts).toBe(false);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("throws when no stash entries", () => {
|
|
237
|
+
expect(() => stashPop(repo)).toThrow("no stash entries");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("detects conflicts on stash pop", () => {
|
|
241
|
+
// Create a stash, then modify same file on current branch
|
|
242
|
+
writeFileSync(join(repo, "README.md"), "stash-version");
|
|
243
|
+
git("stash push -u", repo);
|
|
244
|
+
writeFileSync(join(repo, "README.md"), "branch-version");
|
|
245
|
+
git("add .", repo);
|
|
246
|
+
git("commit -m conflict", repo);
|
|
247
|
+
const result = stashPop(repo);
|
|
248
|
+
expect(result.conflicts).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { createHeadlessPidRegistry } from "../headless-pid-registry.js";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { mkdtempSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import type { ChildProcess } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
function mockProcess(): ChildProcess {
|
|
10
|
+
return new EventEmitter() as any;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function makeTempDir() {
|
|
14
|
+
return mkdtempSync(join(tmpdir(), "pid-reg-test-"));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("HeadlessPidRegistry", () => {
|
|
18
|
+
it("should register and track a process", () => {
|
|
19
|
+
const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
|
|
20
|
+
const proc = mockProcess();
|
|
21
|
+
registry.register(100, "/projects/app", proc);
|
|
22
|
+
expect(registry.size()).toBe(1);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should remove entry on process exit", () => {
|
|
26
|
+
const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
|
|
27
|
+
const proc = mockProcess();
|
|
28
|
+
registry.register(100, "/projects/app", proc);
|
|
29
|
+
expect(registry.size()).toBe(1);
|
|
30
|
+
proc.emit("exit");
|
|
31
|
+
expect(registry.size()).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should link session ID by cwd", () => {
|
|
35
|
+
const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
|
|
36
|
+
const proc = mockProcess();
|
|
37
|
+
registry.register(100, "/projects/app", proc);
|
|
38
|
+
const linked = registry.linkSession("session-1", "/projects/app");
|
|
39
|
+
expect(linked).toBe(true);
|
|
40
|
+
expect(registry.getPid("session-1")).toBe(100);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should return false when linking unknown cwd", () => {
|
|
44
|
+
const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
|
|
45
|
+
const linked = registry.linkSession("session-1", "/unknown");
|
|
46
|
+
expect(linked).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should use FIFO matching for same cwd", () => {
|
|
50
|
+
const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
|
|
51
|
+
const proc1 = mockProcess();
|
|
52
|
+
const proc2 = mockProcess();
|
|
53
|
+
registry.register(100, "/projects/app", proc1);
|
|
54
|
+
registry.register(200, "/projects/app", proc2);
|
|
55
|
+
|
|
56
|
+
registry.linkSession("session-1", "/projects/app");
|
|
57
|
+
expect(registry.getPid("session-1")).toBe(100);
|
|
58
|
+
|
|
59
|
+
registry.linkSession("session-2", "/projects/app");
|
|
60
|
+
expect(registry.getPid("session-2")).toBe(200);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should not link to already-linked entries", () => {
|
|
64
|
+
const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
|
|
65
|
+
const proc = mockProcess();
|
|
66
|
+
registry.register(100, "/projects/app", proc);
|
|
67
|
+
registry.linkSession("session-1", "/projects/app");
|
|
68
|
+
|
|
69
|
+
const linked = registry.linkSession("session-2", "/projects/app");
|
|
70
|
+
expect(linked).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("should return undefined for unknown session ID", () => {
|
|
74
|
+
const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
|
|
75
|
+
expect(registry.getPid("unknown")).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should kill process by session ID", () => {
|
|
79
|
+
const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
|
|
80
|
+
const proc = mockProcess();
|
|
81
|
+
registry.register(process.pid, "/projects/app", proc);
|
|
82
|
+
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
|
83
|
+
|
|
84
|
+
registry.linkSession("session-1", "/projects/app");
|
|
85
|
+
const killed = registry.killBySessionId("session-1");
|
|
86
|
+
expect(killed).toBe(true);
|
|
87
|
+
expect(killSpy).toHaveBeenCalledWith(-process.pid, "SIGTERM");
|
|
88
|
+
expect(registry.size()).toBe(0);
|
|
89
|
+
|
|
90
|
+
killSpy.mockRestore();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("should return false when killing unknown session", () => {
|
|
94
|
+
const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
|
|
95
|
+
const killed = registry.killBySessionId("unknown");
|
|
96
|
+
expect(killed).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should handle kill failure gracefully", () => {
|
|
100
|
+
const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
|
|
101
|
+
const proc = mockProcess();
|
|
102
|
+
registry.register(999999, "/projects/app", proc);
|
|
103
|
+
registry.linkSession("session-1", "/projects/app");
|
|
104
|
+
|
|
105
|
+
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
|
|
106
|
+
throw new Error("ESRCH");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const killed = registry.killBySessionId("session-1");
|
|
110
|
+
expect(killed).toBe(false);
|
|
111
|
+
expect(registry.size()).toBe(0);
|
|
112
|
+
|
|
113
|
+
killSpy.mockRestore();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("should remove by PID", () => {
|
|
117
|
+
const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
|
|
118
|
+
const proc = mockProcess();
|
|
119
|
+
registry.register(100, "/projects/app", proc);
|
|
120
|
+
registry.remove(100);
|
|
121
|
+
expect(registry.size()).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("should kill all tracked processes", () => {
|
|
125
|
+
const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
|
|
126
|
+
const proc1 = mockProcess();
|
|
127
|
+
const proc2 = mockProcess();
|
|
128
|
+
registry.register(100, "/a", proc1);
|
|
129
|
+
registry.register(200, "/b", proc2);
|
|
130
|
+
|
|
131
|
+
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
|
132
|
+
registry.killAll();
|
|
133
|
+
expect(killSpy).toHaveBeenCalledTimes(2);
|
|
134
|
+
expect(registry.size()).toBe(0);
|
|
135
|
+
killSpy.mockRestore();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("HeadlessPidRegistry persistence", () => {
|
|
140
|
+
it("should persist entries to disk on register", () => {
|
|
141
|
+
const dir = makeTempDir();
|
|
142
|
+
const pidFile = join(dir, "pids.json");
|
|
143
|
+
const registry = createHeadlessPidRegistry({ pidFilePath: pidFile });
|
|
144
|
+
const proc = mockProcess();
|
|
145
|
+
registry.register(100, "/projects/app", proc);
|
|
146
|
+
|
|
147
|
+
const data = JSON.parse(readFileSync(pidFile, "utf-8"));
|
|
148
|
+
expect(data.entries).toHaveLength(1);
|
|
149
|
+
expect(data.entries[0].pid).toBe(100);
|
|
150
|
+
expect(data.entries[0].cwd).toBe("/projects/app");
|
|
151
|
+
expect(data.entries[0].spawnedAt).toBeDefined();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("should remove entry from disk on process exit", () => {
|
|
155
|
+
const dir = makeTempDir();
|
|
156
|
+
const pidFile = join(dir, "pids.json");
|
|
157
|
+
const registry = createHeadlessPidRegistry({ pidFilePath: pidFile });
|
|
158
|
+
const proc = mockProcess();
|
|
159
|
+
registry.register(100, "/projects/app", proc);
|
|
160
|
+
proc.emit("exit");
|
|
161
|
+
|
|
162
|
+
const data = JSON.parse(readFileSync(pidFile, "utf-8"));
|
|
163
|
+
expect(data.entries).toHaveLength(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("should remove entry from disk on remove()", () => {
|
|
167
|
+
const dir = makeTempDir();
|
|
168
|
+
const pidFile = join(dir, "pids.json");
|
|
169
|
+
const registry = createHeadlessPidRegistry({ pidFilePath: pidFile });
|
|
170
|
+
const proc = mockProcess();
|
|
171
|
+
registry.register(100, "/projects/app", proc);
|
|
172
|
+
registry.remove(100);
|
|
173
|
+
|
|
174
|
+
const data = JSON.parse(readFileSync(pidFile, "utf-8"));
|
|
175
|
+
expect(data.entries).toHaveLength(0);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("HeadlessPidRegistry orphan cleanup", () => {
|
|
180
|
+
it("should reclaim alive processes from disk", () => {
|
|
181
|
+
const dir = makeTempDir();
|
|
182
|
+
const pidFile = join(dir, "pids.json");
|
|
183
|
+
|
|
184
|
+
// Pre-populate the PID file with current process PID (guaranteed alive)
|
|
185
|
+
writeFileSync(pidFile, JSON.stringify({
|
|
186
|
+
entries: [{ pid: process.pid, cwd: "/projects/app", spawnedAt: new Date().toISOString() }],
|
|
187
|
+
}));
|
|
188
|
+
|
|
189
|
+
const registry = createHeadlessPidRegistry({ pidFilePath: pidFile });
|
|
190
|
+
registry.cleanupOrphans();
|
|
191
|
+
|
|
192
|
+
expect(registry.size()).toBe(1);
|
|
193
|
+
expect(registry.getPid("any")).toBeUndefined(); // not linked yet
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("should remove dead processes from disk", () => {
|
|
197
|
+
const dir = makeTempDir();
|
|
198
|
+
const pidFile = join(dir, "pids.json");
|
|
199
|
+
|
|
200
|
+
// Use a PID that's almost certainly dead
|
|
201
|
+
writeFileSync(pidFile, JSON.stringify({
|
|
202
|
+
entries: [{ pid: 999999, cwd: "/projects/app", spawnedAt: new Date().toISOString() }],
|
|
203
|
+
}));
|
|
204
|
+
|
|
205
|
+
const registry = createHeadlessPidRegistry({ pidFilePath: pidFile });
|
|
206
|
+
registry.cleanupOrphans();
|
|
207
|
+
|
|
208
|
+
expect(registry.size()).toBe(0);
|
|
209
|
+
const data = JSON.parse(readFileSync(pidFile, "utf-8"));
|
|
210
|
+
expect(data.entries).toHaveLength(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("should kill very old alive orphans (>7 days)", () => {
|
|
214
|
+
const dir = makeTempDir();
|
|
215
|
+
const pidFile = join(dir, "pids.json");
|
|
216
|
+
|
|
217
|
+
const oldDate = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(); // 8 days ago
|
|
218
|
+
writeFileSync(pidFile, JSON.stringify({
|
|
219
|
+
entries: [{ pid: process.pid, cwd: "/projects/app", spawnedAt: oldDate }],
|
|
220
|
+
}));
|
|
221
|
+
|
|
222
|
+
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
|
223
|
+
const registry = createHeadlessPidRegistry({ pidFilePath: pidFile });
|
|
224
|
+
registry.cleanupOrphans();
|
|
225
|
+
|
|
226
|
+
// Should have tried to kill the process group
|
|
227
|
+
expect(killSpy).toHaveBeenCalledWith(-process.pid, "SIGTERM");
|
|
228
|
+
// Should NOT be reclaimed
|
|
229
|
+
expect(registry.size()).toBe(0);
|
|
230
|
+
|
|
231
|
+
killSpy.mockRestore();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test: shutdown fallback kills headless process when bridge is disconnected.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, afterAll } from "vitest";
|
|
5
|
+
import { createServer, type DashboardServer } from "../server.js";
|
|
6
|
+
import { WebSocket } from "ws";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
function waitForOpen(ws: WebSocket): Promise<void> {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
if (ws.readyState === WebSocket.OPEN) return resolve();
|
|
12
|
+
ws.on("open", resolve);
|
|
13
|
+
ws.on("error", reject);
|
|
14
|
+
setTimeout(() => reject(new Error("open timeout")), 3000);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function collectMsgs(ws: WebSocket, ms: number): Promise<any[]> {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
const arr: any[] = [];
|
|
21
|
+
const h = (raw: any) => arr.push(JSON.parse(raw.toString()));
|
|
22
|
+
ws.on("message", h);
|
|
23
|
+
setTimeout(() => { ws.off("message", h); resolve(arr); }, ms);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
28
|
+
const httpPort = 19190;
|
|
29
|
+
const piPort = 19191;
|
|
30
|
+
let server: DashboardServer;
|
|
31
|
+
|
|
32
|
+
describe("Headless shutdown fallback", () => {
|
|
33
|
+
afterAll(async () => {
|
|
34
|
+
if (server) await server.stop();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should kill headless process via SIGTERM when bridge is disconnected", async () => {
|
|
38
|
+
server = await createServer({
|
|
39
|
+
port: httpPort, piPort, dev: true,
|
|
40
|
+
autoShutdown: false, shutdownIdleSeconds: 999, tunnel: false,
|
|
41
|
+
editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
|
|
42
|
+
});
|
|
43
|
+
await server.start();
|
|
44
|
+
|
|
45
|
+
// Spawn a real dummy process (sleep) to act as the headless pi session
|
|
46
|
+
const dummy = spawn("sleep", ["60"], { detached: true, stdio: "ignore" });
|
|
47
|
+
dummy.unref();
|
|
48
|
+
const pid = dummy.pid!;
|
|
49
|
+
|
|
50
|
+
// Register it in the headless registry with a known cwd
|
|
51
|
+
const registry = server.browserGateway.headlessPidRegistry;
|
|
52
|
+
registry.register(pid, "/test/cwd", dummy);
|
|
53
|
+
|
|
54
|
+
// Simulate bridge connecting and registering with that cwd
|
|
55
|
+
const bridge = new WebSocket(`ws://localhost:${piPort}`);
|
|
56
|
+
await waitForOpen(bridge);
|
|
57
|
+
bridge.send(JSON.stringify({
|
|
58
|
+
type: "session_register", sessionId: "headless-1", cwd: "/test/cwd", source: "tui",
|
|
59
|
+
}));
|
|
60
|
+
// Wait for session_register to be processed (may need longer under load)
|
|
61
|
+
for (let i = 0; i < 20; i++) {
|
|
62
|
+
if (registry.getPid("headless-1") !== undefined) break;
|
|
63
|
+
await delay(50);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Verify the session got linked
|
|
67
|
+
expect(registry.getPid("headless-1")).toBe(pid);
|
|
68
|
+
|
|
69
|
+
// Now disconnect the bridge (simulating bridge gone)
|
|
70
|
+
bridge.close();
|
|
71
|
+
await delay(200);
|
|
72
|
+
|
|
73
|
+
// Browser sends shutdown — bridge is disconnected, should fallback to kill
|
|
74
|
+
const browser = new WebSocket(`ws://localhost:${httpPort}/ws`);
|
|
75
|
+
await waitForOpen(browser);
|
|
76
|
+
await delay(100);
|
|
77
|
+
|
|
78
|
+
browser.send(JSON.stringify({ type: "shutdown", sessionId: "headless-1" }));
|
|
79
|
+
await delay(300);
|
|
80
|
+
|
|
81
|
+
// Verify process was killed
|
|
82
|
+
let alive = true;
|
|
83
|
+
try {
|
|
84
|
+
process.kill(pid, 0); // signal 0 = check if alive
|
|
85
|
+
} catch {
|
|
86
|
+
alive = false;
|
|
87
|
+
}
|
|
88
|
+
expect(alive).toBe(false);
|
|
89
|
+
|
|
90
|
+
browser.close();
|
|
91
|
+
await delay(50);
|
|
92
|
+
}, 15000);
|
|
93
|
+
|
|
94
|
+
it("should not crash when no PID is linked for shutdown", async () => {
|
|
95
|
+
const browser = new WebSocket(`ws://localhost:${httpPort}/ws`);
|
|
96
|
+
await waitForOpen(browser);
|
|
97
|
+
await delay(100);
|
|
98
|
+
|
|
99
|
+
// Send shutdown for an unknown session — should not crash
|
|
100
|
+
browser.send(JSON.stringify({ type: "shutdown", sessionId: "nonexistent" }));
|
|
101
|
+
await delay(200);
|
|
102
|
+
|
|
103
|
+
// If we get here without crashing, it's a pass
|
|
104
|
+
expect(browser.readyState).toBe(WebSocket.OPEN);
|
|
105
|
+
|
|
106
|
+
browser.close();
|
|
107
|
+
await delay(50);
|
|
108
|
+
}, 10000);
|
|
109
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for GET /api/health endpoint.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
5
|
+
import { createServer, type DashboardServer } from "../server.js";
|
|
6
|
+
|
|
7
|
+
const httpPort = 19090;
|
|
8
|
+
const piPort = 19091;
|
|
9
|
+
let server: DashboardServer;
|
|
10
|
+
|
|
11
|
+
describe("GET /api/health", () => {
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
if (server) {
|
|
14
|
+
try { await server.stop(); } catch { /* already stopped */ }
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should return ok, pid, and uptime", async () => {
|
|
19
|
+
server = await createServer({
|
|
20
|
+
port: httpPort, piPort, dev: true,
|
|
21
|
+
autoShutdown: false, shutdownIdleSeconds: 999, tunnel: false,
|
|
22
|
+
editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
|
|
23
|
+
});
|
|
24
|
+
await server.start();
|
|
25
|
+
|
|
26
|
+
const res = await fetch(`http://localhost:${httpPort}/api/health`);
|
|
27
|
+
expect(res.status).toBe(200);
|
|
28
|
+
|
|
29
|
+
const body = await res.json();
|
|
30
|
+
expect(body.ok).toBe(true);
|
|
31
|
+
expect(body.pid).toBe(process.pid);
|
|
32
|
+
expect(typeof body.uptime).toBe("number");
|
|
33
|
+
expect(body.uptime).toBeGreaterThanOrEqual(0);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for heartbeat_ack response in pi-gateway.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
5
|
+
import { createPiGateway } from "../pi-gateway.js";
|
|
6
|
+
import { createMemorySessionManager } from "../memory-session-manager.js";
|
|
7
|
+
import { WebSocket } from "ws";
|
|
8
|
+
|
|
9
|
+
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
10
|
+
|
|
11
|
+
function waitForOpen(ws: WebSocket): Promise<void> {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
if (ws.readyState === WebSocket.OPEN) return resolve();
|
|
14
|
+
ws.on("open", resolve);
|
|
15
|
+
ws.on("error", reject);
|
|
16
|
+
setTimeout(() => reject(new Error("open timeout")), 3000);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let portCounter = 19550;
|
|
21
|
+
|
|
22
|
+
describe("heartbeat_ack", () => {
|
|
23
|
+
let gateway: ReturnType<typeof createPiGateway>;
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
gateway?.stop();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should respond with heartbeat_ack when receiving session_heartbeat", async () => {
|
|
30
|
+
const sessionManager = createMemorySessionManager();
|
|
31
|
+
gateway = createPiGateway(sessionManager, { heartbeatTimeout: 5000 });
|
|
32
|
+
const port = portCounter++;
|
|
33
|
+
gateway.start(port);
|
|
34
|
+
|
|
35
|
+
const ws = new WebSocket(`ws://localhost:${port}`);
|
|
36
|
+
await waitForOpen(ws);
|
|
37
|
+
|
|
38
|
+
// Register session first
|
|
39
|
+
ws.send(JSON.stringify({
|
|
40
|
+
type: "session_register", sessionId: "ack-test", cwd: "/tmp", source: "tui",
|
|
41
|
+
}));
|
|
42
|
+
await delay(100);
|
|
43
|
+
|
|
44
|
+
// Collect messages
|
|
45
|
+
const messages: any[] = [];
|
|
46
|
+
ws.on("message", (raw) => {
|
|
47
|
+
messages.push(JSON.parse(raw.toString()));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Send heartbeat
|
|
51
|
+
ws.send(JSON.stringify({
|
|
52
|
+
type: "session_heartbeat", sessionId: "ack-test",
|
|
53
|
+
}));
|
|
54
|
+
await delay(100);
|
|
55
|
+
|
|
56
|
+
// Should have received heartbeat_ack
|
|
57
|
+
const ack = messages.find((m) => m.type === "heartbeat_ack");
|
|
58
|
+
expect(ack).toBeDefined();
|
|
59
|
+
expect(ack.type).toBe("heartbeat_ack");
|
|
60
|
+
|
|
61
|
+
ws.close();
|
|
62
|
+
}, 10000);
|
|
63
|
+
});
|