github-worker main.ts Bootstrap Smoke + require.main Guard
SummaryExtended the bootstrap/lifecycle smoke pattern established for 4 NestJS services in Sprint 197~199 to github-worker, a pure Node.js service with no app.module. Added a bootstrap smoke test that runs main()'s entrypoint wiring (startMetricsServer → CircuitBreakerManager → GitHubWorker → worker.start() → SIGTERM/SIGINT handlers) in a single pass, completing 'tests green = bootstrap works' for github-worker. Per-component unit specs (worker/config/circuit-breaker/metrics) were broad, but the path where main() wires them together was the one uncovered gap. Since the NestJS .compile() pattern does not apply, adopted approach B — export main and gate the void main() call behind require.main === module (preserves direct-execution behavior + blocks auto-run on test import), and change the return value to { worker, cbManager } to expose teardown handles. main.init.spec.ts reuses jest.mock(ioredis/config/amqplib) + mocks only startMetricsServer (via requireActual) to block real port listen. The negative check picks a throw point before any resource is created (startMetricsServer) to prove main() rejects without leaking opossum timers. Exploration found ai-analysis (FastAPI) already has bootstrap coverage via TestLifespan/TestStartupShutdownEvents + test_config.py import-time negative checks → excluded as pure duplication. tsc 0 · ESLint 0 · jest 9 suites 181 pass (funcs 100% · lines 100%, main.ts excluded from collectCoverageFrom so thresholds unaffected) · open handles 0 · CI #353 CLEAN · Critic (Codex) Critical/High 0. PR #353 squash → 40ad681.
Goal
- Extend the bootstrap/lifecycle smoke pattern established for the 4 NestJS services (gateway·submission·problem·identity) in Sprint 197~199 to github-worker, a pure Node.js service with no app.module.
- Add a bootstrap smoke test that runs github-worker
main.ts's entrypoint wiring (startMetricsServer→CircuitBreakerManager→GitHubWorker→worker.start()→ SIGTERM/SIGINT handlers) in a single pass, completing "tests green = bootstrap works" for github-worker.
Background
- github-worker is a RabbitMQ consumer worker (Node.js + amqplib + ioredis + Octokit + opossum + prom-client). It is not NestJS and has no app.module, so the
Test.createTestingModule({imports:[AppModule]}).compile()/.init()pattern from Sprint 197~199 cannot be applied as-is. - Existing test assets were broad per-component —
worker.spec.ts(GitHubWorker.start/stop/message handling),config.spec.ts(missing-env throw negative check),circuit-breaker.spec.ts,metrics.spec.ts, etc. But the path wheremain.ts'smain()wires them together had no dedicated spec and was uncovered (main.tsis excluded fromcollectCoverageFrom—jest.config.ts:10'!**/main.ts'). - That is, each component is verified with its own mocks, but it was never checked whether
startMetricsServer()+new CircuitBreakerManager(registry)+new GitHubWorker(cbManager)+await worker.start()+ signal handler registration throws when run together — the one real gap for this sprint. - Technical hurdles:
main.tsuses a top-levelvoid main().catch()fire-and-forget pattern, so merely importing it auto-runsmain(). AlsostartMetricsServer()actually listens on a port (metrics.ts:97), theGitHubWorkerconstructor callsnew Redis()(worker.ts:96), andworker.start()callsamqplib.connect(worker.ts:154), so external I/O must be mocked. opossum breakers hold a stats-refreshsetInterval, so without teardown the test hangs.
Decision
D0. Scope — github-worker only (rescoped from exploration)
- The sprint started as "extend bootstrap smoke to github-worker + ai-analysis", but pre-work exploration found ai-analysis (FastAPI) already has effectively complete bootstrap smoke coverage:
tests/test_main.py'sclientfixture (from src.main import app→ config import + app wiring),TestStartupShutdownEvents(callsstartup_event()directly → Worker/Redis init),TestLifespan(TestClient context runs the full lifespan startup/shutdown).tests/test_config.py's import-timeValidationErrornegative checks (empty string/missing/whitespace →INTERNAL_API_KEY).
- Adding new tests would be pure duplication, so ai-analysis is excluded and we focus on the github-worker
main.tsgap (confirmed via user AskUserQuestion).
D1. Approach B — export + require.main guard (user-recommended, approved)
- Change
async function main()→export async function main(), and wrap the top-levelvoid main().catch()in anif (require.main === module) { ... }guard. - Rationale: bootstrap runs only when executed directly as the entrypoint, so production behavior is unchanged, while tests can
import { main }without auto-running andawait main()to clearly verify wiring completes. The@ci-measurement: sprint-105-post-baselineanchor comment sits above the import and is preserved. - Alternative A (no source change, fire-and-forget): would have to auto-run via
await import('./main')while mockingprocess.exit+ flushing microtasks to infer completion, and the test cannot obtain references tomain()'s internalcbManager/worker, making opossumsetIntervalteardown awkward → open-handle risk, so rejected.
D2. Change main() return value to { worker, cbManager } — expose teardown handles
- Change
main()'s signature fromPromise<void>→Promise<{ worker; cbManager }>. The production entrypoint (void main().catch()) ignores the return value (harmless), while tests use the returned handles to callworker.stop()+cbManager.shutdown(), cleaning up the MQ connection and the opossumsetInterval. The JSDoc states the returned handles are for graceful shutdown and test teardown.
D3. Partial mock of metrics — requireActual + only startMetricsServer no-op
jest.mock('./metrics', () => ({ ...jest.requireActual('./metrics'), startMetricsServer: jest.fn() })). OnlystartMetricsServeris no-op'd to block real port listen, whileregistry/Counters use real prom-client instances (sincenew CircuitBreakerManager(registry)actually uses the registry). jest's per-file module isolation avoids prom-client duplicate-registration conflicts (Sprint 197 lesson).- ioredis/config/amqplib reuse
worker.spec.ts's mock patterns as-is (jest.mock('ioredis'),jest.mock('./config')with fixed config,jest.mock('amqplib')connect → mock channel). config load/missing-throw verification is already owned byconfig.spec.ts, so this smoke focuses on the wiring flow.
D4. Negative-check throw point — a stage before any resource is created (startMetricsServer)
- The negative check proves "a wiring-stage defect is caught by the smoke", but the throw point is chosen as
startMetricsServer(the first wiring stage):(startMetricsServer as jest.Mock).mockImplementationOnce(() => { throw ... })→main()rejects. - Rationale: startMetricsServer runs before
cbManager/workerare created, so a throw here means the opossum breaker is not yet created and there is nosetIntervalleak. Rejectingworker.start()'samqplib.connectinstead would, at that point, have already createdcbManager/worker(holding opossum timers) that the rejectedmain()cannot return for cleanup → teardown leak, so rejected.
Implementation
Implementation commit (1 commit, PR #353 squash → 40ad681)
fb2c733test(github-worker): main.ts bootstrap smoke + require.main guard (+134/-5)main.ts:export async function main()+ return{ worker, cbManager }+if (require.main === module)guard.@ci-measurementanchor preserved. Added main() JSDoc.main.init.spec.ts(new):jest.mock('ioredis'|'./config'|'amqplib')+ metrics viarequireActualwith onlystartMetricsServerno-op. logger stdout suppressed. 2 its:main()completes without throw +startMetricsServercalled once + SIGTERM/SIGINT handlers registered → teardown (worker.stop()+cbManager.shutdown()).[negative check]startMetricsServerthrows →main()rejects (SP200_NEGATIVE_CHECK).
- afterEach:
process.removeAllListeners('SIGTERM'|'SIGINT')+jest.clearAllMocks()to prevent signal-handler leaks.
Verification
- Type/build:
tsc --noEmit0. ESLint 0 (main.ts+main.init.spec.ts). - Tests: new spec passes standalone (
--detectOpenHandleswarnings 0) + full regression 9 suites / 181 pass. Coverage stmts 99.8% / branch 97.61% / funcs 100% / lines 100% → thresholds (92/100/98/98) all pass.main.tsis excluded fromcollectCoverageFrom(!**/main.ts) so it is not in the coverage table → thresholds unaffected. - Critic:
codex review --base main(Codex, session019e4e41-1294-7fc0-8f1f-797af6106b85) — Critical/High 0 ("The changes cleanly expose the bootstrap function for testing while preserving direct execution behavior via the CommonJS entrypoint guard. The added smoke test mocks external I/O and typechecking succeeds; no actionable correctness issues were found in the diff"). ✅ Mergeable. - CI #353:
mergeStateStatus: CLEAN,mergeable: MERGEABLE, Failed 0 (github-worker Quality/Audit/Test + E2E Programmers pass; unchanged areas skipping via path filter) → Squash merge.
New Patterns
- Pure Node.js entrypoint bootstrap smoke — export main + separate via
require.main === moduleguard, expose teardown handles (worker/manager) via the return value, mock external I/O (metrics listen/RabbitMQ/Redis) (partial mock =requireActual+ specific export override), and tear down side-effect resources (opossum setInterval) via the returned handles. The non-NestJS (github-worker) counterpart toapp.module.spec. - Pre-resource-creation negative check — embedding a temporary throw in the first wiring stage (startMetricsServer) to confirm
main()rejects proves "a wiring defect is caught by the smoke" without creating side-effect resources like opossum timers.