@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,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `ToolResolver.resolveJiti` — ported from the prior
|
|
3
|
+
* `resolve-jiti.test.ts`. Exercises every anchor in the resolution
|
|
4
|
+
* chain (managed-pi upstream/legacy, system-pi, anchor walk-up,
|
|
5
|
+
* argv fallback, all-miss) plus the URL-shape invariants
|
|
6
|
+
* (`file://` URL output, Windows drive-letter wrapping, upstream
|
|
7
|
+
* jiti chosen before legacy fork).
|
|
8
|
+
*
|
|
9
|
+
* Test seams (`_pathExists`, `_realpath`, `_whichPi`, `_argv1`,
|
|
10
|
+
* `_managedDir`, `resolver`) keep the test pure — no fs / process
|
|
11
|
+
* mutation, no managed-dir on disk.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect } from "vitest";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { ToolResolver, MANAGED_PI_PACKAGES, JITI_PACKAGES } from "../platform/binary-lookup.js";
|
|
16
|
+
|
|
17
|
+
const MANAGED_DIR = "/fake/.pi-dashboard";
|
|
18
|
+
|
|
19
|
+
function makeResolver(installed: Record<string, string>) {
|
|
20
|
+
return (spec: string): string => {
|
|
21
|
+
if (spec in installed) return installed[spec]!;
|
|
22
|
+
throw new Error(`Cannot find module '${spec}'`);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("MANAGED_PI_PACKAGES + JITI_PACKAGES contract", () => {
|
|
27
|
+
it("upstream pi pkg first, legacy fork fallback", () => {
|
|
28
|
+
expect(MANAGED_PI_PACKAGES).toEqual([
|
|
29
|
+
"@earendil-works/pi-coding-agent",
|
|
30
|
+
"@mariozechner/pi-coding-agent",
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("upstream jiti first, legacy fork fallback", () => {
|
|
35
|
+
expect(JITI_PACKAGES).toEqual(["jiti", "@mariozechner/jiti"]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("ToolResolver.resolveJiti — managed pi", () => {
|
|
40
|
+
it("hits upstream managed pi (@earendil-works) when only it is present", () => {
|
|
41
|
+
const upstreamPkgJson = path.join(
|
|
42
|
+
MANAGED_DIR, "node_modules", "@earendil-works", "pi-coding-agent", "package.json",
|
|
43
|
+
);
|
|
44
|
+
const jitiPkgJson = "/managed/upstream/node_modules/jiti/package.json";
|
|
45
|
+
const url = new ToolResolver().resolveJiti({
|
|
46
|
+
_managedDir: MANAGED_DIR,
|
|
47
|
+
_pathExists: (p) => p === upstreamPkgJson || p === path.join(path.dirname(jitiPkgJson), "lib", "jiti-register.mjs"),
|
|
48
|
+
_whichPi: () => null,
|
|
49
|
+
_argv1: undefined,
|
|
50
|
+
resolver: makeResolver({ "jiti/package.json": jitiPkgJson }),
|
|
51
|
+
});
|
|
52
|
+
expect(url).not.toBeNull();
|
|
53
|
+
expect(url!.startsWith("file://")).toBe(true);
|
|
54
|
+
expect(url!).toMatch(/\/jiti\/lib\/jiti-register\.mjs$/);
|
|
55
|
+
expect(url!).not.toContain("@mariozechner");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("falls through to legacy managed pi (@mariozechner) when upstream is absent", () => {
|
|
59
|
+
const legacyPkgJson = path.join(
|
|
60
|
+
MANAGED_DIR, "node_modules", "@mariozechner", "pi-coding-agent", "package.json",
|
|
61
|
+
);
|
|
62
|
+
const jitiPkgJson = "/managed/legacy/node_modules/@mariozechner/jiti/package.json";
|
|
63
|
+
const url = new ToolResolver().resolveJiti({
|
|
64
|
+
_managedDir: MANAGED_DIR,
|
|
65
|
+
_pathExists: (p) =>
|
|
66
|
+
p === legacyPkgJson ||
|
|
67
|
+
p === path.join(path.dirname(jitiPkgJson), "lib", "jiti-register.mjs"),
|
|
68
|
+
_whichPi: () => null,
|
|
69
|
+
_argv1: undefined,
|
|
70
|
+
resolver: makeResolver({
|
|
71
|
+
"@mariozechner/jiti/package.json": jitiPkgJson,
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
expect(url).not.toBeNull();
|
|
75
|
+
expect(url!).toContain("@mariozechner/jiti");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("prefers upstream pi over legacy when BOTH managed pkgs are present", () => {
|
|
79
|
+
const upstream = path.join(MANAGED_DIR, "node_modules", "@earendil-works", "pi-coding-agent", "package.json");
|
|
80
|
+
const legacy = path.join(MANAGED_DIR, "node_modules", "@mariozechner", "pi-coding-agent", "package.json");
|
|
81
|
+
const upstreamJiti = "/managed/upstream/jiti/package.json";
|
|
82
|
+
const legacyJiti = "/managed/legacy/@mariozechner/jiti/package.json";
|
|
83
|
+
const calls: string[] = [];
|
|
84
|
+
const resolver = (spec: string): string => {
|
|
85
|
+
calls.push(spec);
|
|
86
|
+
if (spec === "jiti/package.json") return upstreamJiti;
|
|
87
|
+
if (spec === "@mariozechner/jiti/package.json") return legacyJiti;
|
|
88
|
+
throw new Error(`nope ${spec}`);
|
|
89
|
+
};
|
|
90
|
+
const url = new ToolResolver().resolveJiti({
|
|
91
|
+
_managedDir: MANAGED_DIR,
|
|
92
|
+
_pathExists: (p) =>
|
|
93
|
+
p === upstream || p === legacy ||
|
|
94
|
+
p === path.join(path.dirname(upstreamJiti), "lib", "jiti-register.mjs"),
|
|
95
|
+
_whichPi: () => null,
|
|
96
|
+
_argv1: undefined,
|
|
97
|
+
resolver,
|
|
98
|
+
});
|
|
99
|
+
expect(url!).toMatch(/\/jiti\/lib\/jiti-register\.mjs$/);
|
|
100
|
+
expect(url!).not.toContain("@mariozechner");
|
|
101
|
+
// Upstream pi anchor produced upstream jiti — legacy pi anchor never tried.
|
|
102
|
+
expect(calls).toEqual(["jiti/package.json"]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("ToolResolver.resolveJiti — system pi", () => {
|
|
107
|
+
it("uses which(\"pi\") when managed pi absent", () => {
|
|
108
|
+
const piBin = "/usr/local/bin/pi";
|
|
109
|
+
const piReal = "/usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js";
|
|
110
|
+
const jitiPkgJson = "/usr/local/lib/node_modules/jiti/package.json";
|
|
111
|
+
const url = new ToolResolver().resolveJiti({
|
|
112
|
+
_managedDir: MANAGED_DIR,
|
|
113
|
+
_pathExists: (p) => p === path.join(path.dirname(jitiPkgJson), "lib", "jiti-register.mjs"),
|
|
114
|
+
_whichPi: () => piBin,
|
|
115
|
+
_realpath: (p) => (p === piBin ? piReal : p),
|
|
116
|
+
_argv1: undefined,
|
|
117
|
+
resolver: makeResolver({ "jiti/package.json": jitiPkgJson }),
|
|
118
|
+
});
|
|
119
|
+
expect(url!.startsWith("file://")).toBe(true);
|
|
120
|
+
expect(url!).toMatch(/\/jiti\/lib\/jiti-register\.mjs$/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("realpaths a symlinked pi binary before resolving", () => {
|
|
124
|
+
const piSymlink = "/usr/local/bin/pi";
|
|
125
|
+
const piTarget = "/opt/pi/dist/cli.js";
|
|
126
|
+
const jitiPkgJson = "/opt/pi/node_modules/jiti/package.json";
|
|
127
|
+
let realpathArg: string | null = null;
|
|
128
|
+
const url = new ToolResolver().resolveJiti({
|
|
129
|
+
_managedDir: MANAGED_DIR,
|
|
130
|
+
// Managed-pi miss; only the symlinked register file exists.
|
|
131
|
+
_pathExists: (p) => p === path.join(path.dirname(jitiPkgJson), "lib", "jiti-register.mjs"),
|
|
132
|
+
_whichPi: () => piSymlink,
|
|
133
|
+
_realpath: (p) => { realpathArg = p; return piTarget; },
|
|
134
|
+
_argv1: undefined,
|
|
135
|
+
resolver: makeResolver({ "jiti/package.json": jitiPkgJson }),
|
|
136
|
+
});
|
|
137
|
+
expect(realpathArg).toBe(piSymlink);
|
|
138
|
+
expect(url).not.toBeNull();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("ToolResolver.resolveJiti — anchor walk-up + argv fallback", () => {
|
|
143
|
+
it("uses caller-supplied anchor when prior layers miss", () => {
|
|
144
|
+
const anchor = "/custom/cli/path.js";
|
|
145
|
+
const jitiPkgJson = "/custom/node_modules/jiti/package.json";
|
|
146
|
+
const url = new ToolResolver().resolveJiti({
|
|
147
|
+
anchor,
|
|
148
|
+
_managedDir: MANAGED_DIR,
|
|
149
|
+
_pathExists: (p) => p === anchor || p === path.join(path.dirname(jitiPkgJson), "lib", "jiti-register.mjs"),
|
|
150
|
+
_whichPi: () => null,
|
|
151
|
+
_argv1: undefined,
|
|
152
|
+
resolver: makeResolver({ "jiti/package.json": jitiPkgJson }),
|
|
153
|
+
});
|
|
154
|
+
expect(url).not.toBeNull();
|
|
155
|
+
expect(url!).toMatch(/\/jiti\/lib\/jiti-register\.mjs$/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("returns null when caller-supplied anchor does not exist on disk", () => {
|
|
159
|
+
const url = new ToolResolver().resolveJiti({
|
|
160
|
+
anchor: "/missing/path.js",
|
|
161
|
+
_managedDir: MANAGED_DIR,
|
|
162
|
+
_pathExists: () => false,
|
|
163
|
+
_whichPi: () => null,
|
|
164
|
+
_argv1: undefined,
|
|
165
|
+
resolver: () => "/whatever/jiti/package.json",
|
|
166
|
+
});
|
|
167
|
+
expect(url).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("falls back to process.argv[1] (test seam) when all earlier anchors miss", () => {
|
|
171
|
+
const argv = "/runtime/argv1/cli.js";
|
|
172
|
+
const jitiPkgJson = "/runtime/node_modules/jiti/package.json";
|
|
173
|
+
const url = new ToolResolver().resolveJiti({
|
|
174
|
+
_managedDir: MANAGED_DIR,
|
|
175
|
+
_pathExists: () => true,
|
|
176
|
+
_whichPi: () => null,
|
|
177
|
+
_realpath: (p) => p,
|
|
178
|
+
_argv1: argv,
|
|
179
|
+
resolver: makeResolver({ "jiti/package.json": jitiPkgJson }),
|
|
180
|
+
});
|
|
181
|
+
expect(url).not.toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("returns null when every anchor misses", () => {
|
|
185
|
+
const url = new ToolResolver().resolveJiti({
|
|
186
|
+
_managedDir: MANAGED_DIR,
|
|
187
|
+
_pathExists: () => false,
|
|
188
|
+
_whichPi: () => null,
|
|
189
|
+
_argv1: undefined,
|
|
190
|
+
resolver: () => { throw new Error("nope"); },
|
|
191
|
+
});
|
|
192
|
+
expect(url).toBeNull();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("ToolResolver.resolveJiti — URL contract", () => {
|
|
197
|
+
it("returns a file:// URL parseable by new URL()", () => {
|
|
198
|
+
const url = new ToolResolver().resolveJiti({
|
|
199
|
+
_managedDir: MANAGED_DIR,
|
|
200
|
+
_pathExists: () => true,
|
|
201
|
+
_whichPi: () => null,
|
|
202
|
+
_argv1: "/runtime/argv1/cli.js",
|
|
203
|
+
_realpath: (p) => p,
|
|
204
|
+
resolver: makeResolver({ "jiti/package.json": "/r/node_modules/jiti/package.json" }),
|
|
205
|
+
});
|
|
206
|
+
expect(url!.startsWith("file://")).toBe(true);
|
|
207
|
+
expect(() => new URL(url!)).not.toThrow();
|
|
208
|
+
expect(url!.endsWith("/lib/jiti-register.mjs")).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("URL-wraps Windows drive-letter pkg.json paths (regression for ERR_UNSUPPORTED_ESM_URL_SCHEME)", () => {
|
|
212
|
+
const winPkgJson = "B:\\Dev\\Nodejs\\global\\node_modules\\@mariozechner\\jiti\\package.json";
|
|
213
|
+
const url = new ToolResolver().resolveJiti({
|
|
214
|
+
_managedDir: MANAGED_DIR,
|
|
215
|
+
_pathExists: () => true,
|
|
216
|
+
_whichPi: () => null,
|
|
217
|
+
_argv1: "C:\\runtime\\cli.js",
|
|
218
|
+
_realpath: (p) => p,
|
|
219
|
+
resolver: makeResolver({ "@mariozechner/jiti/package.json": winPkgJson }),
|
|
220
|
+
});
|
|
221
|
+
expect(url).not.toBeNull();
|
|
222
|
+
expect(url!.startsWith("file:///")).toBe(true);
|
|
223
|
+
expect(() => new URL(url!)).not.toThrow();
|
|
224
|
+
expect(new URL(url!).protocol).toBe("file:");
|
|
225
|
+
expect(url!.toLowerCase()).toContain("/b:/");
|
|
226
|
+
expect(url!.endsWith("/lib/jiti-register.mjs")).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -104,3 +104,77 @@ describe("loadConfig — openspec poll block", () => {
|
|
|
104
104
|
expect(second.openspec).toEqual(first.openspec);
|
|
105
105
|
});
|
|
106
106
|
});
|
|
107
|
+
|
|
108
|
+
describe("loadConfig — openspec.enabled (auto-hide-empty-session-subcards)", () => {
|
|
109
|
+
let testDir: string;
|
|
110
|
+
let configFile: string;
|
|
111
|
+
let origHome: string;
|
|
112
|
+
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
testDir = path.join(
|
|
115
|
+
os.tmpdir(),
|
|
116
|
+
`test-config-openspec-enabled-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
117
|
+
);
|
|
118
|
+
fs.mkdirSync(path.join(testDir, ".pi", "dashboard"), { recursive: true });
|
|
119
|
+
configFile = path.join(testDir, ".pi", "dashboard", "config.json");
|
|
120
|
+
origHome = process.env.HOME!;
|
|
121
|
+
process.env.HOME = testDir;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
afterEach(() => {
|
|
125
|
+
process.env.HOME = origHome;
|
|
126
|
+
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("defaults to true when openspec block is absent", () => {
|
|
130
|
+
fs.writeFileSync(configFile, JSON.stringify({ port: 8000 }));
|
|
131
|
+
expect(loadConfig().openspec.enabled).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("defaults to true when openspec block has other fields but no `enabled`", () => {
|
|
135
|
+
fs.writeFileSync(
|
|
136
|
+
configFile,
|
|
137
|
+
JSON.stringify({ openspec: { pollIntervalSeconds: 60 } }),
|
|
138
|
+
);
|
|
139
|
+
expect(loadConfig().openspec.enabled).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("preserves explicit `false`", () => {
|
|
143
|
+
fs.writeFileSync(
|
|
144
|
+
configFile,
|
|
145
|
+
JSON.stringify({ openspec: { enabled: false } }),
|
|
146
|
+
);
|
|
147
|
+
const cfg = loadConfig();
|
|
148
|
+
expect(cfg.openspec.enabled).toBe(false);
|
|
149
|
+
// sibling fields keep their defaults
|
|
150
|
+
expect(cfg.openspec.pollIntervalSeconds).toBe(30);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("preserves explicit `true`", () => {
|
|
154
|
+
fs.writeFileSync(
|
|
155
|
+
configFile,
|
|
156
|
+
JSON.stringify({ openspec: { enabled: true } }),
|
|
157
|
+
);
|
|
158
|
+
expect(loadConfig().openspec.enabled).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("falls back to default true on non-boolean", () => {
|
|
162
|
+
fs.writeFileSync(
|
|
163
|
+
configFile,
|
|
164
|
+
JSON.stringify({ openspec: { enabled: "yes" } }),
|
|
165
|
+
);
|
|
166
|
+
expect(loadConfig().openspec.enabled).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("round-trips through load → stringify → load", () => {
|
|
170
|
+
fs.writeFileSync(
|
|
171
|
+
configFile,
|
|
172
|
+
JSON.stringify({ openspec: { enabled: false, pollIntervalSeconds: 90 } }),
|
|
173
|
+
);
|
|
174
|
+
const first = loadConfig();
|
|
175
|
+
fs.writeFileSync(configFile, JSON.stringify(first));
|
|
176
|
+
const second = loadConfig();
|
|
177
|
+
expect(second.openspec.enabled).toBe(false);
|
|
178
|
+
expect(second.openspec.pollIntervalSeconds).toBe(90);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
parseModelProxyConfig,
|
|
4
|
+
DEFAULT_MODEL_PROXY,
|
|
5
|
+
type ModelProxyConfig,
|
|
6
|
+
} from "../config.js";
|
|
7
|
+
|
|
8
|
+
describe("parseModelProxyConfig", () => {
|
|
9
|
+
it("returns defaults when input is missing", () => {
|
|
10
|
+
expect(parseModelProxyConfig(undefined)).toEqual(DEFAULT_MODEL_PROXY);
|
|
11
|
+
expect(parseModelProxyConfig(null)).toEqual(DEFAULT_MODEL_PROXY);
|
|
12
|
+
expect(parseModelProxyConfig("string")).toEqual(DEFAULT_MODEL_PROXY);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("returns defaults when input is empty object", () => {
|
|
16
|
+
const result = parseModelProxyConfig({});
|
|
17
|
+
expect(result.enabled).toBe(true);
|
|
18
|
+
expect(result.maxConcurrentStreams).toBe(16);
|
|
19
|
+
expect(result.perKeyConcurrentStreams).toBe(4);
|
|
20
|
+
expect(result.logRequests).toBe(false);
|
|
21
|
+
expect(result.apiKeys).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("preserves valid enabled flag", () => {
|
|
25
|
+
expect(parseModelProxyConfig({ enabled: false }).enabled).toBe(false);
|
|
26
|
+
expect(parseModelProxyConfig({ enabled: true }).enabled).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("clamps maxConcurrentStreams to [1, 256]", () => {
|
|
30
|
+
expect(parseModelProxyConfig({ maxConcurrentStreams: 0 }).maxConcurrentStreams).toBe(1);
|
|
31
|
+
expect(parseModelProxyConfig({ maxConcurrentStreams: -5 }).maxConcurrentStreams).toBe(1);
|
|
32
|
+
expect(parseModelProxyConfig({ maxConcurrentStreams: 500 }).maxConcurrentStreams).toBe(256);
|
|
33
|
+
expect(parseModelProxyConfig({ maxConcurrentStreams: 32 }).maxConcurrentStreams).toBe(32);
|
|
34
|
+
// Non-number falls back to default
|
|
35
|
+
expect(parseModelProxyConfig({ maxConcurrentStreams: "ten" }).maxConcurrentStreams).toBe(16);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("clamps perKeyConcurrentStreams to [1, 64]", () => {
|
|
39
|
+
expect(parseModelProxyConfig({ perKeyConcurrentStreams: 0 }).perKeyConcurrentStreams).toBe(1);
|
|
40
|
+
expect(parseModelProxyConfig({ perKeyConcurrentStreams: 100 }).perKeyConcurrentStreams).toBe(64);
|
|
41
|
+
expect(parseModelProxyConfig({ perKeyConcurrentStreams: 8 }).perKeyConcurrentStreams).toBe(8);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("accepts arbitrary string keys in perProviderCaps", () => {
|
|
45
|
+
const result = parseModelProxyConfig({
|
|
46
|
+
perProviderCaps: { anthropic: 5, google: 10, "custom-provider": 2 },
|
|
47
|
+
});
|
|
48
|
+
expect(result.perProviderCaps).toEqual({ anthropic: 5, google: 10, "custom-provider": 2 });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("filters invalid perProviderCaps entries", () => {
|
|
52
|
+
const result = parseModelProxyConfig({
|
|
53
|
+
perProviderCaps: { valid: 5, bad: "nope", zero: 0, negative: -1 },
|
|
54
|
+
});
|
|
55
|
+
expect(result.perProviderCaps).toEqual({ valid: 5 });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("clamps perProviderCaps values to max 256", () => {
|
|
59
|
+
const result = parseModelProxyConfig({
|
|
60
|
+
perProviderCaps: { huge: 999 },
|
|
61
|
+
});
|
|
62
|
+
expect(result.perProviderCaps).toEqual({ huge: 256 });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("omits perProviderCaps when not an object", () => {
|
|
66
|
+
expect(parseModelProxyConfig({ perProviderCaps: "bad" }).perProviderCaps).toBeUndefined();
|
|
67
|
+
expect(parseModelProxyConfig({ perProviderCaps: [1] }).perProviderCaps).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("validates apiKeys entries — rejects missing hash", () => {
|
|
71
|
+
const result = parseModelProxyConfig({
|
|
72
|
+
apiKeys: [
|
|
73
|
+
{ id: "a", label: "test", createdAt: 1000 }, // missing hash
|
|
74
|
+
{ id: "b", label: "ok", hash: "abc123", createdAt: 2000 },
|
|
75
|
+
],
|
|
76
|
+
});
|
|
77
|
+
expect(result.apiKeys).toHaveLength(1);
|
|
78
|
+
expect(result.apiKeys[0].id).toBe("b");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("validates apiKeys entries — rejects missing id/label/createdAt", () => {
|
|
82
|
+
const result = parseModelProxyConfig({
|
|
83
|
+
apiKeys: [
|
|
84
|
+
{ label: "no-id", hash: "h", createdAt: 1 },
|
|
85
|
+
{ id: "no-label", hash: "h", createdAt: 1 },
|
|
86
|
+
{ id: "no-time", label: "x", hash: "h" },
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
expect(result.apiKeys).toHaveLength(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("preserves optional apiKey fields", () => {
|
|
93
|
+
const result = parseModelProxyConfig({
|
|
94
|
+
apiKeys: [
|
|
95
|
+
{
|
|
96
|
+
id: "k1",
|
|
97
|
+
label: "full",
|
|
98
|
+
hash: "sha",
|
|
99
|
+
createdAt: 1000,
|
|
100
|
+
createdBy: "alice@x",
|
|
101
|
+
scopes: ["all"],
|
|
102
|
+
lastUsedAt: 2000,
|
|
103
|
+
expiresAt: 9999,
|
|
104
|
+
revokedAt: 3000,
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
});
|
|
108
|
+
expect(result.apiKeys[0]).toEqual({
|
|
109
|
+
id: "k1",
|
|
110
|
+
label: "full",
|
|
111
|
+
hash: "sha",
|
|
112
|
+
createdAt: 1000,
|
|
113
|
+
createdBy: "alice@x",
|
|
114
|
+
scopes: ["all"],
|
|
115
|
+
lastUsedAt: 2000,
|
|
116
|
+
expiresAt: 9999,
|
|
117
|
+
revokedAt: 3000,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("filters non-string scopes in apiKeys", () => {
|
|
122
|
+
const result = parseModelProxyConfig({
|
|
123
|
+
apiKeys: [
|
|
124
|
+
{ id: "k1", label: "t", hash: "h", createdAt: 1, scopes: ["chat", 42, null, "messages"] },
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
expect(result.apiKeys[0].scopes).toEqual(["chat", "messages"]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("validates secondPort range [1024, 65535]", () => {
|
|
131
|
+
expect(parseModelProxyConfig({ secondPort: 9876 }).secondPort).toBe(9876);
|
|
132
|
+
expect(parseModelProxyConfig({ secondPort: 80 }).secondPort).toBeUndefined();
|
|
133
|
+
expect(parseModelProxyConfig({ secondPort: 70000 }).secondPort).toBeUndefined();
|
|
134
|
+
expect(parseModelProxyConfig({ secondPort: "bad" }).secondPort).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("preserves defaultModel string", () => {
|
|
138
|
+
expect(parseModelProxyConfig({ defaultModel: "gpt-4" }).defaultModel).toBe("gpt-4");
|
|
139
|
+
expect(parseModelProxyConfig({ defaultModel: 42 }).defaultModel).toBeUndefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("preserves logRequests boolean", () => {
|
|
143
|
+
expect(parseModelProxyConfig({ logRequests: true }).logRequests).toBe(true);
|
|
144
|
+
expect(parseModelProxyConfig({ logRequests: "yes" }).logRequests).toBe(false); // falls back
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -20,10 +20,12 @@ import url from "node:url";
|
|
|
20
20
|
/** Files allowed to reference --import / --loader with raw identifiers. */
|
|
21
21
|
const ALLOWLIST: readonly string[] = [
|
|
22
22
|
"packages/shared/src/platform/node-spawn.ts",
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
23
|
+
// server-launcher.ts is the single shared spawn primitive for the
|
|
24
|
+
// dashboard server (Bridge / Standalone CLI / Electron). It mentions
|
|
25
|
+
// "--import" in commentary; argv construction itself is delegated to
|
|
26
|
+
// node-spawn.ts via `spawnNodeScript` / `buildNodeImportArgvParts`.
|
|
27
|
+
// See change: unify-server-launch-ts-loader.
|
|
28
|
+
"packages/shared/src/server-launcher.ts",
|
|
27
29
|
];
|
|
28
30
|
|
|
29
31
|
/** Per-line opt-out for intentional usages (e.g. comment examples). */
|
|
@@ -49,7 +51,7 @@ const IMPORT_ARGV_RE =
|
|
|
49
51
|
/["']--(?:import|loader)["']\s*,\s*([^,\]]+?)\s*,\s*([^,\]]+?)(?:\s*,|\s*\])/g;
|
|
50
52
|
|
|
51
53
|
const URL_LOOKING_RE =
|
|
52
|
-
/^(?:["']file:|toFileUrl\s*\(|pathToFileURL\s*\([^)]*\)\s*\.href
|
|
54
|
+
/^(?:["']file:|toFileUrl\s*\(|pathToFileURL\s*\([^)]*\)\s*\.href)/;
|
|
53
55
|
|
|
54
56
|
/** Recursively walk a directory, yielding .ts / .tsx files. */
|
|
55
57
|
async function* walk(dir: string): AsyncGenerator<string> {
|
|
@@ -180,6 +180,57 @@ describe("spawnNodeScript", () => {
|
|
|
180
180
|
});
|
|
181
181
|
});
|
|
182
182
|
|
|
183
|
+
describe("buildNodeImportArgvParts", () => {
|
|
184
|
+
// Pure helper shared by spawnNodeScript and restart-helper.ts so the
|
|
185
|
+
// `--import` argv shape lives in exactly one place.
|
|
186
|
+
it("POSIX + jiti: entry passed RAW (jiti rejects file:// URL entries)", async () => {
|
|
187
|
+
const { buildNodeImportArgvParts } = await import("../platform/node-spawn.js");
|
|
188
|
+
const parts = buildNodeImportArgvParts({
|
|
189
|
+
loader: "/usr/lib/jiti/lib/jiti-register.mjs",
|
|
190
|
+
entry: "/srv/cli.ts",
|
|
191
|
+
args: ["start", "--port", "8000"],
|
|
192
|
+
platform: "linux",
|
|
193
|
+
});
|
|
194
|
+
expect(parts[0]).toBe("--import");
|
|
195
|
+
expect(parts[1]).toMatch(/^file:\/\//);
|
|
196
|
+
expect(parts[2]).toBe("/srv/cli.ts"); // RAW
|
|
197
|
+
expect(parts.slice(3)).toEqual(["start", "--port", "8000"]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("Windows + jiti: entry URL-wrapped", async () => {
|
|
201
|
+
const { buildNodeImportArgvParts } = await import("../platform/node-spawn.js");
|
|
202
|
+
const parts = buildNodeImportArgvParts({
|
|
203
|
+
loader: "B:\\Dev\\jiti\\lib\\jiti-register.mjs",
|
|
204
|
+
entry: "B:\\srv\\cli.ts",
|
|
205
|
+
args: ["start"],
|
|
206
|
+
platform: "win32",
|
|
207
|
+
});
|
|
208
|
+
expect(parts[1]).toBe("file:///B:/Dev/jiti/lib/jiti-register.mjs");
|
|
209
|
+
expect(parts[2]).toBe("file:///B:/srv/cli.ts");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("tsx loader: entry RAW on any platform", async () => {
|
|
213
|
+
const { buildNodeImportArgvParts } = await import("../platform/node-spawn.js");
|
|
214
|
+
const parts = buildNodeImportArgvParts({
|
|
215
|
+
loader: "/x/tsx/dist/esm/index.mjs",
|
|
216
|
+
entry: "C:\\srv\\cli.ts",
|
|
217
|
+
args: [],
|
|
218
|
+
platform: "win32",
|
|
219
|
+
});
|
|
220
|
+
expect(parts[2]).toBe("C:\\srv\\cli.ts"); // RAW (tsx rejects file:// entries)
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("omits args when none supplied", async () => {
|
|
224
|
+
const { buildNodeImportArgvParts } = await import("../platform/node-spawn.js");
|
|
225
|
+
const parts = buildNodeImportArgvParts({
|
|
226
|
+
loader: "/x/jiti/lib/jiti-register.mjs",
|
|
227
|
+
entry: "/srv/cli.ts",
|
|
228
|
+
platform: "linux",
|
|
229
|
+
});
|
|
230
|
+
expect(parts).toEqual(["--import", `file://${"/x/jiti/lib/jiti-register.mjs"}`, "/srv/cli.ts"]);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
183
234
|
describe("shouldUrlWrapEntry", () => {
|
|
184
235
|
it("returns false for tsx loader on any platform", () => {
|
|
185
236
|
const tsxLoader = "file:///home/u/node_modules/tsx/dist/esm/index.mjs";
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-level tests for OpenSpec change-grouping shared types.
|
|
3
|
+
*
|
|
4
|
+
* Asserts:
|
|
5
|
+
* - `OpenSpecGroup` shape compiles.
|
|
6
|
+
* - `OpenSpecChange.groupId?: string | null` is optional.
|
|
7
|
+
* - `OpenSpecGroupsFile` shape compiles.
|
|
8
|
+
* - `OPENSPEC_GROUPS_SCHEMA_VERSION` is the literal `1`.
|
|
9
|
+
* - `BrowserOpenSpecGroupsUpdateMessage` is a member of `ServerToBrowserMessage`
|
|
10
|
+
* (otherwise esbuild would dead-code-eliminate the consumer switch arm).
|
|
11
|
+
* - REST request/response shapes for the five `/api/openspec/groups*` routes compile.
|
|
12
|
+
*
|
|
13
|
+
* See change: add-openspec-change-grouping (tasks 1.1–1.7).
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect } from "vitest";
|
|
16
|
+
import type {
|
|
17
|
+
OpenSpecGroup,
|
|
18
|
+
OpenSpecChange,
|
|
19
|
+
OpenSpecGroupsFile,
|
|
20
|
+
} from "../types.js";
|
|
21
|
+
import { OPENSPEC_GROUPS_SCHEMA_VERSION } from "../types.js";
|
|
22
|
+
import type {
|
|
23
|
+
ServerToBrowserMessage,
|
|
24
|
+
BrowserOpenSpecGroupsUpdateMessage,
|
|
25
|
+
} from "../browser-protocol.js";
|
|
26
|
+
import type {
|
|
27
|
+
GetOpenSpecGroupsResponse,
|
|
28
|
+
CreateOpenSpecGroupRequest,
|
|
29
|
+
CreateOpenSpecGroupResponse,
|
|
30
|
+
UpdateOpenSpecGroupRequest,
|
|
31
|
+
UpdateOpenSpecGroupResponse,
|
|
32
|
+
DeleteOpenSpecGroupResponse,
|
|
33
|
+
SetOpenSpecGroupAssignmentRequest,
|
|
34
|
+
SetOpenSpecGroupAssignmentResponse,
|
|
35
|
+
} from "../rest-api.js";
|
|
36
|
+
|
|
37
|
+
// Type-level assertion: if the type does NOT extend the union, this will fail to compile.
|
|
38
|
+
type AssertExtends<T, U> = T extends U ? true : never;
|
|
39
|
+
|
|
40
|
+
// 1.5 — broadcast variant lives in the union.
|
|
41
|
+
type _GroupsUpdateInBrowserUnion = AssertExtends<
|
|
42
|
+
BrowserOpenSpecGroupsUpdateMessage,
|
|
43
|
+
ServerToBrowserMessage
|
|
44
|
+
>;
|
|
45
|
+
|
|
46
|
+
// 1.1 — group shape.
|
|
47
|
+
type _GroupShape = AssertExtends<
|
|
48
|
+
{ id: string; name: string; color?: string; order: number },
|
|
49
|
+
OpenSpecGroup
|
|
50
|
+
>;
|
|
51
|
+
|
|
52
|
+
// 1.2 — `groupId?` is optional on `OpenSpecChange`.
|
|
53
|
+
type _ChangeGroupIdOptional = AssertExtends<
|
|
54
|
+
{ name: string; status: "in-progress"; completedTasks: 0; totalTasks: 0; artifacts: [] },
|
|
55
|
+
OpenSpecChange
|
|
56
|
+
>;
|
|
57
|
+
// And it accepts a string when present.
|
|
58
|
+
type _ChangeWithGroupId = AssertExtends<
|
|
59
|
+
{
|
|
60
|
+
name: string;
|
|
61
|
+
status: "in-progress";
|
|
62
|
+
completedTasks: 0;
|
|
63
|
+
totalTasks: 0;
|
|
64
|
+
artifacts: [];
|
|
65
|
+
groupId: "ui";
|
|
66
|
+
},
|
|
67
|
+
OpenSpecChange
|
|
68
|
+
>;
|
|
69
|
+
|
|
70
|
+
// 1.4 — file shape.
|
|
71
|
+
type _FileShape = AssertExtends<
|
|
72
|
+
{
|
|
73
|
+
schemaVersion: 1;
|
|
74
|
+
groups: OpenSpecGroup[];
|
|
75
|
+
assignments: Record<string, string>;
|
|
76
|
+
},
|
|
77
|
+
OpenSpecGroupsFile
|
|
78
|
+
>;
|
|
79
|
+
|
|
80
|
+
// 1.6 — REST shapes (compile-time only).
|
|
81
|
+
const _getResp: GetOpenSpecGroupsResponse = {
|
|
82
|
+
success: true,
|
|
83
|
+
data: { schemaVersion: 1, groups: [], assignments: {} },
|
|
84
|
+
};
|
|
85
|
+
const _createReq: CreateOpenSpecGroupRequest = { name: "UI", color: "#3b82f6" };
|
|
86
|
+
const _createResp: CreateOpenSpecGroupResponse = {
|
|
87
|
+
success: true,
|
|
88
|
+
data: { id: "ui", name: "UI", color: "#3b82f6", order: 0 },
|
|
89
|
+
};
|
|
90
|
+
const _updateReq: UpdateOpenSpecGroupRequest = { name: "Frontend" };
|
|
91
|
+
const _updateResp: UpdateOpenSpecGroupResponse = {
|
|
92
|
+
success: true,
|
|
93
|
+
data: { id: "ui", name: "Frontend", order: 0 },
|
|
94
|
+
};
|
|
95
|
+
const _deleteResp: DeleteOpenSpecGroupResponse = { success: true };
|
|
96
|
+
const _putReq: SetOpenSpecGroupAssignmentRequest = { changeName: "add-foo", groupId: "ui" };
|
|
97
|
+
const _putReqNull: SetOpenSpecGroupAssignmentRequest = { changeName: "add-foo", groupId: null };
|
|
98
|
+
const _putResp: SetOpenSpecGroupAssignmentResponse = { success: true };
|
|
99
|
+
|
|
100
|
+
// Suppress unused-locals for compile-time-only declarations.
|
|
101
|
+
void _getResp;
|
|
102
|
+
void _createReq;
|
|
103
|
+
void _createResp;
|
|
104
|
+
void _updateReq;
|
|
105
|
+
void _updateResp;
|
|
106
|
+
void _deleteResp;
|
|
107
|
+
void _putReq;
|
|
108
|
+
void _putReqNull;
|
|
109
|
+
void _putResp;
|
|
110
|
+
|
|
111
|
+
describe("OpenSpec change-grouping shared types", () => {
|
|
112
|
+
it("OPENSPEC_GROUPS_SCHEMA_VERSION is the literal 1", () => {
|
|
113
|
+
expect(OPENSPEC_GROUPS_SCHEMA_VERSION).toBe(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("openspec_groups_update is reachable in a runtime switch over ServerToBrowserMessage", () => {
|
|
117
|
+
// Runtime check that the discriminant survives type-narrowing — mirrors
|
|
118
|
+
// the prompt-message regression guard in browser-protocol-types.test.ts.
|
|
119
|
+
const sample: ServerToBrowserMessage = {
|
|
120
|
+
type: "openspec_groups_update",
|
|
121
|
+
cwd: "/tmp/foo",
|
|
122
|
+
groups: [],
|
|
123
|
+
assignments: {},
|
|
124
|
+
};
|
|
125
|
+
let hit = false;
|
|
126
|
+
switch (sample.type) {
|
|
127
|
+
case "openspec_groups_update":
|
|
128
|
+
hit = true;
|
|
129
|
+
break;
|
|
130
|
+
default:
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
expect(hit).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
});
|