diff --git a/Dockerfile.relay b/Dockerfile.relay index 743c35ebc..75ec5b3bb 100644 --- a/Dockerfile.relay +++ b/Dockerfile.relay @@ -22,6 +22,15 @@ RUN npm ci --prefix scripts --omit=dev COPY scripts/ais-relay.cjs ./scripts/ais-relay.cjs COPY scripts/_proxy-utils.cjs ./scripts/_proxy-utils.cjs COPY scripts/_seed-utils.mjs ./scripts/_seed-utils.mjs +# _seed-envelope-source.mjs and _seed-contract.mjs are transitively imported +# by _seed-utils.mjs (lines 9-10) and by seed-chokepoint-flows.mjs / +# seed-ember-electricity.mjs directly. Missing them here = silent +# ERR_MODULE_NOT_FOUND on every execFile invocation, which looks like a hung +# Railway cron (the initial-seed path throws, the 6h setInterval keeps firing +# but each child dies on import). tests/dockerfile-relay-imports.test.mjs +# guards this COPY list against future regressions. +COPY scripts/_seed-envelope-source.mjs ./scripts/_seed-envelope-source.mjs +COPY scripts/_seed-contract.mjs ./scripts/_seed-contract.mjs COPY scripts/_country-resolver.mjs ./scripts/_country-resolver.mjs COPY scripts/seed-climate-news.mjs ./scripts/seed-climate-news.mjs COPY scripts/seed-chokepoint-flows.mjs ./scripts/seed-chokepoint-flows.mjs diff --git a/tests/dockerfile-relay-imports.test.mjs b/tests/dockerfile-relay-imports.test.mjs new file mode 100644 index 000000000..83766f550 --- /dev/null +++ b/tests/dockerfile-relay-imports.test.mjs @@ -0,0 +1,111 @@ +// Static guard: every scripts/*.mjs COPY'd into the relay container must +// have ALL its relative-path imports ALSO COPY'd. A missing transitive +// import looks like a silent Railway cron hang — the child process dies +// on ERR_MODULE_NOT_FOUND with output only on the parent's stderr, which +// is easy to miss when the relay handles many other messages. +// +// Historical failures this test would have caught: +// - 2026-04-14 to 2026-04-16: _seed-envelope-source.mjs added to +// _seed-utils.mjs but not COPY'd, breaking chokepoint-flows for 32h +// (fixed alongside PR #3128 port-activity work). + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import { dirname, resolve, basename } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); + +function readCopyList(dockerfilePath) { + const src = readFileSync(dockerfilePath, 'utf-8'); + const copied = new Set(); + // Matches: COPY scripts/foo.mjs ./scripts/foo.mjs + const re = /^COPY\s+(scripts\/[^\s]+\.(mjs|cjs))\s+/gm; + for (const m of src.matchAll(re)) copied.add(m[1]); + return copied; +} + +function collectRelativeImports(filePath) { + const src = readFileSync(filePath, 'utf-8'); + const imports = new Set(); + // ESM: import ... from './x.mjs' | export ... from './x.mjs' + const esmRe = /(?:^|\s|;)(?:import|export)\s+(?:[\s\S]*?\s+from\s+)?['"](\.[^'"]+)['"]/g; + for (const m of src.matchAll(esmRe)) imports.add(m[1]); + // CJS direct: require('./x.cjs') + const cjsRe = /(?:^|[^a-zA-Z0-9_$])require\s*\(\s*['"](\.[^'"]+)['"]/g; + for (const m of src.matchAll(cjsRe)) imports.add(m[1]); + // CJS chained: createRequire(import.meta.url)('./x.cjs') + // — the final `('./x')` argument is applied to createRequire's return, + // not to a `require(` token, so the cjsRe above misses it. + const createRequireRe = /createRequire\s*\([^)]*\)\s*\(\s*['"](\.[^'"]+)['"]/g; + for (const m of src.matchAll(createRequireRe)) imports.add(m[1]); + return imports; +} + +function resolveImport(fromFile, relImport) { + const abs = resolve(dirname(fromFile), relImport); + if (existsSync(abs)) return abs; + for (const ext of ['.mjs', '.cjs', '.js']) { + if (existsSync(abs + ext)) return abs + ext; + } + return null; +} + +describe('Dockerfile.relay — transitive-import closure', () => { + const dockerfile = resolve(root, 'Dockerfile.relay'); + const copied = readCopyList(dockerfile); + const entrypoints = [...copied].filter(p => p.endsWith('.mjs') || p.endsWith('.cjs')); + + it('COPY list is non-empty (sanity)', () => { + assert.ok(copied.size > 0, 'Dockerfile.relay has no COPY scripts/*.mjs|cjs lines'); + }); + + it('scanner catches both ESM imports and CJS require/createRequire', () => { + // Regression guard for the scanner itself: _seed-utils.mjs has both + // `import { ... } from './_seed-envelope-source.mjs'` (ESM) AND + // `createRequire(import.meta.url)('./_proxy-utils.cjs')` (CJS). If + // collectRelativeImports ever stops picking up either, a future + // createRequire/require pointing at a new uncopied helper would slip + // past the BFS test below without anyone noticing. + const seedUtils = resolve(root, 'scripts/_seed-utils.mjs'); + const imports = collectRelativeImports(seedUtils); + assert.ok(imports.has('./_seed-envelope-source.mjs'), 'ESM import not detected'); + assert.ok(imports.has('./_proxy-utils.cjs'), 'CJS createRequire not detected'); + + const relayCjs = resolve(root, 'scripts/ais-relay.cjs'); + const relayImports = collectRelativeImports(relayCjs); + assert.ok(relayImports.has('./_proxy-utils.cjs'), 'CJS require not detected'); + }); + + // BFS the import graph from each COPY'd entrypoint. Every .mjs/.cjs reached + // via a relative import must itself be COPY'd. + it('every transitively-imported scripts/*.mjs|cjs is also COPY\'d', () => { + const missing = []; + const visited = new Set(); + const queue = entrypoints.map(p => resolve(root, p)); + while (queue.length) { + const file = queue.shift(); + if (visited.has(file)) continue; + visited.add(file); + if (!existsSync(file)) continue; + for (const rel of collectRelativeImports(file)) { + const resolved = resolveImport(file, rel); + if (!resolved) continue; + const relToRoot = resolved.startsWith(root + '/') ? resolved.slice(root.length + 1) : null; + if (!relToRoot || !relToRoot.startsWith('scripts/')) continue; + if (!copied.has(relToRoot)) { + missing.push(`${relToRoot} (imported by ${file.slice(root.length + 1)})`); + } + queue.push(resolved); + } + } + assert.deepEqual( + missing, + [], + `Dockerfile.relay is missing COPY lines for:\n ${missing.join('\n ')}\n` + + `Add a 'COPY ./' line per missing file.`, + ); + }); +});