singularity-forge/src/tests/integration/web-subprocess-module-resolution.test.ts
Iouri Goussev b6e105b058 perf(test): compile unit tests with esbuild, reclassify integration tests, fix node_modules symlink (#2809)
* fix(test): wire src/resources/extensions/shared/tests/ into test:unit runner

The test:unit glob excluded src/resources/extensions/shared/tests/ entirely,
leaving format-utils.test.ts (and any future tests there) silently unfired.

- Add shared/tests/*.test.ts to the test:unit glob in package.json
- Export newestSrcMtime from ensure-workspace-builds.cjs (require.main guard
  prevents side-effects on require) so the staleness logic can be tested
- Add src/tests/ensure-workspace-builds.test.ts covering newestSrcMtime:
  non-existent dir, no .ts files, single file, max of multiple, recursion,
  node_modules skip

Closes #2808

* perf(test): compile unit tests with esbuild and fix dist-test/node_modules

Replace per-file --experimental-strip-types with a single esbuild compilation
step (scripts/compile-tests.mjs) that compiles all src/ TypeScript to dist-test/
in ~3s, then runs the pre-compiled JS. Eliminates ~1.7s Node startup overhead
per test file.

- scripts/compile-tests.mjs: esbuild compilation, asset copy, .ts→.js rewrite,
  stale file cleanup; creates dist-test/node_modules symlink so resource-loader.ts
  resolves gsdNodeModules to a real path (fixes node-modules-symlink test failure)
- scripts/dist-test-resolve.mjs: ESM loader hook for @gsd/* bare specifiers and
  .ts→.js fallback rewriting at runtime
- .gitignore: exclude dist-test/ from version control
- package.json: add test:compile script; update test:unit to compile-then-run;
  update test:integration globs to cover new integration/ subdirectories
- worker-registry.ts: unref() cleanup timer so it does not keep the Node process
  alive after tests complete

Closes #2858

* fix(test): update relative imports in tests/integration/ after directory move

When tests were moved from tests/ to tests/integration/ in the previous
commit, relative imports weren't updated. ../foo now resolves one level
too shallow.

Fix all 117 import paths across 43 test files:
- ../foo → ../../foo (source files at gsd/ level)
- ../../get-secrets-from-user.ts → ../../../ (at extensions/ level)
- ../../subagent/worker-registry.ts → ../../../ (at extensions/ level)
- ./marketplace-test-fixtures.js → ../marketplace-test-fixtures.ts
- ./test-helpers.ts → ../test-helpers.ts

typecheck:extensions now passes with zero errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(integration): set 10-minute timeout for integration test runner

build job takes ~7min on main. Without a global timeout, hanging tests
block the suite indefinitely. --test-timeout=600000 caps each test at 10min.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Revert "test(integration): set 10-minute timeout for integration test runner"

This reverts commit be77ead77d369ad8569292ae6b69ba56435f5433.

* fix(test): correct formatDuration(0) edge case and docker test root path

- formatDuration(0) now returns '0s' instead of '0ms' by guarding the
  sub-second branch with ms > 0
- docker-template.test.ts root path goes ../../.. from dist-test/src/tests/
  to reach project root instead of landing in dist-test/
- replace require() calls in skill-health.ts and visualizer-overlay.ts
  with proper ES module imports

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): correct relative import paths in integration tests

All affected tests were one directory level off — importing from ../web/
and ../resources/ when the correct paths are ../../web/ and ../../resources/.
Tests live at src/tests/integration/, not src/tests/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): add esbuild to root devDeps and wire dist-test-resolve hook

P1: esbuild was only in web/package.json — compile-tests.mjs requires it
at the root node_modules path, so CI failed on clean installs.

P2: dist-test-resolve.mjs existed but was never loaded; @gsd/* imports in
compiled tests resolved to installed workspace packages instead of freshly
compiled dist-test output. Add --import to test:unit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(deps): align esbuild version with lock file (0.25.12)

^0.27.4 didn't satisfy the existing lock file entry. Use the version
already present so npm ci passes without regenerating the lock file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): correct all relative import depths in src/tests/integration/

Tests in src/tests/integration/ need 3 levels up (../../..) to reach
project-root dirs (web/, packages/) and 2 levels up (../..) to reach
src-level dirs (src/web/, src/cli-web-branch.ts).

Fixes:
- ../../web/lib/ → ../../../web/lib/   (Next.js app, not src/web/)
- ../../web/app/ → ../../../web/app/
- ../../packages/ → ../../../packages/
- ../cli-web-branch.ts → ../../cli-web-branch.ts
- ../web-mode.ts → ../../web-mode.ts
- ../resources/extensions/ → ../../resources/extensions/
- ci_monitor ROOT path: 2 levels up → 3 levels up
- web-responsive WEB_ROOT: 2 levels up → 3 levels up

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(test): use dot reporter for test:unit to reduce noise

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(test): switch test:unit reporter to tap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(test): compact test reporter — silent on pass, failures + summary only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(test): include shared/tests in test:coverage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): correct path depths in tests moved to integration/

Tests moved from tests/ to tests/integration/ need one extra ../
to reach the same source files. Also fix web component paths — those
files live at web/ not src/web/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): fix web component paths in web-session-parity-contract

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): use process.cwd() for project root in docker-template test

Resolving relative to __dirname breaks under test:coverage which runs
source files directly from src/tests/ — needs ../.. not ../../..
(the extra level only exists in the compiled dist-test/ output).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: retrigger CI

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:51:49 -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)",
)
}
}
})