@blackbelt-technology/pi-agent-dashboard 0.2.9 → 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,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP-level tests for the openspec task list / toggle endpoints.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
5
|
+
import Fastify, { type FastifyInstance } from "fastify";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { registerOpenSpecRoutes } from "../routes/openspec-routes.js";
|
|
10
|
+
|
|
11
|
+
const PASSTHRU_GUARD = async () => {};
|
|
12
|
+
|
|
13
|
+
// Simulates a non-loopback guard that 403s every request.
|
|
14
|
+
const DENY_GUARD = async (_req: any, reply: any) => {
|
|
15
|
+
reply.code(403).send({ success: false, error: "forbidden" });
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function makeDirectoryService(): any {
|
|
19
|
+
return {
|
|
20
|
+
refreshOpenSpec: vi.fn(async () => ({ initialized: true, changes: [] })),
|
|
21
|
+
getOpenSpecData: vi.fn(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("openspec tasks REST routes", () => {
|
|
26
|
+
let tmpDir: string;
|
|
27
|
+
let changeName: string;
|
|
28
|
+
let tasksFile: string;
|
|
29
|
+
let fastify: FastifyInstance;
|
|
30
|
+
|
|
31
|
+
const initialMd = [
|
|
32
|
+
"## 1. Setup",
|
|
33
|
+
"",
|
|
34
|
+
"- [ ] 1.1 First task",
|
|
35
|
+
"- [x] 1.2 Second task",
|
|
36
|
+
"",
|
|
37
|
+
"## 2. Docs",
|
|
38
|
+
"- [ ] 2.1 Third task",
|
|
39
|
+
"",
|
|
40
|
+
].join("\n");
|
|
41
|
+
|
|
42
|
+
beforeEach(async () => {
|
|
43
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openspec-routes-test-"));
|
|
44
|
+
changeName = "demo-change";
|
|
45
|
+
const dir = path.join(tmpDir, "openspec", "changes", changeName);
|
|
46
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
47
|
+
tasksFile = path.join(dir, "tasks.md");
|
|
48
|
+
fs.writeFileSync(tasksFile, initialMd, "utf-8");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
if (fastify) await fastify.close();
|
|
53
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
async function setup(opts: {
|
|
57
|
+
networkGuard?: any;
|
|
58
|
+
onOpenSpecChanged?: (cwd: string) => void;
|
|
59
|
+
} = {}) {
|
|
60
|
+
fastify = Fastify();
|
|
61
|
+
const directoryService = makeDirectoryService();
|
|
62
|
+
registerOpenSpecRoutes(fastify, {
|
|
63
|
+
sessionManager: { listAll: () => [] } as any,
|
|
64
|
+
preferencesStore: { getPinnedDirectories: () => [] } as any,
|
|
65
|
+
directoryService,
|
|
66
|
+
networkGuard: opts.networkGuard ?? PASSTHRU_GUARD,
|
|
67
|
+
onOpenSpecChanged: opts.onOpenSpecChanged,
|
|
68
|
+
});
|
|
69
|
+
await fastify.ready();
|
|
70
|
+
return { directoryService };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
it("GET /api/openspec/tasks → 200 with parsed tasks + groups", async () => {
|
|
74
|
+
await setup();
|
|
75
|
+
const res = await fastify.inject({
|
|
76
|
+
method: "GET",
|
|
77
|
+
url: `/api/openspec/tasks?cwd=${encodeURIComponent(tmpDir)}&change=${changeName}`,
|
|
78
|
+
});
|
|
79
|
+
expect(res.statusCode).toBe(200);
|
|
80
|
+
const body = JSON.parse(res.payload);
|
|
81
|
+
expect(body.success).toBe(true);
|
|
82
|
+
expect(body.data.tasks.map((t: any) => t.id)).toEqual(["1.1", "1.2", "2.1"]);
|
|
83
|
+
expect(body.data.groups).toEqual(["1. Setup", "2. Docs"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("GET /api/openspec/tasks → 400 when required query missing", async () => {
|
|
87
|
+
await setup();
|
|
88
|
+
const res = await fastify.inject({ method: "GET", url: "/api/openspec/tasks" });
|
|
89
|
+
expect(res.statusCode).toBe(400);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("GET /api/openspec/tasks → 404 when tasks.md missing", async () => {
|
|
93
|
+
await setup();
|
|
94
|
+
const res = await fastify.inject({
|
|
95
|
+
method: "GET",
|
|
96
|
+
url: `/api/openspec/tasks?cwd=${encodeURIComponent(tmpDir)}&change=does-not-exist`,
|
|
97
|
+
});
|
|
98
|
+
expect(res.statusCode).toBe(404);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("GET /api/openspec/tasks → 403 when network guard denies", async () => {
|
|
102
|
+
await setup({ networkGuard: DENY_GUARD });
|
|
103
|
+
const res = await fastify.inject({
|
|
104
|
+
method: "GET",
|
|
105
|
+
url: `/api/openspec/tasks?cwd=${encodeURIComponent(tmpDir)}&change=${changeName}`,
|
|
106
|
+
});
|
|
107
|
+
expect(res.statusCode).toBe(403);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("POST toggle → 200 ticks task, rewrites file, and triggers broadcast", async () => {
|
|
111
|
+
const onOpenSpecChanged = vi.fn();
|
|
112
|
+
const { directoryService } = await setup({ onOpenSpecChanged });
|
|
113
|
+
const res = await fastify.inject({
|
|
114
|
+
method: "POST",
|
|
115
|
+
url: "/api/openspec/tasks/toggle",
|
|
116
|
+
payload: { cwd: tmpDir, change: changeName, id: "1.1", done: true, line: 3 },
|
|
117
|
+
});
|
|
118
|
+
expect(res.statusCode).toBe(200);
|
|
119
|
+
const body = JSON.parse(res.payload);
|
|
120
|
+
expect(body.data.task.done).toBe(true);
|
|
121
|
+
const after = fs.readFileSync(tasksFile, "utf-8");
|
|
122
|
+
expect(after).toContain("- [x] 1.1 First task");
|
|
123
|
+
// Allow the fire-and-forget refresh promise to resolve
|
|
124
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
125
|
+
expect(directoryService.refreshOpenSpec).toHaveBeenCalledWith(tmpDir);
|
|
126
|
+
expect(onOpenSpecChanged).toHaveBeenCalledWith(tmpDir);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("POST toggle → 409 on line mismatch (line already in target state)", async () => {
|
|
130
|
+
await setup();
|
|
131
|
+
const res = await fastify.inject({
|
|
132
|
+
method: "POST",
|
|
133
|
+
url: "/api/openspec/tasks/toggle",
|
|
134
|
+
payload: { cwd: tmpDir, change: changeName, id: "1.2", done: true, line: 4 },
|
|
135
|
+
});
|
|
136
|
+
expect(res.statusCode).toBe(409);
|
|
137
|
+
// File untouched
|
|
138
|
+
expect(fs.readFileSync(tasksFile, "utf-8")).toBe(initialMd);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("POST toggle → 400 when target line is not a checkbox", async () => {
|
|
142
|
+
await setup();
|
|
143
|
+
const res = await fastify.inject({
|
|
144
|
+
method: "POST",
|
|
145
|
+
url: "/api/openspec/tasks/toggle",
|
|
146
|
+
payload: { cwd: tmpDir, change: changeName, id: "1.1", done: true, line: 1 },
|
|
147
|
+
});
|
|
148
|
+
expect(res.statusCode).toBe(400);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("POST toggle → 400 on malformed body", async () => {
|
|
152
|
+
await setup();
|
|
153
|
+
const res = await fastify.inject({
|
|
154
|
+
method: "POST",
|
|
155
|
+
url: "/api/openspec/tasks/toggle",
|
|
156
|
+
payload: { cwd: tmpDir },
|
|
157
|
+
});
|
|
158
|
+
expect(res.statusCode).toBe(400);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("POST toggle → 404 when tasks.md missing", async () => {
|
|
162
|
+
await setup();
|
|
163
|
+
const res = await fastify.inject({
|
|
164
|
+
method: "POST",
|
|
165
|
+
url: "/api/openspec/tasks/toggle",
|
|
166
|
+
payload: { cwd: tmpDir, change: "missing", id: "1.1", done: true, line: 3 },
|
|
167
|
+
});
|
|
168
|
+
expect(res.statusCode).toBe(404);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("POST toggle → 403 when network guard denies", async () => {
|
|
172
|
+
await setup({ networkGuard: DENY_GUARD });
|
|
173
|
+
const res = await fastify.inject({
|
|
174
|
+
method: "POST",
|
|
175
|
+
url: "/api/openspec/tasks/toggle",
|
|
176
|
+
payload: { cwd: tmpDir, change: changeName, id: "1.1", done: true, line: 3 },
|
|
177
|
+
});
|
|
178
|
+
expect(res.statusCode).toBe(403);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for loadPiPackageManager() resolution chain in package-manager-wrapper.ts.
|
|
3
|
+
*
|
|
4
|
+
* Separate from package-manager-wrapper.test.ts because that file mocks
|
|
5
|
+
* "@mariozechner/pi-coding-agent" so direct-import succeeds and the
|
|
6
|
+
* fallback paths never execute.
|
|
7
|
+
*
|
|
8
|
+
* These tests exercise the managed-install and global-npm fallbacks.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
|
|
15
|
+
// Force the direct import to fail so resolution falls through to the
|
|
16
|
+
// managed-install / global-npm paths. vi.mock is hoisted; the factory
|
|
17
|
+
// throws at import time which mimics pi not being an installed dependency.
|
|
18
|
+
vi.mock("@mariozechner/pi-coding-agent", () => {
|
|
19
|
+
throw new Error("not installed as direct dependency");
|
|
20
|
+
});
|
|
21
|
+
vi.mock("@oh-my-pi/pi-coding-agent", () => {
|
|
22
|
+
throw new Error("not installed as direct dependency");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Override os.homedir() by setting the env vars libuv reads. */
|
|
26
|
+
function withHome(tmpHome: string): () => void {
|
|
27
|
+
const prev = {
|
|
28
|
+
HOME: process.env.HOME,
|
|
29
|
+
USERPROFILE: process.env.USERPROFILE,
|
|
30
|
+
};
|
|
31
|
+
process.env.HOME = tmpHome;
|
|
32
|
+
process.env.USERPROFILE = tmpHome;
|
|
33
|
+
return () => {
|
|
34
|
+
if (prev.HOME === undefined) delete process.env.HOME; else process.env.HOME = prev.HOME;
|
|
35
|
+
if (prev.USERPROFILE === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = prev.USERPROFILE;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("loadPiPackageManager resolution chain", () => {
|
|
40
|
+
const cleanupPaths: string[] = [];
|
|
41
|
+
const restoreFns: Array<() => void> = [];
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
for (const r of restoreFns) r();
|
|
45
|
+
restoreFns.length = 0;
|
|
46
|
+
vi.restoreAllMocks();
|
|
47
|
+
vi.resetModules();
|
|
48
|
+
for (const p of cleanupPaths) {
|
|
49
|
+
try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
50
|
+
}
|
|
51
|
+
cleanupPaths.length = 0;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("resolves pi from managed install at ~/.pi-dashboard/node_modules/ when direct import fails", async () => {
|
|
55
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-dash-home-managed-"));
|
|
56
|
+
cleanupPaths.push(tmpHome);
|
|
57
|
+
|
|
58
|
+
// Create a fake managed pi install with a real ESM entry file
|
|
59
|
+
const fakeDistDir = path.join(
|
|
60
|
+
tmpHome,
|
|
61
|
+
".pi-dashboard",
|
|
62
|
+
"node_modules",
|
|
63
|
+
"@mariozechner",
|
|
64
|
+
"pi-coding-agent",
|
|
65
|
+
"dist",
|
|
66
|
+
);
|
|
67
|
+
fs.mkdirSync(fakeDistDir, { recursive: true });
|
|
68
|
+
fs.writeFileSync(
|
|
69
|
+
path.join(fakeDistDir, "index.js"),
|
|
70
|
+
[
|
|
71
|
+
"export function DefaultPackageManager() {",
|
|
72
|
+
" return {",
|
|
73
|
+
" listConfiguredPackages: () => [{ source: 'npm:from-managed', scope: 'user', filtered: false }],",
|
|
74
|
+
" };",
|
|
75
|
+
"}",
|
|
76
|
+
"export const SettingsManager = { create: () => ({}) };",
|
|
77
|
+
].join("\n"),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
restoreFns.push(withHome(tmpHome));
|
|
81
|
+
expect(os.homedir()).toBe(tmpHome);
|
|
82
|
+
vi.resetModules();
|
|
83
|
+
|
|
84
|
+
const { PackageManagerWrapper } = await import("../package-manager-wrapper.js");
|
|
85
|
+
const wrapper = new PackageManagerWrapper();
|
|
86
|
+
const result = await wrapper.listInstalled("global");
|
|
87
|
+
|
|
88
|
+
expect(result).toEqual([
|
|
89
|
+
{ source: "npm:from-managed", scope: "user", filtered: false },
|
|
90
|
+
]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("falls through to global npm without crashing when managed install is absent", async () => {
|
|
94
|
+
// tmp home with NO ~/.pi-dashboard directory -> managed resolution must
|
|
95
|
+
// silently fail and continue to the global-npm path.
|
|
96
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-dash-home-empty-"));
|
|
97
|
+
cleanupPaths.push(tmpHome);
|
|
98
|
+
|
|
99
|
+
restoreFns.push(withHome(tmpHome));
|
|
100
|
+
expect(os.homedir()).toBe(tmpHome);
|
|
101
|
+
|
|
102
|
+
// Stub execSync so `npm root -g` returns a directory where pi is also
|
|
103
|
+
// absent. With direct-import + managed + global all missing, the
|
|
104
|
+
// function must surface the final "pi-coding-agent is not installed"
|
|
105
|
+
// error — proving the managed block didn't throw early.
|
|
106
|
+
vi.doMock("node:child_process", async (importOriginal) => {
|
|
107
|
+
const actual = await importOriginal<typeof import("node:child_process")>();
|
|
108
|
+
return {
|
|
109
|
+
...actual,
|
|
110
|
+
execSync: vi.fn(() => tmpHome), // no pi inside tmpHome
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
vi.resetModules();
|
|
115
|
+
const { PackageManagerWrapper } = await import("../package-manager-wrapper.js");
|
|
116
|
+
const wrapper = new PackageManagerWrapper();
|
|
117
|
+
|
|
118
|
+
await expect(wrapper.listInstalled("global")).rejects.toThrow(
|
|
119
|
+
/pi-coding-agent is not installed/,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { PiCoreChecker, CORE_PACKAGE_NAMES, _internal } from "../pi-core-checker.js";
|
|
6
|
+
|
|
7
|
+
describe("PiCoreChecker._internal.looksLikePiEcosystem", () => {
|
|
8
|
+
it("matches known core packages", () => {
|
|
9
|
+
for (const name of CORE_PACKAGE_NAMES) {
|
|
10
|
+
expect(_internal.looksLikePiEcosystem(name)).toBe(true);
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("matches bare pi- packages", () => {
|
|
15
|
+
expect(_internal.looksLikePiEcosystem("pi-web-access")).toBe(true);
|
|
16
|
+
expect(_internal.looksLikePiEcosystem("pi-agent-browser")).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("matches scoped pi- packages", () => {
|
|
20
|
+
expect(_internal.looksLikePiEcosystem("@tintinweb/pi-subagents")).toBe(true);
|
|
21
|
+
expect(_internal.looksLikePiEcosystem("@benvargas/pi-claude-code-use")).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("rejects non-pi packages", () => {
|
|
25
|
+
expect(_internal.looksLikePiEcosystem("react")).toBe(false);
|
|
26
|
+
expect(_internal.looksLikePiEcosystem("@types/node")).toBe(false);
|
|
27
|
+
expect(_internal.looksLikePiEcosystem("piano")).toBe(false);
|
|
28
|
+
expect(_internal.looksLikePiEcosystem("@scope/notpi")).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("PiCoreChecker.getStatus", () => {
|
|
33
|
+
let tmpManagedDir: string;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
tmpManagedDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-core-test-"));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function writeManagedPackage(managedDir: string, name: string, version: string) {
|
|
40
|
+
const dir = path.join(managedDir, "node_modules", name);
|
|
41
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
42
|
+
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name, version }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
it("discovers global pi packages via npm list", async () => {
|
|
46
|
+
const checker = new PiCoreChecker({
|
|
47
|
+
npmList: async () =>
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
dependencies: {
|
|
50
|
+
"@mariozechner/pi-coding-agent": { version: "0.67.1" },
|
|
51
|
+
"pi-web-access": { version: "0.10.6" },
|
|
52
|
+
react: { version: "19.0.0" }, // must be ignored
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
fetchLatest: async (name) => {
|
|
56
|
+
if (name === "@mariozechner/pi-coding-agent") return "0.67.6";
|
|
57
|
+
if (name === "pi-web-access") return "0.10.6";
|
|
58
|
+
return null;
|
|
59
|
+
},
|
|
60
|
+
managedDir: tmpManagedDir,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const status = await checker.getStatus();
|
|
64
|
+
|
|
65
|
+
expect(status.packages.length).toBe(2);
|
|
66
|
+
const pi = status.packages.find((p) => p.name === "@mariozechner/pi-coding-agent")!;
|
|
67
|
+
expect(pi.displayName).toBe("pi (core agent)");
|
|
68
|
+
expect(pi.currentVersion).toBe("0.67.1");
|
|
69
|
+
expect(pi.latestVersion).toBe("0.67.6");
|
|
70
|
+
expect(pi.updateAvailable).toBe(true);
|
|
71
|
+
expect(pi.installSource).toBe("global");
|
|
72
|
+
|
|
73
|
+
const web = status.packages.find((p) => p.name === "pi-web-access")!;
|
|
74
|
+
expect(web.displayName).toBe("pi-web-access");
|
|
75
|
+
expect(web.updateAvailable).toBe(false);
|
|
76
|
+
expect(web.installSource).toBe("global");
|
|
77
|
+
|
|
78
|
+
expect(status.updatesAvailable).toBe(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("discovers managed packages and prefers them over global duplicates", async () => {
|
|
82
|
+
writeManagedPackage(tmpManagedDir, "@mariozechner/pi-coding-agent", "0.67.5");
|
|
83
|
+
|
|
84
|
+
const checker = new PiCoreChecker({
|
|
85
|
+
npmList: async () =>
|
|
86
|
+
JSON.stringify({
|
|
87
|
+
dependencies: {
|
|
88
|
+
"@mariozechner/pi-coding-agent": { version: "0.67.1" },
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
fetchLatest: async () => "0.67.6",
|
|
92
|
+
managedDir: tmpManagedDir,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const status = await checker.getStatus();
|
|
96
|
+
expect(status.packages.length).toBe(1);
|
|
97
|
+
expect(status.packages[0].currentVersion).toBe("0.67.5");
|
|
98
|
+
expect(status.packages[0].installSource).toBe("managed");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("returns empty list when managed dir missing and npm list fails", async () => {
|
|
102
|
+
const checker = new PiCoreChecker({
|
|
103
|
+
npmList: async () => {
|
|
104
|
+
throw new Error("npm not found");
|
|
105
|
+
},
|
|
106
|
+
fetchLatest: async () => null,
|
|
107
|
+
managedDir: path.join(tmpManagedDir, "nonexistent"),
|
|
108
|
+
});
|
|
109
|
+
const status = await checker.getStatus();
|
|
110
|
+
expect(status.packages).toEqual([]);
|
|
111
|
+
expect(status.updatesAvailable).toBe(0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("tolerates non-zero npm list exit when stdout contains valid JSON", async () => {
|
|
115
|
+
const checker = new PiCoreChecker({
|
|
116
|
+
npmList: async () => {
|
|
117
|
+
const err = new Error("npm warn") as Error & { stdout: string };
|
|
118
|
+
err.stdout = JSON.stringify({
|
|
119
|
+
dependencies: { "pi-web-access": { version: "0.10.6" } },
|
|
120
|
+
});
|
|
121
|
+
throw err;
|
|
122
|
+
},
|
|
123
|
+
fetchLatest: async () => "0.10.6",
|
|
124
|
+
managedDir: path.join(tmpManagedDir, "nope"),
|
|
125
|
+
});
|
|
126
|
+
const status = await checker.getStatus();
|
|
127
|
+
expect(status.packages.length).toBe(1);
|
|
128
|
+
expect(status.packages[0].name).toBe("pi-web-access");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("caches results within 5 minutes", async () => {
|
|
132
|
+
let calls = 0;
|
|
133
|
+
const checker = new PiCoreChecker({
|
|
134
|
+
npmList: async () => {
|
|
135
|
+
calls++;
|
|
136
|
+
return JSON.stringify({ dependencies: { "pi-web-access": { version: "0.10.6" } } });
|
|
137
|
+
},
|
|
138
|
+
fetchLatest: async () => "0.10.6",
|
|
139
|
+
managedDir: path.join(tmpManagedDir, "nope"),
|
|
140
|
+
});
|
|
141
|
+
await checker.getStatus();
|
|
142
|
+
await checker.getStatus();
|
|
143
|
+
expect(calls).toBe(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("force-refresh invalidates the cache", async () => {
|
|
147
|
+
let calls = 0;
|
|
148
|
+
const checker = new PiCoreChecker({
|
|
149
|
+
npmList: async () => {
|
|
150
|
+
calls++;
|
|
151
|
+
return JSON.stringify({ dependencies: { "pi-web-access": { version: "0.10.6" } } });
|
|
152
|
+
},
|
|
153
|
+
fetchLatest: async () => "0.10.6",
|
|
154
|
+
managedDir: path.join(tmpManagedDir, "nope"),
|
|
155
|
+
});
|
|
156
|
+
await checker.getStatus();
|
|
157
|
+
await checker.getStatus(true);
|
|
158
|
+
expect(calls).toBe(2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("treats fetch failure as latestVersion=null, updateAvailable=false", async () => {
|
|
162
|
+
const checker = new PiCoreChecker({
|
|
163
|
+
npmList: async () =>
|
|
164
|
+
JSON.stringify({ dependencies: { "pi-web-access": { version: "0.10.6" } } }),
|
|
165
|
+
fetchLatest: async () => {
|
|
166
|
+
throw new Error("network down");
|
|
167
|
+
},
|
|
168
|
+
managedDir: path.join(tmpManagedDir, "nope"),
|
|
169
|
+
});
|
|
170
|
+
const status = await checker.getStatus();
|
|
171
|
+
expect(status.packages.length).toBe(1);
|
|
172
|
+
expect(status.packages[0].latestVersion).toBeNull();
|
|
173
|
+
expect(status.packages[0].updateAvailable).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("sorts known core packages first", async () => {
|
|
177
|
+
const checker = new PiCoreChecker({
|
|
178
|
+
npmList: async () =>
|
|
179
|
+
JSON.stringify({
|
|
180
|
+
dependencies: {
|
|
181
|
+
"pi-web-access": { version: "0.10.6" },
|
|
182
|
+
"@mariozechner/pi-coding-agent": { version: "0.67.1" },
|
|
183
|
+
"pi-agent-browser": { version: "0.1.0" },
|
|
184
|
+
},
|
|
185
|
+
}),
|
|
186
|
+
fetchLatest: async () => null,
|
|
187
|
+
managedDir: path.join(tmpManagedDir, "nope"),
|
|
188
|
+
});
|
|
189
|
+
const status = await checker.getStatus();
|
|
190
|
+
expect(status.packages[0].name).toBe("@mariozechner/pi-coding-agent");
|
|
191
|
+
// remaining are alphabetical
|
|
192
|
+
expect(status.packages[1].name).toBe("pi-agent-browser");
|
|
193
|
+
expect(status.packages[2].name).toBe("pi-web-access");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import Fastify, { type FastifyInstance } from "fastify";
|
|
3
|
+
import { registerPiCoreRoutes } from "../routes/pi-core-routes.js";
|
|
4
|
+
import { PackageOperationBusyError } from "../package-manager-wrapper.js";
|
|
5
|
+
import type { PiCoreStatus, PiCorePackage } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
|
|
6
|
+
|
|
7
|
+
function makePkg(name: string, updateAvailable = false): PiCorePackage {
|
|
8
|
+
return {
|
|
9
|
+
name,
|
|
10
|
+
displayName: name,
|
|
11
|
+
currentVersion: "0.1.0",
|
|
12
|
+
latestVersion: updateAvailable ? "0.2.0" : "0.1.0",
|
|
13
|
+
updateAvailable,
|
|
14
|
+
installSource: "global",
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("pi-core-routes", () => {
|
|
19
|
+
let app: FastifyInstance;
|
|
20
|
+
let checker: any;
|
|
21
|
+
let updater: any;
|
|
22
|
+
let onUpdateComplete: any;
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
checker = {
|
|
26
|
+
getStatus: vi.fn<(refresh?: boolean) => Promise<PiCoreStatus>>(),
|
|
27
|
+
invalidate: vi.fn(),
|
|
28
|
+
};
|
|
29
|
+
updater = {
|
|
30
|
+
update: vi.fn(),
|
|
31
|
+
};
|
|
32
|
+
onUpdateComplete = vi.fn();
|
|
33
|
+
app = Fastify({ logger: false });
|
|
34
|
+
registerPiCoreRoutes(app, {
|
|
35
|
+
piCoreChecker: checker,
|
|
36
|
+
piCoreUpdater: updater,
|
|
37
|
+
onUpdateComplete,
|
|
38
|
+
});
|
|
39
|
+
await app.ready();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
await app.close();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("GET /api/pi-core/versions returns cached status", async () => {
|
|
47
|
+
const status: PiCoreStatus = {
|
|
48
|
+
packages: [makePkg("pi-web-access")],
|
|
49
|
+
updatesAvailable: 0,
|
|
50
|
+
lastChecked: new Date().toISOString(),
|
|
51
|
+
};
|
|
52
|
+
checker.getStatus.mockResolvedValue(status);
|
|
53
|
+
|
|
54
|
+
const res = await app.inject({ method: "GET", url: "/api/pi-core/versions" });
|
|
55
|
+
expect(res.statusCode).toBe(200);
|
|
56
|
+
const body = res.json() as any;
|
|
57
|
+
expect(body.success).toBe(true);
|
|
58
|
+
expect(body.data).toEqual(status);
|
|
59
|
+
expect(checker.getStatus).toHaveBeenCalledWith(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("GET /api/pi-core/versions?refresh=true forces refresh", async () => {
|
|
63
|
+
checker.getStatus.mockResolvedValue({
|
|
64
|
+
packages: [],
|
|
65
|
+
updatesAvailable: 0,
|
|
66
|
+
lastChecked: new Date().toISOString(),
|
|
67
|
+
});
|
|
68
|
+
const res = await app.inject({ method: "GET", url: "/api/pi-core/versions?refresh=true" });
|
|
69
|
+
expect(res.statusCode).toBe(200);
|
|
70
|
+
expect(checker.getStatus).toHaveBeenCalledWith(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("POST /api/pi-core/update with empty body updates all packages with updateAvailable", async () => {
|
|
74
|
+
checker.getStatus.mockResolvedValue({
|
|
75
|
+
packages: [makePkg("pi-web-access", true), makePkg("pi-foo", false)],
|
|
76
|
+
updatesAvailable: 1,
|
|
77
|
+
lastChecked: new Date().toISOString(),
|
|
78
|
+
});
|
|
79
|
+
updater.update.mockResolvedValue({
|
|
80
|
+
results: [{ name: "pi-web-access", success: true }],
|
|
81
|
+
sessionsReloaded: 2,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const res = await app.inject({ method: "POST", url: "/api/pi-core/update", payload: {} });
|
|
85
|
+
expect(res.statusCode).toBe(200);
|
|
86
|
+
const body = res.json() as any;
|
|
87
|
+
expect(body.success).toBe(true);
|
|
88
|
+
expect(body.data.results).toHaveLength(1);
|
|
89
|
+
expect(body.data.sessionsReloaded).toBe(2);
|
|
90
|
+
|
|
91
|
+
// Only updates the one with updateAvailable
|
|
92
|
+
expect(updater.update).toHaveBeenCalledTimes(1);
|
|
93
|
+
const arg = updater.update.mock.calls[0][0];
|
|
94
|
+
expect(arg).toHaveLength(1);
|
|
95
|
+
expect(arg[0].name).toBe("pi-web-access");
|
|
96
|
+
|
|
97
|
+
// Cache invalidated so next status reflects new versions
|
|
98
|
+
expect(checker.invalidate).toHaveBeenCalled();
|
|
99
|
+
|
|
100
|
+
// onUpdateComplete called so server can broadcast to browsers (badge refetch)
|
|
101
|
+
expect(onUpdateComplete).toHaveBeenCalledTimes(1);
|
|
102
|
+
expect(onUpdateComplete).toHaveBeenCalledWith({
|
|
103
|
+
results: [{ name: "pi-web-access", success: true }],
|
|
104
|
+
sessionsReloaded: 2,
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("POST /api/pi-core/update does not call onUpdateComplete on busy-error", async () => {
|
|
109
|
+
checker.getStatus.mockResolvedValue({
|
|
110
|
+
packages: [makePkg("pi-web-access", true)],
|
|
111
|
+
updatesAvailable: 1,
|
|
112
|
+
lastChecked: new Date().toISOString(),
|
|
113
|
+
});
|
|
114
|
+
updater.update.mockRejectedValue(new PackageOperationBusyError());
|
|
115
|
+
|
|
116
|
+
const res = await app.inject({ method: "POST", url: "/api/pi-core/update", payload: {} });
|
|
117
|
+
expect(res.statusCode).toBe(409);
|
|
118
|
+
expect(onUpdateComplete).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("POST /api/pi-core/update with specific packages filters to those", async () => {
|
|
122
|
+
checker.getStatus.mockResolvedValue({
|
|
123
|
+
packages: [makePkg("pi-web-access", true), makePkg("pi-foo", true)],
|
|
124
|
+
updatesAvailable: 2,
|
|
125
|
+
lastChecked: new Date().toISOString(),
|
|
126
|
+
});
|
|
127
|
+
updater.update.mockResolvedValue({ results: [{ name: "pi-foo", success: true }], sessionsReloaded: 0 });
|
|
128
|
+
|
|
129
|
+
const res = await app.inject({
|
|
130
|
+
method: "POST",
|
|
131
|
+
url: "/api/pi-core/update",
|
|
132
|
+
payload: { packages: ["pi-foo"] },
|
|
133
|
+
});
|
|
134
|
+
expect(res.statusCode).toBe(200);
|
|
135
|
+
expect(updater.update.mock.calls[0][0].map((p: PiCorePackage) => p.name)).toEqual(["pi-foo"]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("POST /api/pi-core/update rejects unknown packages with 400", async () => {
|
|
139
|
+
checker.getStatus.mockResolvedValue({
|
|
140
|
+
packages: [makePkg("pi-web-access", true)],
|
|
141
|
+
updatesAvailable: 1,
|
|
142
|
+
lastChecked: new Date().toISOString(),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const res = await app.inject({
|
|
146
|
+
method: "POST",
|
|
147
|
+
url: "/api/pi-core/update",
|
|
148
|
+
payload: { packages: ["not-a-real-package"] },
|
|
149
|
+
});
|
|
150
|
+
expect(res.statusCode).toBe(400);
|
|
151
|
+
const body = res.json() as any;
|
|
152
|
+
expect(body.success).toBe(false);
|
|
153
|
+
expect(body.error).toMatch(/not-a-real-package/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("POST /api/pi-core/update returns 409 when busy", async () => {
|
|
157
|
+
checker.getStatus.mockResolvedValue({
|
|
158
|
+
packages: [makePkg("pi-web-access", true)],
|
|
159
|
+
updatesAvailable: 1,
|
|
160
|
+
lastChecked: new Date().toISOString(),
|
|
161
|
+
});
|
|
162
|
+
updater.update.mockRejectedValue(new PackageOperationBusyError());
|
|
163
|
+
|
|
164
|
+
const res = await app.inject({ method: "POST", url: "/api/pi-core/update", payload: {} });
|
|
165
|
+
expect(res.statusCode).toBe(409);
|
|
166
|
+
const body = res.json() as any;
|
|
167
|
+
expect(body.success).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("POST /api/pi-core/update returns empty result when nothing to update", async () => {
|
|
171
|
+
checker.getStatus.mockResolvedValue({
|
|
172
|
+
packages: [makePkg("pi-web-access", false)],
|
|
173
|
+
updatesAvailable: 0,
|
|
174
|
+
lastChecked: new Date().toISOString(),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const res = await app.inject({ method: "POST", url: "/api/pi-core/update", payload: {} });
|
|
178
|
+
expect(res.statusCode).toBe(200);
|
|
179
|
+
const body = res.json() as any;
|
|
180
|
+
expect(body.data.results).toEqual([]);
|
|
181
|
+
expect(body.data.sessionsReloaded).toBe(0);
|
|
182
|
+
expect(updater.update).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
});
|