singularity-forge/src/tests/web-subprocess-module-resolution.test.ts
Tom Boucher 7a413bb84f fix(web): resolve compiled .js modules for all subprocess calls under node_modules (#2320)
Node v24 unconditionally refuses .ts files under node_modules/ — even
with --experimental-transform-types. When GSD is installed globally via
npm, every web service subprocess that loads a .ts extension module
crashes with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING.

Add resolveSubprocessModule() and buildSubprocessPrefixArgs() to
ts-subprocess-flags.ts. When packageRoot is under node_modules/ and the
compiled dist/*.js file exists, subprocess calls use the compiled JS
directly without TS flags or the resolve-ts.mjs loader.

Updated all 14 web service files: auto-dashboard, bridge, captures,
cleanup, doctor, export, forensics, history, hooks, recovery-diagnostics,
settings, skill-health, undo, and visualizer.

Fixes #2279

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 07:34:41 -06:00

157 lines
5.4 KiB
TypeScript

import test from "node:test"
import assert from "node:assert/strict"
import { join } from "node:path"
import {
isUnderNodeModules,
resolveSubprocessModule,
} from "../web/ts-subprocess-flags.ts"
// ---------------------------------------------------------------------------
// isUnderNodeModules — exported utility
// ---------------------------------------------------------------------------
test("isUnderNodeModules returns false for paths outside node_modules", () => {
assert.equal(isUnderNodeModules("/home/user/projects/gsd"), false)
})
test("isUnderNodeModules returns true for Unix paths under node_modules/", () => {
assert.equal(
isUnderNodeModules("/usr/lib/node_modules/gsd-pi"),
true,
)
})
test("isUnderNodeModules returns true for Windows paths under node_modules/", () => {
assert.equal(
isUnderNodeModules("C:\\Users\\dev\\AppData\\node_modules\\gsd-pi"),
true,
)
})
test("isUnderNodeModules returns false for substring match without trailing slash", () => {
assert.equal(
isUnderNodeModules("/home/user/my_node_modules_backup/gsd"),
false,
)
})
// ---------------------------------------------------------------------------
// resolveSubprocessModule — resolves .ts → dist .js under node_modules
// ---------------------------------------------------------------------------
test("resolveSubprocessModule returns source .ts path when NOT under node_modules", () => {
const packageRoot = "/home/user/projects/gsd"
const result = resolveSubprocessModule(
packageRoot,
"resources/extensions/gsd/workspace-index.ts",
// existsSync not needed — should return src path without checking dist
)
assert.deepEqual(result, {
modulePath: join(packageRoot, "src", "resources/extensions/gsd/workspace-index.ts"),
useCompiledJs: false,
})
})
test("resolveSubprocessModule returns compiled .js path when under node_modules and dist file exists", () => {
const packageRoot = "/usr/lib/node_modules/gsd-pi"
const distPath = join(packageRoot, "dist", "resources/extensions/gsd/workspace-index.js")
const result = resolveSubprocessModule(
packageRoot,
"resources/extensions/gsd/workspace-index.ts",
(p: string) => p === distPath,
)
assert.deepEqual(result, {
modulePath: distPath,
useCompiledJs: true,
})
})
test("resolveSubprocessModule falls back to source .ts when under node_modules but dist file missing", () => {
const packageRoot = "/usr/lib/node_modules/gsd-pi"
const result = resolveSubprocessModule(
packageRoot,
"resources/extensions/gsd/workspace-index.ts",
() => false, // dist file does not exist
)
assert.deepEqual(result, {
modulePath: join(packageRoot, "src", "resources/extensions/gsd/workspace-index.ts"),
useCompiledJs: false,
})
})
test("resolveSubprocessModule handles Windows paths under node_modules", () => {
const packageRoot = "C:\\Users\\dev\\AppData\\node_modules\\gsd-pi"
const distPath = join(packageRoot, "dist", "resources/extensions/gsd/auto.js")
const result = resolveSubprocessModule(
packageRoot,
"resources/extensions/gsd/auto.ts",
(p: string) => p === distPath,
)
assert.deepEqual(result, {
modulePath: distPath,
useCompiledJs: true,
})
})
test("resolveSubprocessModule strips .ts extension when building dist .js path", () => {
const packageRoot = "/usr/lib/node_modules/gsd-pi"
let checkedPath = ""
resolveSubprocessModule(
packageRoot,
"resources/extensions/gsd/doctor.ts",
(p: string) => { checkedPath = p; return true },
)
assert.equal(
checkedPath,
join(packageRoot, "dist", "resources/extensions/gsd/doctor.js"),
"should check for .js file in dist/, not .ts",
)
})
// ---------------------------------------------------------------------------
// Integration: bridge-service subprocess resolution pattern
// ---------------------------------------------------------------------------
test("bridge-service workspace-index subprocess uses compiled JS when under node_modules (source audit)", async () => {
// Verify bridge-service.ts calls resolveSubprocessModule for workspace-index
const { readFileSync } = await import("node:fs")
const bridgeSource = readFileSync(
join(process.cwd(), "src", "web", "bridge-service.ts"),
"utf-8",
)
assert.match(
bridgeSource,
/resolveSubprocessModule/,
"bridge-service.ts must use resolveSubprocessModule to resolve workspace-index path — " +
"hardcoded .ts paths fail with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING on Node v24 (see #2279)",
)
})
test("all web service files use resolveSubprocessModule instead of hardcoded .ts paths (source audit)", async () => {
const { readFileSync, readdirSync } = await import("node:fs")
const serviceFiles = readdirSync(join(process.cwd(), "src", "web"))
.filter((f: string) => f.endsWith("-service.ts"))
for (const file of serviceFiles) {
const source = readFileSync(join(process.cwd(), "src", "web", file), "utf-8")
// If the service file imports resolveTypeStrippingFlag it spawns subprocesses
// and must also use resolveSubprocessModule
if (source.includes("resolveTypeStrippingFlag")) {
assert.match(
source,
/resolveSubprocessModule/,
`${file} uses resolveTypeStrippingFlag but does not use resolveSubprocessModule — ` +
"subprocess .ts paths will fail under node_modules/ on Node v24 (#2279)",
)
}
}
})