@blackbelt-technology/pi-agent-dashboard 0.4.0 → 0.4.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 +104 -35
- package/README.md +390 -494
- package/docs/architecture.md +423 -20
- package/package.json +11 -8
- package/packages/extension/package.json +11 -4
- package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +91 -15
- package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
- package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
- package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
- package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
- package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
- package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
- package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
- package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
- package/packages/extension/src/ask-user-tool.ts +170 -61
- package/packages/extension/src/bridge.ts +199 -19
- package/packages/extension/src/multiselect-decode.ts +40 -0
- package/packages/extension/src/multiselect-list.ts +146 -0
- package/packages/extension/src/multiselect-polyfill.ts +73 -0
- package/packages/extension/src/server-launcher.ts +15 -3
- package/packages/extension/src/ui-modules.ts +272 -0
- package/packages/server/package.json +11 -5
- package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
- package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
- package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
- package/packages/server/src/__tests__/directory-service.test.ts +174 -0
- package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
- package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
- package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
- package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
- package/packages/server/src/__tests__/package-routes.test.ts +136 -3
- package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
- package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
- package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
- package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +72 -0
- package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
- package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
- package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
- package/packages/server/src/__tests__/restart-helper.test.ts +34 -6
- package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
- package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
- package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
- package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
- package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
- package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
- package/packages/server/src/browse.ts +118 -13
- package/packages/server/src/browser-gateway.ts +19 -0
- package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
- package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
- package/packages/server/src/browser-handlers/handler-context.ts +15 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
- package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
- package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
- package/packages/server/src/cli.ts +61 -15
- package/packages/server/src/directory-service.ts +156 -15
- package/packages/server/src/event-wiring.ts +111 -10
- package/packages/server/src/installed-package-enricher.ts +143 -0
- package/packages/server/src/package-manager-wrapper.ts +305 -8
- package/packages/server/src/package-source-helpers.ts +104 -0
- package/packages/server/src/pending-attach-registry.ts +112 -0
- package/packages/server/src/pending-resume-intent-registry.ts +107 -0
- package/packages/server/src/pi-core-checker.ts +9 -14
- package/packages/server/src/pi-gateway.ts +14 -0
- package/packages/server/src/pi-version-skew.ts +12 -1
- package/packages/server/src/proposal-attach-naming.ts +47 -0
- package/packages/server/src/restart-helper.ts +13 -2
- package/packages/server/src/routes/file-routes.ts +29 -3
- package/packages/server/src/routes/package-routes.ts +72 -3
- package/packages/server/src/routes/plugin-config-routes.ts +129 -0
- package/packages/server/src/routes/system-routes.ts +2 -0
- package/packages/server/src/server.ts +339 -10
- package/packages/server/src/session-api.ts +30 -5
- package/packages/server/src/session-order-manager.ts +22 -0
- package/packages/server/src/session-scanner.ts +10 -1
- package/packages/shared/package.json +9 -2
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
- package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
- package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
- package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
- package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
- package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
- package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
- package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
- package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
- package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
- package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
- package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
- package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
- package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
- package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
- package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
- package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
- package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
- package/packages/shared/src/browser-protocol.ts +110 -4
- package/packages/shared/src/config.ts +45 -0
- package/packages/shared/src/dashboard-plugin/index.ts +11 -0
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
- package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
- package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
- package/packages/shared/src/openspec-activity-detector.ts +18 -22
- package/packages/shared/src/openspec-design-evidence.ts +109 -0
- package/packages/shared/src/openspec-poller.ts +117 -3
- package/packages/shared/src/openspec-specs-evidence.ts +79 -0
- package/packages/shared/src/platform/binary-lookup.ts +96 -1
- package/packages/shared/src/platform/index.ts +1 -0
- package/packages/shared/src/platform/node-spawn.ts +154 -0
- package/packages/shared/src/plugin-bridge-register.ts +139 -0
- package/packages/shared/src/protocol.ts +79 -2
- package/packages/shared/src/recommended-extensions.ts +7 -1
- package/packages/shared/src/rest-api.ts +68 -3
- package/packages/shared/src/state-replay.ts +20 -1
- package/packages/shared/src/tool-registry/definitions.ts +92 -0
- package/packages/shared/src/tool-registry/strategies.ts +17 -3
- package/packages/shared/src/types.ts +160 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifies `buildOpenSpecData`'s specs-artifact override behavior.
|
|
3
|
+
* Invariants: promote-only, specs-only, never demote, isComplete only
|
|
4
|
+
* promoted to true (never demoted from CLI true), no-factory call site
|
|
5
|
+
* is verbatim back-compat.
|
|
6
|
+
*
|
|
7
|
+
* See change: fix-openspec-specs-mtime-gate-blind-spot.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import { buildOpenSpecData } from "../openspec-poller.js";
|
|
11
|
+
import type { SpecsEvidenceProbe } from "../openspec-specs-evidence.js";
|
|
12
|
+
|
|
13
|
+
function specsProbe(satisfied: boolean, calls?: { count: number }): SpecsEvidenceProbe {
|
|
14
|
+
return {
|
|
15
|
+
hasAnySpecFile: () => {
|
|
16
|
+
if (calls) calls.count++;
|
|
17
|
+
return satisfied;
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const listResult = {
|
|
23
|
+
changes: [
|
|
24
|
+
{ name: "x", status: "in-progress", completedTasks: 1, totalTasks: 3 },
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe("buildOpenSpecData specs override", () => {
|
|
29
|
+
it("promotes specs ready→done when probe satisfies", () => {
|
|
30
|
+
const statusResults = new Map<string, any>([
|
|
31
|
+
[
|
|
32
|
+
"x",
|
|
33
|
+
{
|
|
34
|
+
artifacts: [
|
|
35
|
+
{ id: "proposal", status: "done" },
|
|
36
|
+
{ id: "design", status: "done" },
|
|
37
|
+
{ id: "specs", status: "ready" },
|
|
38
|
+
{ id: "tasks", status: "ready" },
|
|
39
|
+
],
|
|
40
|
+
isComplete: false,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
]);
|
|
44
|
+
const data = buildOpenSpecData(
|
|
45
|
+
listResult,
|
|
46
|
+
statusResults,
|
|
47
|
+
undefined, // no design probe
|
|
48
|
+
() => specsProbe(true),
|
|
49
|
+
);
|
|
50
|
+
const x = data.changes[0];
|
|
51
|
+
expect(x.artifacts.find((a) => a.id === "specs")!.status).toBe("done");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("does NOT promote when probe says not satisfied", () => {
|
|
55
|
+
const statusResults = new Map<string, any>([
|
|
56
|
+
[
|
|
57
|
+
"x",
|
|
58
|
+
{
|
|
59
|
+
artifacts: [{ id: "specs", status: "ready" }],
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
]);
|
|
63
|
+
const data = buildOpenSpecData(
|
|
64
|
+
listResult,
|
|
65
|
+
statusResults,
|
|
66
|
+
undefined,
|
|
67
|
+
() => specsProbe(false),
|
|
68
|
+
);
|
|
69
|
+
expect(data.changes[0].artifacts.find((a) => a.id === "specs")!.status).toBe("ready");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("never promotes blocked → done", () => {
|
|
73
|
+
const statusResults = new Map<string, any>([
|
|
74
|
+
[
|
|
75
|
+
"x",
|
|
76
|
+
{
|
|
77
|
+
artifacts: [{ id: "specs", status: "blocked" }],
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
]);
|
|
81
|
+
const data = buildOpenSpecData(
|
|
82
|
+
listResult,
|
|
83
|
+
statusResults,
|
|
84
|
+
undefined,
|
|
85
|
+
() => specsProbe(true),
|
|
86
|
+
);
|
|
87
|
+
expect(data.changes[0].artifacts.find((a) => a.id === "specs")!.status).toBe("blocked");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("never demotes done → ready (CLI says done; we trust it)", () => {
|
|
91
|
+
const statusResults = new Map<string, any>([
|
|
92
|
+
[
|
|
93
|
+
"x",
|
|
94
|
+
{
|
|
95
|
+
artifacts: [{ id: "specs", status: "done" }],
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
]);
|
|
99
|
+
const data = buildOpenSpecData(
|
|
100
|
+
listResult,
|
|
101
|
+
statusResults,
|
|
102
|
+
undefined,
|
|
103
|
+
() => specsProbe(false),
|
|
104
|
+
);
|
|
105
|
+
expect(data.changes[0].artifacts.find((a) => a.id === "specs")!.status).toBe("done");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("only mutates specs — other artifact statuses pass through", () => {
|
|
109
|
+
const statusResults = new Map<string, any>([
|
|
110
|
+
[
|
|
111
|
+
"x",
|
|
112
|
+
{
|
|
113
|
+
artifacts: [
|
|
114
|
+
{ id: "proposal", status: "ready" },
|
|
115
|
+
{ id: "design", status: "blocked" },
|
|
116
|
+
{ id: "specs", status: "ready" },
|
|
117
|
+
{ id: "tasks", status: "ready" },
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
]);
|
|
122
|
+
const data = buildOpenSpecData(
|
|
123
|
+
listResult,
|
|
124
|
+
statusResults,
|
|
125
|
+
undefined,
|
|
126
|
+
() => specsProbe(true),
|
|
127
|
+
);
|
|
128
|
+
const arts = data.changes[0].artifacts;
|
|
129
|
+
expect(arts.find((a) => a.id === "proposal")!.status).toBe("ready");
|
|
130
|
+
expect(arts.find((a) => a.id === "design")!.status).toBe("blocked");
|
|
131
|
+
expect(arts.find((a) => a.id === "specs")!.status).toBe("done");
|
|
132
|
+
expect(arts.find((a) => a.id === "tasks")!.status).toBe("ready");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("re-derives isComplete=true when all artifacts done after override", () => {
|
|
136
|
+
const statusResults = new Map<string, any>([
|
|
137
|
+
[
|
|
138
|
+
"x",
|
|
139
|
+
{
|
|
140
|
+
artifacts: [
|
|
141
|
+
{ id: "proposal", status: "done" },
|
|
142
|
+
{ id: "design", status: "done" },
|
|
143
|
+
{ id: "specs", status: "ready" },
|
|
144
|
+
{ id: "tasks", status: "done" },
|
|
145
|
+
],
|
|
146
|
+
isComplete: false,
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
]);
|
|
150
|
+
const data = buildOpenSpecData(
|
|
151
|
+
listResult,
|
|
152
|
+
statusResults,
|
|
153
|
+
undefined,
|
|
154
|
+
() => specsProbe(true),
|
|
155
|
+
);
|
|
156
|
+
expect(data.changes[0].isComplete).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("does NOT promote isComplete when any non-specs artifact is not done", () => {
|
|
160
|
+
const statusResults = new Map<string, any>([
|
|
161
|
+
[
|
|
162
|
+
"x",
|
|
163
|
+
{
|
|
164
|
+
artifacts: [
|
|
165
|
+
{ id: "proposal", status: "done" },
|
|
166
|
+
{ id: "design", status: "ready" },
|
|
167
|
+
{ id: "specs", status: "ready" },
|
|
168
|
+
{ id: "tasks", status: "blocked" },
|
|
169
|
+
],
|
|
170
|
+
isComplete: false,
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
]);
|
|
174
|
+
const data = buildOpenSpecData(
|
|
175
|
+
listResult,
|
|
176
|
+
statusResults,
|
|
177
|
+
undefined,
|
|
178
|
+
() => specsProbe(true),
|
|
179
|
+
);
|
|
180
|
+
expect(data.changes[0].isComplete).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("never demotes CLI isComplete=true to false", () => {
|
|
184
|
+
const statusResults = new Map<string, any>([
|
|
185
|
+
[
|
|
186
|
+
"x",
|
|
187
|
+
{
|
|
188
|
+
artifacts: [{ id: "specs", status: "ready" }],
|
|
189
|
+
isComplete: true,
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
]);
|
|
193
|
+
const data = buildOpenSpecData(
|
|
194
|
+
listResult,
|
|
195
|
+
statusResults,
|
|
196
|
+
undefined,
|
|
197
|
+
() => specsProbe(false),
|
|
198
|
+
);
|
|
199
|
+
expect(data.changes[0].isComplete).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("no-factory call site preserves today's behavior verbatim", () => {
|
|
203
|
+
const statusResults = new Map<string, any>([
|
|
204
|
+
[
|
|
205
|
+
"x",
|
|
206
|
+
{
|
|
207
|
+
artifacts: [
|
|
208
|
+
{ id: "specs", status: "ready" },
|
|
209
|
+
{ id: "tasks", status: "blocked" },
|
|
210
|
+
],
|
|
211
|
+
isComplete: false,
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
]);
|
|
215
|
+
// Both probe params omitted — must match pre-change behavior verbatim.
|
|
216
|
+
const data = buildOpenSpecData(listResult, statusResults);
|
|
217
|
+
expect(data.changes[0].artifacts.find((a) => a.id === "specs")!.status).toBe("ready");
|
|
218
|
+
expect(data.changes[0].isComplete).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("specs probe factory receives the change name", () => {
|
|
222
|
+
const seen: string[] = [];
|
|
223
|
+
const statusResults = new Map<string, any>([
|
|
224
|
+
[
|
|
225
|
+
"x",
|
|
226
|
+
{
|
|
227
|
+
artifacts: [{ id: "specs", status: "ready" }],
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
]);
|
|
231
|
+
buildOpenSpecData(listResult, statusResults, undefined, (changeName) => {
|
|
232
|
+
seen.push(changeName);
|
|
233
|
+
return specsProbe(false);
|
|
234
|
+
});
|
|
235
|
+
expect(seen).toContain("x");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("specs probe is NOT consulted when CLI says specs is already done", () => {
|
|
239
|
+
const calls = { count: 0 };
|
|
240
|
+
const statusResults = new Map<string, any>([
|
|
241
|
+
[
|
|
242
|
+
"x",
|
|
243
|
+
{
|
|
244
|
+
artifacts: [{ id: "specs", status: "done" }],
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
]);
|
|
248
|
+
buildOpenSpecData(listResult, statusResults, undefined, () => specsProbe(true, calls));
|
|
249
|
+
expect(calls.count).toBe(0);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("design and specs overrides compose: both ready, both promoted, isComplete becomes true", () => {
|
|
253
|
+
const statusResults = new Map<string, any>([
|
|
254
|
+
[
|
|
255
|
+
"x",
|
|
256
|
+
{
|
|
257
|
+
artifacts: [
|
|
258
|
+
{ id: "proposal", status: "done" },
|
|
259
|
+
{ id: "design", status: "ready" },
|
|
260
|
+
{ id: "specs", status: "ready" },
|
|
261
|
+
{ id: "tasks", status: "done" },
|
|
262
|
+
],
|
|
263
|
+
isComplete: false,
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
]);
|
|
267
|
+
const data = buildOpenSpecData(
|
|
268
|
+
listResult,
|
|
269
|
+
statusResults,
|
|
270
|
+
// Design probe satisfies.
|
|
271
|
+
() => ({
|
|
272
|
+
hasDesignFile: () => true,
|
|
273
|
+
hasDesignDirWithMd: () => false,
|
|
274
|
+
tasksHasCheckboxes: () => false,
|
|
275
|
+
}),
|
|
276
|
+
// Specs probe satisfies.
|
|
277
|
+
() => specsProbe(true),
|
|
278
|
+
);
|
|
279
|
+
const arts = data.changes[0].artifacts;
|
|
280
|
+
expect(arts.find((a) => a.id === "design")!.status).toBe("done");
|
|
281
|
+
expect(arts.find((a) => a.id === "specs")!.status).toBe("done");
|
|
282
|
+
expect(data.changes[0].isComplete).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the local-specs-evidence override that protects against
|
|
3
|
+
* cache staleness on multi-spec changes.
|
|
4
|
+
* See change: fix-openspec-specs-mtime-gate-blind-spot.
|
|
5
|
+
*/
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
7
|
+
import { mkdirSync, rmSync, writeFileSync, symlinkSync, chmodSync } from "node:fs";
|
|
8
|
+
import { mkdtempSync } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import {
|
|
12
|
+
evaluateLocalSpecsSatisfaction,
|
|
13
|
+
createFsSpecsEvidenceProbe,
|
|
14
|
+
type SpecsEvidenceProbe,
|
|
15
|
+
} from "../openspec-specs-evidence.js";
|
|
16
|
+
|
|
17
|
+
/** In-memory probe stub. */
|
|
18
|
+
function probe(hasAny: boolean): SpecsEvidenceProbe {
|
|
19
|
+
return { hasAnySpecFile: () => hasAny };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("evaluateLocalSpecsSatisfaction", () => {
|
|
23
|
+
it("returns true when probe reports a spec file present", () => {
|
|
24
|
+
expect(evaluateLocalSpecsSatisfaction("/c", probe(true))).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns false when probe reports no spec file", () => {
|
|
28
|
+
expect(evaluateLocalSpecsSatisfaction("/c", probe(false))).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("createFsSpecsEvidenceProbe", () => {
|
|
33
|
+
let tmp: string;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
tmp = mkdtempSync(path.join(os.tmpdir(), "specs-evidence-"));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
try {
|
|
41
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
42
|
+
} catch {
|
|
43
|
+
// best-effort cleanup
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("(a) specs/<cap>/spec.md exists → true", () => {
|
|
48
|
+
mkdirSync(path.join(tmp, "specs", "cap-a"), { recursive: true });
|
|
49
|
+
writeFileSync(path.join(tmp, "specs", "cap-a", "spec.md"), "## ADDED Requirements\n");
|
|
50
|
+
expect(createFsSpecsEvidenceProbe().hasAnySpecFile(tmp)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("(b) empty specs/ directory → false", () => {
|
|
54
|
+
mkdirSync(path.join(tmp, "specs"), { recursive: true });
|
|
55
|
+
expect(createFsSpecsEvidenceProbe().hasAnySpecFile(tmp)).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("(c) specs/ does not exist → false (no throw)", () => {
|
|
59
|
+
// tmp deliberately has no specs/
|
|
60
|
+
expect(() => createFsSpecsEvidenceProbe().hasAnySpecFile(tmp)).not.toThrow();
|
|
61
|
+
expect(createFsSpecsEvidenceProbe().hasAnySpecFile(tmp)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("(d) deep layout specs/<cap>/sub/file.md → true", () => {
|
|
65
|
+
mkdirSync(path.join(tmp, "specs", "cap-a", "sub"), { recursive: true });
|
|
66
|
+
writeFileSync(path.join(tmp, "specs", "cap-a", "sub", "file.md"), "deep");
|
|
67
|
+
expect(createFsSpecsEvidenceProbe().hasAnySpecFile(tmp)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("(e) flat layout specs/cap.md → true", () => {
|
|
71
|
+
mkdirSync(path.join(tmp, "specs"), { recursive: true });
|
|
72
|
+
writeFileSync(path.join(tmp, "specs", "cap.md"), "flat");
|
|
73
|
+
expect(createFsSpecsEvidenceProbe().hasAnySpecFile(tmp)).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("only counts *.md files (other extensions ignored)", () => {
|
|
77
|
+
mkdirSync(path.join(tmp, "specs", "cap-a"), { recursive: true });
|
|
78
|
+
writeFileSync(path.join(tmp, "specs", "cap-a", "notes.txt"), "not a spec");
|
|
79
|
+
writeFileSync(path.join(tmp, "specs", "cap-a", "schema.json"), "{}");
|
|
80
|
+
expect(createFsSpecsEvidenceProbe().hasAnySpecFile(tmp)).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("(f) symlinked .md outside specs is NOT counted by virtue of being inside specs anyway when symlinked-in", () => {
|
|
84
|
+
// Create a sibling .md outside specs/, then symlink it INTO specs/. fs.readdirSync with
|
|
85
|
+
// withFileTypes returns Dirent.isFile() = false for symlinks; .isSymbolicLink() = true.
|
|
86
|
+
// Since the probe only treats `.isFile()` as a hit, symlinks are correctly ignored.
|
|
87
|
+
writeFileSync(path.join(tmp, "outside.md"), "outside the specs tree");
|
|
88
|
+
mkdirSync(path.join(tmp, "specs", "cap-a"), { recursive: true });
|
|
89
|
+
try {
|
|
90
|
+
symlinkSync(
|
|
91
|
+
path.join(tmp, "outside.md"),
|
|
92
|
+
path.join(tmp, "specs", "cap-a", "linked.md"),
|
|
93
|
+
);
|
|
94
|
+
} catch {
|
|
95
|
+
// skip on platforms that don't allow symlinks (e.g. Windows without admin)
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
expect(createFsSpecsEvidenceProbe().hasAnySpecFile(tmp)).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("(g) probe never throws on permission errors", () => {
|
|
102
|
+
// Deny read on specs/. POSIX-only; on Windows chmod is a no-op so we just
|
|
103
|
+
// assert no throw under the platform's actual semantics.
|
|
104
|
+
mkdirSync(path.join(tmp, "specs", "cap-a"), { recursive: true });
|
|
105
|
+
writeFileSync(path.join(tmp, "specs", "cap-a", "spec.md"), "x");
|
|
106
|
+
try {
|
|
107
|
+
chmodSync(path.join(tmp, "specs"), 0o000);
|
|
108
|
+
} catch {
|
|
109
|
+
// chmod may not be supported on this fs/platform; skip the unreadable case
|
|
110
|
+
}
|
|
111
|
+
let result: boolean | "threw" = "threw";
|
|
112
|
+
try {
|
|
113
|
+
result = createFsSpecsEvidenceProbe().hasAnySpecFile(tmp);
|
|
114
|
+
} catch {
|
|
115
|
+
// restore perms before failing
|
|
116
|
+
try {
|
|
117
|
+
chmodSync(path.join(tmp, "specs"), 0o755);
|
|
118
|
+
} catch {
|
|
119
|
+
/* ignore */
|
|
120
|
+
}
|
|
121
|
+
throw new Error("probe threw on unreadable specs/ — must be defensive");
|
|
122
|
+
}
|
|
123
|
+
// restore perms so afterEach cleanup can rm -rf
|
|
124
|
+
try {
|
|
125
|
+
chmodSync(path.join(tmp, "specs"), 0o755);
|
|
126
|
+
} catch {
|
|
127
|
+
/* ignore */
|
|
128
|
+
}
|
|
129
|
+
// result is implementation-defined (true if readable, false if not), but
|
|
130
|
+
// MUST NOT throw. Both valid outcomes are accepted.
|
|
131
|
+
expect(typeof result).toBe("boolean");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("short-circuits on first match (does not enumerate further siblings)", () => {
|
|
135
|
+
// Indirect proof: deeply nested tree with hundreds of empty dirs plus one
|
|
136
|
+
// spec.md at the root of specs/. Should still return true quickly.
|
|
137
|
+
mkdirSync(path.join(tmp, "specs"), { recursive: true });
|
|
138
|
+
writeFileSync(path.join(tmp, "specs", "cap-a.md"), "first");
|
|
139
|
+
for (let i = 0; i < 50; i++) {
|
|
140
|
+
mkdirSync(path.join(tmp, "specs", `empty-${i}`), { recursive: true });
|
|
141
|
+
}
|
|
142
|
+
expect(createFsSpecsEvidenceProbe().hasAnySpecFile(tmp)).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for isAppImageSelfHit — pure helper that flags candidate
|
|
3
|
+
* binary paths as the running Electron AppImage launcher (self-hit).
|
|
4
|
+
*
|
|
5
|
+
* See change: fix-electron-appimage-cli-self-detection (D1).
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
import { isAppImageSelfHit } from "../../platform/binary-lookup.js";
|
|
13
|
+
|
|
14
|
+
describe("isAppImageSelfHit", () => {
|
|
15
|
+
let tmpDir: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "appimage-self-hit-"));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
try {
|
|
23
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
24
|
+
} catch { /* ignore */ }
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns false when no env vars are set and execPath is unrelated", () => {
|
|
28
|
+
const candidate = path.join(tmpDir, "unrelated-binary");
|
|
29
|
+
fs.writeFileSync(candidate, "#!/bin/sh\n");
|
|
30
|
+
const result = isAppImageSelfHit(candidate, {
|
|
31
|
+
execPath: "/totally/different/electron",
|
|
32
|
+
appDir: undefined,
|
|
33
|
+
appImage: undefined,
|
|
34
|
+
});
|
|
35
|
+
expect(result).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("matches when candidate lives under APPDIR", () => {
|
|
39
|
+
// Synthesize a fake AppImage mount layout under tmpDir.
|
|
40
|
+
const appDir = path.join(tmpDir, "mount_PI-Das");
|
|
41
|
+
fs.mkdirSync(appDir, { recursive: true });
|
|
42
|
+
const candidate = path.join(appDir, "pi-dashboard");
|
|
43
|
+
fs.writeFileSync(candidate, "#!/bin/sh\n");
|
|
44
|
+
|
|
45
|
+
const result = isAppImageSelfHit(candidate, {
|
|
46
|
+
execPath: "/some/other/electron",
|
|
47
|
+
appDir,
|
|
48
|
+
appImage: undefined,
|
|
49
|
+
});
|
|
50
|
+
expect(result).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("does NOT match when APPDIR is set but candidate is outside it", () => {
|
|
54
|
+
const appDir = path.join(tmpDir, "mount_PI-Das");
|
|
55
|
+
fs.mkdirSync(appDir, { recursive: true });
|
|
56
|
+
const candidate = path.join(tmpDir, "other-place", "pi-dashboard");
|
|
57
|
+
fs.mkdirSync(path.dirname(candidate), { recursive: true });
|
|
58
|
+
fs.writeFileSync(candidate, "#!/bin/sh\n");
|
|
59
|
+
|
|
60
|
+
const result = isAppImageSelfHit(candidate, {
|
|
61
|
+
execPath: "/some/other/electron",
|
|
62
|
+
appDir,
|
|
63
|
+
appImage: undefined,
|
|
64
|
+
});
|
|
65
|
+
expect(result).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("matches when realpath of candidate equals realpath of APPIMAGE", () => {
|
|
69
|
+
const appImage = path.join(tmpDir, "PI-Dashboard.AppImage");
|
|
70
|
+
fs.writeFileSync(appImage, "AppImage payload");
|
|
71
|
+
|
|
72
|
+
const symlink = path.join(tmpDir, "alias-link");
|
|
73
|
+
fs.symlinkSync(appImage, symlink);
|
|
74
|
+
|
|
75
|
+
const result = isAppImageSelfHit(symlink, {
|
|
76
|
+
execPath: "/some/other/electron",
|
|
77
|
+
appDir: undefined,
|
|
78
|
+
appImage,
|
|
79
|
+
});
|
|
80
|
+
expect(result).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("matches when realpath of candidate equals realpath of execPath", () => {
|
|
84
|
+
const exec = path.join(tmpDir, "electron-binary");
|
|
85
|
+
fs.writeFileSync(exec, "Electron");
|
|
86
|
+
|
|
87
|
+
const symlink = path.join(tmpDir, "fake-pi-dashboard");
|
|
88
|
+
fs.symlinkSync(exec, symlink);
|
|
89
|
+
|
|
90
|
+
const result = isAppImageSelfHit(symlink, {
|
|
91
|
+
execPath: exec,
|
|
92
|
+
appDir: undefined,
|
|
93
|
+
appImage: undefined,
|
|
94
|
+
});
|
|
95
|
+
expect(result).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("does NOT match when execPath is unrelated to candidate", () => {
|
|
99
|
+
const candidate = path.join(tmpDir, "real-cli");
|
|
100
|
+
fs.writeFileSync(candidate, "#!/bin/sh\n");
|
|
101
|
+
|
|
102
|
+
const exec = path.join(tmpDir, "electron-binary");
|
|
103
|
+
fs.writeFileSync(exec, "Electron");
|
|
104
|
+
|
|
105
|
+
const result = isAppImageSelfHit(candidate, {
|
|
106
|
+
execPath: exec,
|
|
107
|
+
appDir: undefined,
|
|
108
|
+
appImage: undefined,
|
|
109
|
+
});
|
|
110
|
+
expect(result).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("falls back to literal compare for broken-symlink / ENOENT candidates", () => {
|
|
114
|
+
// Candidate path does not exist on disk.
|
|
115
|
+
const candidate = path.join(tmpDir, "ghost");
|
|
116
|
+
const exec = candidate; // exact-string match — should still match
|
|
117
|
+
|
|
118
|
+
expect(() =>
|
|
119
|
+
isAppImageSelfHit(candidate, {
|
|
120
|
+
execPath: exec,
|
|
121
|
+
appDir: undefined,
|
|
122
|
+
appImage: undefined,
|
|
123
|
+
}),
|
|
124
|
+
).not.toThrow();
|
|
125
|
+
|
|
126
|
+
const result = isAppImageSelfHit(candidate, {
|
|
127
|
+
execPath: exec,
|
|
128
|
+
appDir: undefined,
|
|
129
|
+
appImage: undefined,
|
|
130
|
+
});
|
|
131
|
+
expect(result).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("does not throw when APPDIR points at a non-existent directory", () => {
|
|
135
|
+
const candidate = path.join(tmpDir, "real-cli");
|
|
136
|
+
fs.writeFileSync(candidate, "#!/bin/sh\n");
|
|
137
|
+
const ghostAppDir = path.join(tmpDir, "does-not-exist");
|
|
138
|
+
|
|
139
|
+
expect(() =>
|
|
140
|
+
isAppImageSelfHit(candidate, {
|
|
141
|
+
execPath: "/unrelated",
|
|
142
|
+
appDir: ghostAppDir,
|
|
143
|
+
appImage: undefined,
|
|
144
|
+
}),
|
|
145
|
+
).not.toThrow();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("falls back to reading process.env / process.execPath when opts is omitted", () => {
|
|
149
|
+
const savedAppDir = process.env.APPDIR;
|
|
150
|
+
const savedAppImage = process.env.APPIMAGE;
|
|
151
|
+
try {
|
|
152
|
+
// Construct a self-hit relative to the current process.execPath.
|
|
153
|
+
// realpath(candidate) === realpath(process.execPath) → matches.
|
|
154
|
+
const result = isAppImageSelfHit(process.execPath);
|
|
155
|
+
// process.execPath always exists, so this should match.
|
|
156
|
+
expect(result).toBe(true);
|
|
157
|
+
} finally {
|
|
158
|
+
if (savedAppDir === undefined) delete process.env.APPDIR;
|
|
159
|
+
else process.env.APPDIR = savedAppDir;
|
|
160
|
+
if (savedAppImage === undefined) delete process.env.APPIMAGE;
|
|
161
|
+
else process.env.APPIMAGE = savedAppImage;
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task 9.3: Bridge auto-register extended tests.
|
|
3
|
+
* Verifies: write on boot, remove on disable, preserve user entries,
|
|
4
|
+
* surface path-mismatch conflicts.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import {
|
|
11
|
+
registerPluginBridge,
|
|
12
|
+
deregisterPluginBridge,
|
|
13
|
+
listManagedBridges,
|
|
14
|
+
} from "../plugin-bridge-register.js";
|
|
15
|
+
|
|
16
|
+
let tmpDir: string;
|
|
17
|
+
let homedir: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bridge-ext-test-"));
|
|
21
|
+
homedir = tmpDir;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function settingsPath() {
|
|
29
|
+
return path.join(homedir, ".pi", "agent", "settings.json");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readSettings(): Record<string, unknown> {
|
|
33
|
+
return JSON.parse(fs.readFileSync(settingsPath(), "utf-8"));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("bridge auto-register boot + disable lifecycle", () => {
|
|
37
|
+
it("registers bridge on first boot", () => {
|
|
38
|
+
const result = registerPluginBridge("openspec", "/opt/dashboard/openspec/bridge.js", { homedir });
|
|
39
|
+
expect(result.type).toBe("ok");
|
|
40
|
+
const managed = listManagedBridges({ homedir });
|
|
41
|
+
expect(managed["dashboard-openspec"]).toBe("/opt/dashboard/openspec/bridge.js");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("deregisters bridge on disable", () => {
|
|
45
|
+
registerPluginBridge("openspec", "/opt/dashboard/openspec/bridge.js", { homedir });
|
|
46
|
+
deregisterPluginBridge("openspec", { homedir });
|
|
47
|
+
const managed = listManagedBridges({ homedir });
|
|
48
|
+
expect(Object.keys(managed)).toHaveLength(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("preserves user-owned packages array", () => {
|
|
52
|
+
fs.mkdirSync(path.join(homedir, ".pi", "agent"), { recursive: true });
|
|
53
|
+
fs.writeFileSync(
|
|
54
|
+
settingsPath(),
|
|
55
|
+
JSON.stringify({
|
|
56
|
+
packages: ["/user/my-extension", "/user/another"],
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
registerPluginBridge("demo", "/demo/bridge.js", { homedir });
|
|
60
|
+
const settings = readSettings();
|
|
61
|
+
expect(settings.packages).toEqual(["/user/my-extension", "/user/another"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("surfaces path-mismatch conflict without overwriting", () => {
|
|
65
|
+
registerPluginBridge("openspec", "/old/bridge.js", { homedir });
|
|
66
|
+
const result = registerPluginBridge("openspec", "/new/bridge.js", { homedir });
|
|
67
|
+
expect(result.type).toBe("conflict");
|
|
68
|
+
// Original path preserved
|
|
69
|
+
const managed = listManagedBridges({ homedir });
|
|
70
|
+
expect(managed["dashboard-openspec"]).toBe("/old/bridge.js");
|
|
71
|
+
});
|
|
72
|
+
});
|