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>
80 lines
2.1 KiB
TypeScript
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("");
|
|
});
|
|
});
|