Files
hanarang-rails/tests/config.test.ts
이랑이 fcd2e56129 feat(sprint-004): 4-agent handoff engine — Transport 추상화 + runner + Discord marker
Sprint 004 핵심 구현 — F3 (QA 자동 라우팅 누락) + F4 (핸드오프 불안정) 해결:

Config (범용):
- src/config/schema.ts — Zod RailsConfig (pipeline/agents/discord)
- src/config/loader.ts — YAML + 환경변수 interpolation (${VAR})
- rails.config.example.yaml — 샘플 설정

Handoff:
- src/handoff/message.ts — HandoffMessage discriminated union (plan/implement/review/deploy)
- src/handoff/transport.ts — SisterTransport 인터페이스
- src/handoff/mock-transport.ts — 시나리오 override 가능한 mock
- src/handoff/discord-transport.ts — encodeInvokeMarker / decodeResultMarker
  (HTML 주석 + json 블록 — 자매는 LLM 우회 파서로 처리)
  DiscordPoster 인터페이스 주입으로 discord.js 와 독립 테스트 가능

Orchestrator:
- src/orchestrator/runner.ts — runPipeline E2E
  state → stage 매핑 → transport.invoke → HandoffMessage → FSM 이벤트
  타임아웃/에러는 ERROR 이벤트로 변환해 FSM 에 위임

CLI:
- rails run <project> [-r requirements] [-c config.yaml] [--mock]

Tests (16 신규, 57 total pass):
- HandoffMessage discriminated union 검증
- MockTransport 기본/오버라이드 시나리오
- Discord marker encode/decode round-trip
- DiscordTransport with fake poster
- Config loader YAML + 환경변수 interpolation

검증: tsc --noEmit ✓ | vitest 57/57 ✓ | build ✓ | rails run --help ✓

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:34:28 +09:00

80 lines
2.1 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { loadConfig } from "../src/config/loader.js";
import { DEFAULT_CONFIG } from "../src/config/schema.js";
let testDir: string;
beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), "rails-config-test-"));
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
describe("loadConfig", () => {
it("returns defaults when no path provided", async () => {
const cfg = await loadConfig();
expect(cfg).toEqual(DEFAULT_CONFIG);
});
it("returns defaults when file does not exist", async () => {
const cfg = await loadConfig(join(testDir, "missing.yaml"));
expect(cfg).toEqual(DEFAULT_CONFIG);
});
it("parses valid yaml config", async () => {
const yamlPath = join(testDir, "rails.config.yaml");
await writeFile(
yamlPath,
`
pipeline:
stages: [plan, implement, review]
agents:
plan:
role: plan
displayName: TestPlanner
transport: mock
timeoutMs: 15000
`,
);
const cfg = await loadConfig(yamlPath);
expect(cfg.pipeline.stages).toEqual(["plan", "implement", "review"]);
expect(cfg.agents["plan"]?.displayName).toBe("TestPlanner");
expect(cfg.agents["plan"]?.timeoutMs).toBe(15_000);
});
it("interpolates environment variables", async () => {
const yamlPath = join(testDir, "rails.config.yaml");
await writeFile(
yamlPath,
`
discord:
enabled: true
railsToken: \${MY_TEST_TOKEN}
guildId: fixed-guild
`,
);
const cfg = await loadConfig(yamlPath, { MY_TEST_TOKEN: "secret-abc" });
expect(cfg.discord.railsToken).toBe("secret-abc");
expect(cfg.discord.guildId).toBe("fixed-guild");
});
it("defaults missing env vars to empty string", async () => {
const yamlPath = join(testDir, "rails.config.yaml");
await writeFile(
yamlPath,
`
discord:
enabled: false
railsToken: \${MISSING_VAR}
`,
);
const cfg = await loadConfig(yamlPath, {});
expect(cfg.discord.railsToken).toBe("");
});
});