Node v24 forbids --experimental-strip-types for files under node_modules/.
When GSD is globally installed, all src/ files live under node_modules/gsd-pi/,
causing every subprocess worker to crash with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING.
Bug 1: Extract resolveTypeStrippingFlag() into src/web/ts-subprocess-flags.ts.
When the package root is under node_modules/ and Node >= 22.7, the function
returns --experimental-transform-types (which handles node_modules paths).
All 15 service files and cli-entry.ts now call this function instead of
hardcoding --experimental-strip-types.
Bug 2: waitForBootReady() now tracks consecutive 5xx responses and aborts
after 3 in a row, including the response body in the error message.
Connection-level errors (transient during cold start) reset the counter.
Bug 3: The /api/boot route handler now wraps collectBootPayload() in
try/catch and returns { error: message } with status 500, matching the
error response pattern used by other API routes.
Fixes #1849
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
151 lines
5.3 KiB
TypeScript
151 lines
5.3 KiB
TypeScript
import test from "node:test"
|
|
import assert from "node:assert/strict"
|
|
|
|
import { resolveTypeStrippingFlag } from "../web/ts-subprocess-flags.ts"
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bug 1 — resolveTypeStrippingFlag selects the correct flag
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("resolveTypeStrippingFlag returns --experimental-strip-types for paths outside node_modules", () => {
|
|
const flag = resolveTypeStrippingFlag("/home/user/projects/gsd")
|
|
assert.equal(flag, "--experimental-strip-types")
|
|
})
|
|
|
|
test("resolveTypeStrippingFlag returns --experimental-strip-types for path with node_modules substring not as directory", () => {
|
|
// e.g. /home/user/my_node_modules_backup/gsd — not actually under node_modules/
|
|
const flag = resolveTypeStrippingFlag("/home/user/my_node_modules_backup/gsd")
|
|
assert.equal(flag, "--experimental-strip-types")
|
|
})
|
|
|
|
test("resolveTypeStrippingFlag returns --experimental-transform-types for paths under node_modules/ on Node >= 22.7", () => {
|
|
const [major, minor] = process.versions.node.split(".").map(Number)
|
|
const flag = resolveTypeStrippingFlag("/usr/lib/node_modules/gsd-pi")
|
|
|
|
if (major > 22 || (major === 22 && minor >= 7)) {
|
|
assert.equal(flag, "--experimental-transform-types")
|
|
} else {
|
|
// On older Node, falls back to strip-types since transform-types isn't available
|
|
assert.equal(flag, "--experimental-strip-types")
|
|
}
|
|
})
|
|
|
|
test("resolveTypeStrippingFlag handles Windows-style paths under node_modules", () => {
|
|
const [major, minor] = process.versions.node.split(".").map(Number)
|
|
const flag = resolveTypeStrippingFlag("C:\\Users\\dev\\AppData\\node_modules\\gsd-pi")
|
|
|
|
if (major > 22 || (major === 22 && minor >= 7)) {
|
|
assert.equal(flag, "--experimental-transform-types")
|
|
} else {
|
|
assert.equal(flag, "--experimental-strip-types")
|
|
}
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bug 2 — waitForBootReady fails fast on consecutive 5xx
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// The waitForBootReady function is not exported, but the behavior is testable
|
|
// by verifying the launchWebMode deps injection. We test the core logic
|
|
// pattern directly: 3 consecutive 5xx should abort without waiting for timeout.
|
|
|
|
test("waitForBootReady pattern: consecutive 5xx detection aborts early", async () => {
|
|
// Simulate the retry logic extracted from waitForBootReady
|
|
let consecutive5xx = 0
|
|
const MAX_CONSECUTIVE_5XX = 3
|
|
const responses = [500, 500, 500] // three deterministic 500s
|
|
let abortedEarly = false
|
|
|
|
for (const statusCode of responses) {
|
|
if (statusCode >= 500) {
|
|
consecutive5xx++
|
|
if (consecutive5xx >= MAX_CONSECUTIVE_5XX) {
|
|
abortedEarly = true
|
|
break
|
|
}
|
|
} else {
|
|
consecutive5xx = 0
|
|
}
|
|
}
|
|
|
|
assert.equal(abortedEarly, true, "should abort after 3 consecutive 5xx responses")
|
|
assert.equal(consecutive5xx, 3)
|
|
})
|
|
|
|
test("waitForBootReady pattern: non-5xx responses reset the consecutive counter", () => {
|
|
let consecutive5xx = 0
|
|
const MAX_CONSECUTIVE_5XX = 3
|
|
// 500, 500, connection-refused (resets), 500, 500 — should NOT trigger abort
|
|
const events = [
|
|
{ type: "response", status: 500 },
|
|
{ type: "response", status: 500 },
|
|
{ type: "error" }, // connection refused resets counter
|
|
{ type: "response", status: 500 },
|
|
{ type: "response", status: 500 },
|
|
]
|
|
let abortedEarly = false
|
|
|
|
for (const event of events) {
|
|
if (event.type === "response" && (event.status ?? 0) >= 500) {
|
|
consecutive5xx++
|
|
if (consecutive5xx >= MAX_CONSECUTIVE_5XX) {
|
|
abortedEarly = true
|
|
break
|
|
}
|
|
} else {
|
|
consecutive5xx = 0
|
|
}
|
|
}
|
|
|
|
assert.equal(abortedEarly, false, "should not abort when errors reset the counter")
|
|
})
|
|
|
|
test("waitForBootReady pattern: mixed 4xx and 5xx only counts 5xx", () => {
|
|
let consecutive5xx = 0
|
|
const MAX_CONSECUTIVE_5XX = 3
|
|
const responses = [500, 404, 500, 500]
|
|
let abortedEarly = false
|
|
|
|
for (const statusCode of responses) {
|
|
if (statusCode >= 500) {
|
|
consecutive5xx++
|
|
if (consecutive5xx >= MAX_CONSECUTIVE_5XX) {
|
|
abortedEarly = true
|
|
break
|
|
}
|
|
} else {
|
|
consecutive5xx = 0
|
|
}
|
|
}
|
|
|
|
assert.equal(abortedEarly, false, "404 should reset the consecutive 5xx counter")
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bug 3 — /api/boot route error handling
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("boot route returns { error } JSON on handler failure", async () => {
|
|
// Read the route source to verify try/catch wrapping is present
|
|
const { readFileSync } = await import("node:fs")
|
|
const { join } = await import("node:path")
|
|
|
|
const routeSource = readFileSync(
|
|
join(process.cwd(), "web", "app", "api", "boot", "route.ts"),
|
|
"utf-8",
|
|
)
|
|
|
|
// The route must catch errors and return { error: message }
|
|
assert.match(routeSource, /try\s*\{/, "boot route must have try block")
|
|
assert.match(routeSource, /catch\s*\(/, "boot route must have catch block")
|
|
assert.match(
|
|
routeSource,
|
|
/\{\s*error:\s*message\s*\}/,
|
|
"boot route must return { error: message } on failure",
|
|
)
|
|
assert.match(
|
|
routeSource,
|
|
/status:\s*500/,
|
|
"boot route must return status 500 on error",
|
|
)
|
|
})
|