@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,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression: `resolveNpmArgv` from `bootstrap-install.ts` MUST consult
|
|
3
|
+
* the injected `ToolRegistry` for `npm` before falling back to the
|
|
4
|
+
* platform `npm` / `npm.cmd` PATH binary.
|
|
5
|
+
*
|
|
6
|
+
* The change `embed-managed-node-runtime` adds a managedRuntime
|
|
7
|
+
* strategy to the npm chain in the registry. If a future refactor
|
|
8
|
+
* accidentally bypasses the registry inside `resolveNpmArgv`, the
|
|
9
|
+
* managed Node runtime would stop being preferred for shared bootstrap
|
|
10
|
+
* spawns \u2014 the user-visible regression class this whole change exists
|
|
11
|
+
* to prevent.
|
|
12
|
+
*
|
|
13
|
+
* See change: embed-managed-node-runtime (task 4.2).
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it } from "vitest";
|
|
16
|
+
import { resolveNpmArgv } from "../bootstrap-install.js";
|
|
17
|
+
import type {
|
|
18
|
+
Resolution,
|
|
19
|
+
ToolRegistry,
|
|
20
|
+
} from "../tool-registry/index.js";
|
|
21
|
+
|
|
22
|
+
function fakeRegistry(opts: {
|
|
23
|
+
hasNpm?: boolean;
|
|
24
|
+
resolveResult?: Partial<Resolution>;
|
|
25
|
+
}): ToolRegistry {
|
|
26
|
+
// Only the methods `resolveNpmArgv` actually calls.
|
|
27
|
+
return {
|
|
28
|
+
has: (name: string) => name === "npm" && (opts.hasNpm ?? true),
|
|
29
|
+
resolve: () =>
|
|
30
|
+
({
|
|
31
|
+
name: "npm",
|
|
32
|
+
ok: true,
|
|
33
|
+
path: "/managed/node/bin/npm",
|
|
34
|
+
source: "managed",
|
|
35
|
+
tried: [],
|
|
36
|
+
resolvedAt: Date.now(),
|
|
37
|
+
...opts.resolveResult,
|
|
38
|
+
}) as Resolution,
|
|
39
|
+
} as unknown as ToolRegistry;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("resolveNpmArgv", () => {
|
|
43
|
+
it("explicit npmArgv wins over registry", () => {
|
|
44
|
+
const argv = resolveNpmArgv({
|
|
45
|
+
npmArgv: ["/explicit/node", "/explicit/npm-cli.js"],
|
|
46
|
+
registry: fakeRegistry({}),
|
|
47
|
+
});
|
|
48
|
+
expect(argv).toEqual(["/explicit/node", "/explicit/npm-cli.js"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("uses ToolRegistry.resolve('npm') when no explicit argv", () => {
|
|
52
|
+
const argv = resolveNpmArgv({
|
|
53
|
+
registry: fakeRegistry({
|
|
54
|
+
resolveResult: { ok: true, path: "/managed/node/bin/npm" } as Partial<Resolution>,
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
expect(argv).toEqual(["/managed/node/bin/npm"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("falls back to npm/npm.cmd on PATH when registry has no entry", () => {
|
|
61
|
+
const argv = resolveNpmArgv({
|
|
62
|
+
registry: {
|
|
63
|
+
has: () => false,
|
|
64
|
+
resolve: () => {
|
|
65
|
+
throw new Error("should not be called");
|
|
66
|
+
},
|
|
67
|
+
} as unknown as ToolRegistry,
|
|
68
|
+
});
|
|
69
|
+
expect(argv).toHaveLength(1);
|
|
70
|
+
expect(argv[0]).toMatch(/^npm(\.cmd)?$/);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -12,8 +12,13 @@ import type {
|
|
|
12
12
|
BrowserPromptDismissMessage,
|
|
13
13
|
BrowserPromptCancelMessage,
|
|
14
14
|
BrowserExtUiDecoratorMessage,
|
|
15
|
+
BrowserAssetRegisterMessage,
|
|
15
16
|
} from "../browser-protocol.js";
|
|
16
|
-
import type {
|
|
17
|
+
import type {
|
|
18
|
+
ExtensionToServerMessage,
|
|
19
|
+
ExtUiDecoratorMessage,
|
|
20
|
+
AssetRegisterMessage,
|
|
21
|
+
} from "../protocol.js";
|
|
17
22
|
import type { DecoratorDescriptor } from "../types.js";
|
|
18
23
|
|
|
19
24
|
// Type-level assertion: if these types are NOT in the union, this will fail to compile.
|
|
@@ -26,6 +31,11 @@ type _PromptCancelInUnion = AssertExtends<BrowserPromptCancelMessage, ServerToBr
|
|
|
26
31
|
// esbuild strips the switch arms in production builds.
|
|
27
32
|
type _ExtUiDecoratorInExtensionUnion = AssertExtends<ExtUiDecoratorMessage, ExtensionToServerMessage>;
|
|
28
33
|
type _ExtUiDecoratorInBrowserUnion = AssertExtends<BrowserExtUiDecoratorMessage, ServerToBrowserMessage>;
|
|
34
|
+
// chat-markdown-local-images-and-math: asset_register must live in BOTH the
|
|
35
|
+
// extension→server union (so the server's switch arm survives esbuild) AND
|
|
36
|
+
// the server→browser union (so the client's reducer arm survives esbuild).
|
|
37
|
+
type _AssetRegisterInExtensionUnion = AssertExtends<AssetRegisterMessage, ExtensionToServerMessage>;
|
|
38
|
+
type _AssetRegisterInBrowserUnion = AssertExtends<BrowserAssetRegisterMessage, ServerToBrowserMessage>;
|
|
29
39
|
|
|
30
40
|
// Runtime verification that the type discriminants are reachable in a switch
|
|
31
41
|
function extractPromptType(msg: ServerToBrowserMessage): string | null {
|
|
@@ -119,3 +129,39 @@ describe("ext_ui_decorator is a member of both protocol unions", () => {
|
|
|
119
129
|
expect(msg.descriptor.kind).toBe("footer-segment");
|
|
120
130
|
});
|
|
121
131
|
});
|
|
132
|
+
|
|
133
|
+
// chat-markdown-local-images-and-math: asset_register switch-arm reachability.
|
|
134
|
+
function extractAssetHash(msg: ServerToBrowserMessage): string | null {
|
|
135
|
+
switch (msg.type) {
|
|
136
|
+
case "asset_register":
|
|
137
|
+
return msg.hash;
|
|
138
|
+
default:
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
describe("asset_register is a member of both protocol unions", () => {
|
|
144
|
+
it("server→browser asset_register is a valid discriminant", () => {
|
|
145
|
+
const msg: BrowserAssetRegisterMessage = {
|
|
146
|
+
type: "asset_register",
|
|
147
|
+
sessionId: "s1",
|
|
148
|
+
hash: "abc1234567890123",
|
|
149
|
+
mimeType: "image/png",
|
|
150
|
+
data: "iVBORw0KGgo=",
|
|
151
|
+
};
|
|
152
|
+
expect(extractAssetHash(msg)).toBe("abc1234567890123");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("extension→server asset_register carries the same shape", () => {
|
|
156
|
+
const msg: AssetRegisterMessage = {
|
|
157
|
+
type: "asset_register",
|
|
158
|
+
sessionId: "s1",
|
|
159
|
+
hash: "abc1234567890123",
|
|
160
|
+
mimeType: "image/svg+xml",
|
|
161
|
+
data: "PHN2Zy8+",
|
|
162
|
+
};
|
|
163
|
+
expect(msg.type).toBe("asset_register");
|
|
164
|
+
expect(msg.hash).toBe("abc1234567890123");
|
|
165
|
+
expect(msg.mimeType).toBe("image/svg+xml");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -471,3 +471,51 @@ describe("loadConfig reattachPlacement", () => {
|
|
|
471
471
|
expect(content.reattachPlacement).toBeUndefined();
|
|
472
472
|
});
|
|
473
473
|
});
|
|
474
|
+
|
|
475
|
+
describe("loadConfig spawnRegisterTimeoutMs", () => {
|
|
476
|
+
let testDir: string;
|
|
477
|
+
let configFile: string;
|
|
478
|
+
let origHome: string;
|
|
479
|
+
|
|
480
|
+
beforeEach(() => {
|
|
481
|
+
testDir = path.join(os.tmpdir(), `test-config-srt-${Date.now()}`);
|
|
482
|
+
fs.mkdirSync(path.join(testDir, ".pi", "dashboard"), { recursive: true });
|
|
483
|
+
configFile = path.join(testDir, ".pi", "dashboard", "config.json");
|
|
484
|
+
origHome = process.env.HOME!;
|
|
485
|
+
process.env.HOME = testDir;
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
afterEach(() => {
|
|
489
|
+
process.env.HOME = origHome;
|
|
490
|
+
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("defaults to 30000 when field is omitted", () => {
|
|
494
|
+
expect(loadConfig().spawnRegisterTimeoutMs).toBe(30000);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("preserves in-range value", () => {
|
|
498
|
+
fs.writeFileSync(configFile, JSON.stringify({ spawnRegisterTimeoutMs: 45000 }));
|
|
499
|
+
expect(loadConfig().spawnRegisterTimeoutMs).toBe(45000);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("clamps below-range value to 5000", () => {
|
|
503
|
+
fs.writeFileSync(configFile, JSON.stringify({ spawnRegisterTimeoutMs: 1000 }));
|
|
504
|
+
expect(loadConfig().spawnRegisterTimeoutMs).toBe(5000);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("clamps above-range value to 120000", () => {
|
|
508
|
+
fs.writeFileSync(configFile, JSON.stringify({ spawnRegisterTimeoutMs: 999999 }));
|
|
509
|
+
expect(loadConfig().spawnRegisterTimeoutMs).toBe(120000);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("falls back to default for non-number string", () => {
|
|
513
|
+
fs.writeFileSync(configFile, JSON.stringify({ spawnRegisterTimeoutMs: "thirty" }));
|
|
514
|
+
expect(loadConfig().spawnRegisterTimeoutMs).toBe(30000);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("falls back to default for null", () => {
|
|
518
|
+
fs.writeFileSync(configFile, JSON.stringify({ spawnRegisterTimeoutMs: null }));
|
|
519
|
+
expect(loadConfig().spawnRegisterTimeoutMs).toBe(30000);
|
|
520
|
+
});
|
|
521
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { parseDashboardStarter } from "../dashboard-starter.js";
|
|
3
|
+
|
|
4
|
+
describe("parseDashboardStarter", () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.restoreAllMocks();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("returns Standalone when env is empty object", () => {
|
|
10
|
+
expect(parseDashboardStarter({})).toBe("Standalone");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns Standalone when DASHBOARD_STARTER is undefined", () => {
|
|
14
|
+
expect(parseDashboardStarter({ DASHBOARD_STARTER: undefined })).toBe("Standalone");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns Standalone when DASHBOARD_STARTER is empty string", () => {
|
|
18
|
+
expect(parseDashboardStarter({ DASHBOARD_STARTER: "" })).toBe("Standalone");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns Bridge for valid value", () => {
|
|
22
|
+
expect(parseDashboardStarter({ DASHBOARD_STARTER: "Bridge" })).toBe("Bridge");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns Standalone for valid value", () => {
|
|
26
|
+
expect(parseDashboardStarter({ DASHBOARD_STARTER: "Standalone" })).toBe("Standalone");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns Electron for valid value", () => {
|
|
30
|
+
expect(parseDashboardStarter({ DASHBOARD_STARTER: "Electron" })).toBe("Electron");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns Standalone and warns on invalid value", () => {
|
|
34
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
35
|
+
const result = parseDashboardStarter({ DASHBOARD_STARTER: "bogus" });
|
|
36
|
+
expect(result).toBe("Standalone");
|
|
37
|
+
expect(warnSpy).toHaveBeenCalledOnce();
|
|
38
|
+
expect(warnSpy.mock.calls[0]![0]).toContain("bogus");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -61,6 +61,30 @@ describe("spawnDetached", () => {
|
|
|
61
61
|
rmSync(path.dirname(logPath), { recursive: true, force: true });
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
// See change: fix-electron-extracted-jiti-and-stdio-capture.
|
|
65
|
+
it("redirects BOTH stdout and stderr to logFd when provided", async () => {
|
|
66
|
+
const logPath = tmpLog();
|
|
67
|
+
const fd = openSync(logPath, "a");
|
|
68
|
+
try {
|
|
69
|
+
const r = await spawnDetached({
|
|
70
|
+
cmd: process.execPath,
|
|
71
|
+
args: [
|
|
72
|
+
"-e",
|
|
73
|
+
"console.log('STDOUT-LINE'); process.stderr.write('STDERR-LINE'); setTimeout(() => process.exit(0), 100)",
|
|
74
|
+
],
|
|
75
|
+
logFd: fd,
|
|
76
|
+
});
|
|
77
|
+
expect(r.ok).toBe(true);
|
|
78
|
+
await new Promise((res) => r.process!.once("exit", res));
|
|
79
|
+
} finally {
|
|
80
|
+
try { closeSync(fd); } catch { /* ignore */ }
|
|
81
|
+
}
|
|
82
|
+
const content = readFileSync(logPath, "utf-8");
|
|
83
|
+
expect(content).toContain("STDOUT-LINE");
|
|
84
|
+
expect(content).toContain("STDERR-LINE");
|
|
85
|
+
rmSync(path.dirname(logPath), { recursive: true, force: true });
|
|
86
|
+
});
|
|
87
|
+
|
|
64
88
|
it("does not keep parent event loop alive (unref)", async () => {
|
|
65
89
|
// Can only check behaviour indirectly: the returned pid/process exist
|
|
66
90
|
// and the child is running detached. Lifecycle survival is covered by
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor core — section assignment, suggestion taxonomy, and the
|
|
3
|
+
* Decision-8 lint (every non-ok check has non-empty
|
|
4
|
+
* message/detail/suggestion). See change: doctor-rich-output.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
SECTION_OF,
|
|
9
|
+
SUGGESTIONS,
|
|
10
|
+
stampSectionsAndSuggestions,
|
|
11
|
+
type DoctorCheck,
|
|
12
|
+
type DoctorStatus,
|
|
13
|
+
} from "../doctor-core.js";
|
|
14
|
+
|
|
15
|
+
const ALL_CHECK_NAMES = Object.keys(SECTION_OF);
|
|
16
|
+
|
|
17
|
+
describe("SECTION_OF", () => {
|
|
18
|
+
it("maps every canonical check name to one of the five sections", () => {
|
|
19
|
+
const allowed = new Set(["runtime", "pi-tooling", "server", "setup", "diagnostics"]);
|
|
20
|
+
for (const name of ALL_CHECK_NAMES) {
|
|
21
|
+
expect(allowed.has(SECTION_OF[name])).toBe(true);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("covers all five sections (none empty)", () => {
|
|
26
|
+
const sections = new Set(Object.values(SECTION_OF));
|
|
27
|
+
for (const s of ["runtime", "pi-tooling", "server", "setup", "diagnostics"]) {
|
|
28
|
+
expect(sections.has(s as never)).toBe(true);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("SUGGESTIONS", () => {
|
|
34
|
+
it("returns undefined for status=ok across every check name", () => {
|
|
35
|
+
for (const name of ALL_CHECK_NAMES) {
|
|
36
|
+
const fn = SUGGESTIONS[name];
|
|
37
|
+
expect(fn).toBeDefined();
|
|
38
|
+
expect(fn?.("ok")).toBeUndefined();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns a non-empty string for status=error or warning when defined", () => {
|
|
43
|
+
for (const name of ALL_CHECK_NAMES) {
|
|
44
|
+
const fn = SUGGESTIONS[name];
|
|
45
|
+
// Electron is the only one that returns undefined even for non-ok
|
|
46
|
+
// (because today it never fails). Skip it.
|
|
47
|
+
if (name === "Electron") continue;
|
|
48
|
+
const w = fn?.("warning");
|
|
49
|
+
const e = fn?.("error");
|
|
50
|
+
expect(typeof w === "string" && w.length > 0).toBe(true);
|
|
51
|
+
expect(typeof e === "string" && e.length > 0).toBe(true);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("constrains suggestion text to the allowed Markdown subset", () => {
|
|
56
|
+
// Allowed: **bold**, single-backtick code, [text](url). Disallow: tables,
|
|
57
|
+
// headings, fenced blocks, raw HTML.
|
|
58
|
+
for (const name of ALL_CHECK_NAMES) {
|
|
59
|
+
const fn = SUGGESTIONS[name];
|
|
60
|
+
const candidates: (string | undefined)[] = [
|
|
61
|
+
fn?.("warning"),
|
|
62
|
+
fn?.("error"),
|
|
63
|
+
fn?.("error", undefined, "not-found"),
|
|
64
|
+
fn?.("error", undefined, "permission-denied"),
|
|
65
|
+
fn?.("error", undefined, "timeout"),
|
|
66
|
+
fn?.("error", undefined, "non-zero-exit"),
|
|
67
|
+
];
|
|
68
|
+
for (const s of candidates) {
|
|
69
|
+
if (!s) continue;
|
|
70
|
+
// No fenced code blocks.
|
|
71
|
+
expect(/```/.test(s)).toBe(false);
|
|
72
|
+
// No headings at line start.
|
|
73
|
+
expect(/^#{1,6}\s/m.test(s)).toBe(false);
|
|
74
|
+
// No raw HTML tags (closing, self-closing, or with attributes).
|
|
75
|
+
// Plain `<placeholder>` text is allowed (used as prose).
|
|
76
|
+
expect(/<\/[a-zA-Z]|<[a-zA-Z][^>]*\s+[^>]+>|<[a-zA-Z][^>]*\/>/.test(s)).toBe(false);
|
|
77
|
+
// Triple-asterisk or underline for bold not allowed.
|
|
78
|
+
expect(/\*\*\*|___/.test(s)).toBe(false);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("stampSectionsAndSuggestions (Decision 8 lint)", () => {
|
|
85
|
+
it("stamps section + suggestion on non-ok rows by name", () => {
|
|
86
|
+
const checks: DoctorCheck[] = [
|
|
87
|
+
{ name: "pi CLI", section: undefined as unknown as never, status: "error", message: "Not found", detail: "Searched PATH" },
|
|
88
|
+
{ name: "System Node.js", section: undefined as unknown as never, status: "ok", message: "v22 at /usr/bin/node" },
|
|
89
|
+
];
|
|
90
|
+
const out = stampSectionsAndSuggestions(checks);
|
|
91
|
+
expect(out[0].section).toBe("pi-tooling");
|
|
92
|
+
expect(out[0].suggestion).toBeDefined();
|
|
93
|
+
expect(out[1].section).toBe("runtime");
|
|
94
|
+
expect(out[1].suggestion).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("every non-ok row produced through stamping has non-empty message + detail + suggestion", () => {
|
|
98
|
+
const statuses: DoctorStatus[] = ["warning", "error"];
|
|
99
|
+
for (const name of ALL_CHECK_NAMES) {
|
|
100
|
+
// Electron suggestion is always undefined (decision-by-design); skip.
|
|
101
|
+
if (name === "Electron") continue;
|
|
102
|
+
for (const status of statuses) {
|
|
103
|
+
const checks: DoctorCheck[] = [
|
|
104
|
+
{
|
|
105
|
+
name,
|
|
106
|
+
section: undefined as unknown as never,
|
|
107
|
+
status,
|
|
108
|
+
message: "synthetic message",
|
|
109
|
+
detail: "synthetic detail",
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
const [stamped] = stampSectionsAndSuggestions(checks);
|
|
113
|
+
expect(stamped.message.length).toBeGreaterThan(0);
|
|
114
|
+
expect((stamped.detail ?? "").length).toBeGreaterThan(0);
|
|
115
|
+
expect((stamped.suggestion ?? "").length).toBeGreaterThan(0);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("does not overwrite an existing suggestion", () => {
|
|
121
|
+
const checks: DoctorCheck[] = [
|
|
122
|
+
{
|
|
123
|
+
name: "pi CLI",
|
|
124
|
+
section: "pi-tooling",
|
|
125
|
+
status: "error",
|
|
126
|
+
message: "x",
|
|
127
|
+
detail: "y",
|
|
128
|
+
suggestion: "custom",
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
const out = stampSectionsAndSuggestions(checks);
|
|
132
|
+
expect(out[0].suggestion).toBe("custom");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor fault-tolerance helpers — safeCheck / safeExec / assumedMandatory.
|
|
3
|
+
* See change: doctor-rich-output (design.md Decision 7).
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { mkdtempSync, rmSync, existsSync, statSync, writeFileSync, readFileSync, chmodSync } from "node:fs";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
safeCheck,
|
|
12
|
+
safeExec,
|
|
13
|
+
assumedMandatory,
|
|
14
|
+
stripAnsi,
|
|
15
|
+
} from "../doctor-core.js";
|
|
16
|
+
|
|
17
|
+
describe("stripAnsi", () => {
|
|
18
|
+
it("removes CSI sequences", () => {
|
|
19
|
+
expect(stripAnsi("\u001b[31mred\u001b[0m text")).toBe("red text");
|
|
20
|
+
expect(stripAnsi("\u001b[1;33;40myellow\u001b[m")).toBe("yellow");
|
|
21
|
+
});
|
|
22
|
+
it("removes OSC sequences", () => {
|
|
23
|
+
expect(stripAnsi("\u001b]0;title\u0007hello")).toBe("hello");
|
|
24
|
+
});
|
|
25
|
+
it("preserves printable text untouched", () => {
|
|
26
|
+
expect(stripAnsi("a | b | c\nfoo")).toBe("a | b | c\nfoo");
|
|
27
|
+
});
|
|
28
|
+
it("handles empty input", () => {
|
|
29
|
+
expect(stripAnsi("")).toBe("");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("safeCheck", () => {
|
|
34
|
+
it("returns the row on success", async () => {
|
|
35
|
+
const r = await safeCheck("X", "diagnostics", () => ({
|
|
36
|
+
name: "X",
|
|
37
|
+
section: "diagnostics",
|
|
38
|
+
status: "ok",
|
|
39
|
+
message: "fine",
|
|
40
|
+
}));
|
|
41
|
+
expect(r.status).toBe("ok");
|
|
42
|
+
expect(r.name).toBe("X");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("swallows synchronous throws and returns a fallback row", async () => {
|
|
46
|
+
const r = await safeCheck("Boom", "runtime", () => {
|
|
47
|
+
throw new Error("kaboom");
|
|
48
|
+
});
|
|
49
|
+
expect(r.status).toBe("error");
|
|
50
|
+
expect(r.message).toMatch(/Check failed/i);
|
|
51
|
+
expect(r.detail).toContain("kaboom");
|
|
52
|
+
expect(r.suggestion?.length ?? 0).toBeGreaterThan(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("swallows promise rejections and returns a fallback row", async () => {
|
|
56
|
+
const r = await safeCheck("Boom", "runtime", async () => {
|
|
57
|
+
throw new Error("async-boom");
|
|
58
|
+
});
|
|
59
|
+
expect(r.status).toBe("error");
|
|
60
|
+
expect(r.detail).toContain("async-boom");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("never throws even when fn returns a non-DoctorCheck", async () => {
|
|
64
|
+
// @ts-expect-error — exercising runtime tolerance
|
|
65
|
+
const r = await safeCheck("X", "runtime", () => null);
|
|
66
|
+
// The post-pass treats the missing section as the wrapper-provided
|
|
67
|
+
// default, so we should still get a row of some shape.
|
|
68
|
+
expect(r).toBeDefined();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("safeExec — error classification", () => {
|
|
73
|
+
it("classifies ENOENT as not-found", () => {
|
|
74
|
+
const r = safeExec("definitely-not-a-binary-xyz-1729 --version", { timeoutMs: 2000 });
|
|
75
|
+
expect(r.ok).toBe(false);
|
|
76
|
+
if (!r.ok) {
|
|
77
|
+
// Some shells/platforms classify the missing executable differently:
|
|
78
|
+
// POSIX with /bin/sh raises a non-zero shell exit (kind=non-zero-exit),
|
|
79
|
+
// direct exec gets ENOENT (kind=not-found). Both are acceptable failure
|
|
80
|
+
// signals for "binary missing".
|
|
81
|
+
expect(["not-found", "non-zero-exit", "unknown"]).toContain(r.kind);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("classifies non-zero exits", () => {
|
|
86
|
+
const r = safeExec(`node -e "process.exit(7)"`, { timeoutMs: 5000 });
|
|
87
|
+
expect(r.ok).toBe(false);
|
|
88
|
+
if (!r.ok) {
|
|
89
|
+
expect(r.kind).toBe("non-zero-exit");
|
|
90
|
+
expect(r.exitCode).toBe(7);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("captures stderr tail and runs it through stripAnsi", () => {
|
|
95
|
+
const r = safeExec(
|
|
96
|
+
`node -e "process.stderr.write('\\u001b[31mboom\\u001b[0m'); process.exit(1)"`,
|
|
97
|
+
{ timeoutMs: 5000 },
|
|
98
|
+
);
|
|
99
|
+
expect(r.ok).toBe(false);
|
|
100
|
+
if (!r.ok) {
|
|
101
|
+
expect(r.stderrTail).toContain("boom");
|
|
102
|
+
expect(r.stderrTail).not.toContain("\u001b");
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("classifies timeouts and reflects the deadline in the message", () => {
|
|
107
|
+
const r = safeExec(`node -e "setTimeout(()=>{}, 5000)"`, { timeoutMs: 200 });
|
|
108
|
+
expect(r.ok).toBe(false);
|
|
109
|
+
if (!r.ok) {
|
|
110
|
+
// Node's execSync timeout typically surfaces as ETIMEDOUT or
|
|
111
|
+
// SIGTERM signal — both classify as "timeout" in our wrapper.
|
|
112
|
+
expect(r.kind).toBe("timeout");
|
|
113
|
+
expect(r.message).toMatch(/0?\s*s/);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("honours the 15s timeout override (uses it for cold-start probes)", () => {
|
|
118
|
+
// We don't actually wait 15s; we just verify the wrapper carries the
|
|
119
|
+
// configured timeoutMs into the SafeExecErr.
|
|
120
|
+
const r = safeExec(`node -e "process.exit(1)"`, { timeoutMs: 15000 });
|
|
121
|
+
expect(r.ok).toBe(false);
|
|
122
|
+
if (!r.ok) {
|
|
123
|
+
expect(r.timeoutMs).toBe(15000);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns ok with stdout on success", () => {
|
|
128
|
+
const r = safeExec(`node -e "console.log('hi')"`, { timeoutMs: 5000 });
|
|
129
|
+
expect(r.ok).toBe(true);
|
|
130
|
+
if (r.ok) {
|
|
131
|
+
expect(r.stdout.trim()).toBe("hi");
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("assumedMandatory", () => {
|
|
137
|
+
let tmp: string;
|
|
138
|
+
beforeEach(() => {
|
|
139
|
+
tmp = mkdtempSync(path.join(os.tmpdir(), "doctor-am-"));
|
|
140
|
+
});
|
|
141
|
+
afterEach(() => {
|
|
142
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns ok value when fn does not throw", () => {
|
|
146
|
+
const r = assumedMandatory("read-foo", () => 42, { managedDir: tmp });
|
|
147
|
+
expect(r.ok).toBe(true);
|
|
148
|
+
if (r.ok) expect(r.value).toBe(42);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("logs to <managedDir>/doctor.log on throw and surfaces a diagnostics row", () => {
|
|
152
|
+
const r = assumedMandatory(
|
|
153
|
+
"read-foo",
|
|
154
|
+
() => {
|
|
155
|
+
throw new Error("filesystem-down");
|
|
156
|
+
},
|
|
157
|
+
{ managedDir: tmp },
|
|
158
|
+
);
|
|
159
|
+
expect(r.ok).toBe(false);
|
|
160
|
+
if (!r.ok) {
|
|
161
|
+
expect(r.row.section).toBe("diagnostics");
|
|
162
|
+
expect(r.row.status).toBe("error");
|
|
163
|
+
expect(r.row.name).toMatch(/Doctor internal: read-foo/);
|
|
164
|
+
expect(r.row.detail).toContain("filesystem-down");
|
|
165
|
+
expect(r.row.suggestion?.length ?? 0).toBeGreaterThan(0);
|
|
166
|
+
}
|
|
167
|
+
const logPath = path.join(tmp, "doctor.log");
|
|
168
|
+
expect(existsSync(logPath)).toBe(true);
|
|
169
|
+
const log = readFileSync(logPath, "utf-8").trim();
|
|
170
|
+
const parsed = JSON.parse(log.split("\n")[0]);
|
|
171
|
+
expect(parsed.label).toBe("read-foo");
|
|
172
|
+
expect(parsed.message).toBe("filesystem-down");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("tolerates an unwriteable log file (never propagates)", () => {
|
|
176
|
+
// Make managedDir read-only — append should fail silently.
|
|
177
|
+
if (process.platform === "win32") {
|
|
178
|
+
// chmod semantics on Windows are unreliable; skip.
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
chmodSync(tmp, 0o500);
|
|
182
|
+
try {
|
|
183
|
+
const r = assumedMandatory(
|
|
184
|
+
"x",
|
|
185
|
+
() => {
|
|
186
|
+
throw new Error("z");
|
|
187
|
+
},
|
|
188
|
+
{ managedDir: tmp },
|
|
189
|
+
);
|
|
190
|
+
expect(r.ok).toBe(false);
|
|
191
|
+
// Did not throw.
|
|
192
|
+
} finally {
|
|
193
|
+
chmodSync(tmp, 0o700);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("rotates doctor.log when it exceeds 1 MB", () => {
|
|
198
|
+
const logPath = path.join(tmp, "doctor.log");
|
|
199
|
+
// Pre-fill log with > 1 MB of data.
|
|
200
|
+
writeFileSync(logPath, Buffer.alloc(1.2 * 1024 * 1024, "x".charCodeAt(0)));
|
|
201
|
+
const beforeSize = statSync(logPath).size;
|
|
202
|
+
expect(beforeSize).toBeGreaterThan(1024 * 1024);
|
|
203
|
+
|
|
204
|
+
assumedMandatory(
|
|
205
|
+
"rotate-test",
|
|
206
|
+
() => {
|
|
207
|
+
throw new Error("trigger");
|
|
208
|
+
},
|
|
209
|
+
{ managedDir: tmp },
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const rotated = path.join(tmp, "doctor.log.1");
|
|
213
|
+
expect(existsSync(rotated)).toBe(true);
|
|
214
|
+
// The fresh log should be small (just one JSON line).
|
|
215
|
+
const fresh = statSync(logPath).size;
|
|
216
|
+
expect(fresh).toBeLessThan(2048);
|
|
217
|
+
});
|
|
218
|
+
});
|