diff --git a/src/tests/web-subprocess-runner.test.ts b/src/tests/web-subprocess-runner.test.ts new file mode 100644 index 000000000..ab3004619 --- /dev/null +++ b/src/tests/web-subprocess-runner.test.ts @@ -0,0 +1,177 @@ +import test from "node:test" +import assert from "node:assert/strict" + +const { runSubprocess, resolveModulePaths } = await import("../web/subprocess-runner.ts") + +// --------------------------------------------------------------------------- +// resolveModulePaths — centralised TS loader + module path resolution +// --------------------------------------------------------------------------- + +test("resolveModulePaths returns tsLoaderPath and validates it exists", () => { + const packageRoot = "/fake/package" + const result = resolveModulePaths(packageRoot, { + modules: [{ envKey: "MOD", relativePath: "src/mod.ts" }], + existsSync: () => true, + }) + assert.equal( + result.tsLoaderPath, + "/fake/package/src/resources/extensions/gsd/tests/resolve-ts.mjs", + ) +}) + +test("resolveModulePaths throws when TS loader is missing", () => { + const packageRoot = "/fake/package" + assert.throws( + () => + resolveModulePaths(packageRoot, { + modules: [{ envKey: "MOD", relativePath: "src/mod.ts" }], + existsSync: () => false, + label: "test-service", + }), + (error: Error) => { + assert.match(error.message, /test-service/) + assert.match(error.message, /not found/) + return true + }, + ) +}) + +test("resolveModulePaths throws when any module path is missing", () => { + const packageRoot = "/fake/package" + const existingSets = new Set([ + "/fake/package/src/resources/extensions/gsd/tests/resolve-ts.mjs", + ]) + assert.throws( + () => + resolveModulePaths(packageRoot, { + modules: [ + { envKey: "MOD_A", relativePath: "src/a.ts" }, + { envKey: "MOD_B", relativePath: "src/b.ts" }, + ], + existsSync: (p: string) => existingSets.has(p), + label: "multi-mod", + }), + (error: Error) => { + assert.match(error.message, /multi-mod/) + return true + }, + ) +}) + +test("resolveModulePaths returns env entries for each module", () => { + const packageRoot = "/fake/package" + const result = resolveModulePaths(packageRoot, { + modules: [ + { envKey: "GSD_MOD_A", relativePath: "src/a.ts" }, + { envKey: "GSD_MOD_B", relativePath: "src/b.ts" }, + ], + existsSync: () => true, + }) + assert.deepEqual(result.env, { + GSD_MOD_A: "/fake/package/src/a.ts", + GSD_MOD_B: "/fake/package/src/b.ts", + }) +}) + +// --------------------------------------------------------------------------- +// runSubprocess — shared execFile + JSON.parse wrapper +// --------------------------------------------------------------------------- + +test("runSubprocess returns parsed JSON from a child process", async () => { + const result = await runSubprocess<{ hello: string }>({ + packageRoot: process.cwd(), + script: 'process.stdout.write(JSON.stringify({ hello: "world" }));', + env: {}, + label: "test", + }) + assert.deepEqual(result, { hello: "world" }) +}) + +test("runSubprocess rejects when child process exits with error", async () => { + await assert.rejects( + () => + runSubprocess({ + packageRoot: process.cwd(), + script: 'process.exit(1);', + env: {}, + label: "exit-test", + }), + (error: Error) => { + assert.match(error.message, /exit-test/) + assert.match(error.message, /subprocess failed/) + return true + }, + ) +}) + +test("runSubprocess rejects on invalid JSON output", async () => { + await assert.rejects( + () => + runSubprocess({ + packageRoot: process.cwd(), + script: 'process.stdout.write("not json");', + env: {}, + label: "json-test", + }), + (error: Error) => { + assert.match(error.message, /json-test/) + assert.match(error.message, /invalid JSON/) + return true + }, + ) +}) + +test("runSubprocess applies timeout option", async () => { + await assert.rejects( + () => + runSubprocess({ + packageRoot: process.cwd(), + script: 'setTimeout(() => {}, 60000);', + env: {}, + label: "timeout-test", + timeoutMs: 500, + }), + (error: Error) => { + assert.match(error.message, /timeout-test/) + return true + }, + ) +}) + +test("runSubprocess accepts custom maxBuffer", async () => { + // Verify it does not throw with a reasonable buffer + const result = await runSubprocess<{ ok: boolean }>({ + packageRoot: process.cwd(), + script: 'process.stdout.write(JSON.stringify({ ok: true }));', + env: {}, + label: "buffer-test", + maxBuffer: 512, + }) + assert.equal(result.ok, true) +}) + +test("runSubprocess passes env vars to child process", async () => { + const result = await runSubprocess<{ val: string }>({ + packageRoot: process.cwd(), + script: 'process.stdout.write(JSON.stringify({ val: process.env.TEST_VAR }));', + env: { TEST_VAR: "hello_from_parent" }, + label: "env-test", + }) + assert.equal(result.val, "hello_from_parent") +}) + +test("runSubprocess includes stderr in error message on failure", async () => { + await assert.rejects( + () => + runSubprocess({ + packageRoot: process.cwd(), + script: 'process.stderr.write("detailed error info"); process.exit(1);', + env: {}, + label: "stderr-test", + }), + (error: Error) => { + assert.match(error.message, /detailed error info/) + return true + }, + ) +}) diff --git a/src/web/subprocess-runner.ts b/src/web/subprocess-runner.ts new file mode 100644 index 000000000..e4d67710f --- /dev/null +++ b/src/web/subprocess-runner.ts @@ -0,0 +1,168 @@ +/** + * Shared subprocess runner for web service files. + * + * Every web service that loads upstream GSD extension modules needs to spawn + * a Node child process with the TS loader, type-stripping flag, and --eval. + * This module centralises that boilerplate so services only specify what + * varies: the script, env vars, and module paths. + */ + +import { execFile } from "node:child_process" +import { existsSync as defaultExistsSync } from "node:fs" +import { join } from "node:path" +import { pathToFileURL } from "node:url" + +import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts" + +const DEFAULT_MAX_BUFFER = 2 * 1024 * 1024 +const DEFAULT_TIMEOUT_MS = 30_000 + +// --------------------------------------------------------------------------- +// Module path resolution +// --------------------------------------------------------------------------- + +export interface ModuleSpec { + /** Environment variable name the child process reads to find this module. */ + envKey: string + /** Path relative to packageRoot (e.g. "src/resources/extensions/gsd/doctor.ts"). */ + relativePath: string +} + +export interface ResolveModulePathsOptions { + modules: ModuleSpec[] + /** Override for testing — defaults to fs.existsSync. */ + existsSync?: (path: string) => boolean + /** Label used in error messages (e.g. "doctor-service"). */ + label?: string +} + +export interface ResolvedPaths { + /** Absolute path to resolve-ts.mjs. */ + tsLoaderPath: string + /** Environment variable entries mapping each module's envKey to its absolute path. */ + env: Record +} + +/** + * Resolves the TS loader path and all module paths, validating that every + * path exists on disk. Throws a descriptive error if any path is missing. + */ +export function resolveModulePaths( + packageRoot: string, + options: ResolveModulePathsOptions, +): ResolvedPaths { + const checkExists = options.existsSync ?? defaultExistsSync + const label = options.label ?? "subprocess" + + const tsLoaderPath = join( + packageRoot, + "src", + "resources", + "extensions", + "gsd", + "tests", + "resolve-ts.mjs", + ) + + const modulePaths: Record = {} + const allPaths = [tsLoaderPath] + + for (const mod of options.modules) { + const fullPath = join(packageRoot, mod.relativePath) + modulePaths[mod.envKey] = fullPath + allPaths.push(fullPath) + } + + for (const p of allPaths) { + if (!checkExists(p)) { + throw new Error(`${label} data provider not found; missing=${p}`) + } + } + + return { tsLoaderPath, env: modulePaths } +} + +// --------------------------------------------------------------------------- +// Subprocess runner +// --------------------------------------------------------------------------- + +export interface RunSubprocessOptions { + /** Absolute path to the package root (used as cwd and for flag resolution). */ + packageRoot: string + /** The --eval script to run in the child process. */ + script: string + /** Extra environment variables merged onto process.env for the child. */ + env: Record + /** Label for error messages (e.g. "doctor", "forensics"). */ + label: string + /** Override cwd (defaults to packageRoot). */ + cwd?: string + /** Max stdout buffer in bytes. Defaults to 2 MB. */ + maxBuffer?: number + /** Subprocess timeout in milliseconds. Defaults to 30 s. */ + timeoutMs?: number + /** Resolved TS loader path — if omitted, resolves from packageRoot. */ + tsLoaderPath?: string + /** Override process.execPath for testing. */ + execPath?: string +} + +/** + * Spawns a Node child process that evaluates `script` with the TS loader and + * type-stripping flag, parses the stdout as JSON, and returns the result. + * + * Replaces the identical `new Promise((resolve, reject) => execFile(...))` + * callback boilerplate that was duplicated across 12+ web service files. + */ +export async function runSubprocess(options: RunSubprocessOptions): Promise { + const { + packageRoot, + script, + env: extraEnv, + label, + cwd = packageRoot, + maxBuffer = DEFAULT_MAX_BUFFER, + timeoutMs = DEFAULT_TIMEOUT_MS, + execPath = process.execPath, + } = options + + const tsLoaderPath = + options.tsLoaderPath ?? + join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs") + + return await new Promise((resolveResult, reject) => { + execFile( + execPath, + [ + "--import", + pathToFileURL(tsLoaderPath).href, + resolveTypeStrippingFlag(packageRoot), + "--input-type=module", + "--eval", + script, + ], + { + cwd, + env: { ...process.env, ...extraEnv }, + maxBuffer, + timeout: timeoutMs, + }, + (error, stdout, stderr) => { + if (error) { + reject(new Error(`${label} subprocess failed: ${stderr || error.message}`)) + return + } + + try { + resolveResult(JSON.parse(stdout) as T) + } catch (parseError) { + reject( + new Error( + `${label} subprocess returned invalid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`, + ), + ) + } + }, + ) + }) +}