@blackbelt-technology/pi-agent-dashboard 0.2.8 → 0.3.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 +114 -9
- package/README.md +218 -97
- package/docs/architecture.md +107 -7
- package/package.json +9 -4
- package/packages/extension/package.json +1 -1
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
- package/packages/extension/src/ask-user-tool.ts +289 -20
- package/packages/extension/src/bridge.ts +38 -4
- package/packages/extension/src/command-handler.ts +34 -39
- package/packages/extension/src/prompt-expander.ts +25 -4
- package/packages/server/package.json +2 -1
- 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__/browse-endpoint.test.ts +229 -10
- package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
- package/packages/server/src/__tests__/cors.test.ts +34 -2
- 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 +3 -2
- package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
- 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__/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 +122 -0
- 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__/provider-auth-routes.test.ts +13 -3
- package/packages/server/src/__tests__/recommended-routes.test.ts +389 -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__/test-server-canary.test.ts +31 -0
- package/packages/server/src/__tests__/tunnel.test.ts +91 -0
- package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
- package/packages/server/src/browse.ts +100 -6
- package/packages/server/src/browser-gateway.ts +16 -3
- package/packages/server/src/editor-manager.ts +20 -1
- package/packages/server/src/editor-pid-registry.ts +198 -0
- package/packages/server/src/fix-pty-permissions.ts +44 -0
- package/packages/server/src/headless-pid-registry.ts +9 -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 +31 -0
- package/packages/server/src/pi-core-checker.ts +290 -0
- package/packages/server/src/pi-core-updater.ts +166 -0
- package/packages/server/src/pi-gateway.ts +7 -0
- package/packages/server/src/process-manager.ts +1 -1
- package/packages/server/src/routes/file-routes.ts +30 -3
- package/packages/server/src/routes/openspec-routes.ts +83 -1
- package/packages/server/src/routes/pi-core-routes.ts +117 -0
- package/packages/server/src/routes/provider-auth-routes.ts +4 -2
- package/packages/server/src/routes/provider-routes.ts +12 -2
- package/packages/server/src/routes/recommended-routes.ts +227 -0
- package/packages/server/src/routes/system-routes.ts +10 -1
- package/packages/server/src/server.ts +151 -15
- package/packages/server/src/terminal-manager.ts +4 -0
- 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 +132 -8
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/config.test.ts +3 -3
- package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +123 -0
- package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
- package/packages/shared/src/browser-protocol.ts +23 -1
- package/packages/shared/src/openspec-poller.ts +8 -3
- package/packages/shared/src/recommended-extensions.ts +180 -0
- package/packages/shared/src/rest-api.ts +71 -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/types.ts +7 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, readFileSync, existsSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
createEditorPidRegistry,
|
|
9
|
+
isDashboardOwnedCodeServer,
|
|
10
|
+
type PersistedEditorEntry,
|
|
11
|
+
} from "../editor-pid-registry.js";
|
|
12
|
+
|
|
13
|
+
function tempPidFile(): string {
|
|
14
|
+
const dir = mkdtempSync(join(tmpdir(), "editor-pid-reg-"));
|
|
15
|
+
return join(dir, "editor-pids.json");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readEntries(file: string): PersistedEditorEntry[] {
|
|
19
|
+
if (!existsSync(file)) return [];
|
|
20
|
+
return JSON.parse(readFileSync(file, "utf-8")).entries ?? [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const VALID_CMDLINE = `/usr/local/bin/code-server --auth none --bind-addr 127.0.0.1:63584 --user-data-dir ${path.join(os.homedir(), ".pi", "dashboard", "editors", "abc123def456")} /Users/me/project`;
|
|
24
|
+
|
|
25
|
+
const UNRELATED_CMDLINE = "/usr/local/bin/code-server --user-data-dir /Users/me/.config/Code";
|
|
26
|
+
|
|
27
|
+
describe("isDashboardOwnedCodeServer", () => {
|
|
28
|
+
it("returns true for a dashboard-owned code-server cmdline", () => {
|
|
29
|
+
expect(isDashboardOwnedCodeServer(VALID_CMDLINE)).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns false for an unrelated code-server", () => {
|
|
33
|
+
expect(isDashboardOwnedCodeServer(UNRELATED_CMDLINE)).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns false for null cmdline", () => {
|
|
37
|
+
expect(isDashboardOwnedCodeServer(null)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns false when --user-data-dir is missing", () => {
|
|
41
|
+
expect(isDashboardOwnedCodeServer("/usr/local/bin/code-server --bind-addr 127.0.0.1:1234")).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("createEditorPidRegistry — register/remove/persist", () => {
|
|
46
|
+
it("register writes an entry to the JSON file", () => {
|
|
47
|
+
const file = tempPidFile();
|
|
48
|
+
const reg = createEditorPidRegistry({ pidFilePath: file });
|
|
49
|
+
reg.register({ id: "editor-aaa", pid: 5961, port: 63584, cwd: "/projects/app", dataDir: "/data" });
|
|
50
|
+
expect(reg.size()).toBe(1);
|
|
51
|
+
const entries = readEntries(file);
|
|
52
|
+
expect(entries).toHaveLength(1);
|
|
53
|
+
expect(entries[0]).toMatchObject({ id: "editor-aaa", pid: 5961, port: 63584, cwd: "/projects/app", dataDir: "/data" });
|
|
54
|
+
expect(entries[0].spawnedAt).toBeDefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("remove deletes the entry from the JSON file", () => {
|
|
58
|
+
const file = tempPidFile();
|
|
59
|
+
const reg = createEditorPidRegistry({ pidFilePath: file });
|
|
60
|
+
reg.register({ id: "editor-aaa", pid: 1, port: 1, cwd: "/a", dataDir: "/d" });
|
|
61
|
+
reg.register({ id: "editor-bbb", pid: 2, port: 2, cwd: "/b", dataDir: "/d2" });
|
|
62
|
+
reg.remove("editor-aaa");
|
|
63
|
+
expect(reg.size()).toBe(1);
|
|
64
|
+
const entries = readEntries(file);
|
|
65
|
+
expect(entries).toHaveLength(1);
|
|
66
|
+
expect(entries[0].id).toBe("editor-bbb");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("persistence write failure does not throw from register", () => {
|
|
70
|
+
// Portable way to force writeJsonFile to fail: make the target path a
|
|
71
|
+
// directory so fs.writeFileSync (to path + ".tmp") succeeds but rename()
|
|
72
|
+
// onto a directory fails with EISDIR/EPERM on every platform.
|
|
73
|
+
const file = tempPidFile();
|
|
74
|
+
mkdirSync(file, { recursive: true }); // target exists as a directory
|
|
75
|
+
const reg = createEditorPidRegistry({ pidFilePath: file });
|
|
76
|
+
expect(() => reg.register({ id: "editor-aaa", pid: 1, port: 1, cwd: "/a", dataDir: "/d" })).not.toThrow();
|
|
77
|
+
// In-memory entry still tracked even when disk write failed.
|
|
78
|
+
expect(reg.size()).toBe(1);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("createEditorPidRegistry — cleanupOrphans", () => {
|
|
83
|
+
it("returns without throwing when file does not exist", async () => {
|
|
84
|
+
const file = join(mkdtempSync(join(tmpdir(), "missing-")), "nope.json");
|
|
85
|
+
const reg = createEditorPidRegistry({ pidFilePath: file });
|
|
86
|
+
await expect(reg.cleanupOrphans()).resolves.toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns without throwing when file is corrupt", async () => {
|
|
90
|
+
const file = tempPidFile();
|
|
91
|
+
writeFileSync(file, "{not json");
|
|
92
|
+
const reg = createEditorPidRegistry({ pidFilePath: file });
|
|
93
|
+
await expect(reg.cleanupOrphans()).resolves.toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("skips dead PIDs", async () => {
|
|
97
|
+
const file = tempPidFile();
|
|
98
|
+
writeFileSync(file, JSON.stringify({
|
|
99
|
+
entries: [{ id: "editor-x", pid: 999999, port: 1, cwd: "/a", dataDir: "/d", spawnedAt: new Date().toISOString() }],
|
|
100
|
+
}));
|
|
101
|
+
const killed: Array<{ pid: number; sig: string }> = [];
|
|
102
|
+
const reg = createEditorPidRegistry({
|
|
103
|
+
pidFilePath: file,
|
|
104
|
+
isProcessAlive: () => false,
|
|
105
|
+
getCmdline: () => VALID_CMDLINE,
|
|
106
|
+
kill: (pid, sig) => { killed.push({ pid, sig }); return true; },
|
|
107
|
+
graceMs: 1,
|
|
108
|
+
});
|
|
109
|
+
await reg.cleanupOrphans();
|
|
110
|
+
expect(killed).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("does NOT signal a live PID whose cmdline doesn't match (PID reuse)", async () => {
|
|
114
|
+
const file = tempPidFile();
|
|
115
|
+
writeFileSync(file, JSON.stringify({
|
|
116
|
+
entries: [{ id: "editor-x", pid: 1234, port: 1, cwd: "/a", dataDir: "/d", spawnedAt: new Date().toISOString() }],
|
|
117
|
+
}));
|
|
118
|
+
const killed: Array<{ pid: number; sig: string }> = [];
|
|
119
|
+
const reg = createEditorPidRegistry({
|
|
120
|
+
pidFilePath: file,
|
|
121
|
+
isProcessAlive: () => true,
|
|
122
|
+
getCmdline: () => UNRELATED_CMDLINE,
|
|
123
|
+
kill: (pid, sig) => { killed.push({ pid, sig }); return true; },
|
|
124
|
+
graceMs: 1,
|
|
125
|
+
});
|
|
126
|
+
await reg.cleanupOrphans();
|
|
127
|
+
expect(killed).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("does NOT signal when cmdline lookup fails (cannot verify)", async () => {
|
|
131
|
+
const file = tempPidFile();
|
|
132
|
+
writeFileSync(file, JSON.stringify({
|
|
133
|
+
entries: [{ id: "editor-x", pid: 1234, port: 1, cwd: "/a", dataDir: "/d", spawnedAt: new Date().toISOString() }],
|
|
134
|
+
}));
|
|
135
|
+
const killed: Array<{ pid: number; sig: string }> = [];
|
|
136
|
+
const reg = createEditorPidRegistry({
|
|
137
|
+
pidFilePath: file,
|
|
138
|
+
isProcessAlive: () => true,
|
|
139
|
+
getCmdline: () => null,
|
|
140
|
+
kill: (pid, sig) => { killed.push({ pid, sig }); return true; },
|
|
141
|
+
graceMs: 1,
|
|
142
|
+
});
|
|
143
|
+
await reg.cleanupOrphans();
|
|
144
|
+
expect(killed).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("SIGTERMs verified live orphans then SIGKILLs survivors", async () => {
|
|
148
|
+
const file = tempPidFile();
|
|
149
|
+
writeFileSync(file, JSON.stringify({
|
|
150
|
+
entries: [
|
|
151
|
+
{ id: "editor-x", pid: 5961, port: 63584, cwd: "/a", dataDir: "/d1", spawnedAt: new Date().toISOString() },
|
|
152
|
+
{ id: "editor-y", pid: 5962, port: 63585, cwd: "/b", dataDir: "/d2", spawnedAt: new Date().toISOString() },
|
|
153
|
+
],
|
|
154
|
+
}));
|
|
155
|
+
const killed: Array<{ pid: number; sig: string }> = [];
|
|
156
|
+
// First call: alive. After SIGTERM grace, simulate that 5961 died, 5962 survived.
|
|
157
|
+
let phase: "before" | "after" = "before";
|
|
158
|
+
const reg = createEditorPidRegistry({
|
|
159
|
+
pidFilePath: file,
|
|
160
|
+
isProcessAlive: (pid) => phase === "before" ? true : pid === 5962,
|
|
161
|
+
getCmdline: () => VALID_CMDLINE,
|
|
162
|
+
kill: (pid, sig) => { killed.push({ pid, sig }); return true; },
|
|
163
|
+
graceMs: 1,
|
|
164
|
+
});
|
|
165
|
+
// Toggle phase right after SIGTERMs are sent.
|
|
166
|
+
const origSetTimeout = setTimeout;
|
|
167
|
+
const promise = reg.cleanupOrphans();
|
|
168
|
+
// microtask flip
|
|
169
|
+
queueMicrotask(() => { phase = "after"; });
|
|
170
|
+
await promise;
|
|
171
|
+
|
|
172
|
+
expect(killed.filter((k) => k.sig === "SIGTERM").map((k) => k.pid).sort()).toEqual([5961, 5962]);
|
|
173
|
+
expect(killed.filter((k) => k.sig === "SIGKILL").map((k) => k.pid)).toEqual([5962]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("rewrites the registry file empty after sweep", async () => {
|
|
177
|
+
const file = tempPidFile();
|
|
178
|
+
writeFileSync(file, JSON.stringify({
|
|
179
|
+
entries: [{ id: "editor-x", pid: 5961, port: 1, cwd: "/a", dataDir: "/d", spawnedAt: new Date().toISOString() }],
|
|
180
|
+
}));
|
|
181
|
+
const reg = createEditorPidRegistry({
|
|
182
|
+
pidFilePath: file,
|
|
183
|
+
isProcessAlive: () => true,
|
|
184
|
+
getCmdline: () => VALID_CMDLINE,
|
|
185
|
+
kill: () => true,
|
|
186
|
+
graceMs: 1,
|
|
187
|
+
});
|
|
188
|
+
await reg.cleanupOrphans();
|
|
189
|
+
expect(readEntries(file)).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -77,8 +77,9 @@ describe("editor-registry", () => {
|
|
|
77
77
|
mockedExecSync.mockImplementation((cmd) => {
|
|
78
78
|
const s = String(cmd);
|
|
79
79
|
if (s.includes("pgrep")) {
|
|
80
|
-
// Only Zed is running
|
|
81
|
-
|
|
80
|
+
// Only Zed is running — match on both the macOS pattern
|
|
81
|
+
// ("/Applications/Zed.app") and the Linux pattern ("zed").
|
|
82
|
+
if (s.includes("Zed") || s.includes("zed")) return Buffer.from("12345\n");
|
|
82
83
|
throw new Error("not found");
|
|
83
84
|
}
|
|
84
85
|
if (s.includes("which")) {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test for the postinstall `fix-pty-permissions.cjs` script.
|
|
3
|
+
*
|
|
4
|
+
* Ensures that after `npm install` the native `spawn-helper` binary in the
|
|
5
|
+
* current platform's `node-pty` prebuild directory has at least one execute
|
|
6
|
+
* bit set. Without this, `pty.spawn(...)` fails with "posix_spawnp failed."
|
|
7
|
+
* and the dashboard's "New Terminal" button appears dead.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from "vitest";
|
|
10
|
+
import { existsSync, statSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
|
|
14
|
+
describe("fix-pty-permissions", () => {
|
|
15
|
+
it.skipIf(process.platform === "win32")(
|
|
16
|
+
"spawn-helper for current platform is executable",
|
|
17
|
+
() => {
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const ptyPkg = require.resolve("node-pty/package.json");
|
|
20
|
+
const ptyRoot = dirname(ptyPkg);
|
|
21
|
+
const platformDir =
|
|
22
|
+
process.platform === "darwin"
|
|
23
|
+
? process.arch === "arm64"
|
|
24
|
+
? "darwin-arm64"
|
|
25
|
+
: "darwin-x64"
|
|
26
|
+
: process.arch === "arm64"
|
|
27
|
+
? "linux-arm64"
|
|
28
|
+
: "linux-x64";
|
|
29
|
+
|
|
30
|
+
// node-pty ships pre-packed binaries under `prebuilds/<platform>-<arch>/`
|
|
31
|
+
// when the tarball includes a prebuild for the host platform (macOS,
|
|
32
|
+
// Windows, some Linux builds). Otherwise node-pty's install script
|
|
33
|
+
// falls back to `node-gyp rebuild`, producing artifacts under
|
|
34
|
+
// `build/Release/` instead. Accept either location so this test is
|
|
35
|
+
// stable across local dev (prebuilt) and Linux CI (built from source).
|
|
36
|
+
const candidates = [
|
|
37
|
+
join(ptyRoot, "prebuilds", platformDir, "spawn-helper"),
|
|
38
|
+
join(ptyRoot, "build", "Release", "spawn-helper"),
|
|
39
|
+
];
|
|
40
|
+
const helper = candidates.find((p) => existsSync(p));
|
|
41
|
+
|
|
42
|
+
if (!helper) {
|
|
43
|
+
// No spawn-helper anywhere means node-pty's install step did not
|
|
44
|
+
// produce the binary on this host (e.g. missing build toolchain).
|
|
45
|
+
// The fix-permissions script is defensive — it silently skips when
|
|
46
|
+
// the prebuild dir is absent — so skipping here matches runtime
|
|
47
|
+
// behavior rather than masking a real regression.
|
|
48
|
+
console.warn(
|
|
49
|
+
`[fix-pty-permissions.test] no spawn-helper found at any of: ${candidates.join(", ")} — skipping`,
|
|
50
|
+
);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const mode = statSync(helper).mode;
|
|
55
|
+
// At least one execute bit (owner/group/other) must be set.
|
|
56
|
+
expect(mode & 0o111).not.toBe(0);
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
});
|
|
@@ -18,7 +18,9 @@ function git(cmd: string, cwd: string) {
|
|
|
18
18
|
|
|
19
19
|
function makeRepo(): string {
|
|
20
20
|
const dir = mkdtempSync(join(tmpdir(), "git-ops-test-"));
|
|
21
|
-
|
|
21
|
+
// Force `main` as the default branch so tests are deterministic regardless
|
|
22
|
+
// of the host user's `init.defaultBranch` config.
|
|
23
|
+
git("-c init.defaultBranch=main init", dir);
|
|
22
24
|
git("config user.email test@test.com", dir);
|
|
23
25
|
git("config user.name Test", dir);
|
|
24
26
|
// Initial commit so we have a branch
|
|
@@ -122,7 +124,7 @@ describe("git-operations", () => {
|
|
|
122
124
|
writeFileSync(join(repo, "remote.txt"), "data");
|
|
123
125
|
git("add .", repo);
|
|
124
126
|
git("commit -m remote-only", repo);
|
|
125
|
-
git("checkout
|
|
127
|
+
git("checkout main", repo);
|
|
126
128
|
|
|
127
129
|
// Fetch in clone
|
|
128
130
|
git("fetch origin", clone);
|
|
@@ -151,7 +153,7 @@ describe("git-operations", () => {
|
|
|
151
153
|
describe("checkoutBranch", () => {
|
|
152
154
|
it("checks out a local branch on clean repo", () => {
|
|
153
155
|
git("checkout -b feature-x", repo);
|
|
154
|
-
git("checkout
|
|
156
|
+
git("checkout main", repo);
|
|
155
157
|
const result = checkoutBranch(repo, "feature-x", false);
|
|
156
158
|
expect(result.success).toBe(true);
|
|
157
159
|
const head = execSync("git rev-parse --abbrev-ref HEAD", { cwd: repo, encoding: "utf-8" }).trim();
|
|
@@ -160,7 +162,7 @@ describe("git-operations", () => {
|
|
|
160
162
|
|
|
161
163
|
it("returns dirty when working tree is dirty and stash=false", () => {
|
|
162
164
|
git("checkout -b feature-y", repo);
|
|
163
|
-
git("checkout
|
|
165
|
+
git("checkout main", repo);
|
|
164
166
|
writeFileSync(join(repo, "README.md"), "dirty");
|
|
165
167
|
const result = checkoutBranch(repo, "feature-y", false);
|
|
166
168
|
expect(result.success).toBe(false);
|
|
@@ -172,7 +174,7 @@ describe("git-operations", () => {
|
|
|
172
174
|
|
|
173
175
|
it("stashes and checks out when stash=true", () => {
|
|
174
176
|
git("checkout -b feature-z", repo);
|
|
175
|
-
git("checkout
|
|
177
|
+
git("checkout main", repo);
|
|
176
178
|
writeFileSync(join(repo, "README.md"), "dirty");
|
|
177
179
|
const result = checkoutBranch(repo, "feature-z", true);
|
|
178
180
|
expect(result.success).toBe(true);
|
|
@@ -184,7 +186,7 @@ describe("git-operations", () => {
|
|
|
184
186
|
});
|
|
185
187
|
|
|
186
188
|
it("returns success when already on target branch", () => {
|
|
187
|
-
const result = checkoutBranch(repo, "
|
|
189
|
+
const result = checkoutBranch(repo, "main", false);
|
|
188
190
|
expect(result.success).toBe(true);
|
|
189
191
|
});
|
|
190
192
|
|
|
@@ -194,7 +196,7 @@ describe("git-operations", () => {
|
|
|
194
196
|
writeFileSync(join(repo, "r.txt"), "data");
|
|
195
197
|
git("add .", repo);
|
|
196
198
|
git("commit -m r", repo);
|
|
197
|
-
git("checkout
|
|
199
|
+
git("checkout main", repo);
|
|
198
200
|
|
|
199
201
|
const clone = mkdtempSync(join(tmpdir(), "git-ops-clone-"));
|
|
200
202
|
try {
|
|
@@ -2,28 +2,26 @@
|
|
|
2
2
|
* Tests for GET /api/health endpoint.
|
|
3
3
|
*/
|
|
4
4
|
import { describe, it, expect, afterEach } from "vitest";
|
|
5
|
-
import {
|
|
5
|
+
import { createTestServer, type TestServerHandle } from "../test-support/test-server.js";
|
|
6
|
+
import type { DashboardServer } from "../server.js";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
let server: DashboardServer;
|
|
8
|
+
let handle: TestServerHandle | undefined;
|
|
9
|
+
let server: DashboardServer | undefined;
|
|
10
10
|
|
|
11
11
|
describe("GET /api/health", () => {
|
|
12
12
|
afterEach(async () => {
|
|
13
|
-
if (
|
|
14
|
-
try { await
|
|
13
|
+
if (handle) {
|
|
14
|
+
try { await handle.stop(); } catch { /* already stopped */ }
|
|
15
|
+
handle = undefined;
|
|
16
|
+
server = undefined;
|
|
15
17
|
}
|
|
16
18
|
});
|
|
17
19
|
|
|
18
20
|
it("should return ok, pid, and uptime", async () => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
autoShutdown: false, shutdownIdleSeconds: 999, tunnel: false,
|
|
22
|
-
editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
|
|
23
|
-
});
|
|
24
|
-
await server.start();
|
|
21
|
+
handle = await createTestServer();
|
|
22
|
+
server = handle.server;
|
|
25
23
|
|
|
26
|
-
const res = await fetch(`http://localhost:${httpPort}/api/health`);
|
|
24
|
+
const res = await fetch(`http://localhost:${handle.httpPort}/api/health`);
|
|
27
25
|
expect(res.status).toBe(200);
|
|
28
26
|
|
|
29
27
|
const body = await res.json();
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import {
|
|
6
|
+
parseTasksMarkdown,
|
|
7
|
+
readTasks,
|
|
8
|
+
toggleTask,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
LineMismatchError,
|
|
11
|
+
NotACheckboxError,
|
|
12
|
+
} from "../openspec-tasks.js";
|
|
13
|
+
|
|
14
|
+
describe("parseTasksMarkdown", () => {
|
|
15
|
+
it("parses ticked + unticked mix and tracks groups", () => {
|
|
16
|
+
const md = [
|
|
17
|
+
"## 1. Setup",
|
|
18
|
+
"",
|
|
19
|
+
"- [ ] 1.1 Create module",
|
|
20
|
+
"- [x] 1.2 Add dep",
|
|
21
|
+
"",
|
|
22
|
+
"## 2. Tests",
|
|
23
|
+
"- [x] 2.1 Write vitest",
|
|
24
|
+
"- [ ] 2.2 Write e2e",
|
|
25
|
+
].join("\n");
|
|
26
|
+
const tasks = parseTasksMarkdown(md);
|
|
27
|
+
expect(tasks).toEqual([
|
|
28
|
+
{ id: "1.1", text: "Create module", done: false, line: 3, group: "1. Setup" },
|
|
29
|
+
{ id: "1.2", text: "Add dep", done: true, line: 4, group: "1. Setup" },
|
|
30
|
+
{ id: "2.1", text: "Write vitest", done: true, line: 7, group: "2. Tests" },
|
|
31
|
+
{ id: "2.2", text: "Write e2e", done: false, line: 8, group: "2. Tests" },
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("ignores unparseable lines without failing", () => {
|
|
36
|
+
const md = [
|
|
37
|
+
"## Misc",
|
|
38
|
+
"- foo bar",
|
|
39
|
+
"- [ ] 1.1 Valid task",
|
|
40
|
+
"random text",
|
|
41
|
+
" - [ ] 1.2 Indented, not top-level",
|
|
42
|
+
"- [x] 1.3 Another valid",
|
|
43
|
+
].join("\n");
|
|
44
|
+
const tasks = parseTasksMarkdown(md);
|
|
45
|
+
expect(tasks.map((t) => t.id)).toEqual(["1.1", "1.3"]);
|
|
46
|
+
expect(tasks.map((t) => t.done)).toEqual([false, true]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("handles capital X as done", () => {
|
|
50
|
+
const tasks = parseTasksMarkdown("## G\n- [X] 1.1 Done-uppercase");
|
|
51
|
+
expect(tasks).toHaveLength(1);
|
|
52
|
+
expect(tasks[0].done).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("handles CRLF line endings", () => {
|
|
56
|
+
const md = "## 1. G\r\n- [ ] 1.1 Test\r\n- [x] 1.2 Done\r\n";
|
|
57
|
+
const tasks = parseTasksMarkdown(md);
|
|
58
|
+
expect(tasks).toHaveLength(2);
|
|
59
|
+
expect(tasks[0].group).toBe("1. G");
|
|
60
|
+
expect(tasks[0].line).toBe(2);
|
|
61
|
+
expect(tasks[1].done).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns empty list for no tasks", () => {
|
|
65
|
+
expect(parseTasksMarkdown("# Title\n\nJust prose.\n")).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("handles tasks without a preceding heading (empty group)", () => {
|
|
69
|
+
const tasks = parseTasksMarkdown("- [ ] 1.1 Loose task");
|
|
70
|
+
expect(tasks[0].group).toBe("");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("readTasks + toggleTask (writer)", () => {
|
|
75
|
+
let tmpDir: string;
|
|
76
|
+
let changeDir: string;
|
|
77
|
+
let tasksFile: string;
|
|
78
|
+
const CWD_CHANGE = ["my-cwd-placeholder", "demo-change"] as const;
|
|
79
|
+
|
|
80
|
+
const initialMd = [
|
|
81
|
+
"## 1. Setup",
|
|
82
|
+
"",
|
|
83
|
+
"- [ ] 1.1 First task",
|
|
84
|
+
"- [x] 1.2 Second task",
|
|
85
|
+
"",
|
|
86
|
+
"## 2. Docs",
|
|
87
|
+
"- [ ] 2.1 Third task",
|
|
88
|
+
"",
|
|
89
|
+
].join("\n");
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openspec-tasks-test-"));
|
|
93
|
+
changeDir = path.join(tmpDir, "openspec", "changes", CWD_CHANGE[1]);
|
|
94
|
+
fs.mkdirSync(changeDir, { recursive: true });
|
|
95
|
+
tasksFile = path.join(changeDir, "tasks.md");
|
|
96
|
+
fs.writeFileSync(tasksFile, initialMd, "utf-8");
|
|
97
|
+
});
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("readTasks returns parsed entries", async () => {
|
|
103
|
+
const tasks = await readTasks(tmpDir, CWD_CHANGE[1]);
|
|
104
|
+
expect(tasks.map((t) => t.id)).toEqual(["1.1", "1.2", "2.1"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("readTasks throws NotFoundError when file is missing", async () => {
|
|
108
|
+
await expect(readTasks(tmpDir, "does-not-exist")).rejects.toBeInstanceOf(NotFoundError);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("toggle ticks an unticked task and preserves other lines", async () => {
|
|
112
|
+
const result = await toggleTask(tmpDir, CWD_CHANGE[1], "1.1", true, 3);
|
|
113
|
+
expect(result.done).toBe(true);
|
|
114
|
+
expect(result.id).toBe("1.1");
|
|
115
|
+
expect(result.line).toBe(3);
|
|
116
|
+
expect(result.group).toBe("1. Setup");
|
|
117
|
+
|
|
118
|
+
const after = fs.readFileSync(tasksFile, "utf-8");
|
|
119
|
+
const expected = initialMd.replace("- [ ] 1.1 First task", "- [x] 1.1 First task");
|
|
120
|
+
expect(after).toBe(expected);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("toggle unticks a ticked task", async () => {
|
|
124
|
+
const result = await toggleTask(tmpDir, CWD_CHANGE[1], "1.2", false, 4);
|
|
125
|
+
expect(result.done).toBe(false);
|
|
126
|
+
const after = fs.readFileSync(tasksFile, "utf-8");
|
|
127
|
+
expect(after).toBe(initialMd.replace("- [x] 1.2 Second task", "- [ ] 1.2 Second task"));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("toggle raises LineMismatchError when line is already in the target state", async () => {
|
|
131
|
+
// Line 4 is already done=true; requesting done=true again → mismatch
|
|
132
|
+
await expect(toggleTask(tmpDir, CWD_CHANGE[1], "1.2", true, 4)).rejects.toBeInstanceOf(
|
|
133
|
+
LineMismatchError,
|
|
134
|
+
);
|
|
135
|
+
// File untouched
|
|
136
|
+
expect(fs.readFileSync(tasksFile, "utf-8")).toBe(initialMd);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("toggle raises LineMismatchError when id does not match target line", async () => {
|
|
140
|
+
await expect(toggleTask(tmpDir, CWD_CHANGE[1], "9.9", true, 3)).rejects.toBeInstanceOf(
|
|
141
|
+
LineMismatchError,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("toggle raises LineMismatchError for out-of-range line", async () => {
|
|
146
|
+
await expect(toggleTask(tmpDir, CWD_CHANGE[1], "1.1", true, 9999)).rejects.toBeInstanceOf(
|
|
147
|
+
LineMismatchError,
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("toggle raises NotACheckboxError when target line is a heading", async () => {
|
|
152
|
+
// Line 1 is "## 1. Setup"
|
|
153
|
+
await expect(toggleTask(tmpDir, CWD_CHANGE[1], "1.1", true, 1)).rejects.toBeInstanceOf(
|
|
154
|
+
NotACheckboxError,
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("toggle raises NotFoundError when file is absent", async () => {
|
|
159
|
+
await expect(toggleTask(tmpDir, "missing", "1.1", true, 3)).rejects.toBeInstanceOf(
|
|
160
|
+
NotFoundError,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("toggle writes atomically (no .tmp left behind)", async () => {
|
|
165
|
+
await toggleTask(tmpDir, CWD_CHANGE[1], "1.1", true, 3);
|
|
166
|
+
const files = fs.readdirSync(changeDir);
|
|
167
|
+
expect(files.some((f) => f.endsWith(".tmp"))).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("toggle preserves byte-for-byte all other lines", async () => {
|
|
171
|
+
const weirdMd =
|
|
172
|
+
"# Title line\n\n## 1. Group\n- [ ] 1.1 Task one\n> quote line\n\n indented code\n- [x] 1.2 Task two\n";
|
|
173
|
+
fs.writeFileSync(tasksFile, weirdMd, "utf-8");
|
|
174
|
+
await toggleTask(tmpDir, CWD_CHANGE[1], "1.1", true, 4);
|
|
175
|
+
const after = fs.readFileSync(tasksFile, "utf-8");
|
|
176
|
+
expect(after).toBe(weirdMd.replace("- [ ] 1.1 Task one", "- [x] 1.1 Task one"));
|
|
177
|
+
});
|
|
178
|
+
});
|