@blackbelt-technology/pi-agent-dashboard 0.4.0 → 0.4.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 +30 -8
- package/README.md +386 -494
- package/docs/architecture.md +63 -9
- package/package.json +8 -5
- package/packages/extension/package.json +6 -4
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +40 -8
- package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
- package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
- package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
- package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
- package/packages/extension/src/ask-user-tool.ts +5 -4
- package/packages/extension/src/bridge.ts +102 -15
- package/packages/extension/src/multiselect-list.ts +146 -0
- package/packages/extension/src/multiselect-polyfill.ts +43 -0
- package/packages/extension/src/server-launcher.ts +15 -3
- package/packages/server/package.json +5 -5
- package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
- package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +72 -0
- package/packages/server/src/__tests__/restart-helper.test.ts +34 -6
- package/packages/server/src/cli.ts +56 -9
- package/packages/server/src/pi-version-skew.ts +12 -1
- package/packages/server/src/restart-helper.ts +13 -2
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
- package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
- package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
- package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
- package/packages/shared/src/platform/index.ts +1 -0
- package/packages/shared/src/platform/node-spawn.ts +154 -0
- package/packages/shared/src/protocol.ts +23 -0
- package/packages/shared/src/state-replay.ts +9 -0
- package/packages/shared/src/tool-registry/definitions.ts +92 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-level invariant: build-time scripts (CI workflows, Dockerfiles,
|
|
3
|
+
* shell scripts, root-level CJS helpers) MUST NOT hardcode
|
|
4
|
+
* `node_modules/electron` or `node_modules/node-pty` paths. Instead, they
|
|
5
|
+
* MUST resolve through the tool registry — either via the shared shell
|
|
6
|
+
* wrapper at `packages/shared/bin/pi-dashboard-resolve-tool.cjs`, or
|
|
7
|
+
* (for postinstall paths that run before the shared package is built)
|
|
8
|
+
* via `require.resolve("<pkg>/package.json")` matching the registry's
|
|
9
|
+
* `bare-import` strategy semantics.
|
|
10
|
+
*
|
|
11
|
+
* This invariant exists because npm workspace hoisting moves these
|
|
12
|
+
* packages between `packages/<workspace>/node_modules/<pkg>/` (nested)
|
|
13
|
+
* and `<repoRoot>/node_modules/<pkg>/` (hoisted) depending on the
|
|
14
|
+
* workspaces config and npm version. The v0.4.0 release crisis was
|
|
15
|
+
* caused exactly by this: `cd packages/electron/node_modules/electron`
|
|
16
|
+
* stopped working after `f51e352` switched workspace cross-refs to
|
|
17
|
+
* plain semver.
|
|
18
|
+
*
|
|
19
|
+
* If this test fails, replace the offending substring with one of:
|
|
20
|
+
*
|
|
21
|
+
* # Shell / YAML / Dockerfile (build-time, has access to repo source):
|
|
22
|
+
* ELECTRON_DIR=$(node packages/shared/bin/pi-dashboard-resolve-tool.cjs electron)
|
|
23
|
+
* cd "$ELECTRON_DIR" && ...
|
|
24
|
+
*
|
|
25
|
+
* # CJS root postinstall (runs DURING npm install — must inline):
|
|
26
|
+
* const ptyPkg = require.resolve("node-pty/package.json");
|
|
27
|
+
* const prebuildsDir = path.join(path.dirname(ptyPkg), "prebuilds");
|
|
28
|
+
*
|
|
29
|
+
* See change: register-build-time-tools.
|
|
30
|
+
*/
|
|
31
|
+
import { describe, expect, it } from "vitest";
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import url from "node:url";
|
|
35
|
+
|
|
36
|
+
/** Banned substrings (after comment-stripping). */
|
|
37
|
+
const PATTERNS: readonly { re: RegExp; suggestion: string }[] = [
|
|
38
|
+
{
|
|
39
|
+
re: /node_modules\/electron(?:\/|\b)/,
|
|
40
|
+
suggestion:
|
|
41
|
+
"Use `node packages/shared/bin/pi-dashboard-resolve-tool.cjs electron`",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
re: /node_modules\/node-pty(?:\/|\b)/,
|
|
45
|
+
suggestion:
|
|
46
|
+
'Use `require.resolve("node-pty/package.json")` (mirrors the registry\'s bare-import strategy)',
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Files explicitly allowed to contain the banned substrings. Each entry
|
|
52
|
+
* is a repo-relative path matched exactly. Add an entry only when the
|
|
53
|
+
* substring appears as a non-path token (e.g. an argument to
|
|
54
|
+
* `require.resolve`, a comment quoting an example, or this lint file
|
|
55
|
+
* itself). Document the reason inline.
|
|
56
|
+
*/
|
|
57
|
+
const ALLOWLIST: readonly string[] = [
|
|
58
|
+
// The lint file itself contains every banned substring as test data.
|
|
59
|
+
"packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts",
|
|
60
|
+
// Root postinstall — uses `require.resolve("node-pty/package.json")`,
|
|
61
|
+
// which contains "node-pty" as an argument string but not as a
|
|
62
|
+
// hardcoded path. Allowlisted because it must run before the shared
|
|
63
|
+
// package is unpacked. See file header for full reasoning.
|
|
64
|
+
"scripts/fix-pty-permissions.cjs",
|
|
65
|
+
// Sister postinstall script (workspace-scoped) — same rationale.
|
|
66
|
+
"packages/server/scripts/fix-pty-permissions.cjs",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Repo-relative file list to scan.
|
|
71
|
+
*
|
|
72
|
+
* The scope is intentionally narrow: only the build-time sites that the
|
|
73
|
+
* `register-build-time-tools` change migrated, plus the postinstall
|
|
74
|
+
* scripts that mirror the registry's `bare-import` semantics. Bundle /
|
|
75
|
+
* Docker entrypoint scripts (`bundle-server.sh`, `docker-make.sh`,
|
|
76
|
+
* `test-electron-install-inner.sh`, etc.) are NOT in scope: those
|
|
77
|
+
* operate on a known WORKDIR with deterministic node_modules layout
|
|
78
|
+
* inside the build image and are not affected by host-side hoisting.
|
|
79
|
+
*/
|
|
80
|
+
const SCAN_FILES: readonly string[] = [
|
|
81
|
+
".github/workflows/publish.yml",
|
|
82
|
+
".github/workflows/ci.yml",
|
|
83
|
+
"packages/electron/scripts/Dockerfile.build",
|
|
84
|
+
"scripts/fix-pty-permissions.cjs",
|
|
85
|
+
"packages/server/scripts/fix-pty-permissions.cjs",
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
interface Violation {
|
|
89
|
+
file: string;
|
|
90
|
+
line: number;
|
|
91
|
+
col: number;
|
|
92
|
+
text: string;
|
|
93
|
+
suggestion: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Strip a single line's trailing comment for YAML / shell / JS-style
|
|
98
|
+
* line comments. Preserves substring matches inside strings as actual
|
|
99
|
+
* content (we don't try to parse string literals — keeping it simple).
|
|
100
|
+
*
|
|
101
|
+
* Specifically:
|
|
102
|
+
* - `# ...` (YAML, shell): everything from a `#` not preceded by a
|
|
103
|
+
* non-space alphanumeric is dropped. Matches GitHub Actions /
|
|
104
|
+
* bash conventions.
|
|
105
|
+
* - `// ...` (JS): everything from `//` to end of line is dropped.
|
|
106
|
+
*
|
|
107
|
+
* This is intentionally simple. False positives only matter if a banned
|
|
108
|
+
* pattern appears INSIDE a string literal (which would still be the
|
|
109
|
+
* bug we want to catch); false negatives only matter for inline
|
|
110
|
+
* comments (`echo foo # comment node_modules/electron`), which we
|
|
111
|
+
* exclude correctly.
|
|
112
|
+
*/
|
|
113
|
+
function stripLineComment(line: string): string {
|
|
114
|
+
// JS-style first.
|
|
115
|
+
const jsIdx = line.indexOf("//");
|
|
116
|
+
if (jsIdx >= 0) line = line.slice(0, jsIdx);
|
|
117
|
+
// Shell/YAML `#` — only when preceded by whitespace or start-of-line.
|
|
118
|
+
const hashMatch = line.match(/(^|\s)#/);
|
|
119
|
+
if (hashMatch && typeof hashMatch.index === "number") {
|
|
120
|
+
line = line.slice(0, hashMatch.index);
|
|
121
|
+
}
|
|
122
|
+
return line;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe("no hardcoded node_modules/<dep> paths in build-time files", () => {
|
|
126
|
+
it("only allowlisted files reference node_modules/electron or node_modules/node-pty", () => {
|
|
127
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
128
|
+
const repoRoot = path.resolve(here, "..", "..", "..", "..");
|
|
129
|
+
|
|
130
|
+
const violations: Violation[] = [];
|
|
131
|
+
const allowSet = new Set(
|
|
132
|
+
ALLOWLIST.map((p) => path.resolve(repoRoot, p).replace(/\\/g, "/")),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
for (const rel of SCAN_FILES) {
|
|
136
|
+
const file = path.resolve(repoRoot, rel);
|
|
137
|
+
if (!fs.existsSync(file)) continue;
|
|
138
|
+
const normalized = file.replace(/\\/g, "/");
|
|
139
|
+
if (allowSet.has(normalized)) continue;
|
|
140
|
+
|
|
141
|
+
const content = fs.readFileSync(file, "utf-8");
|
|
142
|
+
const lines = content.split(/\r?\n/);
|
|
143
|
+
|
|
144
|
+
lines.forEach((rawLine, idx) => {
|
|
145
|
+
const stripped = stripLineComment(rawLine);
|
|
146
|
+
for (const { re, suggestion } of PATTERNS) {
|
|
147
|
+
const m = stripped.match(re);
|
|
148
|
+
if (!m) continue;
|
|
149
|
+
const col = rawLine.indexOf(m[0]);
|
|
150
|
+
violations.push({
|
|
151
|
+
file: path.relative(repoRoot, file),
|
|
152
|
+
line: idx + 1,
|
|
153
|
+
col: col >= 0 ? col + 1 : 1,
|
|
154
|
+
text: rawLine.trim(),
|
|
155
|
+
suggestion,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (violations.length > 0) {
|
|
162
|
+
const msg =
|
|
163
|
+
`Hardcoded \`node_modules/<dep>\` path(s) found in build-time files.\n` +
|
|
164
|
+
`These break under npm workspace hoisting changes (see v0.4.0 release crisis).\n` +
|
|
165
|
+
`Use the tool registry instead. See change: register-build-time-tools.\n\n` +
|
|
166
|
+
`Offenders (${violations.length}):\n` +
|
|
167
|
+
violations
|
|
168
|
+
.map(
|
|
169
|
+
(v) =>
|
|
170
|
+
` ${v.file}:${v.line}:${v.col} ${v.text}\n → ${v.suggestion}`,
|
|
171
|
+
)
|
|
172
|
+
.join("\n");
|
|
173
|
+
expect(violations, msg).toEqual([]);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-level invariant: any source file that passes an argv to Node
|
|
3
|
+
* with `--import` or `--loader` MUST wrap the following positions
|
|
4
|
+
* (loader and entry script) in `file://` URLs via `toFileUrl(...)` or
|
|
5
|
+
* `pathToFileURL(...).href`. Raw OS paths on Windows drives whose
|
|
6
|
+
* letter collides with URL-scheme parsing (e.g. `B:\`) crash Node with
|
|
7
|
+
* `ERR_UNSUPPORTED_ESM_URL_SCHEME`.
|
|
8
|
+
*
|
|
9
|
+
* If this test fails, migrate the offending file to use
|
|
10
|
+
* `spawnNodeScript` or wrap the entry/loader with `toFileUrl` from
|
|
11
|
+
* `@blackbelt-technology/pi-dashboard-shared/platform/node-spawn.js`.
|
|
12
|
+
*
|
|
13
|
+
* See change: fix-windows-entry-script-url.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect } from "vitest";
|
|
16
|
+
import fs from "node:fs/promises";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import url from "node:url";
|
|
19
|
+
|
|
20
|
+
/** Files allowed to reference --import / --loader with raw identifiers. */
|
|
21
|
+
const ALLOWLIST: readonly string[] = [
|
|
22
|
+
"packages/shared/src/platform/node-spawn.ts",
|
|
23
|
+
// resolve-jiti.ts returns a file:// URL to callers; it does not itself
|
|
24
|
+
// build a `["--import", X, Y]` argv. Allowlisted as the documented
|
|
25
|
+
// source of loader URLs referenced in server spawn call sites.
|
|
26
|
+
"packages/shared/src/resolve-jiti.ts",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/** Per-line opt-out for intentional usages (e.g. comment examples). */
|
|
30
|
+
const OPT_OUT_MARKER = "ban:raw-node-import-ok";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Detect argv arrays containing `"--import"` or `"--loader"` followed by
|
|
34
|
+
* a bare identifier (not a string literal and not a wrapped call).
|
|
35
|
+
*
|
|
36
|
+
* We match the argv-literal shape:
|
|
37
|
+
* ["--import", X, Y]
|
|
38
|
+
* args: ["--import", X, Y, ...]
|
|
39
|
+
*
|
|
40
|
+
* Then check that both X and Y are either:
|
|
41
|
+
* - a string literal starting with "file:" (already a URL)
|
|
42
|
+
* - a call expression to toFileUrl(...) or pathToFileURL(...).href
|
|
43
|
+
* - the identifier resolveJitiImport() / resolveJitiFromAnchor() (which
|
|
44
|
+
* are documented to return file:// URLs — allowlisted by name)
|
|
45
|
+
*
|
|
46
|
+
* Anything else is flagged.
|
|
47
|
+
*/
|
|
48
|
+
const IMPORT_ARGV_RE =
|
|
49
|
+
/["']--(?:import|loader)["']\s*,\s*([^,\]]+?)\s*,\s*([^,\]]+?)(?:\s*,|\s*\])/g;
|
|
50
|
+
|
|
51
|
+
const URL_LOOKING_RE =
|
|
52
|
+
/^(?:["']file:|toFileUrl\s*\(|pathToFileURL\s*\([^)]*\)\s*\.href|resolveJitiImport\s*\(|resolveJitiFromAnchor\s*\()/;
|
|
53
|
+
|
|
54
|
+
/** Recursively walk a directory, yielding .ts / .tsx files. */
|
|
55
|
+
async function* walk(dir: string): AsyncGenerator<string> {
|
|
56
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
const full = path.join(dir, entry.name);
|
|
59
|
+
if (entry.isDirectory()) {
|
|
60
|
+
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "__tests__") continue;
|
|
61
|
+
yield* walk(full);
|
|
62
|
+
} else if (entry.isFile() && /\.(ts|tsx|mts|cts)$/.test(entry.name)) {
|
|
63
|
+
yield full;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("no raw paths passed to node --import / --loader", () => {
|
|
69
|
+
it("only URL-wrapped or allowlisted argv positions follow --import / --loader", async () => {
|
|
70
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
71
|
+
const repoRoot = path.resolve(here, "..", "..", "..", "..");
|
|
72
|
+
const packagesDir = path.resolve(repoRoot, "packages");
|
|
73
|
+
|
|
74
|
+
const allowSet = new Set(
|
|
75
|
+
ALLOWLIST.map((p) => path.resolve(repoRoot, p).replace(/\\/g, "/")),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const violations: Array<{ file: string; line: number; text: string }> = [];
|
|
79
|
+
|
|
80
|
+
for (const pkg of await fs.readdir(packagesDir, { withFileTypes: true })) {
|
|
81
|
+
if (!pkg.isDirectory()) continue;
|
|
82
|
+
const srcDir = path.join(packagesDir, pkg.name, "src");
|
|
83
|
+
try {
|
|
84
|
+
await fs.access(srcDir);
|
|
85
|
+
} catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
for await (const file of walk(srcDir)) {
|
|
89
|
+
const normalized = file.replace(/\\/g, "/");
|
|
90
|
+
if (allowSet.has(normalized)) continue;
|
|
91
|
+
|
|
92
|
+
const content = await fs.readFile(file, "utf-8");
|
|
93
|
+
const lines = content.split(/\r?\n/);
|
|
94
|
+
|
|
95
|
+
// Walk each line and check for the argv pattern. Track byte
|
|
96
|
+
// offsets so we can compute line numbers for multi-line matches.
|
|
97
|
+
let offset = 0;
|
|
98
|
+
for (let i = 0; i < lines.length; i++) {
|
|
99
|
+
const line = lines[i]!;
|
|
100
|
+
// Fast path: only inspect lines that mention --import or --loader.
|
|
101
|
+
if (!line.includes("--import") && !line.includes("--loader")) {
|
|
102
|
+
offset += line.length + 1;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (line.includes(OPT_OUT_MARKER)) {
|
|
106
|
+
offset += line.length + 1;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
// Check the current line alone (we allow argv to be on one line;
|
|
110
|
+
// multi-line argv arrays are a rare style and would still trip
|
|
111
|
+
// the quick search above).
|
|
112
|
+
IMPORT_ARGV_RE.lastIndex = 0;
|
|
113
|
+
let m: RegExpExecArray | null;
|
|
114
|
+
while ((m = IMPORT_ARGV_RE.exec(line)) !== null) {
|
|
115
|
+
const loaderArg = m[1]!.trim();
|
|
116
|
+
const entryArg = m[2]!.trim();
|
|
117
|
+
const loaderOk = URL_LOOKING_RE.test(loaderArg);
|
|
118
|
+
const entryOk = URL_LOOKING_RE.test(entryArg);
|
|
119
|
+
if (!loaderOk || !entryOk) {
|
|
120
|
+
violations.push({
|
|
121
|
+
file: path.relative(repoRoot, file),
|
|
122
|
+
line: i + 1,
|
|
123
|
+
text: line.trim(),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
offset += line.length + 1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (violations.length > 0) {
|
|
133
|
+
const msg =
|
|
134
|
+
`Raw filesystem paths passed to node --import / --loader found.\n` +
|
|
135
|
+
`Migrate each call site to use spawnNodeScript() or wrap the\n` +
|
|
136
|
+
`loader/entry with toFileUrl(...) from:\n` +
|
|
137
|
+
` import { toFileUrl, spawnNodeScript } from\n` +
|
|
138
|
+
` "@blackbelt-technology/pi-dashboard-shared/platform/node-spawn.js";\n\n` +
|
|
139
|
+
`Offenders (${violations.length}):\n` +
|
|
140
|
+
violations
|
|
141
|
+
.map((v) => ` ${v.file}:${v.line} ${v.text}`)
|
|
142
|
+
.join("\n");
|
|
143
|
+
expect(violations, msg).toEqual([]);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `platform/node-spawn.ts` — the canonical helper for
|
|
3
|
+
* constructing `node --import <loader> <entry>` argv.
|
|
4
|
+
*
|
|
5
|
+
* See change: fix-windows-entry-script-url.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi } from "vitest";
|
|
8
|
+
import { toFileUrl, spawnNodeScript, isTsxLoader, shouldUrlWrapEntry } from "../platform/node-spawn.js";
|
|
9
|
+
import * as execModule from "../platform/exec.js";
|
|
10
|
+
|
|
11
|
+
describe("toFileUrl", () => {
|
|
12
|
+
it("returns a file:// URL input unchanged (idempotent)", () => {
|
|
13
|
+
expect(toFileUrl("file:///C:/foo.ts")).toBe("file:///C:/foo.ts");
|
|
14
|
+
expect(toFileUrl("file:///usr/local/bin/cli.js")).toBe("file:///usr/local/bin/cli.js");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("wraps Windows B:\\ drive-letter paths on any host OS", () => {
|
|
18
|
+
expect(toFileUrl("B:\\Dev\\cli.ts")).toBe("file:///B:/Dev/cli.ts");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("wraps Windows C:\\ drive-letter paths on any host OS", () => {
|
|
22
|
+
expect(toFileUrl("C:\\Users\\x\\cli.ts")).toBe("file:///C:/Users/x/cli.ts");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("wraps forward-slash Windows paths", () => {
|
|
26
|
+
expect(toFileUrl("B:/Dev/cli.ts")).toBe("file:///B:/Dev/cli.ts");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("wraps POSIX absolute paths", () => {
|
|
30
|
+
expect(toFileUrl("/usr/local/bin/cli.js")).toBe("file:///usr/local/bin/cli.js");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("handles uppercase and lowercase drive letters identically", () => {
|
|
34
|
+
expect(toFileUrl("b:\\Dev\\cli.ts")).toBe("file:///b:/Dev/cli.ts");
|
|
35
|
+
expect(toFileUrl("Z:\\foo.ts")).toBe("file:///Z:/foo.ts");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("isTsxLoader", () => {
|
|
40
|
+
it("returns true for loader URLs containing /tsx/", () => {
|
|
41
|
+
expect(isTsxLoader("file:///home/x/node_modules/tsx/dist/esm/index.mjs")).toBe(true);
|
|
42
|
+
expect(isTsxLoader("file:///C:/project/node_modules/tsx/dist/esm/index.mjs")).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns true for raw paths with /tsx/ segment", () => {
|
|
46
|
+
expect(isTsxLoader("/home/x/node_modules/tsx/dist/esm/index.mjs")).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns true for Windows raw paths with \\tsx\\ segment", () => {
|
|
50
|
+
expect(isTsxLoader("C:\\project\\node_modules\\tsx\\dist\\esm\\index.mjs")).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns false for jiti loader", () => {
|
|
54
|
+
expect(isTsxLoader("file:///home/x/node_modules/@mariozechner/jiti/lib/jiti-register.mjs")).toBe(false);
|
|
55
|
+
expect(isTsxLoader("/home/x/node_modules/@mariozechner/jiti/lib/jiti-register.mjs")).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns false for undefined / empty", () => {
|
|
59
|
+
expect(isTsxLoader(undefined)).toBe(false);
|
|
60
|
+
expect(isTsxLoader("")).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("spawnNodeScript", () => {
|
|
65
|
+
it("URL-wraps both loader and entry when loader is NOT tsx (jiti/default)", () => {
|
|
66
|
+
const spawnSpy = vi
|
|
67
|
+
.spyOn(execModule, "spawn")
|
|
68
|
+
.mockImplementation(() => ({ unref: () => {} } as unknown as ReturnType<typeof execModule.spawn>));
|
|
69
|
+
|
|
70
|
+
spawnNodeScript({
|
|
71
|
+
nodeBin: "C:\\Program Files\\nodejs\\node.exe",
|
|
72
|
+
loader: "B:\\jiti\\register.mjs",
|
|
73
|
+
entry: "B:\\Dev\\cli.ts",
|
|
74
|
+
args: ["start", "--dev"],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
|
78
|
+
const [bin, argv] = spawnSpy.mock.calls[0]!;
|
|
79
|
+
expect(bin).toBe("C:\\Program Files\\nodejs\\node.exe");
|
|
80
|
+
// On Linux host: entry stays raw even with a Windows-styled path
|
|
81
|
+
// (shouldUrlWrapEntry consults process.platform, which is Linux).
|
|
82
|
+
// The Windows-wrapped branch is exercised separately via shouldUrlWrapEntry.
|
|
83
|
+
expect(argv).toEqual([
|
|
84
|
+
"--import",
|
|
85
|
+
"file:///B:/jiti/register.mjs",
|
|
86
|
+
"B:\\Dev\\cli.ts",
|
|
87
|
+
"start",
|
|
88
|
+
"--dev",
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
spawnSpy.mockRestore();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("URL-wraps loader but passes RAW entry when loader is tsx", () => {
|
|
95
|
+
const spawnSpy = vi
|
|
96
|
+
.spyOn(execModule, "spawn")
|
|
97
|
+
.mockImplementation(() => ({ unref: () => {} } as unknown as ReturnType<typeof execModule.spawn>));
|
|
98
|
+
|
|
99
|
+
spawnNodeScript({
|
|
100
|
+
nodeBin: "/usr/bin/node",
|
|
101
|
+
loader: "/home/u/node_modules/tsx/dist/esm/index.mjs",
|
|
102
|
+
entry: "/home/u/repo/packages/server/src/cli.ts",
|
|
103
|
+
args: ["start"],
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const [, argv] = spawnSpy.mock.calls[0]!;
|
|
107
|
+
expect(argv).toEqual([
|
|
108
|
+
"--import",
|
|
109
|
+
"file:///home/u/node_modules/tsx/dist/esm/index.mjs",
|
|
110
|
+
"/home/u/repo/packages/server/src/cli.ts", // RAW, not URL
|
|
111
|
+
"start",
|
|
112
|
+
]);
|
|
113
|
+
spawnSpy.mockRestore();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("defaults nodeBin to process.execPath when omitted", () => {
|
|
117
|
+
const spawnSpy = vi
|
|
118
|
+
.spyOn(execModule, "spawn")
|
|
119
|
+
.mockImplementation(() => ({ unref: () => {} } as unknown as ReturnType<typeof execModule.spawn>));
|
|
120
|
+
|
|
121
|
+
spawnNodeScript({
|
|
122
|
+
entry: "/usr/local/cli.ts",
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const [bin] = spawnSpy.mock.calls[0]!;
|
|
126
|
+
expect(bin).toBe(process.execPath);
|
|
127
|
+
spawnSpy.mockRestore();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("omits --import when no loader is provided", () => {
|
|
131
|
+
const spawnSpy = vi
|
|
132
|
+
.spyOn(execModule, "spawn")
|
|
133
|
+
.mockImplementation(() => ({ unref: () => {} } as unknown as ReturnType<typeof execModule.spawn>));
|
|
134
|
+
|
|
135
|
+
spawnNodeScript({
|
|
136
|
+
entry: "B:\\Dev\\cli.ts",
|
|
137
|
+
args: ["help"],
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const [, argv] = spawnSpy.mock.calls[0]!;
|
|
141
|
+
// No loader → shouldUrlWrapEntry returns false on Linux host → raw entry.
|
|
142
|
+
expect(argv).toEqual(["B:\\Dev\\cli.ts", "help"]);
|
|
143
|
+
spawnSpy.mockRestore();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("passes spawnOptions through to exec.spawn unchanged", () => {
|
|
147
|
+
const spawnSpy = vi
|
|
148
|
+
.spyOn(execModule, "spawn")
|
|
149
|
+
.mockImplementation(() => ({ unref: () => {} } as unknown as ReturnType<typeof execModule.spawn>));
|
|
150
|
+
|
|
151
|
+
const opts = { detached: true, stdio: ["ignore", 1, 2] as ("ignore" | number)[], env: { FOO: "bar" } };
|
|
152
|
+
spawnNodeScript({
|
|
153
|
+
entry: "/usr/local/cli.ts",
|
|
154
|
+
spawnOptions: opts,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const [, , passedOpts] = spawnSpy.mock.calls[0]!;
|
|
158
|
+
expect(passedOpts).toBe(opts);
|
|
159
|
+
spawnSpy.mockRestore();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("accepts a loader that is already a file:// URL without double-wrapping", () => {
|
|
163
|
+
const spawnSpy = vi
|
|
164
|
+
.spyOn(execModule, "spawn")
|
|
165
|
+
.mockImplementation(() => ({ unref: () => {} } as unknown as ReturnType<typeof execModule.spawn>));
|
|
166
|
+
|
|
167
|
+
spawnNodeScript({
|
|
168
|
+
loader: "file:///C:/jiti/register.mjs",
|
|
169
|
+
entry: "B:\\Dev\\cli.ts",
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const [, argv] = spawnSpy.mock.calls[0]!;
|
|
173
|
+
// On Linux host with non-tsx loader: entry stays raw.
|
|
174
|
+
expect(argv).toEqual([
|
|
175
|
+
"--import",
|
|
176
|
+
"file:///C:/jiti/register.mjs",
|
|
177
|
+
"B:\\Dev\\cli.ts",
|
|
178
|
+
]);
|
|
179
|
+
spawnSpy.mockRestore();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("shouldUrlWrapEntry", () => {
|
|
184
|
+
it("returns false for tsx loader on any platform", () => {
|
|
185
|
+
const tsxLoader = "file:///home/u/node_modules/tsx/dist/esm/index.mjs";
|
|
186
|
+
expect(shouldUrlWrapEntry(tsxLoader, "linux")).toBe(false);
|
|
187
|
+
expect(shouldUrlWrapEntry(tsxLoader, "darwin")).toBe(false);
|
|
188
|
+
expect(shouldUrlWrapEntry(tsxLoader, "win32")).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("returns false for non-tsx loader on POSIX (jiti MUST get raw entry)", () => {
|
|
192
|
+
const jiti = "file:///home/u/node_modules/@mariozechner/jiti/lib/jiti-register.mjs";
|
|
193
|
+
expect(shouldUrlWrapEntry(jiti, "linux")).toBe(false);
|
|
194
|
+
expect(shouldUrlWrapEntry(jiti, "darwin")).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("returns true for non-tsx loader on Windows (drive letters need file://)", () => {
|
|
198
|
+
const jiti = "file:///C:/node_modules/@mariozechner/jiti/lib/jiti-register.mjs";
|
|
199
|
+
expect(shouldUrlWrapEntry(jiti, "win32")).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("returns false when no loader is provided, regardless of platform", () => {
|
|
203
|
+
// Without a loader, Node's default resolver handles the entry; the URL
|
|
204
|
+
// wrap was historically used for Windows drive-letter collision, but
|
|
205
|
+
// if we were to spawn without a loader we'd still default to raw on POSIX.
|
|
206
|
+
// On Windows without a loader, callers should wrap themselves.
|
|
207
|
+
expect(shouldUrlWrapEntry(undefined, "linux")).toBe(false);
|
|
208
|
+
expect(shouldUrlWrapEntry(undefined, "darwin")).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the shell-callable resolver wrapper at
|
|
3
|
+
* `packages/shared/bin/pi-dashboard-resolve-tool.cjs`.
|
|
4
|
+
*
|
|
5
|
+
* We spawn the wrapper as a child process (matching its real-world use)
|
|
6
|
+
* and assert stdout/stderr/exit-code per the spec scenarios in
|
|
7
|
+
* openspec/changes/register-build-time-tools/specs/tool-registry/spec.md
|
|
8
|
+
*
|
|
9
|
+
* See change: register-build-time-tools.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import url from "node:url";
|
|
15
|
+
|
|
16
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
17
|
+
const repoRoot = path.resolve(here, "..", "..", "..", "..");
|
|
18
|
+
const SCRIPT = path.join(
|
|
19
|
+
repoRoot,
|
|
20
|
+
"packages",
|
|
21
|
+
"shared",
|
|
22
|
+
"bin",
|
|
23
|
+
"pi-dashboard-resolve-tool.cjs",
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
function run(args: string[]) {
|
|
27
|
+
return spawnSync(process.execPath, [SCRIPT, ...args], {
|
|
28
|
+
cwd: repoRoot,
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
// Isolate from any user override file so tests are deterministic.
|
|
31
|
+
env: {
|
|
32
|
+
...process.env,
|
|
33
|
+
// Point HOME at /tmp so ~/.pi/dashboard/tool-overrides.json is
|
|
34
|
+
// (almost certainly) absent, keeping the resolver in the
|
|
35
|
+
// bare-import branch.
|
|
36
|
+
HOME: "/tmp/pi-dashboard-resolve-tool-test",
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("pi-dashboard-resolve-tool.cjs", () => {
|
|
42
|
+
it("prints absolute path to stdout for `electron`", () => {
|
|
43
|
+
const r = run(["electron"]);
|
|
44
|
+
expect(r.status).toBe(0);
|
|
45
|
+
expect(r.stdout.trim()).toMatch(/[\\/]node_modules[\\/]electron$/);
|
|
46
|
+
expect(r.stderr).toBe("");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("prints absolute path to stdout for `node-pty`", () => {
|
|
50
|
+
const r = run(["node-pty"]);
|
|
51
|
+
expect(r.status).toBe(0);
|
|
52
|
+
expect(r.stdout.trim()).toMatch(/[\\/]node_modules[\\/]node-pty$/);
|
|
53
|
+
expect(r.stderr).toBe("");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("emits JSON Resolution shape with --json", () => {
|
|
57
|
+
const r = run(["electron", "--json"]);
|
|
58
|
+
expect(r.status).toBe(0);
|
|
59
|
+
const parsed = JSON.parse(r.stdout);
|
|
60
|
+
expect(parsed.name).toBe("electron");
|
|
61
|
+
expect(parsed.ok).toBe(true);
|
|
62
|
+
expect(parsed.path).toMatch(/electron$/);
|
|
63
|
+
expect(parsed.source).toBe("bare-import");
|
|
64
|
+
expect(Array.isArray(parsed.tried)).toBe(true);
|
|
65
|
+
// First strategy attempted is `override`, then `bare-import`.
|
|
66
|
+
expect(parsed.tried.map((t: { strategy: string }) => t.strategy)).toEqual([
|
|
67
|
+
"override",
|
|
68
|
+
"bare-import",
|
|
69
|
+
]);
|
|
70
|
+
expect(typeof parsed.resolvedAt).toBe("number");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("exits 1 with stderr when tool name is unknown", () => {
|
|
74
|
+
const r = run(["nonexistent-tool"]);
|
|
75
|
+
expect(r.status).toBe(1);
|
|
76
|
+
expect(r.stdout).toBe("");
|
|
77
|
+
expect(r.stderr).toContain("nonexistent-tool");
|
|
78
|
+
expect(r.stderr).toContain("not registered");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("exits 1 with stderr when --json is passed for unknown tool", () => {
|
|
82
|
+
const r = run(["nonexistent-tool", "--json"]);
|
|
83
|
+
expect(r.status).toBe(1);
|
|
84
|
+
expect(r.stderr).toContain("not registered");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("exits 1 with usage message when no tool name given", () => {
|
|
88
|
+
const r = run([]);
|
|
89
|
+
expect(r.status).toBe(1);
|
|
90
|
+
expect(r.stderr).toContain("usage:");
|
|
91
|
+
expect(r.stderr).toContain("registered:");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("strategy chain order in --json mirrors definitions.ts", () => {
|
|
95
|
+
// Both build-time tools share the same chain shape: override → bare-import.
|
|
96
|
+
for (const tool of ["electron", "node-pty"]) {
|
|
97
|
+
const r = run([tool, "--json"]);
|
|
98
|
+
expect(r.status).toBe(0);
|
|
99
|
+
const parsed = JSON.parse(r.stdout);
|
|
100
|
+
expect(
|
|
101
|
+
parsed.tried.map((t: { strategy: string }) => t.strategy),
|
|
102
|
+
).toEqual(["override", "bare-import"]);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|