@blackbelt-technology/pi-agent-dashboard 0.4.6 → 0.5.1
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 +339 -190
- package/README.md +50 -7
- package/docs/architecture.md +238 -23
- package/package.json +23 -13
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
- package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +68 -0
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
- package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
- package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
- package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
- package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
- package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
- package/packages/extension/src/ask-user-tool.ts +1 -1
- package/packages/extension/src/bridge-context.ts +1 -1
- package/packages/extension/src/bridge.ts +169 -4
- package/packages/extension/src/command-handler.ts +65 -2
- package/packages/extension/src/flow-event-wiring.ts +1 -1
- package/packages/extension/src/markdown-image-inliner.ts +268 -0
- package/packages/extension/src/multiselect-list.ts +1 -1
- package/packages/extension/src/pi-env.d.ts +16 -9
- package/packages/extension/src/prompt-expander.ts +50 -2
- package/packages/extension/src/provider-register.ts +132 -8
- package/packages/extension/src/retry-tracker.ts +123 -0
- package/packages/extension/src/server-launcher.ts +18 -1
- package/packages/extension/src/session-sync.ts +15 -1
- package/packages/extension/src/usage-limit-orderer.ts +76 -0
- package/packages/server/package.json +6 -6
- 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__/changelog-fs.test.ts +171 -0
- package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
- package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +22 -4
- package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service.test.ts +1 -1
- package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +154 -0
- package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
- package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
- package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +83 -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-pi-process.test.ts +1 -1
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
- package/packages/server/src/__tests__/package-routes.test.ts +1 -1
- package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
- package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
- package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +236 -0
- package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
- package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
- 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-routes.test.ts +12 -4
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +44 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +1 -1
- 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-discovery-skill-firstmessage.test.ts +95 -0
- package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -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 +250 -0
- package/packages/server/src/__tests__/spawn-token.test.ts +57 -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-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 +70 -24
- package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
- package/packages/server/src/browser-handlers/handler-context.ts +9 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +159 -18
- package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
- package/packages/server/src/changelog-fs.ts +167 -0
- package/packages/server/src/changelog-parser.ts +321 -0
- package/packages/server/src/changelog-remote.ts +134 -0
- package/packages/server/src/cli.ts +23 -2
- package/packages/server/src/directory-service.ts +31 -0
- package/packages/server/src/event-wiring.ts +105 -5
- package/packages/server/src/headless-pid-registry.ts +54 -5
- 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/pending-client-correlations.ts +73 -0
- package/packages/server/src/pending-fork-registry.ts +24 -12
- package/packages/server/src/pi-core-checker.ts +77 -17
- package/packages/server/src/pi-core-updater.ts +81 -15
- package/packages/server/src/pi-dev-version-check.ts +145 -0
- package/packages/server/src/pi-gateway.ts +10 -0
- package/packages/server/src/pi-version-skew.ts +12 -4
- package/packages/server/src/process-manager.ts +115 -21
- 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 +47 -0
- package/packages/server/src/routes/doctor-routes.ts +140 -0
- package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
- package/packages/server/src/routes/pi-core-routes.ts +1 -1
- package/packages/server/src/routes/provider-auth-routes.ts +14 -1
- package/packages/server/src/routes/provider-routes.ts +4 -4
- package/packages/server/src/routes/system-routes.ts +38 -1
- package/packages/server/src/server.ts +85 -66
- package/packages/server/src/session-api.ts +54 -3
- package/packages/server/src/session-bootstrap.ts +27 -12
- package/packages/server/src/session-discovery.ts +11 -4
- package/packages/server/src/session-file-reader.ts +1 -1
- 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 +291 -0
- package/packages/server/src/spawn-token.ts +20 -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 +25 -17
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +6 -3
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
- 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__/changelog-types.test.ts +78 -0
- 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__/node-spawn-jiti-contract.test.ts +56 -20
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +140 -9
- package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
- package/packages/shared/src/bootstrap-install.ts +197 -3
- package/packages/shared/src/browser-protocol.ts +155 -1
- package/packages/shared/src/changelog-types.ts +111 -0
- package/packages/shared/src/config.ts +15 -0
- package/packages/shared/src/dashboard-starter.ts +33 -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/managed-node-path.ts +77 -0
- package/packages/shared/src/platform/node-spawn.ts +29 -21
- package/packages/shared/src/protocol.ts +54 -2
- package/packages/shared/src/resolve-jiti.ts +62 -9
- 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 +33 -8
- package/packages/shared/src/tool-registry/strategies.ts +42 -0
- package/packages/shared/src/types.ts +64 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor markdown formatter — section ordering, escaping, remediation list.
|
|
3
|
+
* See change: doctor-rich-output (design.md Decision 8).
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
formatDoctorReportMarkdown,
|
|
8
|
+
formatDoctorReportPlain,
|
|
9
|
+
type DoctorReport,
|
|
10
|
+
type DoctorCheck,
|
|
11
|
+
} from "../doctor-core.js";
|
|
12
|
+
|
|
13
|
+
function mkReport(checks: DoctorCheck[]): DoctorReport {
|
|
14
|
+
const summary = {
|
|
15
|
+
ok: checks.filter((c) => c.status === "ok").length,
|
|
16
|
+
warnings: checks.filter((c) => c.status === "warning").length,
|
|
17
|
+
errors: checks.filter((c) => c.status === "error").length,
|
|
18
|
+
};
|
|
19
|
+
return { checks, summary };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("formatDoctorReportMarkdown", () => {
|
|
23
|
+
it("emits one table per non-empty section in fixed order", () => {
|
|
24
|
+
const report = mkReport([
|
|
25
|
+
{ name: "API key", section: "setup", status: "ok", message: "Configured" },
|
|
26
|
+
{ name: "pi CLI", section: "pi-tooling", status: "ok", message: "v1" },
|
|
27
|
+
{ name: "Electron", section: "runtime", status: "ok", message: "v40" },
|
|
28
|
+
{
|
|
29
|
+
name: "Managed install (~/.pi-dashboard)",
|
|
30
|
+
section: "diagnostics",
|
|
31
|
+
status: "ok",
|
|
32
|
+
message: "fine",
|
|
33
|
+
},
|
|
34
|
+
]);
|
|
35
|
+
const md = formatDoctorReportMarkdown(report);
|
|
36
|
+
const runtimeIdx = md.indexOf("## Runtime");
|
|
37
|
+
const piIdx = md.indexOf("## PI Tooling");
|
|
38
|
+
const setupIdx = md.indexOf("## Setup");
|
|
39
|
+
const diagIdx = md.indexOf("## Diagnostics");
|
|
40
|
+
// Server section absent — skipped silently.
|
|
41
|
+
expect(md.includes("## Server")).toBe(false);
|
|
42
|
+
expect(runtimeIdx).toBeGreaterThan(0);
|
|
43
|
+
expect(runtimeIdx).toBeLessThan(piIdx);
|
|
44
|
+
expect(piIdx).toBeLessThan(setupIdx);
|
|
45
|
+
expect(setupIdx).toBeLessThan(diagIdx);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("includes summary line", () => {
|
|
49
|
+
const report = mkReport([
|
|
50
|
+
{ name: "Electron", section: "runtime", status: "ok", message: "v40" },
|
|
51
|
+
{ name: "pi CLI", section: "pi-tooling", status: "error", message: "missing", detail: "x" },
|
|
52
|
+
]);
|
|
53
|
+
const md = formatDoctorReportMarkdown(report);
|
|
54
|
+
expect(md).toMatch(/Summary:.*1 ok.*0 warning.*1 error/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("omits the Remediation section when all rows are ok", () => {
|
|
58
|
+
const report = mkReport([{ name: "Electron", section: "runtime", status: "ok", message: "v40" }]);
|
|
59
|
+
const md = formatDoctorReportMarkdown(report);
|
|
60
|
+
expect(md.includes("## Remediation")).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("renders Remediation bullets for non-ok rows with suggestions", () => {
|
|
64
|
+
const report = mkReport([
|
|
65
|
+
{
|
|
66
|
+
name: "pi CLI",
|
|
67
|
+
section: "pi-tooling",
|
|
68
|
+
status: "error",
|
|
69
|
+
message: "Not found",
|
|
70
|
+
detail: "PATH searched",
|
|
71
|
+
suggestion: "Run setup wizard.",
|
|
72
|
+
},
|
|
73
|
+
]);
|
|
74
|
+
const md = formatDoctorReportMarkdown(report);
|
|
75
|
+
expect(md).toContain("## Remediation");
|
|
76
|
+
expect(md).toContain("- **pi CLI** — Run setup wizard.");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("escapes pipe / newline / backtick in detail so the table column count is preserved", () => {
|
|
80
|
+
const detail = "line1 | with pipe\nline2 with `backtick`";
|
|
81
|
+
const report = mkReport([
|
|
82
|
+
{
|
|
83
|
+
name: "Server launch test",
|
|
84
|
+
section: "server",
|
|
85
|
+
status: "error",
|
|
86
|
+
message: "boom",
|
|
87
|
+
detail,
|
|
88
|
+
},
|
|
89
|
+
]);
|
|
90
|
+
const md = formatDoctorReportMarkdown(report);
|
|
91
|
+
// Find the row line in the output. Each row must have exactly 4 separators
|
|
92
|
+
// outside the leading/trailing ones — i.e. the row should start with `| `
|
|
93
|
+
// and contain exactly 5 `|` characters.
|
|
94
|
+
const tableRow = md
|
|
95
|
+
.split("\n")
|
|
96
|
+
.find((l) => l.startsWith("| ") && l.includes("Server launch test"));
|
|
97
|
+
expect(tableRow).toBeDefined();
|
|
98
|
+
// A 4-column row has 5 unescaped pipes. Count unescaped pipes.
|
|
99
|
+
const unescaped = tableRow!
|
|
100
|
+
.split("")
|
|
101
|
+
.reduce(
|
|
102
|
+
(acc, ch, i, arr) =>
|
|
103
|
+
ch === "|" && arr[i - 1] !== "\\" ? acc + 1 : acc,
|
|
104
|
+
0,
|
|
105
|
+
);
|
|
106
|
+
expect(unescaped).toBe(5);
|
|
107
|
+
// No literal newline inside the cell.
|
|
108
|
+
expect(tableRow!.includes("\n")).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("formatDoctorReportPlain (legacy)", () => {
|
|
113
|
+
it("contains the canonical header and summary footer", () => {
|
|
114
|
+
const report = mkReport([
|
|
115
|
+
{ name: "Electron", section: "runtime", status: "ok", message: "v40" },
|
|
116
|
+
]);
|
|
117
|
+
const out = formatDoctorReportPlain(report);
|
|
118
|
+
expect(out).toContain("PI Dashboard Doctor");
|
|
119
|
+
expect(out).toContain("1 passed, 0 warnings, 0 errors");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pin the call order in `installStandalone` (the Electron entry that
|
|
3
|
+
* the spec calls `installAllTools`):
|
|
4
|
+
*
|
|
5
|
+
* installManagedNode(...) < any sharedBootstrapInstall(...)
|
|
6
|
+
*
|
|
7
|
+
* We can't easily run installStandalone end-to-end here (it's Electron
|
|
8
|
+
* code, requires a packaged resources path, spawns real npm). So this
|
|
9
|
+
* is a script-text test that greps the dependency-installer source for
|
|
10
|
+
* the call sites and asserts their byte offsets are in the right order.
|
|
11
|
+
* If a refactor moves installManagedNode after the first npm install,
|
|
12
|
+
* the regression \u2014 a fresh Windows install having no managed Node when
|
|
13
|
+
* the very first install runs \u2014 cannot land silently.
|
|
14
|
+
*
|
|
15
|
+
* See change: embed-managed-node-runtime (task 4.3).
|
|
16
|
+
*/
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import url from "node:url";
|
|
20
|
+
import { describe, expect, it } from "vitest";
|
|
21
|
+
|
|
22
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
23
|
+
// packages/shared/src/__tests__/ \u2192 ../../../electron/src/lib/dependency-installer.ts
|
|
24
|
+
const SOURCE_PATH = path.resolve(
|
|
25
|
+
__dirname,
|
|
26
|
+
"..",
|
|
27
|
+
"..",
|
|
28
|
+
"..",
|
|
29
|
+
"electron",
|
|
30
|
+
"src",
|
|
31
|
+
"lib",
|
|
32
|
+
"dependency-installer.ts",
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
describe("installStandalone call order", () => {
|
|
36
|
+
it("installManagedNode runs before sharedBootstrapInstall", () => {
|
|
37
|
+
const text = fs.readFileSync(SOURCE_PATH, "utf-8");
|
|
38
|
+
|
|
39
|
+
// Locate the installStandalone function body.
|
|
40
|
+
const fnIdx = text.indexOf("export async function installStandalone");
|
|
41
|
+
expect(fnIdx).toBeGreaterThan(-1);
|
|
42
|
+
|
|
43
|
+
// Scope the search to the function body — find its closing brace.
|
|
44
|
+
// Cheap bracket counter: starts after the first `{` after fnIdx.
|
|
45
|
+
const bodyStart = text.indexOf("{", fnIdx);
|
|
46
|
+
let depth = 0;
|
|
47
|
+
let bodyEnd = bodyStart;
|
|
48
|
+
for (let i = bodyStart; i < text.length; i++) {
|
|
49
|
+
const ch = text[i];
|
|
50
|
+
if (ch === "{") depth++;
|
|
51
|
+
else if (ch === "}") {
|
|
52
|
+
depth--;
|
|
53
|
+
if (depth === 0) {
|
|
54
|
+
bodyEnd = i;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const body = text.slice(bodyStart, bodyEnd);
|
|
60
|
+
|
|
61
|
+
const managedIdx = body.indexOf("installManagedNode(");
|
|
62
|
+
const sharedIdx = body.indexOf("sharedBootstrapInstall(");
|
|
63
|
+
|
|
64
|
+
expect(managedIdx).toBeGreaterThan(-1);
|
|
65
|
+
expect(sharedIdx).toBeGreaterThan(-1);
|
|
66
|
+
expect(managedIdx).toBeLessThan(sharedIdx);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `installManagedNode` from `bootstrap-install.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Uses a real tmp HOME (per the global setup-home tripwire) and a
|
|
5
|
+
* real-on-disk fake bundled-node directory. The bundled-Node version
|
|
6
|
+
* is read via the `_readVersion` test seam so we never spawn `node`
|
|
7
|
+
* here.
|
|
8
|
+
*
|
|
9
|
+
* See change: embed-managed-node-runtime (task 2.4).
|
|
10
|
+
*/
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
15
|
+
import { installManagedNode } from "../bootstrap-install.js";
|
|
16
|
+
|
|
17
|
+
const isWin = process.platform === "win32";
|
|
18
|
+
|
|
19
|
+
function makeFakeBundle(dir: string, opts?: { partial?: boolean }): void {
|
|
20
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
21
|
+
if (isWin) {
|
|
22
|
+
fs.writeFileSync(path.join(dir, "node.exe"), "fake-node-binary");
|
|
23
|
+
if (!opts?.partial) {
|
|
24
|
+
fs.writeFileSync(path.join(dir, "npm.cmd"), "@echo off\nnpm");
|
|
25
|
+
fs.writeFileSync(path.join(dir, "npx.cmd"), "@echo off\nnpx");
|
|
26
|
+
}
|
|
27
|
+
fs.mkdirSync(path.join(dir, "node_modules", "npm", "bin"), {
|
|
28
|
+
recursive: true,
|
|
29
|
+
});
|
|
30
|
+
fs.writeFileSync(
|
|
31
|
+
path.join(dir, "node_modules", "npm", "bin", "npm-cli.js"),
|
|
32
|
+
"// npm-cli",
|
|
33
|
+
);
|
|
34
|
+
} else {
|
|
35
|
+
fs.mkdirSync(path.join(dir, "bin"), { recursive: true });
|
|
36
|
+
fs.writeFileSync(path.join(dir, "bin", "node"), "fake-node-binary");
|
|
37
|
+
fs.writeFileSync(path.join(dir, "bin", "npm"), "#!/bin/sh\nnpm");
|
|
38
|
+
fs.writeFileSync(path.join(dir, "bin", "npx"), "#!/bin/sh\nnpx");
|
|
39
|
+
fs.mkdirSync(path.join(dir, "lib", "node_modules", "npm", "bin"), {
|
|
40
|
+
recursive: true,
|
|
41
|
+
});
|
|
42
|
+
fs.writeFileSync(
|
|
43
|
+
path.join(dir, "lib", "node_modules", "npm", "bin", "npm-cli.js"),
|
|
44
|
+
"// npm-cli",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("installManagedNode", () => {
|
|
50
|
+
let tmpRoot: string;
|
|
51
|
+
let bundledDir: string;
|
|
52
|
+
let managedDir: string;
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "managed-node-test-"));
|
|
56
|
+
bundledDir = path.join(tmpRoot, "bundled");
|
|
57
|
+
managedDir = path.join(tmpRoot, "managed");
|
|
58
|
+
makeFakeBundle(bundledDir);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("first run: copies tree and writes .version marker", async () => {
|
|
66
|
+
const r = await installManagedNode({
|
|
67
|
+
bundledNodeDir: bundledDir,
|
|
68
|
+
managedDir,
|
|
69
|
+
_readVersion: () => "v22.12.0",
|
|
70
|
+
});
|
|
71
|
+
expect(r.ok).toBe(true);
|
|
72
|
+
expect(r.copied).toBe(true);
|
|
73
|
+
expect(r.version).toBe("v22.12.0");
|
|
74
|
+
|
|
75
|
+
const targetBinary = isWin
|
|
76
|
+
? path.join(managedDir, "node", "node.exe")
|
|
77
|
+
: path.join(managedDir, "node", "bin", "node");
|
|
78
|
+
expect(fs.existsSync(targetBinary)).toBe(true);
|
|
79
|
+
|
|
80
|
+
const marker = fs.readFileSync(
|
|
81
|
+
path.join(managedDir, "node", ".version"),
|
|
82
|
+
"utf-8",
|
|
83
|
+
);
|
|
84
|
+
expect(marker.trim()).toBe("v22.12.0");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("idempotent re-run with matching version is a no-op", async () => {
|
|
88
|
+
await installManagedNode({
|
|
89
|
+
bundledNodeDir: bundledDir,
|
|
90
|
+
managedDir,
|
|
91
|
+
_readVersion: () => "v22.12.0",
|
|
92
|
+
});
|
|
93
|
+
const targetBinary = isWin
|
|
94
|
+
? path.join(managedDir, "node", "node.exe")
|
|
95
|
+
: path.join(managedDir, "node", "bin", "node");
|
|
96
|
+
const mtimeBefore = fs.statSync(targetBinary).mtimeMs;
|
|
97
|
+
|
|
98
|
+
// Wait one tick to ensure mtime would change if we recopied.
|
|
99
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
100
|
+
|
|
101
|
+
const r = await installManagedNode({
|
|
102
|
+
bundledNodeDir: bundledDir,
|
|
103
|
+
managedDir,
|
|
104
|
+
_readVersion: () => "v22.12.0",
|
|
105
|
+
});
|
|
106
|
+
expect(r.ok).toBe(true);
|
|
107
|
+
expect(r.copied).toBe(false);
|
|
108
|
+
expect(r.reason).toMatch(/version matches/);
|
|
109
|
+
const mtimeAfter = fs.statSync(targetBinary).mtimeMs;
|
|
110
|
+
expect(mtimeAfter).toBe(mtimeBefore);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("mismatched version triggers a re-copy", async () => {
|
|
114
|
+
await installManagedNode({
|
|
115
|
+
bundledNodeDir: bundledDir,
|
|
116
|
+
managedDir,
|
|
117
|
+
_readVersion: () => "v22.12.0",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Bundle now reports a newer version.
|
|
121
|
+
const r = await installManagedNode({
|
|
122
|
+
bundledNodeDir: bundledDir,
|
|
123
|
+
managedDir,
|
|
124
|
+
_readVersion: () => "v22.13.0",
|
|
125
|
+
});
|
|
126
|
+
expect(r.ok).toBe(true);
|
|
127
|
+
expect(r.copied).toBe(true);
|
|
128
|
+
expect(r.version).toBe("v22.13.0");
|
|
129
|
+
|
|
130
|
+
const marker = fs.readFileSync(
|
|
131
|
+
path.join(managedDir, "node", ".version"),
|
|
132
|
+
"utf-8",
|
|
133
|
+
);
|
|
134
|
+
expect(marker.trim()).toBe("v22.13.0");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("missing bundled source: no-op without error", async () => {
|
|
138
|
+
const r = await installManagedNode({
|
|
139
|
+
bundledNodeDir: null,
|
|
140
|
+
managedDir,
|
|
141
|
+
});
|
|
142
|
+
expect(r.ok).toBe(true);
|
|
143
|
+
expect(r.copied).toBe(false);
|
|
144
|
+
expect(r.reason).toMatch(/no bundled source/);
|
|
145
|
+
expect(fs.existsSync(path.join(managedDir, "node"))).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("bundled binary missing: no-op (treat as no source)", async () => {
|
|
149
|
+
const emptyBundled = path.join(tmpRoot, "empty");
|
|
150
|
+
fs.mkdirSync(emptyBundled, { recursive: true });
|
|
151
|
+
const r = await installManagedNode({
|
|
152
|
+
bundledNodeDir: emptyBundled,
|
|
153
|
+
managedDir,
|
|
154
|
+
_readVersion: () => null,
|
|
155
|
+
});
|
|
156
|
+
expect(r.ok).toBe(true);
|
|
157
|
+
expect(r.copied).toBe(false);
|
|
158
|
+
expect(r.reason).toMatch(/bundled node binary/);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("dir present but marker missing: re-copies (treats as mismatch)", async () => {
|
|
162
|
+
// Pretend a partial copy left behind a directory with no marker.
|
|
163
|
+
fs.mkdirSync(path.join(managedDir, "node", "leftover"), { recursive: true });
|
|
164
|
+
|
|
165
|
+
const r = await installManagedNode({
|
|
166
|
+
bundledNodeDir: bundledDir,
|
|
167
|
+
managedDir,
|
|
168
|
+
_readVersion: () => "v22.12.0",
|
|
169
|
+
});
|
|
170
|
+
expect(r.ok).toBe(true);
|
|
171
|
+
expect(r.copied).toBe(true);
|
|
172
|
+
// Leftover should be gone after the rm-then-copy.
|
|
173
|
+
expect(
|
|
174
|
+
fs.existsSync(path.join(managedDir, "node", "leftover")),
|
|
175
|
+
).toBe(false);
|
|
176
|
+
expect(
|
|
177
|
+
fs.existsSync(path.join(managedDir, "node", ".version")),
|
|
178
|
+
).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("reports progress through the callback", async () => {
|
|
182
|
+
const events: Array<{ step: string; status: string }> = [];
|
|
183
|
+
await installManagedNode({
|
|
184
|
+
bundledNodeDir: bundledDir,
|
|
185
|
+
managedDir,
|
|
186
|
+
_readVersion: () => "v22.12.0",
|
|
187
|
+
progress: (p) => events.push({ step: p.step, status: p.status }),
|
|
188
|
+
});
|
|
189
|
+
expect(events.some((e) => e.step === "node-runtime" && e.status === "running")).toBe(true);
|
|
190
|
+
expect(events.some((e) => e.step === "node-runtime" && e.status === "done")).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
});
|