@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,214 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { PiCoreUpdater } from "../pi-core-updater.js";
|
|
3
|
+
import {
|
|
4
|
+
PackageManagerWrapper,
|
|
5
|
+
PackageOperationBusyError,
|
|
6
|
+
} from "../package-manager-wrapper.js";
|
|
7
|
+
import type { PiCorePackage } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
|
|
8
|
+
|
|
9
|
+
// Pi PM is mocked in other tests via vi.mock; we don't need it here because
|
|
10
|
+
// we never call the install/remove/update methods — only runExclusive().
|
|
11
|
+
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
|
12
|
+
DefaultPackageManager: function () {
|
|
13
|
+
return {};
|
|
14
|
+
},
|
|
15
|
+
SettingsManager: { create: () => ({}) },
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
function pkg(name: string, source: "global" | "managed" = "global"): PiCorePackage {
|
|
19
|
+
return {
|
|
20
|
+
name,
|
|
21
|
+
displayName: name,
|
|
22
|
+
currentVersion: "0.1.0",
|
|
23
|
+
latestVersion: "0.2.0",
|
|
24
|
+
updateAvailable: true,
|
|
25
|
+
installSource: source,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("PiCoreUpdater", () => {
|
|
30
|
+
let wrapper: PackageManagerWrapper;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
wrapper = new PackageManagerWrapper();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("updates packages sequentially and emits start/output/complete events", async () => {
|
|
37
|
+
const events: Array<{ name: string; phase: string; message?: string }> = [];
|
|
38
|
+
const updater = new PiCoreUpdater({
|
|
39
|
+
packageManagerWrapper: wrapper,
|
|
40
|
+
runNpmUpdate: async (p, onOutput) => {
|
|
41
|
+
onOutput("added 1 package");
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
updater.setProgressListener((e) => events.push(e));
|
|
45
|
+
|
|
46
|
+
const out = await updater.update([pkg("pi-foo"), pkg("pi-bar")]);
|
|
47
|
+
|
|
48
|
+
expect(out.results).toEqual([
|
|
49
|
+
{ name: "pi-foo", success: true },
|
|
50
|
+
{ name: "pi-bar", success: true },
|
|
51
|
+
]);
|
|
52
|
+
// Per-package: start, output, complete
|
|
53
|
+
const phases = events.map((e) => `${e.name}:${e.phase}`);
|
|
54
|
+
expect(phases).toEqual([
|
|
55
|
+
"pi-foo:start",
|
|
56
|
+
"pi-foo:output",
|
|
57
|
+
"pi-foo:complete",
|
|
58
|
+
"pi-bar:start",
|
|
59
|
+
"pi-bar:output",
|
|
60
|
+
"pi-bar:complete",
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("continues after a failure and reports per-package errors", async () => {
|
|
65
|
+
const updater = new PiCoreUpdater({
|
|
66
|
+
packageManagerWrapper: wrapper,
|
|
67
|
+
runNpmUpdate: async (p) => {
|
|
68
|
+
if (p.name === "pi-bad") throw new Error("npm update exited with code 1");
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
const events: Array<{ phase: string }> = [];
|
|
72
|
+
updater.setProgressListener((e) => events.push({ phase: e.phase }));
|
|
73
|
+
|
|
74
|
+
const out = await updater.update([pkg("pi-bad"), pkg("pi-good")]);
|
|
75
|
+
|
|
76
|
+
expect(out.results).toEqual([
|
|
77
|
+
{ name: "pi-bad", success: false, error: "npm update exited with code 1" },
|
|
78
|
+
{ name: "pi-good", success: true },
|
|
79
|
+
]);
|
|
80
|
+
// First package emits start + error; second emits start + complete
|
|
81
|
+
expect(events.map((e) => e.phase)).toEqual(["start", "error", "start", "complete"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("invokes onAllComplete only when at least one package succeeded", async () => {
|
|
85
|
+
const onAllComplete = vi.fn().mockResolvedValue(3);
|
|
86
|
+
const updater = new PiCoreUpdater({
|
|
87
|
+
packageManagerWrapper: wrapper,
|
|
88
|
+
runNpmUpdate: async () => {
|
|
89
|
+
/* success */
|
|
90
|
+
},
|
|
91
|
+
onAllComplete,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const out = await updater.update([pkg("pi-foo")]);
|
|
95
|
+
expect(out.sessionsReloaded).toBe(3);
|
|
96
|
+
expect(onAllComplete).toHaveBeenCalledTimes(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("skips onAllComplete when all packages fail", async () => {
|
|
100
|
+
const onAllComplete = vi.fn().mockResolvedValue(99);
|
|
101
|
+
const updater = new PiCoreUpdater({
|
|
102
|
+
packageManagerWrapper: wrapper,
|
|
103
|
+
runNpmUpdate: async () => {
|
|
104
|
+
throw new Error("boom");
|
|
105
|
+
},
|
|
106
|
+
onAllComplete,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const out = await updater.update([pkg("pi-foo"), pkg("pi-bar")]);
|
|
110
|
+
expect(out.sessionsReloaded).toBe(0);
|
|
111
|
+
expect(onAllComplete).not.toHaveBeenCalled();
|
|
112
|
+
expect(out.results.every((r) => !r.success)).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("returns 0 sessionsReloaded and does not throw when onAllComplete rejects", async () => {
|
|
116
|
+
const onAllComplete = vi.fn().mockRejectedValue(new Error("reload failed"));
|
|
117
|
+
const updater = new PiCoreUpdater({
|
|
118
|
+
packageManagerWrapper: wrapper,
|
|
119
|
+
runNpmUpdate: async () => {
|
|
120
|
+
/* success */
|
|
121
|
+
},
|
|
122
|
+
onAllComplete,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const out = await updater.update([pkg("pi-foo")]);
|
|
126
|
+
expect(out.results[0].success).toBe(true);
|
|
127
|
+
expect(out.sessionsReloaded).toBe(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("acquires the shared busy-lock and throws when wrapper is already busy", async () => {
|
|
131
|
+
// Start a long-running runExclusive on the wrapper to simulate an
|
|
132
|
+
// extension operation in flight.
|
|
133
|
+
let release!: () => void;
|
|
134
|
+
const held = new Promise<void>((r) => {
|
|
135
|
+
release = r;
|
|
136
|
+
});
|
|
137
|
+
const locked = wrapper.runExclusive(() => held);
|
|
138
|
+
|
|
139
|
+
const updater = new PiCoreUpdater({
|
|
140
|
+
packageManagerWrapper: wrapper,
|
|
141
|
+
runNpmUpdate: async () => {
|
|
142
|
+
/* no-op */
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await expect(updater.update([pkg("pi-foo")])).rejects.toBeInstanceOf(
|
|
147
|
+
PackageOperationBusyError,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// Release the held lock and confirm a subsequent update succeeds.
|
|
151
|
+
release();
|
|
152
|
+
await locked;
|
|
153
|
+
|
|
154
|
+
const out = await updater.update([pkg("pi-foo")]);
|
|
155
|
+
expect(out.results[0].success).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("releases the busy-lock after update completes (success path)", async () => {
|
|
159
|
+
const updater = new PiCoreUpdater({
|
|
160
|
+
packageManagerWrapper: wrapper,
|
|
161
|
+
runNpmUpdate: async () => {
|
|
162
|
+
/* success */
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
await updater.update([pkg("pi-foo")]);
|
|
166
|
+
expect(wrapper.isBusy()).toBe(false);
|
|
167
|
+
// Second call should be immediately permitted.
|
|
168
|
+
const out = await updater.update([pkg("pi-bar")]);
|
|
169
|
+
expect(out.results[0].success).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("releases the busy-lock even when every package fails", async () => {
|
|
173
|
+
const updater = new PiCoreUpdater({
|
|
174
|
+
packageManagerWrapper: wrapper,
|
|
175
|
+
runNpmUpdate: async () => {
|
|
176
|
+
throw new Error("nope");
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
await updater.update([pkg("pi-foo")]);
|
|
180
|
+
expect(wrapper.isBusy()).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("passes install-source-aware args & cwd to runNpmUpdate", async () => {
|
|
184
|
+
const seen: Array<{ name: string; source: "global" | "managed" }> = [];
|
|
185
|
+
const updater = new PiCoreUpdater({
|
|
186
|
+
packageManagerWrapper: wrapper,
|
|
187
|
+
runNpmUpdate: async (p) => {
|
|
188
|
+
seen.push({ name: p.name, source: p.installSource });
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
await updater.update([pkg("pi-foo", "global"), pkg("pi-bar", "managed")]);
|
|
192
|
+
expect(seen).toEqual([
|
|
193
|
+
{ name: "pi-foo", source: "global" },
|
|
194
|
+
{ name: "pi-bar", source: "managed" },
|
|
195
|
+
]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("swallows progress-listener exceptions without failing the update", async () => {
|
|
199
|
+
const updater = new PiCoreUpdater({
|
|
200
|
+
packageManagerWrapper: wrapper,
|
|
201
|
+
runNpmUpdate: async () => {
|
|
202
|
+
/* success */
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
updater.setProgressListener(() => {
|
|
206
|
+
throw new Error("listener explosion");
|
|
207
|
+
});
|
|
208
|
+
// Silence the console.error emitted by the safe-emit guard
|
|
209
|
+
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
210
|
+
const out = await updater.update([pkg("pi-foo")]);
|
|
211
|
+
expect(out.results[0].success).toBe(true);
|
|
212
|
+
err.mockRestore();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -44,14 +44,22 @@ function createMockPiGateway() {
|
|
|
44
44
|
} as any;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
function createMockBrowserGateway() {
|
|
48
|
+
return {
|
|
49
|
+
broadcastToAll: vi.fn(),
|
|
50
|
+
} as any;
|
|
51
|
+
}
|
|
52
|
+
|
|
47
53
|
describe("provider-auth-routes", () => {
|
|
48
54
|
let app: ReturnType<typeof Fastify>;
|
|
49
55
|
let piGateway: ReturnType<typeof createMockPiGateway>;
|
|
56
|
+
let browserGateway: ReturnType<typeof createMockBrowserGateway>;
|
|
50
57
|
|
|
51
58
|
beforeEach(async () => {
|
|
52
59
|
app = Fastify();
|
|
53
60
|
piGateway = createMockPiGateway();
|
|
54
|
-
|
|
61
|
+
browserGateway = createMockBrowserGateway();
|
|
62
|
+
registerProviderAuthRoutes(app, { piGateway, browserGateway });
|
|
55
63
|
await app.ready();
|
|
56
64
|
});
|
|
57
65
|
|
|
@@ -106,7 +114,7 @@ describe("provider-auth-routes", () => {
|
|
|
106
114
|
|
|
107
115
|
// /exchange endpoint removed — token exchange happens in the callback server's onCode
|
|
108
116
|
|
|
109
|
-
it("PUT /api/provider-auth/api-key saves and notifies", async () => {
|
|
117
|
+
it("PUT /api/provider-auth/api-key saves and notifies bridges and browsers", async () => {
|
|
110
118
|
const { writeCredential } = await import("../provider-auth-storage.js");
|
|
111
119
|
const res = await app.inject({
|
|
112
120
|
method: "PUT",
|
|
@@ -117,9 +125,10 @@ describe("provider-auth-routes", () => {
|
|
|
117
125
|
expect(JSON.parse(res.payload).ok).toBe(true);
|
|
118
126
|
expect(writeCredential).toHaveBeenCalledWith("openai", { type: "api_key", key: "sk-test" });
|
|
119
127
|
expect(piGateway.broadcast).toHaveBeenCalledWith({ type: "credentials_updated" });
|
|
128
|
+
expect(browserGateway.broadcastToAll).toHaveBeenCalledWith({ type: "models_refreshed" });
|
|
120
129
|
});
|
|
121
130
|
|
|
122
|
-
it("DELETE /api/provider-auth/:provider removes and notifies", async () => {
|
|
131
|
+
it("DELETE /api/provider-auth/:provider removes and notifies bridges and browsers", async () => {
|
|
123
132
|
const { removeCredential } = await import("../provider-auth-storage.js");
|
|
124
133
|
const res = await app.inject({
|
|
125
134
|
method: "DELETE",
|
|
@@ -128,6 +137,7 @@ describe("provider-auth-routes", () => {
|
|
|
128
137
|
expect(res.statusCode).toBe(200);
|
|
129
138
|
expect(removeCredential).toHaveBeenCalledWith("anthropic");
|
|
130
139
|
expect(piGateway.broadcast).toHaveBeenCalledWith({ type: "credentials_updated" });
|
|
140
|
+
expect(browserGateway.broadcastToAll).toHaveBeenCalledWith({ type: "models_refreshed" });
|
|
131
141
|
});
|
|
132
142
|
|
|
133
143
|
// /callback/:provider route removed — temp callback server handles this directly
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the GET /api/packages/recommended route and its helpers.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } 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
|
+
|
|
10
|
+
// Mock pi dependency (pulled transitively by package-manager-wrapper)
|
|
11
|
+
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
|
12
|
+
DefaultPackageManager: function () {
|
|
13
|
+
return {};
|
|
14
|
+
},
|
|
15
|
+
SettingsManager: { create: () => ({}) },
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// Mock the npm-search-proxy so we can assert enrichment + failure paths.
|
|
19
|
+
vi.mock("../npm-search-proxy.js", () => ({
|
|
20
|
+
fetchPackageMeta: vi.fn(),
|
|
21
|
+
fetchGithubPackageJson: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
import { fetchPackageMeta, fetchGithubPackageJson } from "../npm-search-proxy.js";
|
|
25
|
+
import {
|
|
26
|
+
registerRecommendedRoutes,
|
|
27
|
+
invalidateRecommendedCache,
|
|
28
|
+
parseSourceKey,
|
|
29
|
+
sourcesMatch,
|
|
30
|
+
} from "../routes/recommended-routes.js";
|
|
31
|
+
|
|
32
|
+
function makeWrapper(installed: {
|
|
33
|
+
global?: Array<{ source: string; installedPath?: string }>;
|
|
34
|
+
local?: Array<{ source: string; installedPath?: string }>;
|
|
35
|
+
}): any {
|
|
36
|
+
return {
|
|
37
|
+
listInstalled: vi.fn(async (scope: string) =>
|
|
38
|
+
scope === "global" ? installed.global ?? [] : installed.local ?? [],
|
|
39
|
+
),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("parseSourceKey", () => {
|
|
44
|
+
it("parses npm: sources", () => {
|
|
45
|
+
expect(parseSourceKey("npm:pi-web-access")).toEqual({
|
|
46
|
+
kind: "npm",
|
|
47
|
+
name: "pi-web-access",
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("parses scoped npm: sources", () => {
|
|
52
|
+
expect(parseSourceKey("npm:@tintinweb/pi-subagents")).toEqual({
|
|
53
|
+
kind: "npm",
|
|
54
|
+
name: "@tintinweb/pi-subagents",
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("strips version from npm: sources", () => {
|
|
59
|
+
expect(parseSourceKey("npm:pi-web-access@1.2.3")).toEqual({
|
|
60
|
+
kind: "npm",
|
|
61
|
+
name: "pi-web-access",
|
|
62
|
+
});
|
|
63
|
+
expect(parseSourceKey("npm:@scope/pkg@1.0.0")).toEqual({
|
|
64
|
+
kind: "npm",
|
|
65
|
+
name: "@scope/pkg",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("parses git@ SSH URLs", () => {
|
|
70
|
+
expect(parseSourceKey("git@github.com:BlackBeltTechnology/pi-flows.git")).toEqual({
|
|
71
|
+
kind: "git",
|
|
72
|
+
host: "github.com",
|
|
73
|
+
owner: "BlackBeltTechnology",
|
|
74
|
+
repo: "pi-flows",
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("parses https git URLs", () => {
|
|
79
|
+
expect(
|
|
80
|
+
parseSourceKey("https://github.com/BlackBeltTechnology/pi-flows.git"),
|
|
81
|
+
).toEqual({
|
|
82
|
+
kind: "git",
|
|
83
|
+
host: "github.com",
|
|
84
|
+
owner: "BlackBeltTechnology",
|
|
85
|
+
repo: "pi-flows",
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("falls back to raw for unknown forms", () => {
|
|
90
|
+
expect(parseSourceKey("/local/path")).toEqual({
|
|
91
|
+
kind: "raw",
|
|
92
|
+
source: "/local/path",
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("sourcesMatch", () => {
|
|
98
|
+
it("matches npm sources with and without version", () => {
|
|
99
|
+
expect(sourcesMatch("npm:pi-web-access", "npm:pi-web-access@1.0.0")).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("matches git SSH and HTTPS forms of the same repo", () => {
|
|
103
|
+
expect(
|
|
104
|
+
sourcesMatch(
|
|
105
|
+
"git@github.com:BlackBeltTechnology/pi-flows.git",
|
|
106
|
+
"https://github.com/BlackBeltTechnology/pi-flows.git",
|
|
107
|
+
),
|
|
108
|
+
).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("is case-insensitive on the git host/owner/repo", () => {
|
|
112
|
+
expect(
|
|
113
|
+
sourcesMatch(
|
|
114
|
+
"git@github.com:BlackBeltTechnology/pi-flows.git",
|
|
115
|
+
"git@github.com:blackbelttechnology/pi-flows.git",
|
|
116
|
+
),
|
|
117
|
+
).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("distinguishes different repos", () => {
|
|
121
|
+
expect(
|
|
122
|
+
sourcesMatch(
|
|
123
|
+
"git@github.com:BlackBeltTechnology/pi-flows.git",
|
|
124
|
+
"git@github.com:BlackBeltTechnology/pi-anthropic-messages.git",
|
|
125
|
+
),
|
|
126
|
+
).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("matches a git URL against a local path whose basename equals the repo name", () => {
|
|
130
|
+
expect(
|
|
131
|
+
sourcesMatch(
|
|
132
|
+
"git@github.com:BlackBeltTechnology/pi-flows.git",
|
|
133
|
+
"../pi-flows",
|
|
134
|
+
),
|
|
135
|
+
).toBe(true);
|
|
136
|
+
expect(
|
|
137
|
+
sourcesMatch(
|
|
138
|
+
"../pi-anthropic-messages",
|
|
139
|
+
"git@github.com:BlackBeltTechnology/pi-anthropic-messages.git",
|
|
140
|
+
),
|
|
141
|
+
).toBe(true);
|
|
142
|
+
expect(
|
|
143
|
+
sourcesMatch(
|
|
144
|
+
"git@github.com:BlackBeltTechnology/pi-flows.git",
|
|
145
|
+
"/home/user/src/pi-flows/",
|
|
146
|
+
),
|
|
147
|
+
).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("does not cross-match a git URL against an unrelated local path", () => {
|
|
151
|
+
expect(
|
|
152
|
+
sourcesMatch(
|
|
153
|
+
"git@github.com:BlackBeltTechnology/pi-flows.git",
|
|
154
|
+
"../pi-web-access",
|
|
155
|
+
),
|
|
156
|
+
).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("GET /api/packages/recommended", () => {
|
|
161
|
+
let fastify: FastifyInstance;
|
|
162
|
+
let tmpHome: string;
|
|
163
|
+
let origCwd: string;
|
|
164
|
+
|
|
165
|
+
beforeEach(() => {
|
|
166
|
+
invalidateRecommendedCache();
|
|
167
|
+
vi.mocked(fetchPackageMeta).mockReset();
|
|
168
|
+
vi.mocked(fetchGithubPackageJson).mockReset();
|
|
169
|
+
|
|
170
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-rec-"));
|
|
171
|
+
process.env.HOME = tmpHome;
|
|
172
|
+
|
|
173
|
+
// chdir to a clean subdirectory so the route's CWD-based local
|
|
174
|
+
// settings read doesn't pick up the host repo's .pi/settings.json.
|
|
175
|
+
origCwd = process.cwd();
|
|
176
|
+
const scratchCwd = path.join(tmpHome, "scratch");
|
|
177
|
+
fs.mkdirSync(scratchCwd, { recursive: true });
|
|
178
|
+
process.chdir(scratchCwd);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
afterEach(async () => {
|
|
182
|
+
if (fastify) await fastify.close();
|
|
183
|
+
process.chdir(origCwd);
|
|
184
|
+
if (fs.existsSync(tmpHome)) fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
async function setupRoute(installed: {
|
|
188
|
+
global?: Array<{ source: string; installedPath?: string }>;
|
|
189
|
+
local?: Array<{ source: string; installedPath?: string }>;
|
|
190
|
+
} = {}): Promise<FastifyInstance> {
|
|
191
|
+
fastify = Fastify();
|
|
192
|
+
const wrapper = makeWrapper(installed);
|
|
193
|
+
registerRecommendedRoutes(fastify, { packageManagerWrapper: wrapper });
|
|
194
|
+
await fastify.ready();
|
|
195
|
+
return fastify;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
it("returns the 5 manifest entries with default (offline) descriptions", async () => {
|
|
199
|
+
vi.mocked(fetchPackageMeta).mockResolvedValue(null);
|
|
200
|
+
vi.mocked(fetchGithubPackageJson).mockResolvedValue(null);
|
|
201
|
+
await setupRoute();
|
|
202
|
+
|
|
203
|
+
const res = await fastify.inject({
|
|
204
|
+
method: "GET",
|
|
205
|
+
url: "/api/packages/recommended",
|
|
206
|
+
});
|
|
207
|
+
expect(res.statusCode).toBe(200);
|
|
208
|
+
const body = JSON.parse(res.payload);
|
|
209
|
+
expect(body.success).toBe(true);
|
|
210
|
+
const entries = body.data.recommended;
|
|
211
|
+
expect(entries).toHaveLength(5);
|
|
212
|
+
// Every entry falls back to fallbackDescription and has no version.
|
|
213
|
+
for (const e of entries) {
|
|
214
|
+
expect(typeof e.description).toBe("string");
|
|
215
|
+
expect(e.description.length).toBeGreaterThan(10);
|
|
216
|
+
expect(e.version).toBeUndefined();
|
|
217
|
+
expect(e.installed.scope).toBeNull();
|
|
218
|
+
expect(e.activeInPi).toBe(false);
|
|
219
|
+
expect(e.updateAvailable).toBe(false);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("uses npm metadata when registry is reachable", async () => {
|
|
224
|
+
vi.mocked(fetchPackageMeta).mockImplementation(async (name: string) => {
|
|
225
|
+
if (name === "pi-web-access") {
|
|
226
|
+
return { description: "LIVE npm desc", version: "9.9.9" };
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
});
|
|
230
|
+
vi.mocked(fetchGithubPackageJson).mockResolvedValue(null);
|
|
231
|
+
await setupRoute();
|
|
232
|
+
|
|
233
|
+
const res = await fastify.inject({ method: "GET", url: "/api/packages/recommended" });
|
|
234
|
+
const body = JSON.parse(res.payload);
|
|
235
|
+
const pwa = body.data.recommended.find((e: any) => e.id === "pi-web-access");
|
|
236
|
+
expect(pwa.description).toBe("LIVE npm desc");
|
|
237
|
+
expect(pwa.version).toBe("9.9.9");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("uses GitHub metadata for git-sourced entries", async () => {
|
|
241
|
+
vi.mocked(fetchPackageMeta).mockResolvedValue(null);
|
|
242
|
+
vi.mocked(fetchGithubPackageJson).mockImplementation(async (owner, repo) => {
|
|
243
|
+
if (owner === "BlackBeltTechnology" && repo === "pi-flows") {
|
|
244
|
+
return { description: "LIVE github desc", version: "0.1.0" };
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
});
|
|
248
|
+
await setupRoute();
|
|
249
|
+
|
|
250
|
+
const res = await fastify.inject({ method: "GET", url: "/api/packages/recommended" });
|
|
251
|
+
const body = JSON.parse(res.payload);
|
|
252
|
+
const flows = body.data.recommended.find((e: any) => e.id === "pi-flows");
|
|
253
|
+
expect(flows.description).toBe("LIVE github desc");
|
|
254
|
+
expect(flows.version).toBe("0.1.0");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("reports installed + activeInPi correctly when settings.json lists the source", async () => {
|
|
258
|
+
vi.mocked(fetchPackageMeta).mockResolvedValue(null);
|
|
259
|
+
vi.mocked(fetchGithubPackageJson).mockResolvedValue(null);
|
|
260
|
+
|
|
261
|
+
// Write settings.json with pi-web-access as an active package
|
|
262
|
+
const settingsDir = path.join(tmpHome, ".pi", "agent");
|
|
263
|
+
fs.mkdirSync(settingsDir, { recursive: true });
|
|
264
|
+
fs.writeFileSync(
|
|
265
|
+
path.join(settingsDir, "settings.json"),
|
|
266
|
+
JSON.stringify({ packages: ["npm:pi-web-access"] }),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
await setupRoute({
|
|
270
|
+
global: [{ source: "npm:pi-web-access", installedPath: "/fake" }],
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const res = await fastify.inject({ method: "GET", url: "/api/packages/recommended" });
|
|
274
|
+
const body = JSON.parse(res.payload);
|
|
275
|
+
const pwa = body.data.recommended.find((e: any) => e.id === "pi-web-access");
|
|
276
|
+
expect(pwa.installed.scope).toBe("global");
|
|
277
|
+
expect(pwa.activeInPi).toBe(true);
|
|
278
|
+
|
|
279
|
+
// Entries not in settings.json remain inactive
|
|
280
|
+
const browser = body.data.recommended.find((e: any) => e.id === "pi-agent-browser");
|
|
281
|
+
expect(browser.installed.scope).toBeNull();
|
|
282
|
+
expect(browser.activeInPi).toBe(false);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("matches git SSH source against git HTTPS active source", async () => {
|
|
286
|
+
vi.mocked(fetchPackageMeta).mockResolvedValue(null);
|
|
287
|
+
vi.mocked(fetchGithubPackageJson).mockResolvedValue(null);
|
|
288
|
+
|
|
289
|
+
const settingsDir = path.join(tmpHome, ".pi", "agent");
|
|
290
|
+
fs.mkdirSync(settingsDir, { recursive: true });
|
|
291
|
+
// User wrote HTTPS in settings; manifest has SSH. They should match.
|
|
292
|
+
fs.writeFileSync(
|
|
293
|
+
path.join(settingsDir, "settings.json"),
|
|
294
|
+
JSON.stringify({
|
|
295
|
+
packages: ["https://github.com/BlackBeltTechnology/pi-flows.git"],
|
|
296
|
+
}),
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
await setupRoute();
|
|
300
|
+
const res = await fastify.inject({ method: "GET", url: "/api/packages/recommended" });
|
|
301
|
+
const body = JSON.parse(res.payload);
|
|
302
|
+
const flows = body.data.recommended.find((e: any) => e.id === "pi-flows");
|
|
303
|
+
expect(flows.activeInPi).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("matches git manifest source against a local-path active source (basename heuristic)", async () => {
|
|
307
|
+
vi.mocked(fetchPackageMeta).mockResolvedValue(null);
|
|
308
|
+
vi.mocked(fetchGithubPackageJson).mockResolvedValue(null);
|
|
309
|
+
|
|
310
|
+
// User has pi-flows checked out locally and registered via `pi install -l`
|
|
311
|
+
// which records the local path in .pi/settings.json. The manifest has the
|
|
312
|
+
// git SSH URL. The two should still match via basename.
|
|
313
|
+
const projectDir = path.join(tmpHome, "workspace");
|
|
314
|
+
fs.mkdirSync(path.join(projectDir, ".pi"), { recursive: true });
|
|
315
|
+
fs.writeFileSync(
|
|
316
|
+
path.join(projectDir, ".pi", "settings.json"),
|
|
317
|
+
JSON.stringify({ packages: ["../pi-flows", "../pi-anthropic-messages"] }),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const origCwd = process.cwd();
|
|
321
|
+
process.chdir(projectDir);
|
|
322
|
+
try {
|
|
323
|
+
await setupRoute();
|
|
324
|
+
const res = await fastify.inject({ method: "GET", url: "/api/packages/recommended" });
|
|
325
|
+
const body = JSON.parse(res.payload);
|
|
326
|
+
const flows = body.data.recommended.find((e: any) => e.id === "pi-flows");
|
|
327
|
+
const msg = body.data.recommended.find(
|
|
328
|
+
(e: any) => e.id === "pi-anthropic-messages",
|
|
329
|
+
);
|
|
330
|
+
expect(flows.activeInPi).toBe(true);
|
|
331
|
+
expect(msg.activeInPi).toBe(true);
|
|
332
|
+
} finally {
|
|
333
|
+
process.chdir(origCwd);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("considers project-local .pi/settings.json for activeInPi", async () => {
|
|
338
|
+
vi.mocked(fetchPackageMeta).mockResolvedValue(null);
|
|
339
|
+
vi.mocked(fetchGithubPackageJson).mockResolvedValue(null);
|
|
340
|
+
|
|
341
|
+
const projectDir = path.join(tmpHome, "workspace");
|
|
342
|
+
fs.mkdirSync(path.join(projectDir, ".pi"), { recursive: true });
|
|
343
|
+
fs.writeFileSync(
|
|
344
|
+
path.join(projectDir, ".pi", "settings.json"),
|
|
345
|
+
JSON.stringify({ packages: ["npm:pi-web-access"] }),
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const origCwd = process.cwd();
|
|
349
|
+
process.chdir(projectDir);
|
|
350
|
+
try {
|
|
351
|
+
await setupRoute();
|
|
352
|
+
const res = await fastify.inject({ method: "GET", url: "/api/packages/recommended" });
|
|
353
|
+
const body = JSON.parse(res.payload);
|
|
354
|
+
const pwa = body.data.recommended.find((e: any) => e.id === "pi-web-access");
|
|
355
|
+
expect(pwa.activeInPi).toBe(true);
|
|
356
|
+
} finally {
|
|
357
|
+
process.chdir(origCwd);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("serves cached data on the second call within 60s", async () => {
|
|
362
|
+
vi.mocked(fetchPackageMeta).mockResolvedValue({
|
|
363
|
+
description: "cached",
|
|
364
|
+
version: "1.0.0",
|
|
365
|
+
});
|
|
366
|
+
vi.mocked(fetchGithubPackageJson).mockResolvedValue(null);
|
|
367
|
+
await setupRoute();
|
|
368
|
+
|
|
369
|
+
await fastify.inject({ method: "GET", url: "/api/packages/recommended" });
|
|
370
|
+
const callsAfterFirst = vi.mocked(fetchPackageMeta).mock.calls.length;
|
|
371
|
+
await fastify.inject({ method: "GET", url: "/api/packages/recommended" });
|
|
372
|
+
expect(vi.mocked(fetchPackageMeta).mock.calls.length).toBe(callsAfterFirst);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("refetches after invalidateRecommendedCache()", async () => {
|
|
376
|
+
vi.mocked(fetchPackageMeta).mockResolvedValue({
|
|
377
|
+
description: "refresh",
|
|
378
|
+
version: "1.0.0",
|
|
379
|
+
});
|
|
380
|
+
vi.mocked(fetchGithubPackageJson).mockResolvedValue(null);
|
|
381
|
+
await setupRoute();
|
|
382
|
+
|
|
383
|
+
await fastify.inject({ method: "GET", url: "/api/packages/recommended" });
|
|
384
|
+
const before = vi.mocked(fetchPackageMeta).mock.calls.length;
|
|
385
|
+
invalidateRecommendedCache();
|
|
386
|
+
await fastify.inject({ method: "GET", url: "/api/packages/recommended" });
|
|
387
|
+
expect(vi.mocked(fetchPackageMeta).mock.calls.length).toBeGreaterThan(before);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* This prevents resuming a stale session from loading the wrong conversation.
|
|
5
5
|
*/
|
|
6
6
|
import { describe, it, expect, afterAll } from "vitest";
|
|
7
|
-
import {
|
|
7
|
+
import { createTestServer, type TestServerHandle } from "../test-support/test-server.js";
|
|
8
|
+
import type { DashboardServer } from "../server.js";
|
|
8
9
|
import { WebSocket } from "ws";
|
|
9
10
|
|
|
10
11
|
function waitForOpen(ws: WebSocket): Promise<void> {
|
|
@@ -26,22 +27,21 @@ function collectMsgs(ws: WebSocket, ms: number): Promise<any[]> {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
29
|
-
|
|
30
|
-
const piPort = 19091;
|
|
30
|
+
let handle: TestServerHandle;
|
|
31
31
|
let server: DashboardServer;
|
|
32
|
+
let piPort: number;
|
|
33
|
+
|
|
32
34
|
|
|
33
35
|
describe("session file deduplication", () => {
|
|
34
36
|
afterAll(async () => {
|
|
35
|
-
if (
|
|
37
|
+
if (handle) await handle.stop();
|
|
36
38
|
});
|
|
37
39
|
|
|
38
40
|
it("clears sessionFile from old session when new session registers with same file", async () => {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
});
|
|
44
|
-
await server.start();
|
|
41
|
+
handle = await createTestServer();
|
|
42
|
+
server = handle.server;
|
|
43
|
+
piPort = handle.piPort;
|
|
44
|
+
const httpPort = handle.httpPort;
|
|
45
45
|
|
|
46
46
|
const sharedFile = "/tmp/sessions/test.jsonl";
|
|
47
47
|
|