@blackbelt-technology/pi-agent-dashboard 0.4.5 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +342 -267
- package/README.md +51 -2
- package/docs/architecture.md +266 -25
- package/package.json +14 -4
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
- package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
- package/packages/extension/src/__tests__/prompt-bus.test.ts +44 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
- package/packages/extension/src/__tests__/vcs-info-jj.test.ts +145 -0
- package/packages/extension/src/__tests__/{git-info.test.ts → vcs-info.test.ts} +6 -6
- package/packages/extension/src/bridge-context.ts +7 -0
- package/packages/extension/src/bridge.ts +142 -4
- package/packages/extension/src/command-handler.ts +6 -0
- package/packages/extension/src/markdown-image-inliner.ts +268 -0
- package/packages/extension/src/model-tracker.ts +35 -1
- package/packages/extension/src/prompt-bus.ts +4 -3
- package/packages/extension/src/prompt-expander.ts +50 -2
- package/packages/extension/src/provider-register.ts +117 -0
- package/packages/extension/src/server-launcher.ts +18 -1
- package/packages/extension/src/session-sync.ts +6 -1
- package/packages/extension/src/vcs-info.ts +184 -0
- package/packages/server/package.json +4 -4
- package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
- package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
- package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
- package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
- package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
- package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
- package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
- package/packages/server/src/__tests__/health-shape.test.ts +43 -0
- package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
- package/packages/server/src/__tests__/is-unread-trigger.test.ts +4 -2
- package/packages/server/src/__tests__/jj-routes.test.ts +93 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
- package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +114 -0
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
- package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
- package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
- package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
- package/packages/server/src/__tests__/session-diff-vcs.test.ts +61 -0
- package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
- package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
- package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
- package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
- package/packages/server/src/__tests__/system-routes-restart.test.ts +4 -4
- package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
- package/packages/server/src/bootstrap-install-from-list.ts +232 -0
- package/packages/server/src/bootstrap-state.ts +18 -0
- package/packages/server/src/browser-gateway.ts +58 -21
- package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
- package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
- package/packages/server/src/cli.ts +22 -0
- package/packages/server/src/directory-service.ts +31 -0
- package/packages/server/src/event-wiring.ts +57 -2
- package/packages/server/src/home-lock.d.ts +124 -0
- package/packages/server/src/home-lock.js +330 -0
- package/packages/server/src/home-lock.js.map +1 -0
- package/packages/server/src/idle-timer.ts +15 -1
- package/packages/server/src/openspec-tasks.ts +50 -19
- package/packages/server/src/pi-core-updater.ts +65 -9
- package/packages/server/src/pi-gateway.ts +6 -0
- package/packages/server/src/process-manager.ts +62 -11
- package/packages/server/src/provider-auth-handlers.ts +9 -0
- package/packages/server/src/provider-auth-storage.ts +83 -51
- package/packages/server/src/provider-catalogue-cache.ts +41 -0
- package/packages/server/src/routes/doctor-routes.ts +140 -0
- package/packages/server/src/routes/jj-routes.ts +386 -0
- package/packages/server/src/routes/provider-auth-routes.ts +9 -0
- package/packages/server/src/routes/session-routes.ts +12 -3
- package/packages/server/src/routes/system-routes.ts +38 -1
- package/packages/server/src/server.ts +16 -9
- package/packages/server/src/session-bootstrap.ts +27 -12
- package/packages/server/src/session-diff.ts +118 -1
- package/packages/server/src/session-discovery.ts +10 -3
- package/packages/server/src/session-scanner.ts +4 -2
- package/packages/server/src/spawn-failure-log.ts +130 -0
- package/packages/server/src/spawn-preflight.ts +82 -0
- package/packages/server/src/spawn-register-watchdog.ts +236 -0
- package/packages/server/src/terminal-manager.ts +12 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
- package/packages/shared/src/__tests__/config.test.ts +48 -0
- package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
- package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
- package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
- package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
- package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
- package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
- package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
- package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
- package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
- package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
- package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
- package/packages/shared/src/__tests__/platform-jj.test.ts +339 -0
- package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +18 -2
- package/packages/shared/src/bootstrap-install.ts +196 -2
- package/packages/shared/src/browser-protocol.ts +112 -1
- package/packages/shared/src/config.ts +29 -0
- package/packages/shared/src/dashboard-starter.ts +33 -0
- package/packages/shared/src/diff-types.ts +17 -0
- package/packages/shared/src/doctor-core.ts +821 -0
- package/packages/shared/src/index.ts +9 -0
- package/packages/shared/src/installable-list.ts +152 -0
- package/packages/shared/src/launch-source-flag.ts +14 -0
- package/packages/shared/src/launch-source-types.ts +18 -0
- package/packages/shared/src/openspec-activity-detector.ts +25 -7
- package/packages/shared/src/platform/detached-spawn.ts +13 -2
- package/packages/shared/src/platform/jj.ts +405 -0
- package/packages/shared/src/platform/managed-node-path.ts +77 -0
- package/packages/shared/src/protocol.ts +60 -2
- package/packages/shared/src/rest-api.ts +4 -0
- package/packages/shared/src/skill-block-parser.ts +115 -0
- package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
- package/packages/shared/src/tool-registry/definitions.ts +19 -5
- package/packages/shared/src/tool-registry/strategies.ts +42 -0
- package/packages/shared/src/types.ts +91 -0
- package/packages/extension/src/git-info.ts +0 -55
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end test: `providers_list` arriving from a (fake) bridge updates
|
|
3
|
+
* the provider-catalogue cache, and `getAuthStatus()` reflects it.
|
|
4
|
+
* See change: replace-hardcoded-provider-lists.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
7
|
+
import { WebSocket } from "ws";
|
|
8
|
+
import { createServer, type DashboardServer } from "../server.js";
|
|
9
|
+
import { _resetForTests, getLatestCatalogue } from "../provider-catalogue-cache.js";
|
|
10
|
+
import { getAuthStatus } from "../provider-auth-storage.js";
|
|
11
|
+
|
|
12
|
+
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
13
|
+
|
|
14
|
+
async function connectSession(piPort: number, sessionId: string): Promise<WebSocket> {
|
|
15
|
+
const ws = new WebSocket(`ws://localhost:${piPort}`);
|
|
16
|
+
await new Promise<void>((resolve) => {
|
|
17
|
+
ws.on("open", () => {
|
|
18
|
+
ws.send(JSON.stringify({
|
|
19
|
+
type: "session_register",
|
|
20
|
+
sessionId,
|
|
21
|
+
cwd: "/tmp",
|
|
22
|
+
source: "cli",
|
|
23
|
+
}));
|
|
24
|
+
ws.send(JSON.stringify({ type: "replay_complete", sessionId }));
|
|
25
|
+
setTimeout(resolve, 60);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
return ws;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("providers_list — server wiring", () => {
|
|
32
|
+
let server: DashboardServer;
|
|
33
|
+
let piPort: number;
|
|
34
|
+
let browserPort: number;
|
|
35
|
+
let testPort = 19500;
|
|
36
|
+
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
_resetForTests();
|
|
39
|
+
testPort += 2;
|
|
40
|
+
browserPort = testPort;
|
|
41
|
+
piPort = testPort + 1;
|
|
42
|
+
server = await createServer({
|
|
43
|
+
port: browserPort,
|
|
44
|
+
piPort,
|
|
45
|
+
dev: true,
|
|
46
|
+
autoShutdown: false,
|
|
47
|
+
shutdownIdleSeconds: 999,
|
|
48
|
+
tunnel: false,
|
|
49
|
+
editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
|
|
50
|
+
});
|
|
51
|
+
await server.start();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(async () => {
|
|
55
|
+
await server.stop();
|
|
56
|
+
_resetForTests();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("incoming providers_list updates the cache and is visible via getAuthStatus", async () => {
|
|
60
|
+
const piWs = await connectSession(piPort, "p1");
|
|
61
|
+
expect(getLatestCatalogue()).toEqual([]);
|
|
62
|
+
|
|
63
|
+
piWs.send(JSON.stringify({
|
|
64
|
+
type: "providers_list",
|
|
65
|
+
sessionId: "p1",
|
|
66
|
+
providers: [
|
|
67
|
+
{ id: "deepseek", displayName: "DeepSeek", hasOAuth: false, configured: false },
|
|
68
|
+
{ id: "fireworks", displayName: "Fireworks", hasOAuth: false, configured: false, envVar: "FIREWORKS_API_KEY" },
|
|
69
|
+
],
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
await wait(80);
|
|
73
|
+
|
|
74
|
+
const cached = getLatestCatalogue();
|
|
75
|
+
expect(cached).toHaveLength(2);
|
|
76
|
+
expect(cached.map((p) => p.id).sort()).toEqual(["deepseek", "fireworks"]);
|
|
77
|
+
|
|
78
|
+
const status = getAuthStatus();
|
|
79
|
+
const deepseekRow = status.find((r) => r.id === "deepseek");
|
|
80
|
+
const fireworksRow = status.find((r) => r.id === "fireworks");
|
|
81
|
+
expect(deepseekRow).toBeDefined();
|
|
82
|
+
expect(deepseekRow?.flowType).toBe("api_key");
|
|
83
|
+
expect(fireworksRow?.envVar).toBe("FIREWORKS_API_KEY");
|
|
84
|
+
|
|
85
|
+
piWs.close();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `hasOpenSpecDir` — synchronous spawn-free probe used by
|
|
3
|
+
* the WS on-connect snapshot to disambiguate "no openspec here" from
|
|
4
|
+
* "openspec here, polling pending".
|
|
5
|
+
*
|
|
6
|
+
* See change: fix-cold-boot-openspec-protocol.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { hasOpenSpecDir } from "../directory-service.js";
|
|
13
|
+
|
|
14
|
+
describe("hasOpenSpecDir", () => {
|
|
15
|
+
let tmp: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "has-openspec-dir-"));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns true when <cwd>/openspec/changes exists as a directory", () => {
|
|
26
|
+
fs.mkdirSync(path.join(tmp, "openspec", "changes"), { recursive: true });
|
|
27
|
+
expect(hasOpenSpecDir(tmp)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns false when openspec dir is absent (ENOENT)", () => {
|
|
31
|
+
expect(hasOpenSpecDir(tmp)).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns false when openspec exists but openspec/changes does not", () => {
|
|
35
|
+
fs.mkdirSync(path.join(tmp, "openspec"));
|
|
36
|
+
expect(hasOpenSpecDir(tmp)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns false when openspec/changes is a regular file, not a directory", () => {
|
|
40
|
+
fs.mkdirSync(path.join(tmp, "openspec"));
|
|
41
|
+
fs.writeFileSync(path.join(tmp, "openspec", "changes"), "not a dir");
|
|
42
|
+
expect(hasOpenSpecDir(tmp)).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns false when openspec/changes is a symlink to a non-directory", () => {
|
|
46
|
+
fs.mkdirSync(path.join(tmp, "openspec"));
|
|
47
|
+
const target = path.join(tmp, "target.txt");
|
|
48
|
+
fs.writeFileSync(target, "x");
|
|
49
|
+
fs.symlinkSync(target, path.join(tmp, "openspec", "changes"));
|
|
50
|
+
expect(hasOpenSpecDir(tmp)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns true when openspec/changes is a symlink to a directory", () => {
|
|
54
|
+
fs.mkdirSync(path.join(tmp, "openspec"));
|
|
55
|
+
const target = path.join(tmp, "target-dir");
|
|
56
|
+
fs.mkdirSync(target);
|
|
57
|
+
fs.symlinkSync(target, path.join(tmp, "openspec", "changes"));
|
|
58
|
+
expect(hasOpenSpecDir(tmp)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns false for a non-existent cwd", () => {
|
|
62
|
+
expect(hasOpenSpecDir("/this/path/does/not/exist/__nope__")).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for /api/health response shape after Phase A additions.
|
|
3
|
+
*
|
|
4
|
+
* Asserts:
|
|
5
|
+
* - `pid` field is present (regression pin).
|
|
6
|
+
* - `starter` field is present, defaults to "Standalone".
|
|
7
|
+
*
|
|
8
|
+
* Note: the "Standalone default for missing DASHBOARD_STARTER" case is
|
|
9
|
+
* also covered exhaustively in packages/shared/src/__tests__/dashboard-starter.test.ts.
|
|
10
|
+
* This test pins the contract at the HTTP layer so a refactor cannot silently
|
|
11
|
+
* drop either field from the health response.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
14
|
+
import { createTestServer, type TestServerHandle } from "../test-support/test-server.js";
|
|
15
|
+
|
|
16
|
+
let handle: TestServerHandle | undefined;
|
|
17
|
+
|
|
18
|
+
describe("GET /api/health — Phase A shape", () => {
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
if (handle) {
|
|
21
|
+
try { await handle.stop(); } catch { /* already stopped */ }
|
|
22
|
+
handle = undefined;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("includes pid field (regression pin)", async () => {
|
|
27
|
+
handle = await createTestServer();
|
|
28
|
+
const res = await fetch(`http://localhost:${handle.httpPort}/api/health`);
|
|
29
|
+
expect(res.status).toBe(200);
|
|
30
|
+
const body = await res.json() as Record<string, unknown>;
|
|
31
|
+
expect(typeof body.pid).toBe("number");
|
|
32
|
+
expect(body.pid).toBe(process.pid);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("includes starter field defaulting to Standalone", async () => {
|
|
36
|
+
handle = await createTestServer();
|
|
37
|
+
const res = await fetch(`http://localhost:${handle.httpPort}/api/health`);
|
|
38
|
+
expect(res.status).toBe(200);
|
|
39
|
+
const body = await res.json() as Record<string, unknown>;
|
|
40
|
+
// When bootstrapState has no starter set, defaults to "Standalone".
|
|
41
|
+
expect(body.starter).toBe("Standalone");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { createIdleTimer } from "../idle-timer.js";
|
|
3
|
+
import type { PiGateway } from "../pi-gateway.js";
|
|
4
|
+
import type { ServerConfig } from "../server.js";
|
|
5
|
+
|
|
6
|
+
// See change: fix-terminal-half-height-dual-mount.
|
|
7
|
+
// Pure unit tests against the idle-timer's predicate-driven gating.
|
|
8
|
+
// Avoids the full-server I/O races that have the auto-shutdown.test.ts
|
|
9
|
+
// suite skipped under fake timers.
|
|
10
|
+
|
|
11
|
+
function makeConfig(): ServerConfig {
|
|
12
|
+
return {
|
|
13
|
+
port: 0,
|
|
14
|
+
piPort: 0,
|
|
15
|
+
dev: true,
|
|
16
|
+
autoShutdown: true,
|
|
17
|
+
shutdownIdleSeconds: 2,
|
|
18
|
+
tunnel: false,
|
|
19
|
+
pingInterval: 0,
|
|
20
|
+
editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
|
|
21
|
+
} as ServerConfig;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeGateway(connectionCount = 0): PiGateway {
|
|
25
|
+
return {
|
|
26
|
+
connectionCount: () => connectionCount,
|
|
27
|
+
onEmpty: undefined,
|
|
28
|
+
onConnection: undefined,
|
|
29
|
+
} as unknown as PiGateway;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("idle-timer respects active terminals", () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.useFakeTimers();
|
|
35
|
+
});
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
vi.useRealTimers();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("does NOT shut down when one or more terminals are alive", async () => {
|
|
41
|
+
const gateway = makeGateway(0);
|
|
42
|
+
let terminalCount = 1;
|
|
43
|
+
const timer = createIdleTimer(makeConfig(), gateway, () => terminalCount > 0);
|
|
44
|
+
const stopFn = vi.fn().mockResolvedValue(undefined);
|
|
45
|
+
timer.setStopFn(stopFn);
|
|
46
|
+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
|
47
|
+
|
|
48
|
+
timer.start();
|
|
49
|
+
// Advance well past shutdownIdleSeconds.
|
|
50
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
51
|
+
|
|
52
|
+
expect(stopFn).not.toHaveBeenCalled();
|
|
53
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
54
|
+
exitSpy.mockRestore();
|
|
55
|
+
timer.cancel();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("shuts down when no pi sessions AND no terminals are alive", async () => {
|
|
59
|
+
const gateway = makeGateway(0);
|
|
60
|
+
const timer = createIdleTimer(makeConfig(), gateway, () => false);
|
|
61
|
+
const stopFn = vi.fn().mockResolvedValue(undefined);
|
|
62
|
+
timer.setStopFn(stopFn);
|
|
63
|
+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
|
64
|
+
|
|
65
|
+
timer.start();
|
|
66
|
+
// First tick: realIdleMs is 0 because lastConnectionTimestamp = 0.
|
|
67
|
+
// The implementation guards on realIdleMs < shutdownIdleSeconds*1000
|
|
68
|
+
// and restarts; so we need two ticks separated by enough wall time.
|
|
69
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
70
|
+
// After the first tick the timer has restarted; advance again.
|
|
71
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
72
|
+
|
|
73
|
+
expect(stopFn).toHaveBeenCalled();
|
|
74
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
75
|
+
exitSpy.mockRestore();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("re-arms (does not shut down) when terminals appear mid-countdown", async () => {
|
|
79
|
+
const gateway = makeGateway(0);
|
|
80
|
+
let terminalCount = 0;
|
|
81
|
+
const timer = createIdleTimer(makeConfig(), gateway, () => terminalCount > 0);
|
|
82
|
+
const stopFn = vi.fn().mockResolvedValue(undefined);
|
|
83
|
+
timer.setStopFn(stopFn);
|
|
84
|
+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
|
85
|
+
|
|
86
|
+
timer.start();
|
|
87
|
+
// Just before the timer fires, a terminal appears.
|
|
88
|
+
await vi.advanceTimersByTimeAsync(1500);
|
|
89
|
+
terminalCount = 1;
|
|
90
|
+
// Let it fire.
|
|
91
|
+
await vi.advanceTimersByTimeAsync(1500);
|
|
92
|
+
|
|
93
|
+
expect(stopFn).not.toHaveBeenCalled();
|
|
94
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
95
|
+
exitSpy.mockRestore();
|
|
96
|
+
timer.cancel();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("default predicate (no terminals) preserves legacy single-arg call site behavior", async () => {
|
|
100
|
+
// Caller may construct without the third arg; default is () => false.
|
|
101
|
+
const gateway = makeGateway(0);
|
|
102
|
+
const timer = createIdleTimer(makeConfig(), gateway);
|
|
103
|
+
const stopFn = vi.fn().mockResolvedValue(undefined);
|
|
104
|
+
timer.setStopFn(stopFn);
|
|
105
|
+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
|
106
|
+
|
|
107
|
+
timer.start();
|
|
108
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
109
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
110
|
+
|
|
111
|
+
expect(stopFn).toHaveBeenCalled();
|
|
112
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
113
|
+
exitSpy.mockRestore();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -67,8 +67,10 @@ describe("isUnreadTrigger", () => {
|
|
|
67
67
|
isUnreadTrigger(
|
|
68
68
|
"agent_end",
|
|
69
69
|
{ status: "streaming", currentTool: null },
|
|
70
|
-
//
|
|
71
|
-
|
|
70
|
+
// The status union is currently "streaming" | "idle" | "active";
|
|
71
|
+
// "ended" is not in the union but is a valid runtime value
|
|
72
|
+
// upstream, so we cast for the simulation.
|
|
73
|
+
{ status: "ended" as unknown as "idle", currentTool: null },
|
|
72
74
|
),
|
|
73
75
|
).toBe(false);
|
|
74
76
|
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the jj REST routes module — focused on the pure helpers
|
|
3
|
+
* (`checkInitColocatedPreconditions`) and validation logic. Full route
|
|
4
|
+
* integration tests are deferred until the test harness wires up a live
|
|
5
|
+
* Fastify instance + browserGateway mock.
|
|
6
|
+
*
|
|
7
|
+
* See change: add-jj-workspace-plugin.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
|
|
14
|
+
const { statusPorcelain } = vi.hoisted(() => ({
|
|
15
|
+
statusPorcelain: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock("@blackbelt-technology/pi-dashboard-shared/platform/git.js", async () => {
|
|
19
|
+
const real = await vi.importActual<
|
|
20
|
+
typeof import("@blackbelt-technology/pi-dashboard-shared/platform/git.js")
|
|
21
|
+
>("@blackbelt-technology/pi-dashboard-shared/platform/git.js");
|
|
22
|
+
return { ...real, statusPorcelain };
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
import { checkInitColocatedPreconditions } from "../routes/jj-routes.js";
|
|
26
|
+
|
|
27
|
+
describe("checkInitColocatedPreconditions", () => {
|
|
28
|
+
let tmpDir: string;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "jj-routes-test-"));
|
|
32
|
+
statusPorcelain.mockReset();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns INVALID_CWD when cwd is empty", () => {
|
|
40
|
+
expect(checkInitColocatedPreconditions("")?.code).toBe("INVALID_CWD");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns INVALID_CWD when cwd does not exist", () => {
|
|
44
|
+
expect(checkInitColocatedPreconditions("/nonexistent/path/12345")?.code).toBe("INVALID_CWD");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns ALREADY_JJ when .jj/ exists", () => {
|
|
48
|
+
fs.mkdirSync(path.join(tmpDir, ".jj"));
|
|
49
|
+
expect(checkInitColocatedPreconditions(tmpDir)?.code).toBe("ALREADY_JJ");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns NOT_GIT_REPO when neither .jj/ nor .git/ exist", () => {
|
|
53
|
+
expect(checkInitColocatedPreconditions(tmpDir)?.code).toBe("NOT_GIT_REPO");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns DIRTY_INDEX when git status has staged entries", () => {
|
|
57
|
+
fs.mkdirSync(path.join(tmpDir, ".git"));
|
|
58
|
+
statusPorcelain.mockReturnValue({
|
|
59
|
+
ok: true,
|
|
60
|
+
value: "M src/foo.ts\nA src/bar.ts\n",
|
|
61
|
+
});
|
|
62
|
+
const result = checkInitColocatedPreconditions(tmpDir);
|
|
63
|
+
expect(result?.code).toBe("DIRTY_INDEX");
|
|
64
|
+
expect(result?.message).toContain("2 entries");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns null on clean .git/ tree (working-tree dirt is fine)", () => {
|
|
68
|
+
fs.mkdirSync(path.join(tmpDir, ".git"));
|
|
69
|
+
// Lines starting with " M" (space then M) are working-tree-only changes.
|
|
70
|
+
// Lines starting with "??" are untracked files. Both are SAFE per spec
|
|
71
|
+
// scenario "Init allowed on unstaged dirty working tree".
|
|
72
|
+
statusPorcelain.mockReturnValue({
|
|
73
|
+
ok: true,
|
|
74
|
+
value: " M src/working-tree-mod.ts\n?? src/new-untracked.ts\n",
|
|
75
|
+
});
|
|
76
|
+
expect(checkInitColocatedPreconditions(tmpDir)).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns null on totally clean tree", () => {
|
|
80
|
+
fs.mkdirSync(path.join(tmpDir, ".git"));
|
|
81
|
+
statusPorcelain.mockReturnValue({ ok: true, value: "" });
|
|
82
|
+
expect(checkInitColocatedPreconditions(tmpDir)).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns null when statusPorcelain itself fails (defensive: don't refuse on probe error)", () => {
|
|
86
|
+
fs.mkdirSync(path.join(tmpDir, ".git"));
|
|
87
|
+
statusPorcelain.mockReturnValue({
|
|
88
|
+
ok: false,
|
|
89
|
+
error: { kind: "not-found", binary: "git" },
|
|
90
|
+
});
|
|
91
|
+
expect(checkInitColocatedPreconditions(tmpDir)).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-connect snapshot semantics: emits exactly one `openspec_update`
|
|
3
|
+
* per known cwd, with correct `pending` value.
|
|
4
|
+
*
|
|
5
|
+
* See change: fix-cold-boot-openspec-protocol.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi } from "vitest";
|
|
8
|
+
import { buildOpenSpecConnectSnapshot } from "../browser-gateway.js";
|
|
9
|
+
import type { OpenSpecData } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
10
|
+
|
|
11
|
+
function ds(map: Record<string, OpenSpecData | undefined>) {
|
|
12
|
+
return {
|
|
13
|
+
knownDirectories: vi.fn(() => Object.keys(map)),
|
|
14
|
+
getOpenSpecData: vi.fn((cwd: string) => map[cwd]),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("buildOpenSpecConnectSnapshot", () => {
|
|
19
|
+
it("emits cached payload for cwds with initialized data (no pending field)", () => {
|
|
20
|
+
const cached: OpenSpecData = { initialized: true, changes: [{ name: "x" } as never] };
|
|
21
|
+
const msgs = buildOpenSpecConnectSnapshot(ds({ "/p": cached }), () => true);
|
|
22
|
+
expect(msgs).toHaveLength(1);
|
|
23
|
+
expect(msgs[0]).toEqual({ type: "openspec_update", cwd: "/p", data: cached });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("emits pending: true when openspec dir exists but cache is empty", () => {
|
|
27
|
+
const msgs = buildOpenSpecConnectSnapshot(
|
|
28
|
+
ds({ "/p": { initialized: false, changes: [] } }),
|
|
29
|
+
(cwd) => cwd === "/p",
|
|
30
|
+
);
|
|
31
|
+
expect(msgs).toEqual([
|
|
32
|
+
{
|
|
33
|
+
type: "openspec_update",
|
|
34
|
+
cwd: "/p",
|
|
35
|
+
data: { initialized: false, pending: true, changes: [] },
|
|
36
|
+
},
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("emits pending: true when openspec dir exists but cache is undefined", () => {
|
|
41
|
+
const msgs = buildOpenSpecConnectSnapshot(
|
|
42
|
+
ds({ "/p": undefined }),
|
|
43
|
+
() => true,
|
|
44
|
+
);
|
|
45
|
+
expect(msgs).toEqual([
|
|
46
|
+
{
|
|
47
|
+
type: "openspec_update",
|
|
48
|
+
cwd: "/p",
|
|
49
|
+
data: { initialized: false, pending: true, changes: [] },
|
|
50
|
+
},
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("emits pending: false when no openspec dir exists", () => {
|
|
55
|
+
const msgs = buildOpenSpecConnectSnapshot(
|
|
56
|
+
ds({ "/p": undefined }),
|
|
57
|
+
() => false,
|
|
58
|
+
);
|
|
59
|
+
expect(msgs).toEqual([
|
|
60
|
+
{
|
|
61
|
+
type: "openspec_update",
|
|
62
|
+
cwd: "/p",
|
|
63
|
+
data: { initialized: false, pending: false, changes: [] },
|
|
64
|
+
},
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("emits exactly one message per known cwd, mixed states preserved", () => {
|
|
69
|
+
const cached: OpenSpecData = { initialized: true, changes: [{ name: "x" } as never] };
|
|
70
|
+
const map = { "/hot": cached, "/cold": undefined, "/none": undefined };
|
|
71
|
+
const msgs = buildOpenSpecConnectSnapshot(
|
|
72
|
+
ds(map),
|
|
73
|
+
(cwd) => cwd === "/cold",
|
|
74
|
+
);
|
|
75
|
+
expect(msgs).toHaveLength(3);
|
|
76
|
+
expect(msgs[0]).toEqual({ type: "openspec_update", cwd: "/hot", data: cached });
|
|
77
|
+
expect(msgs[1]).toEqual({
|
|
78
|
+
type: "openspec_update",
|
|
79
|
+
cwd: "/cold",
|
|
80
|
+
data: { initialized: false, pending: true, changes: [] },
|
|
81
|
+
});
|
|
82
|
+
expect(msgs[2]).toEqual({
|
|
83
|
+
type: "openspec_update",
|
|
84
|
+
cwd: "/none",
|
|
85
|
+
data: { initialized: false, pending: false, changes: [] },
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns empty array when there are no known directories", () => {
|
|
90
|
+
expect(buildOpenSpecConnectSnapshot(ds({}), () => true)).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -69,6 +69,53 @@ describe("parseTasksMarkdown", () => {
|
|
|
69
69
|
const tasks = parseTasksMarkdown("- [ ] 1.1 Loose task");
|
|
70
70
|
expect(tasks[0].group).toBe("");
|
|
71
71
|
});
|
|
72
|
+
|
|
73
|
+
// ─── relax-tasks-parser-id-optional ───────────────────────────────────────
|
|
74
|
+
// The parser MUST accept top-level checkboxes with or without a `1.1`-style
|
|
75
|
+
// numeric id prefix. Id-less lines get a synthesized `L<line>` id.
|
|
76
|
+
|
|
77
|
+
it("parses id-less checkboxes with synthesized L<line> ids", () => {
|
|
78
|
+
const md = [
|
|
79
|
+
"## 1. Workflow matrix", // line 1
|
|
80
|
+
"", // line 2
|
|
81
|
+
"- [ ] Verify runner image", // line 3
|
|
82
|
+
"- [x] Add matrix row", // line 4
|
|
83
|
+
].join("\n");
|
|
84
|
+
const tasks = parseTasksMarkdown(md);
|
|
85
|
+
expect(tasks).toEqual([
|
|
86
|
+
{ id: "L3", text: "Verify runner image", done: false, line: 3, group: "1. Workflow matrix" },
|
|
87
|
+
{ id: "L4", text: "Add matrix row", done: true, line: 4, group: "1. Workflow matrix" },
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("parses files mixing id-ed and id-less checkboxes", () => {
|
|
92
|
+
const md = [
|
|
93
|
+
"## 1. Mix", // 1
|
|
94
|
+
"", // 2
|
|
95
|
+
"- [ ] 1.1 Has id", // 3
|
|
96
|
+
"- [x] No id here", // 4
|
|
97
|
+
"- [ ] 1.3 Skipped 1.2 on purpose", // 5
|
|
98
|
+
].join("\n");
|
|
99
|
+
const tasks = parseTasksMarkdown(md);
|
|
100
|
+
expect(tasks).toEqual([
|
|
101
|
+
{ id: "1.1", text: "Has id", done: false, line: 3, group: "1. Mix" },
|
|
102
|
+
{ id: "L4", text: "No id here", done: true, line: 4, group: "1. Mix" },
|
|
103
|
+
{ id: "1.3", text: "Skipped 1.2 on purpose", done: false, line: 5, group: "1. Mix" },
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("still ignores indented checkboxes (id-less or id-ed)", () => {
|
|
108
|
+
const md = [
|
|
109
|
+
"## G",
|
|
110
|
+
" - [ ] indented id-less",
|
|
111
|
+
" - [ ] 1.1 indented id-ed",
|
|
112
|
+
"- [ ] top-level id-less",
|
|
113
|
+
].join("\n");
|
|
114
|
+
const tasks = parseTasksMarkdown(md);
|
|
115
|
+
expect(tasks).toHaveLength(1);
|
|
116
|
+
expect(tasks[0].id).toBe("L4");
|
|
117
|
+
expect(tasks[0].text).toBe("top-level id-less");
|
|
118
|
+
});
|
|
72
119
|
});
|
|
73
120
|
|
|
74
121
|
describe("readTasks + toggleTask (writer)", () => {
|
|
@@ -175,4 +222,71 @@ describe("readTasks + toggleTask (writer)", () => {
|
|
|
175
222
|
const after = fs.readFileSync(tasksFile, "utf-8");
|
|
176
223
|
expect(after).toBe(weirdMd.replace("- [ ] 1.1 Task one", "- [x] 1.1 Task one"));
|
|
177
224
|
});
|
|
225
|
+
|
|
226
|
+
// ─── relax-tasks-parser-id-optional ───────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
describe("id-less round-trip", () => {
|
|
229
|
+
const idlessMd = [
|
|
230
|
+
"## 1. Workflow matrix", // 1
|
|
231
|
+
"", // 2
|
|
232
|
+
"- [ ] Verify runner image", // 3
|
|
233
|
+
"- [x] Add matrix row", // 4
|
|
234
|
+
"", // 5
|
|
235
|
+
"## 2. Verify rename behavior", // 6
|
|
236
|
+
"- [ ] Inspect releases", // 7
|
|
237
|
+
].join("\n");
|
|
238
|
+
|
|
239
|
+
beforeEach(() => {
|
|
240
|
+
fs.writeFileSync(tasksFile, idlessMd, "utf-8");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("toggle ticks an id-less task addressed by L<line>, no synthetic id leaks into the file", async () => {
|
|
244
|
+
const result = await toggleTask(tmpDir, CWD_CHANGE[1], "L3", true, 3);
|
|
245
|
+
expect(result).toEqual({
|
|
246
|
+
id: "L3",
|
|
247
|
+
text: "Verify runner image",
|
|
248
|
+
done: true,
|
|
249
|
+
line: 3,
|
|
250
|
+
group: "1. Workflow matrix",
|
|
251
|
+
});
|
|
252
|
+
const after = fs.readFileSync(tasksFile, "utf-8");
|
|
253
|
+
// CRITICAL: line shape preserved — no "L3" appears in the file body
|
|
254
|
+
expect(after).toBe(idlessMd.replace("- [ ] Verify runner image", "- [x] Verify runner image"));
|
|
255
|
+
expect(after).not.toContain("L3");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("toggle unticks an id-less task addressed by L<line>", async () => {
|
|
259
|
+
const result = await toggleTask(tmpDir, CWD_CHANGE[1], "L4", false, 4);
|
|
260
|
+
expect(result.done).toBe(false);
|
|
261
|
+
const after = fs.readFileSync(tasksFile, "utf-8");
|
|
262
|
+
expect(after).toBe(idlessMd.replace("- [x] Add matrix row", "- [ ] Add matrix row"));
|
|
263
|
+
expect(after).not.toContain("L4");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("toggle of id-less line with wrong synthesized id throws LineMismatchError", async () => {
|
|
267
|
+
// Line 3 is id-less; passing L99 (or any other L<n>) must reject
|
|
268
|
+
await expect(toggleTask(tmpDir, CWD_CHANGE[1], "L99", true, 3)).rejects.toBeInstanceOf(
|
|
269
|
+
LineMismatchError,
|
|
270
|
+
);
|
|
271
|
+
expect(fs.readFileSync(tasksFile, "utf-8")).toBe(idlessMd);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("toggle of id-less line with a numeric-style id throws LineMismatchError", async () => {
|
|
275
|
+
// Line 3 has no numeric id; passing "1.1" must reject
|
|
276
|
+
await expect(toggleTask(tmpDir, CWD_CHANGE[1], "1.1", true, 3)).rejects.toBeInstanceOf(
|
|
277
|
+
LineMismatchError,
|
|
278
|
+
);
|
|
279
|
+
expect(fs.readFileSync(tasksFile, "utf-8")).toBe(idlessMd);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("toggle of id-ed line addressed by L<n> throws LineMismatchError", async () => {
|
|
283
|
+
// Switch fixture to id-ed for this case
|
|
284
|
+
fs.writeFileSync(tasksFile, initialMd, "utf-8");
|
|
285
|
+
// Line 3 has id "1.1"; addressing it as "L3" must reject
|
|
286
|
+
await expect(toggleTask(tmpDir, CWD_CHANGE[1], "L3", true, 3)).rejects.toBeInstanceOf(
|
|
287
|
+
LineMismatchError,
|
|
288
|
+
);
|
|
289
|
+
expect(fs.readFileSync(tasksFile, "utf-8")).toBe(initialMd);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
178
292
|
});
|