@blackbelt-technology/pi-agent-dashboard 0.2.0 → 0.2.2
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 +3 -1
- package/docs/architecture.md +30 -23
- package/package.json +8 -1
- package/packages/extension/package.json +1 -1
- package/packages/extension/src/__tests__/dashboard-default-adapter.test.ts +77 -0
- package/packages/extension/src/__tests__/dev-build.test.ts +2 -2
- package/packages/extension/src/__tests__/prompt-bus-wiring.test.ts +791 -0
- package/packages/extension/src/__tests__/prompt-bus.test.ts +469 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +35 -34
- package/packages/extension/src/__tests__/tui-prompt-adapter.test.ts +207 -0
- package/packages/extension/src/ask-user-tool.ts +1 -1
- package/packages/extension/src/bridge-context.ts +1 -1
- package/packages/extension/src/bridge.ts +214 -59
- package/packages/extension/src/command-handler.ts +2 -2
- package/packages/extension/src/dashboard-default-adapter.ts +37 -0
- package/packages/extension/src/flow-event-wiring.ts +6 -23
- package/packages/extension/src/pi-env.d.ts +13 -0
- package/packages/extension/src/prompt-bus.ts +240 -0
- package/packages/extension/src/server-launcher.ts +2 -2
- package/packages/extension/src/session-sync.ts +2 -1
- package/packages/server/package.json +1 -1
- package/packages/server/src/__tests__/bridge-register-nondestructive.test.ts +108 -0
- package/packages/server/src/__tests__/extension-register-appimage.test.ts +39 -0
- package/packages/server/src/__tests__/extension-register.test.ts +26 -22
- package/packages/server/src/__tests__/process-manager.test.ts +4 -1
- package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +5 -5
- package/packages/server/src/__tests__/tunnel.test.ts +2 -2
- package/packages/server/src/browser-gateway.ts +55 -16
- package/packages/server/src/cli.ts +1 -1
- package/packages/server/src/editor-manager.ts +1 -1
- package/packages/server/src/event-status-extraction.ts +7 -0
- package/packages/server/src/event-wiring.ts +16 -19
- package/packages/server/src/package-manager-wrapper.ts +1 -1
- package/packages/server/src/process-manager.ts +8 -69
- package/packages/server/src/routes/system-routes.ts +3 -1
- package/packages/server/src/server.ts +6 -4
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/bridge-register.test.ts +136 -0
- package/packages/shared/src/__tests__/tool-resolver.test.ts +164 -0
- package/packages/shared/src/bridge-register.ts +95 -0
- package/packages/shared/src/browser-protocol.ts +10 -0
- package/packages/shared/src/managed-paths.ts +15 -0
- package/packages/shared/src/mdns-discovery.ts +1 -1
- package/packages/shared/src/protocol.ts +46 -0
- package/packages/shared/src/tool-resolver.ts +201 -0
- package/packages/shared/src/types.ts +24 -0
- package/packages/extension/src/__tests__/ui-proxy.test.ts +0 -583
- package/packages/extension/src/ui-proxy.ts +0 -269
- package/packages/server/src/extension-register.ts +0 -92
|
@@ -5,12 +5,22 @@
|
|
|
5
5
|
declare module "@mariozechner/pi-coding-agent" {
|
|
6
6
|
export type ExtensionAPI = import("@oh-my-pi/pi-coding-agent").ExtensionAPI;
|
|
7
7
|
}
|
|
8
|
+
declare module "@mariozechner/pi-ai" {
|
|
9
|
+
export function StringEnum<T extends readonly string[]>(values: T, schema?: Record<string, unknown>): any;
|
|
10
|
+
}
|
|
11
|
+
|
|
8
12
|
declare module "@oh-my-pi/pi-coding-agent" {
|
|
9
13
|
export interface ModelRegistry {
|
|
10
14
|
getAvailable(): Array<{ provider: string; id: string }>;
|
|
11
15
|
refresh(): void;
|
|
12
16
|
}
|
|
13
17
|
|
|
18
|
+
export interface EventBus {
|
|
19
|
+
on(event: string, handler: (...args: any[]) => any): void;
|
|
20
|
+
off(event: string, handler: (...args: any[]) => any): void;
|
|
21
|
+
emit(event: string, ...args: any[]): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
export interface ExtensionAPI {
|
|
15
25
|
on(event: string, handler: (...args: any[]) => any): void;
|
|
16
26
|
getCommands(): any[];
|
|
@@ -18,6 +28,9 @@ declare module "@oh-my-pi/pi-coding-agent" {
|
|
|
18
28
|
setSessionName(name: string): void;
|
|
19
29
|
getSessionName(): string | undefined;
|
|
20
30
|
registerCommand(name: string, options: { description?: string; handler: (args: string, ctx: any) => Promise<void> }): void;
|
|
31
|
+
registerTool(tool: any): void;
|
|
32
|
+
registerProvider(name: string, config: any): void;
|
|
21
33
|
exec(command: string, args: string[], options?: { timeout?: number }): Promise<{ stdout: string; stderr: string; exitCode: number }>;
|
|
34
|
+
events: EventBus;
|
|
22
35
|
}
|
|
23
36
|
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PromptBus — Unified prompt routing infrastructure.
|
|
3
|
+
*
|
|
4
|
+
* Routes prompt requests to registered adapters (TUI, dashboard, custom).
|
|
5
|
+
* Enforces first-response-wins semantics and cross-adapter dismissal.
|
|
6
|
+
* Replaces the ui-proxy race pattern and emitPromptAndAwait event system.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ── Interfaces ──────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export interface PromptComponent {
|
|
12
|
+
/** Component type identifier — maps to a React component on the client */
|
|
13
|
+
type: string;
|
|
14
|
+
/** Serializable props for the component */
|
|
15
|
+
props: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PromptClaim {
|
|
19
|
+
/** Optional custom dashboard UI component. If omitted, adapter handles externally (e.g. TUI). */
|
|
20
|
+
component?: PromptComponent;
|
|
21
|
+
/** Where to render the component on the dashboard client */
|
|
22
|
+
placement?: "widget-bar" | "inline" | "overlay";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PromptRequest {
|
|
26
|
+
id: string;
|
|
27
|
+
pipeline: string;
|
|
28
|
+
type: "select" | "input" | "confirm" | "editor" | "multiselect";
|
|
29
|
+
question: string;
|
|
30
|
+
options?: string[];
|
|
31
|
+
defaultValue?: string;
|
|
32
|
+
metadata?: Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PromptResponse {
|
|
36
|
+
id: string;
|
|
37
|
+
answer?: string;
|
|
38
|
+
cancelled?: boolean;
|
|
39
|
+
source: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PromptAdapter {
|
|
43
|
+
name: string;
|
|
44
|
+
/**
|
|
45
|
+
* Called when a new prompt arrives.
|
|
46
|
+
* Return a PromptClaim to participate, or null/undefined to skip.
|
|
47
|
+
*/
|
|
48
|
+
onRequest(prompt: PromptRequest): PromptClaim | null | undefined | void;
|
|
49
|
+
/** Called when any adapter answered — dismiss your UI if active. */
|
|
50
|
+
onResponse(response: PromptResponse): void;
|
|
51
|
+
/** Called on cancel/timeout — clean up your UI. */
|
|
52
|
+
onCancel(id: string): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Internal types ──────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
interface PendingPrompt {
|
|
58
|
+
request: PromptRequest;
|
|
59
|
+
resolve: (response: PromptResponse) => void;
|
|
60
|
+
timer: ReturnType<typeof setTimeout>;
|
|
61
|
+
claims: Array<{ adapter: PromptAdapter; claim: PromptClaim }>;
|
|
62
|
+
/** Resolved component sent to dashboard at request time (for reconnect replay) */
|
|
63
|
+
resolvedComponent: PromptComponent | undefined;
|
|
64
|
+
/** Resolved placement sent to dashboard at request time (for reconnect replay) */
|
|
65
|
+
resolvedPlacement: string | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface PromptBusOptions {
|
|
69
|
+
/** Default timeout in milliseconds (default: 5 minutes) */
|
|
70
|
+
timeoutMs?: number;
|
|
71
|
+
/** Called when a prompt_request should be sent to the dashboard */
|
|
72
|
+
onDashboardRequest?: (prompt: PromptRequest, component: PromptComponent, placement: string) => void;
|
|
73
|
+
/** Called when a prompt_dismiss should be sent to the dashboard */
|
|
74
|
+
onDashboardDismiss?: (id: string) => void;
|
|
75
|
+
/** Called when a prompt_cancel should be sent to the dashboard */
|
|
76
|
+
onDashboardCancel?: (id: string) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── PromptBus ───────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
82
|
+
|
|
83
|
+
export class PromptBus {
|
|
84
|
+
private adapters: PromptAdapter[] = [];
|
|
85
|
+
private pending = new Map<string, PendingPrompt>();
|
|
86
|
+
private options: PromptBusOptions;
|
|
87
|
+
|
|
88
|
+
constructor(options: PromptBusOptions = {}) {
|
|
89
|
+
this.options = options;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Register an adapter. Returns an unsubscribe function.
|
|
94
|
+
* Re-registering with the same name replaces the previous adapter.
|
|
95
|
+
*/
|
|
96
|
+
registerAdapter(adapter: PromptAdapter): () => void {
|
|
97
|
+
// Replace existing adapter with same name
|
|
98
|
+
this.adapters = this.adapters.filter(a => a.name !== adapter.name);
|
|
99
|
+
this.adapters.push(adapter);
|
|
100
|
+
|
|
101
|
+
return () => {
|
|
102
|
+
this.adapters = this.adapters.filter(a => a !== adapter);
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Submit a prompt. Returns a promise that resolves when any adapter answers.
|
|
108
|
+
*/
|
|
109
|
+
request(options: Omit<PromptRequest, "id">): Promise<PromptResponse> {
|
|
110
|
+
const id = crypto.randomUUID();
|
|
111
|
+
const request: PromptRequest = { id, ...options };
|
|
112
|
+
|
|
113
|
+
return new Promise<PromptResponse>((resolve) => {
|
|
114
|
+
const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
115
|
+
const timer = setTimeout(() => {
|
|
116
|
+
this.cancel(id);
|
|
117
|
+
}, timeoutMs);
|
|
118
|
+
|
|
119
|
+
// Distribute to all adapters and collect claims
|
|
120
|
+
const claims: PendingPrompt["claims"] = [];
|
|
121
|
+
for (const adapter of this.adapters) {
|
|
122
|
+
try {
|
|
123
|
+
const claim = adapter.onRequest(request);
|
|
124
|
+
if (claim) {
|
|
125
|
+
claims.push({ adapter, claim });
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// Adapter error — skip it
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Resolve dashboard rendering: first adapter with a component wins
|
|
133
|
+
const componentClaim = claims.find(c => c.claim.component);
|
|
134
|
+
let resolvedComponent: PromptComponent | undefined;
|
|
135
|
+
let resolvedPlacement: string | undefined;
|
|
136
|
+
if (componentClaim) {
|
|
137
|
+
resolvedComponent = componentClaim.claim.component!;
|
|
138
|
+
resolvedPlacement = componentClaim.claim.placement ?? "inline";
|
|
139
|
+
} else if (this.options.onDashboardRequest) {
|
|
140
|
+
resolvedComponent = {
|
|
141
|
+
type: "generic-dialog",
|
|
142
|
+
props: {
|
|
143
|
+
question: request.question,
|
|
144
|
+
type: request.type,
|
|
145
|
+
options: request.options,
|
|
146
|
+
defaultValue: request.defaultValue,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
resolvedPlacement = "inline";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Store pending state (with resolved component for reconnect replay)
|
|
153
|
+
this.pending.set(id, { request, resolve, timer, claims, resolvedComponent, resolvedPlacement });
|
|
154
|
+
|
|
155
|
+
// Send to dashboard
|
|
156
|
+
if (resolvedComponent && this.options.onDashboardRequest) {
|
|
157
|
+
this.options.onDashboardRequest(request, resolvedComponent, resolvedPlacement!);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* An adapter calls this to answer a prompt. First response wins.
|
|
164
|
+
*/
|
|
165
|
+
respond(response: PromptResponse): void {
|
|
166
|
+
const entry = this.pending.get(response.id);
|
|
167
|
+
if (!entry) return; // Already resolved or unknown
|
|
168
|
+
|
|
169
|
+
this.pending.delete(response.id);
|
|
170
|
+
clearTimeout(entry.timer);
|
|
171
|
+
|
|
172
|
+
// Notify ALL adapters so they can dismiss their UI
|
|
173
|
+
for (const adapter of this.adapters) {
|
|
174
|
+
try {
|
|
175
|
+
adapter.onResponse(response);
|
|
176
|
+
} catch {
|
|
177
|
+
// Adapter error — continue
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Send dismiss to dashboard if a non-dashboard source answered
|
|
182
|
+
if (this.options.onDashboardDismiss) {
|
|
183
|
+
this.options.onDashboardDismiss(response.id);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
entry.resolve(response);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Cancel a pending prompt (e.g. on timeout or abort).
|
|
191
|
+
*/
|
|
192
|
+
cancel(id: string): void {
|
|
193
|
+
const entry = this.pending.get(id);
|
|
194
|
+
if (!entry) return;
|
|
195
|
+
|
|
196
|
+
this.pending.delete(id);
|
|
197
|
+
clearTimeout(entry.timer);
|
|
198
|
+
|
|
199
|
+
// Notify adapters
|
|
200
|
+
for (const adapter of this.adapters) {
|
|
201
|
+
try {
|
|
202
|
+
adapter.onCancel(id);
|
|
203
|
+
} catch {
|
|
204
|
+
// Adapter error — continue
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Notify dashboard
|
|
209
|
+
if (this.options.onDashboardCancel) {
|
|
210
|
+
this.options.onDashboardCancel(id);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
entry.resolve({ id, cancelled: true, source: "__bus__" });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Get pending requests with their resolved dashboard components (for reconnect replay). */
|
|
217
|
+
getPendingRequests(): Array<{ request: PromptRequest; component: PromptComponent; placement: string }> {
|
|
218
|
+
const result: Array<{ request: PromptRequest; component: PromptComponent; placement: string }> = [];
|
|
219
|
+
for (const entry of this.pending.values()) {
|
|
220
|
+
if (entry.resolvedComponent && entry.resolvedPlacement) {
|
|
221
|
+
result.push({
|
|
222
|
+
request: entry.request,
|
|
223
|
+
component: entry.resolvedComponent,
|
|
224
|
+
placement: entry.resolvedPlacement,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Get the number of pending prompts (for testing/diagnostics). */
|
|
232
|
+
get pendingCount(): number {
|
|
233
|
+
return this.pending.size;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Get registered adapter names (for testing/diagnostics). */
|
|
237
|
+
get adapterNames(): string[] {
|
|
238
|
+
return this.adapters.map(a => a.name);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -18,10 +18,10 @@ export interface LaunchResult {
|
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Resolve the dashboard server CLI script path relative to this extension file.
|
|
21
|
-
* From
|
|
21
|
+
* From packages/extension/src/server-launcher.ts → packages/server/src/cli.ts
|
|
22
22
|
*/
|
|
23
23
|
export function resolveServerCliPath(): string {
|
|
24
|
-
return path.resolve(__dirname, "..", "server", "cli.ts");
|
|
24
|
+
return path.resolve(__dirname, "..", "..", "server", "src", "cli.ts");
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
@@ -81,7 +81,8 @@ export function replaySessionEntries(bc: BridgeContext): void {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
|
-
* Handle
|
|
84
|
+
* Handle session change (new/fork/resume): unregister old, register new, replay, sync.
|
|
85
|
+
* Called from session_start when event.reason indicates a session switch.
|
|
85
86
|
*/
|
|
86
87
|
export function handleSessionChange(
|
|
87
88
|
bc: BridgeContext,
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for non-destructive bridge registration cleanup.
|
|
3
|
+
* Verifies that existing valid extension paths are preserved,
|
|
4
|
+
* while stale (non-existent) paths are removed.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
|
|
11
|
+
import { registerBridgeExtension } from "@blackbelt-technology/pi-dashboard-shared/bridge-register.js";
|
|
12
|
+
|
|
13
|
+
describe("non-destructive bridge registration", () => {
|
|
14
|
+
let tmpDir: string;
|
|
15
|
+
let settingsPath: string;
|
|
16
|
+
let origHome: string | undefined;
|
|
17
|
+
let fakeExtensionDir: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bridge-reg-test-"));
|
|
21
|
+
settingsPath = path.join(tmpDir, ".pi", "agent", "settings.json");
|
|
22
|
+
origHome = process.env.HOME;
|
|
23
|
+
process.env.HOME = tmpDir;
|
|
24
|
+
|
|
25
|
+
// Create a fake "existing valid" extension dir that looks like a dev install
|
|
26
|
+
fakeExtensionDir = path.join(tmpDir, "dev-project", "pi-agent-dashboard", "packages", "extension");
|
|
27
|
+
fs.mkdirSync(fakeExtensionDir, { recursive: true });
|
|
28
|
+
fs.writeFileSync(path.join(fakeExtensionDir, "package.json"), '{"name":"test"}');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
process.env.HOME = origHome;
|
|
33
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function writeSettings(data: Record<string, unknown>) {
|
|
37
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
38
|
+
fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readSettings(): Record<string, unknown> {
|
|
42
|
+
return JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
it("preserves existing valid dashboard extension path", () => {
|
|
46
|
+
writeSettings({ packages: [fakeExtensionDir] });
|
|
47
|
+
|
|
48
|
+
const newPath = path.join(tmpDir, "new-extension");
|
|
49
|
+
registerBridgeExtension(newPath);
|
|
50
|
+
|
|
51
|
+
const settings = readSettings();
|
|
52
|
+
const packages = settings.packages as string[];
|
|
53
|
+
expect(packages).toContain(fakeExtensionDir);
|
|
54
|
+
expect(packages).toContain(newPath);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("removes stale (non-existent) dashboard extension path", () => {
|
|
58
|
+
const stalePath = "/old/nonexistent/pi-dashboard/extension";
|
|
59
|
+
writeSettings({ packages: [stalePath, fakeExtensionDir] });
|
|
60
|
+
|
|
61
|
+
const newPath = path.join(tmpDir, "new-extension");
|
|
62
|
+
registerBridgeExtension(newPath);
|
|
63
|
+
|
|
64
|
+
const settings = readSettings();
|
|
65
|
+
const packages = settings.packages as string[];
|
|
66
|
+
expect(packages).not.toContain(stalePath);
|
|
67
|
+
expect(packages).toContain(fakeExtensionDir);
|
|
68
|
+
expect(packages).toContain(newPath);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("does not add duplicate entries", () => {
|
|
72
|
+
writeSettings({ packages: [fakeExtensionDir] });
|
|
73
|
+
|
|
74
|
+
registerBridgeExtension(fakeExtensionDir);
|
|
75
|
+
|
|
76
|
+
const settings = readSettings();
|
|
77
|
+
const packages = settings.packages as string[];
|
|
78
|
+
const count = packages.filter(p => p === fakeExtensionDir).length;
|
|
79
|
+
expect(count).toBe(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("preserves non-dashboard extension paths", () => {
|
|
83
|
+
const otherExt = "/some/other/extension";
|
|
84
|
+
writeSettings({ packages: [otherExt, fakeExtensionDir] });
|
|
85
|
+
|
|
86
|
+
const newPath = path.join(tmpDir, "new-extension");
|
|
87
|
+
registerBridgeExtension(newPath);
|
|
88
|
+
|
|
89
|
+
const settings = readSettings();
|
|
90
|
+
const packages = settings.packages as string[];
|
|
91
|
+
expect(packages).toContain(otherExt);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("removes path without package.json even if directory exists", () => {
|
|
95
|
+
const noPkgDir = path.join(tmpDir, "broken-pi-dashboard");
|
|
96
|
+
fs.mkdirSync(noPkgDir, { recursive: true });
|
|
97
|
+
|
|
98
|
+
writeSettings({ packages: [noPkgDir, fakeExtensionDir] });
|
|
99
|
+
|
|
100
|
+
const newPath = path.join(tmpDir, "new-extension");
|
|
101
|
+
registerBridgeExtension(newPath);
|
|
102
|
+
|
|
103
|
+
const settings = readSettings();
|
|
104
|
+
const packages = settings.packages as string[];
|
|
105
|
+
expect(packages).not.toContain(noPkgDir);
|
|
106
|
+
expect(packages).toContain(fakeExtensionDir);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for AppImage guard in findBundledExtension().
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
|
|
9
|
+
import { findBundledExtension } from "@blackbelt-technology/pi-dashboard-shared/bridge-register.js";
|
|
10
|
+
|
|
11
|
+
describe("findBundledExtension - AppImage guard", () => {
|
|
12
|
+
let tmpDir: string;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "appimage-test-"));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns extension path for stable install location", () => {
|
|
23
|
+
const extDir = path.join(tmpDir, "packages", "extension");
|
|
24
|
+
fs.mkdirSync(extDir, { recursive: true });
|
|
25
|
+
fs.writeFileSync(path.join(extDir, "package.json"), "{}");
|
|
26
|
+
|
|
27
|
+
const result = findBundledExtension(tmpDir);
|
|
28
|
+
expect(result).toBe(extDir);
|
|
29
|
+
expect(result).not.toContain("/tmp/.mount_");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns null when extension does not exist", () => {
|
|
33
|
+
expect(findBundledExtension(tmpDir)).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Note: We can't easily test the /tmp/.mount_ guard with real paths
|
|
37
|
+
// since we'd need to create dirs under /tmp/.mount_PIxxxx.
|
|
38
|
+
// The guard is verified by code inspection and the shared module tests.
|
|
39
|
+
});
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared bridge extension registration (server context).
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
5
|
import fs from "node:fs";
|
|
3
6
|
import path from "node:path";
|
|
4
7
|
import os from "node:os";
|
|
5
8
|
|
|
6
|
-
|
|
7
|
-
import { ensureBridgeExtensionRegistered } from "../extension-register.js";
|
|
9
|
+
import { registerBridgeExtension, findBundledExtension } from "@blackbelt-technology/pi-dashboard-shared/bridge-register.js";
|
|
8
10
|
|
|
9
|
-
describe("
|
|
11
|
+
describe("bridge extension registration (server context)", () => {
|
|
10
12
|
let tmpDir: string;
|
|
11
13
|
let settingsPath: string;
|
|
12
14
|
let origHome: string | undefined;
|
|
@@ -23,39 +25,41 @@ describe("ensureBridgeExtensionRegistered", () => {
|
|
|
23
25
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
24
26
|
});
|
|
25
27
|
|
|
26
|
-
it("
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// The function should not crash and should not create settings.json
|
|
30
|
-
ensureBridgeExtensionRegistered();
|
|
31
|
-
// No assertion needed — just verify no crash
|
|
28
|
+
it("findBundledExtension returns null when extension dir does not exist", () => {
|
|
29
|
+
const result = findBundledExtension(tmpDir);
|
|
30
|
+
expect(result).toBeNull();
|
|
32
31
|
});
|
|
33
32
|
|
|
34
|
-
it("
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
it("findBundledExtension finds extension under base dir", () => {
|
|
34
|
+
const extDir = path.join(tmpDir, "packages", "extension");
|
|
35
|
+
fs.mkdirSync(extDir, { recursive: true });
|
|
36
|
+
fs.writeFileSync(path.join(extDir, "package.json"), "{}");
|
|
37
|
+
expect(findBundledExtension(tmpDir)).toBe(extDir);
|
|
38
|
+
});
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
it("registerBridgeExtension adds extension to empty settings file", () => {
|
|
41
41
|
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
42
42
|
fs.writeFileSync(settingsPath, "{}");
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
registerBridgeExtension("/test/extension");
|
|
45
45
|
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
46
|
-
expect(settings.packages).
|
|
46
|
+
expect(settings.packages).toContain("/test/extension");
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
it("should not crash on malformed settings.json", () => {
|
|
50
50
|
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
51
51
|
fs.writeFileSync(settingsPath, "not valid json{{{");
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// Should not throw — starts fresh
|
|
53
|
+
registerBridgeExtension("/test/extension");
|
|
54
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
55
|
+
expect(settings.packages).toContain("/test/extension");
|
|
54
56
|
});
|
|
55
57
|
|
|
56
58
|
it("should not crash when settings directory does not exist", () => {
|
|
57
59
|
// HOME points to tmpDir but .pi/agent/ doesn't exist
|
|
58
|
-
|
|
59
|
-
// Should
|
|
60
|
+
registerBridgeExtension("/test/extension");
|
|
61
|
+
// Should create the directory and write
|
|
62
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
63
|
+
expect(settings.packages).toContain("/test/extension");
|
|
60
64
|
});
|
|
61
65
|
});
|
|
@@ -168,7 +168,10 @@ describe("Process Manager", () => {
|
|
|
168
168
|
it("should not duplicate managed bin if already present", () => {
|
|
169
169
|
const managedBin = require("path").join(require("os").homedir(), ".pi-dashboard", "node_modules", ".bin");
|
|
170
170
|
const env = buildSpawnEnv({ PATH: `${managedBin}:/usr/bin` });
|
|
171
|
-
|
|
171
|
+
// Managed bin should appear exactly once
|
|
172
|
+
const parts = env.PATH!.split(":");
|
|
173
|
+
const managedCount = parts.filter(p => p === managedBin).length;
|
|
174
|
+
expect(managedCount).toBe(1);
|
|
172
175
|
});
|
|
173
176
|
});
|
|
174
177
|
|
|
@@ -43,7 +43,7 @@ describe("Session lifecycle logging", () => {
|
|
|
43
43
|
}));
|
|
44
44
|
await delay(100);
|
|
45
45
|
|
|
46
|
-
const logs = errorSpy.mock.calls.map((c) => c[0]);
|
|
46
|
+
const logs = errorSpy.mock.calls.map((c: any) => c[0]);
|
|
47
47
|
expect(logs).toContainEqual(expect.stringContaining("[gateway] session registered: log-reg cwd=/tmp/test"));
|
|
48
48
|
ws.close();
|
|
49
49
|
}, 10000);
|
|
@@ -64,7 +64,7 @@ describe("Session lifecycle logging", () => {
|
|
|
64
64
|
ws.send(JSON.stringify({ type: "session_unregister", sessionId: "log-unreg" }));
|
|
65
65
|
await delay(100);
|
|
66
66
|
|
|
67
|
-
const logs = errorSpy.mock.calls.map((c) => c[0]);
|
|
67
|
+
const logs = errorSpy.mock.calls.map((c: any) => c[0]);
|
|
68
68
|
expect(logs).toContainEqual(expect.stringContaining("[gateway] session unregistered: log-unreg (explicit)"));
|
|
69
69
|
ws.close();
|
|
70
70
|
}, 10000);
|
|
@@ -87,7 +87,7 @@ describe("Session lifecycle logging", () => {
|
|
|
87
87
|
ws.close();
|
|
88
88
|
await delay(SHORT_HB + 300);
|
|
89
89
|
|
|
90
|
-
const logs = errorSpy.mock.calls.map((c) => c[0]);
|
|
90
|
+
const logs = errorSpy.mock.calls.map((c: any) => c[0]);
|
|
91
91
|
expect(logs).toContainEqual(expect.stringContaining("[gateway] session timed out: log-timeout (no heartbeat for"));
|
|
92
92
|
}, 10000);
|
|
93
93
|
|
|
@@ -107,7 +107,7 @@ describe("Session lifecycle logging", () => {
|
|
|
107
107
|
ws.close();
|
|
108
108
|
await delay(200);
|
|
109
109
|
|
|
110
|
-
const logs = errorSpy.mock.calls.map((c) => c[0]);
|
|
110
|
+
const logs = errorSpy.mock.calls.map((c: any) => c[0]);
|
|
111
111
|
expect(logs).toContainEqual(expect.stringContaining("[gateway] connection closed: log-close"));
|
|
112
112
|
}, 10000);
|
|
113
113
|
|
|
@@ -132,7 +132,7 @@ describe("Session lifecycle logging", () => {
|
|
|
132
132
|
(ws as any)._socket?.pause();
|
|
133
133
|
await delay(200 * 4);
|
|
134
134
|
|
|
135
|
-
const logs = errorSpy.mock.calls.map((c) => c[0]);
|
|
135
|
+
const logs = errorSpy.mock.calls.map((c: any) => c[0]);
|
|
136
136
|
expect(logs).toContainEqual(expect.stringContaining("[gateway] connection dead (ping timeout, 2 misses): log-ping"));
|
|
137
137
|
}, 10000);
|
|
138
138
|
});
|
|
@@ -7,7 +7,7 @@ vi.mock("node:fs", async (importOriginal) => {
|
|
|
7
7
|
return {
|
|
8
8
|
...actual,
|
|
9
9
|
default: {
|
|
10
|
-
...actual.default,
|
|
10
|
+
...(actual as any).default,
|
|
11
11
|
existsSync: vi.fn(),
|
|
12
12
|
readFileSync: vi.fn(),
|
|
13
13
|
writeFileSync: vi.fn(),
|
|
@@ -25,7 +25,7 @@ vi.mock("node:os", async (importOriginal) => {
|
|
|
25
25
|
const actual = await importOriginal<typeof import("node:os")>();
|
|
26
26
|
return {
|
|
27
27
|
...actual,
|
|
28
|
-
default: { ...actual.default, homedir: vi.fn(() => "/home/testuser") },
|
|
28
|
+
default: { ...(actual as any).default, homedir: vi.fn(() => "/home/testuser") },
|
|
29
29
|
homedir: vi.fn(() => "/home/testuser"),
|
|
30
30
|
};
|
|
31
31
|
});
|