@blackbelt-technology/pi-agent-dashboard 0.5.1 → 0.5.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 +26 -5
- package/README.md +30 -0
- package/docs/architecture.md +129 -1
- package/package.json +6 -6
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +10 -8
- package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
- package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
- package/packages/extension/src/bridge-context.ts +67 -3
- package/packages/extension/src/bridge.ts +20 -8
- package/packages/extension/src/command-handler.ts +36 -13
- package/packages/extension/src/prompt-expander.ts +74 -63
- package/packages/extension/src/server-launcher.ts +31 -70
- package/packages/extension/src/slash-dispatch.ts +123 -0
- package/packages/server/bin/pi-dashboard.mjs +84 -0
- package/packages/server/package.json +6 -5
- package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +12 -18
- package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
- package/packages/server/src/__tests__/directory-service.test.ts +1 -1
- package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
- package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
- package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
- package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
- package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
- package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
- package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
- package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
- package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
- package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
- package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
- package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
- package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
- package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
- package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
- package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
- package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
- package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
- package/packages/server/src/auth-plugin.ts +3 -0
- package/packages/server/src/bootstrap-state.ts +10 -0
- package/packages/server/src/browser-gateway.ts +15 -7
- package/packages/server/src/browser-handlers/session-action-handler.ts +30 -4
- package/packages/server/src/cli.ts +61 -81
- package/packages/server/src/config-api.ts +14 -2
- package/packages/server/src/directory-service.ts +106 -4
- package/packages/server/src/event-wiring.ts +31 -1
- package/packages/server/src/headless-pid-registry.ts +299 -41
- package/packages/server/src/legacy-pi-cleanup.ts +151 -0
- package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
- package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
- package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
- package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
- package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
- package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
- package/packages/server/src/model-proxy/api-key-store.ts +87 -0
- package/packages/server/src/model-proxy/auth-gate.ts +116 -0
- package/packages/server/src/model-proxy/concurrency.ts +76 -0
- package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
- package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
- package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
- package/packages/server/src/model-proxy/convert/index.ts +8 -0
- package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
- package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
- package/packages/server/src/model-proxy/convert/types.ts +70 -0
- package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
- package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
- package/packages/server/src/model-proxy/internal-registry.ts +157 -0
- package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
- package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
- package/packages/server/src/model-proxy/request-log.ts +53 -0
- package/packages/server/src/model-proxy/streamer.ts +59 -0
- package/packages/server/src/openspec-group-store.ts +490 -0
- package/packages/server/src/process-manager.ts +128 -0
- package/packages/server/src/provider-auth-storage.ts +29 -47
- package/packages/server/src/restart-helper.ts +17 -16
- package/packages/server/src/routes/bootstrap-routes.ts +37 -0
- package/packages/server/src/routes/jj-routes.ts +3 -0
- package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
- package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
- package/packages/server/src/routes/model-proxy-routes.ts +330 -0
- package/packages/server/src/routes/openspec-group-routes.ts +231 -0
- package/packages/server/src/routes/provider-auth-routes.ts +3 -0
- package/packages/server/src/routes/provider-routes.ts +24 -1
- package/packages/server/src/routes/system-routes.ts +44 -2
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
- package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
- package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
- package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
- package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
- package/packages/server/src/server.ts +178 -2
- package/packages/server/src/session-api.ts +9 -1
- package/packages/server/src/tunnel-watchdog.ts +230 -0
- package/packages/server/src/tunnel.ts +5 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
- package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
- package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
- package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
- package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
- package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +27 -0
- package/packages/shared/src/config.ts +172 -2
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
- package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
- package/packages/shared/src/platform/binary-lookup.ts +204 -0
- package/packages/shared/src/platform/node-spawn.ts +42 -5
- package/packages/shared/src/protocol.ts +19 -1
- package/packages/shared/src/recommended-extensions.ts +18 -0
- package/packages/shared/src/rest-api.ts +219 -1
- package/packages/shared/src/server-launcher.ts +277 -0
- package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
- package/packages/shared/src/types.ts +55 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -184
- package/packages/shared/src/resolve-jiti.ts +0 -155
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration-style tests for the OpenSpec change-grouping
|
|
3
|
+
* broadcast + caching contract:
|
|
4
|
+
*
|
|
5
|
+
* 4.3 — 100 broadcasts on an unchanged groups file produce 0 readFile
|
|
6
|
+
* calls but ~100 stat calls (cache fast-path).
|
|
7
|
+
* 4.5 — A write through `store.createGroup` triggers a debounced
|
|
8
|
+
* `openspec_groups_update` payload to subscribers.
|
|
9
|
+
*
|
|
10
|
+
* See change: add-openspec-change-grouping.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
13
|
+
import fs from "node:fs/promises";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import {
|
|
17
|
+
createOpenSpecGroupStore,
|
|
18
|
+
type OpenSpecGroupStore,
|
|
19
|
+
} from "../openspec-group-store.js";
|
|
20
|
+
|
|
21
|
+
describe("openspec-group-store — broadcast + cache instrumentation", () => {
|
|
22
|
+
let tmpDir: string;
|
|
23
|
+
let store: OpenSpecGroupStore;
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ogs-broadcast-"));
|
|
27
|
+
store = createOpenSpecGroupStore({ debounceMs: 5 });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
store.dispose();
|
|
32
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("100 sequential reads on an unchanged file produce 0 readFile calls and ~100 stat calls", async () => {
|
|
36
|
+
// Seed the file + warm the cache once.
|
|
37
|
+
await store.createGroup(tmpDir, { name: "UI" });
|
|
38
|
+
// Drain any pending debounce work to keep the spy clean.
|
|
39
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
40
|
+
|
|
41
|
+
const readSpy = vi.spyOn(fs, "readFile");
|
|
42
|
+
const statSpy = vi.spyOn(fs, "stat");
|
|
43
|
+
try {
|
|
44
|
+
for (let i = 0; i < 100; i++) {
|
|
45
|
+
await store.read(tmpDir);
|
|
46
|
+
}
|
|
47
|
+
expect(readSpy).not.toHaveBeenCalled();
|
|
48
|
+
// ~100 stat calls — exact equality because reads are sequential.
|
|
49
|
+
expect(statSpy.mock.calls.length).toBeGreaterThanOrEqual(100);
|
|
50
|
+
// (post-write `fs.stat` to update cache happens before the spy
|
|
51
|
+
// is installed, so it doesn't inflate the count here.)
|
|
52
|
+
} finally {
|
|
53
|
+
readSpy.mockRestore();
|
|
54
|
+
statSpy.mockRestore();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("a single createGroup write produces one debounced subscriber callback with the new payload", async () => {
|
|
59
|
+
const events: Array<{ cwd: string; groupCount: number; assignmentCount: number }> = [];
|
|
60
|
+
store.subscribe((cwd, payload) => {
|
|
61
|
+
events.push({
|
|
62
|
+
cwd,
|
|
63
|
+
groupCount: payload.groups.length,
|
|
64
|
+
assignmentCount: Object.keys(payload.assignments).length,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await store.createGroup(tmpDir, { name: "UI" });
|
|
69
|
+
// Wait past the debounce window.
|
|
70
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
71
|
+
|
|
72
|
+
expect(events).toHaveLength(1);
|
|
73
|
+
expect(events[0]?.cwd).toBe(tmpDir);
|
|
74
|
+
expect(events[0]?.groupCount).toBe(1);
|
|
75
|
+
expect(events[0]?.assignmentCount).toBe(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("an assignment write follows up with a payload reflecting both the group AND the assignment", async () => {
|
|
79
|
+
const events: Array<{ groups: number; assignments: number }> = [];
|
|
80
|
+
store.subscribe((_cwd, payload) => {
|
|
81
|
+
events.push({
|
|
82
|
+
groups: payload.groups.length,
|
|
83
|
+
assignments: Object.keys(payload.assignments).length,
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
const created = await store.createGroup(tmpDir, { name: "UI" });
|
|
87
|
+
await store.setAssignment(tmpDir, "add-foo", created.id);
|
|
88
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
89
|
+
// Two writes inside the same window may coalesce into 1 broadcast,
|
|
90
|
+
// OR resolve as two distinct broadcasts depending on timing. Assert
|
|
91
|
+
// the FINAL event reflects both writes.
|
|
92
|
+
expect(events.length).toBeGreaterThanOrEqual(1);
|
|
93
|
+
const last = events[events.length - 1];
|
|
94
|
+
expect(last?.groups).toBe(1);
|
|
95
|
+
expect(last?.assignments).toBe(1);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the pure `joinGroupIdsToOpenSpecData` helper that injects
|
|
3
|
+
* `groupId` into every `OpenSpecChange` from a per-cwd assignments map.
|
|
4
|
+
*
|
|
5
|
+
* See change: add-openspec-change-grouping (tasks 4.1–4.2).
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from "vitest";
|
|
8
|
+
import { joinGroupIdsToOpenSpecData } from "../openspec-group-store.js";
|
|
9
|
+
import type { OpenSpecData } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
10
|
+
|
|
11
|
+
function mkData(changes: Array<{ name: string }>): OpenSpecData {
|
|
12
|
+
return {
|
|
13
|
+
initialized: true,
|
|
14
|
+
changes: changes.map((c) => ({
|
|
15
|
+
name: c.name,
|
|
16
|
+
status: "in-progress" as const,
|
|
17
|
+
completedTasks: 0,
|
|
18
|
+
totalTasks: 0,
|
|
19
|
+
artifacts: [],
|
|
20
|
+
})),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("joinGroupIdsToOpenSpecData", () => {
|
|
25
|
+
it("populates groupId from the assignments map", () => {
|
|
26
|
+
const data = mkData([{ name: "add-foo" }, { name: "fix-bar" }]);
|
|
27
|
+
const enriched = joinGroupIdsToOpenSpecData(data, { "add-foo": "ui" });
|
|
28
|
+
expect(enriched.changes[0]?.groupId).toBe("ui");
|
|
29
|
+
expect(enriched.changes[1]?.groupId).toBe(null);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("emits null groupId when no assignment exists", () => {
|
|
33
|
+
const data = mkData([{ name: "add-foo" }]);
|
|
34
|
+
const enriched = joinGroupIdsToOpenSpecData(data, {});
|
|
35
|
+
expect(enriched.changes[0]?.groupId).toBe(null);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("ignores assignments for changes that aren't in the live data set", () => {
|
|
39
|
+
const data = mkData([{ name: "add-foo" }]);
|
|
40
|
+
const enriched = joinGroupIdsToOpenSpecData(data, {
|
|
41
|
+
"add-foo": "ui",
|
|
42
|
+
"never-existed": "server",
|
|
43
|
+
});
|
|
44
|
+
expect(enriched.changes).toHaveLength(1);
|
|
45
|
+
expect(enriched.changes[0]?.groupId).toBe("ui");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("preserves all other OpenSpecData fields verbatim", () => {
|
|
49
|
+
const data: OpenSpecData = {
|
|
50
|
+
initialized: true,
|
|
51
|
+
pending: false,
|
|
52
|
+
changes: [
|
|
53
|
+
{
|
|
54
|
+
name: "add-foo",
|
|
55
|
+
status: "complete",
|
|
56
|
+
completedTasks: 7,
|
|
57
|
+
totalTasks: 7,
|
|
58
|
+
artifacts: [{ id: "proposal", status: "done" }],
|
|
59
|
+
isComplete: true,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
const enriched = joinGroupIdsToOpenSpecData(data, { "add-foo": "ui" });
|
|
64
|
+
expect(enriched.initialized).toBe(true);
|
|
65
|
+
expect(enriched.pending).toBe(false);
|
|
66
|
+
const change = enriched.changes[0];
|
|
67
|
+
expect(change?.completedTasks).toBe(7);
|
|
68
|
+
expect(change?.totalTasks).toBe(7);
|
|
69
|
+
expect(change?.artifacts).toEqual([{ id: "proposal", status: "done" }]);
|
|
70
|
+
expect(change?.isComplete).toBe(true);
|
|
71
|
+
expect(change?.groupId).toBe("ui");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("does not mutate the original data object", () => {
|
|
75
|
+
const data = mkData([{ name: "add-foo" }]);
|
|
76
|
+
const original = JSON.parse(JSON.stringify(data));
|
|
77
|
+
joinGroupIdsToOpenSpecData(data, { "add-foo": "ui" });
|
|
78
|
+
expect(data).toEqual(original);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP-level tests for the OpenSpec change-grouping routes.
|
|
3
|
+
*
|
|
4
|
+
* Covers tasks 3.11–3.13 and the spec scenarios under
|
|
5
|
+
* `Requirement: Group CRUD REST routes` and `Requirement: Authentication and cwd validation`.
|
|
6
|
+
*
|
|
7
|
+
* See change: add-openspec-change-grouping.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
10
|
+
import Fastify, { type FastifyInstance } from "fastify";
|
|
11
|
+
import fs from "node:fs/promises";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { registerOpenSpecGroupRoutes } from "../routes/openspec-group-routes.js";
|
|
15
|
+
import { createOpenSpecGroupStore, type OpenSpecGroupStore } from "../openspec-group-store.js";
|
|
16
|
+
|
|
17
|
+
const PASSTHRU_GUARD = async () => {};
|
|
18
|
+
const DENY_GUARD = async (_req: any, reply: any) => {
|
|
19
|
+
reply.code(403).send({ success: false, error: "forbidden" });
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function makeSessionManager(cwd: string): any {
|
|
23
|
+
return {
|
|
24
|
+
listAll: () => [{ id: "s1", cwd, source: "tui" }],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function makePreferencesStore(): any {
|
|
28
|
+
return { getPinnedDirectories: () => [] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("openspec group REST routes", () => {
|
|
32
|
+
let tmpDir: string;
|
|
33
|
+
let fastify: FastifyInstance;
|
|
34
|
+
let store: OpenSpecGroupStore;
|
|
35
|
+
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ogs-routes-"));
|
|
38
|
+
store = createOpenSpecGroupStore({ debounceMs: 5 });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
if (fastify) await fastify.close();
|
|
43
|
+
store.dispose();
|
|
44
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
async function setup(opts: { networkGuard?: any } = {}) {
|
|
48
|
+
fastify = Fastify();
|
|
49
|
+
registerOpenSpecGroupRoutes(fastify, {
|
|
50
|
+
sessionManager: makeSessionManager(tmpDir),
|
|
51
|
+
preferencesStore: makePreferencesStore(),
|
|
52
|
+
networkGuard: opts.networkGuard ?? PASSTHRU_GUARD,
|
|
53
|
+
store,
|
|
54
|
+
});
|
|
55
|
+
await fastify.ready();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── GET ──────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
it("GET → returns the empty default when file is absent", async () => {
|
|
61
|
+
await setup();
|
|
62
|
+
const res = await fastify.inject({
|
|
63
|
+
method: "GET",
|
|
64
|
+
url: `/api/openspec/groups?cwd=${encodeURIComponent(tmpDir)}`,
|
|
65
|
+
});
|
|
66
|
+
expect(res.statusCode).toBe(200);
|
|
67
|
+
const body = JSON.parse(res.payload);
|
|
68
|
+
expect(body).toEqual({
|
|
69
|
+
success: true,
|
|
70
|
+
data: { schemaVersion: 1, groups: [], assignments: {} },
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("GET → 400 when cwd query is missing", async () => {
|
|
75
|
+
await setup();
|
|
76
|
+
const res = await fastify.inject({ method: "GET", url: "/api/openspec/groups" });
|
|
77
|
+
expect(res.statusCode).toBe(400);
|
|
78
|
+
const body = JSON.parse(res.payload);
|
|
79
|
+
expect(body.success).toBe(false);
|
|
80
|
+
expect(body.error).toMatch(/cwd/i);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("GET → 403 when cwd is not in the known-cwd set", async () => {
|
|
84
|
+
await setup();
|
|
85
|
+
const otherDir = await fs.mkdtemp(path.join(os.tmpdir(), "ogs-other-"));
|
|
86
|
+
try {
|
|
87
|
+
const res = await fastify.inject({
|
|
88
|
+
method: "GET",
|
|
89
|
+
url: `/api/openspec/groups?cwd=${encodeURIComponent(otherDir)}`,
|
|
90
|
+
});
|
|
91
|
+
expect(res.statusCode).toBe(403);
|
|
92
|
+
const body = JSON.parse(res.payload);
|
|
93
|
+
expect(body.success).toBe(false);
|
|
94
|
+
expect(body.error).toMatch(/cwd/i);
|
|
95
|
+
} finally {
|
|
96
|
+
await fs.rm(otherDir, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("GET → 422 when on-disk file has unsupported schemaVersion", async () => {
|
|
101
|
+
await setup();
|
|
102
|
+
const file = path.join(tmpDir, "openspec", "groups", "groups.json");
|
|
103
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
104
|
+
await fs.writeFile(file, JSON.stringify({ schemaVersion: 2, groups: [], assignments: {} }));
|
|
105
|
+
const res = await fastify.inject({
|
|
106
|
+
method: "GET",
|
|
107
|
+
url: `/api/openspec/groups?cwd=${encodeURIComponent(tmpDir)}`,
|
|
108
|
+
});
|
|
109
|
+
expect(res.statusCode).toBe(422);
|
|
110
|
+
const body = JSON.parse(res.payload);
|
|
111
|
+
expect(body.success).toBe(false);
|
|
112
|
+
expect(body.error).toMatch(/schema/i);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("network guard 403 short-circuits the route", async () => {
|
|
116
|
+
await setup({ networkGuard: DENY_GUARD });
|
|
117
|
+
const res = await fastify.inject({
|
|
118
|
+
method: "GET",
|
|
119
|
+
url: `/api/openspec/groups?cwd=${encodeURIComponent(tmpDir)}`,
|
|
120
|
+
});
|
|
121
|
+
expect(res.statusCode).toBe(403);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── POST ─────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
it("POST → 201 creates a group with generated id and order=0", async () => {
|
|
127
|
+
await setup();
|
|
128
|
+
const res = await fastify.inject({
|
|
129
|
+
method: "POST",
|
|
130
|
+
url: `/api/openspec/groups?cwd=${encodeURIComponent(tmpDir)}`,
|
|
131
|
+
payload: { name: "UI", color: "#3b82f6" },
|
|
132
|
+
});
|
|
133
|
+
expect(res.statusCode).toBe(201);
|
|
134
|
+
const body = JSON.parse(res.payload);
|
|
135
|
+
expect(body.success).toBe(true);
|
|
136
|
+
expect(body.data).toEqual({ id: "ui", name: "UI", color: "#3b82f6", order: 0 });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("POST → collision suffix on duplicate name", async () => {
|
|
140
|
+
await setup();
|
|
141
|
+
await fastify.inject({
|
|
142
|
+
method: "POST",
|
|
143
|
+
url: `/api/openspec/groups?cwd=${encodeURIComponent(tmpDir)}`,
|
|
144
|
+
payload: { name: "UI" },
|
|
145
|
+
});
|
|
146
|
+
const second = await fastify.inject({
|
|
147
|
+
method: "POST",
|
|
148
|
+
url: `/api/openspec/groups?cwd=${encodeURIComponent(tmpDir)}`,
|
|
149
|
+
payload: { name: "UI" },
|
|
150
|
+
});
|
|
151
|
+
expect(second.statusCode).toBe(201);
|
|
152
|
+
expect(JSON.parse(second.payload).data.id).toBe("ui-2");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("POST → 400 on missing name", async () => {
|
|
156
|
+
await setup();
|
|
157
|
+
const res = await fastify.inject({
|
|
158
|
+
method: "POST",
|
|
159
|
+
url: `/api/openspec/groups?cwd=${encodeURIComponent(tmpDir)}`,
|
|
160
|
+
payload: {},
|
|
161
|
+
});
|
|
162
|
+
expect(res.statusCode).toBe(400);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── PATCH ────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
it("PATCH → 200 updates name without changing id", async () => {
|
|
168
|
+
await setup();
|
|
169
|
+
const created = await fastify.inject({
|
|
170
|
+
method: "POST",
|
|
171
|
+
url: `/api/openspec/groups?cwd=${encodeURIComponent(tmpDir)}`,
|
|
172
|
+
payload: { name: "UI" },
|
|
173
|
+
});
|
|
174
|
+
const id = JSON.parse(created.payload).data.id;
|
|
175
|
+
const res = await fastify.inject({
|
|
176
|
+
method: "PATCH",
|
|
177
|
+
url: `/api/openspec/groups/${id}?cwd=${encodeURIComponent(tmpDir)}`,
|
|
178
|
+
payload: { name: "Frontend" },
|
|
179
|
+
});
|
|
180
|
+
expect(res.statusCode).toBe(200);
|
|
181
|
+
const body = JSON.parse(res.payload);
|
|
182
|
+
expect(body.data.id).toBe("ui");
|
|
183
|
+
expect(body.data.name).toBe("Frontend");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("PATCH → 404 on unknown id", async () => {
|
|
187
|
+
await setup();
|
|
188
|
+
const res = await fastify.inject({
|
|
189
|
+
method: "PATCH",
|
|
190
|
+
url: `/api/openspec/groups/does-not-exist?cwd=${encodeURIComponent(tmpDir)}`,
|
|
191
|
+
payload: { name: "X" },
|
|
192
|
+
});
|
|
193
|
+
expect(res.statusCode).toBe(404);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ── DELETE ───────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
it("DELETE → 200 removes group and cascades through assignments", async () => {
|
|
199
|
+
await setup();
|
|
200
|
+
const a = await fastify.inject({
|
|
201
|
+
method: "POST",
|
|
202
|
+
url: `/api/openspec/groups?cwd=${encodeURIComponent(tmpDir)}`,
|
|
203
|
+
payload: { name: "UI" },
|
|
204
|
+
});
|
|
205
|
+
const b = await fastify.inject({
|
|
206
|
+
method: "POST",
|
|
207
|
+
url: `/api/openspec/groups?cwd=${encodeURIComponent(tmpDir)}`,
|
|
208
|
+
payload: { name: "Server" },
|
|
209
|
+
});
|
|
210
|
+
const aId = JSON.parse(a.payload).data.id;
|
|
211
|
+
const bId = JSON.parse(b.payload).data.id;
|
|
212
|
+
await fastify.inject({
|
|
213
|
+
method: "PUT",
|
|
214
|
+
url: `/api/openspec/groups/assignments?cwd=${encodeURIComponent(tmpDir)}`,
|
|
215
|
+
payload: { changeName: "add-foo", groupId: aId },
|
|
216
|
+
});
|
|
217
|
+
await fastify.inject({
|
|
218
|
+
method: "PUT",
|
|
219
|
+
url: `/api/openspec/groups/assignments?cwd=${encodeURIComponent(tmpDir)}`,
|
|
220
|
+
payload: { changeName: "fix-bar", groupId: bId },
|
|
221
|
+
});
|
|
222
|
+
const res = await fastify.inject({
|
|
223
|
+
method: "DELETE",
|
|
224
|
+
url: `/api/openspec/groups/${aId}?cwd=${encodeURIComponent(tmpDir)}`,
|
|
225
|
+
});
|
|
226
|
+
expect(res.statusCode).toBe(200);
|
|
227
|
+
const data = await store.read(tmpDir);
|
|
228
|
+
expect(data.assignments).toEqual({ "fix-bar": bId });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("DELETE → 404 on unknown id", async () => {
|
|
232
|
+
await setup();
|
|
233
|
+
const res = await fastify.inject({
|
|
234
|
+
method: "DELETE",
|
|
235
|
+
url: `/api/openspec/groups/does-not-exist?cwd=${encodeURIComponent(tmpDir)}`,
|
|
236
|
+
});
|
|
237
|
+
expect(res.statusCode).toBe(404);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ── PUT assignments ──────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
it("PUT → 200 sets assignment", async () => {
|
|
243
|
+
await setup();
|
|
244
|
+
const created = await fastify.inject({
|
|
245
|
+
method: "POST",
|
|
246
|
+
url: `/api/openspec/groups?cwd=${encodeURIComponent(tmpDir)}`,
|
|
247
|
+
payload: { name: "UI" },
|
|
248
|
+
});
|
|
249
|
+
const id = JSON.parse(created.payload).data.id;
|
|
250
|
+
const res = await fastify.inject({
|
|
251
|
+
method: "PUT",
|
|
252
|
+
url: `/api/openspec/groups/assignments?cwd=${encodeURIComponent(tmpDir)}`,
|
|
253
|
+
payload: { changeName: "add-foo", groupId: id },
|
|
254
|
+
});
|
|
255
|
+
expect(res.statusCode).toBe(200);
|
|
256
|
+
const data = await store.read(tmpDir);
|
|
257
|
+
expect(data.assignments).toEqual({ "add-foo": id });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("PUT → 200 with null clears the entry", async () => {
|
|
261
|
+
await setup();
|
|
262
|
+
const created = await fastify.inject({
|
|
263
|
+
method: "POST",
|
|
264
|
+
url: `/api/openspec/groups?cwd=${encodeURIComponent(tmpDir)}`,
|
|
265
|
+
payload: { name: "UI" },
|
|
266
|
+
});
|
|
267
|
+
const id = JSON.parse(created.payload).data.id;
|
|
268
|
+
await fastify.inject({
|
|
269
|
+
method: "PUT",
|
|
270
|
+
url: `/api/openspec/groups/assignments?cwd=${encodeURIComponent(tmpDir)}`,
|
|
271
|
+
payload: { changeName: "add-foo", groupId: id },
|
|
272
|
+
});
|
|
273
|
+
const res = await fastify.inject({
|
|
274
|
+
method: "PUT",
|
|
275
|
+
url: `/api/openspec/groups/assignments?cwd=${encodeURIComponent(tmpDir)}`,
|
|
276
|
+
payload: { changeName: "add-foo", groupId: null },
|
|
277
|
+
});
|
|
278
|
+
expect(res.statusCode).toBe(200);
|
|
279
|
+
const data = await store.read(tmpDir);
|
|
280
|
+
expect(data.assignments).toEqual({});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("PUT → 422 on unknown groupId", async () => {
|
|
284
|
+
await setup();
|
|
285
|
+
const res = await fastify.inject({
|
|
286
|
+
method: "PUT",
|
|
287
|
+
url: `/api/openspec/groups/assignments?cwd=${encodeURIComponent(tmpDir)}`,
|
|
288
|
+
payload: { changeName: "add-foo", groupId: "does-not-exist" },
|
|
289
|
+
});
|
|
290
|
+
expect(res.statusCode).toBe(422);
|
|
291
|
+
const body = JSON.parse(res.payload);
|
|
292
|
+
expect(body.success).toBe(false);
|
|
293
|
+
expect(body.error).toMatch(/group/i);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("PUT → tolerates unknown changeName and persists", async () => {
|
|
297
|
+
await setup();
|
|
298
|
+
const created = await fastify.inject({
|
|
299
|
+
method: "POST",
|
|
300
|
+
url: `/api/openspec/groups?cwd=${encodeURIComponent(tmpDir)}`,
|
|
301
|
+
payload: { name: "UI" },
|
|
302
|
+
});
|
|
303
|
+
const id = JSON.parse(created.payload).data.id;
|
|
304
|
+
const res = await fastify.inject({
|
|
305
|
+
method: "PUT",
|
|
306
|
+
url: `/api/openspec/groups/assignments?cwd=${encodeURIComponent(tmpDir)}`,
|
|
307
|
+
payload: { changeName: "never-existed", groupId: id },
|
|
308
|
+
});
|
|
309
|
+
expect(res.statusCode).toBe(200);
|
|
310
|
+
const data = await store.read(tmpDir);
|
|
311
|
+
expect(data.assignments).toEqual({ "never-existed": id });
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("PUT → 400 on malformed body", async () => {
|
|
315
|
+
await setup();
|
|
316
|
+
const res = await fastify.inject({
|
|
317
|
+
method: "PUT",
|
|
318
|
+
url: `/api/openspec/groups/assignments?cwd=${encodeURIComponent(tmpDir)}`,
|
|
319
|
+
payload: { changeName: 123, groupId: false },
|
|
320
|
+
});
|
|
321
|
+
expect(res.statusCode).toBe(400);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// ── 409 ConcurrentEditError mapping ──────────────────────────
|
|
325
|
+
|
|
326
|
+
it("POST → 409 with current payload when the store reports a sustained race", async () => {
|
|
327
|
+
fastify = Fastify();
|
|
328
|
+
let strikes = 0;
|
|
329
|
+
const racingStore = createOpenSpecGroupStore({
|
|
330
|
+
debounceMs: 5,
|
|
331
|
+
__testHookBeforeRename: async () => {
|
|
332
|
+
strikes++;
|
|
333
|
+
const file = path.join(tmpDir, "openspec", "groups", "groups.json");
|
|
334
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
335
|
+
const future = new Date(Date.now() + 60_000 * strikes);
|
|
336
|
+
await fs.writeFile(
|
|
337
|
+
file,
|
|
338
|
+
JSON.stringify({
|
|
339
|
+
schemaVersion: 1,
|
|
340
|
+
groups: [{ id: "external", name: `External-${strikes}`, order: 0 }],
|
|
341
|
+
assignments: {},
|
|
342
|
+
}),
|
|
343
|
+
);
|
|
344
|
+
await fs.utimes(file, future, future);
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
try {
|
|
348
|
+
registerOpenSpecGroupRoutes(fastify, {
|
|
349
|
+
sessionManager: makeSessionManager(tmpDir),
|
|
350
|
+
preferencesStore: makePreferencesStore(),
|
|
351
|
+
networkGuard: PASSTHRU_GUARD,
|
|
352
|
+
store: racingStore,
|
|
353
|
+
});
|
|
354
|
+
await fastify.ready();
|
|
355
|
+
const res = await fastify.inject({
|
|
356
|
+
method: "POST",
|
|
357
|
+
url: `/api/openspec/groups?cwd=${encodeURIComponent(tmpDir)}`,
|
|
358
|
+
payload: { name: "UI" },
|
|
359
|
+
});
|
|
360
|
+
expect(res.statusCode).toBe(409);
|
|
361
|
+
const body = JSON.parse(res.payload);
|
|
362
|
+
expect(body.success).toBe(false);
|
|
363
|
+
expect(body.error).toMatch(/concurrent/i);
|
|
364
|
+
expect(body.data).toBeDefined();
|
|
365
|
+
expect(body.data.groups).toHaveLength(1);
|
|
366
|
+
} finally {
|
|
367
|
+
racingStore.dispose();
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
});
|