/** * test/e2e/report.mjs — 흐름별 판정·증거를 모아 docs/verification/local-e2e-<날짜>.md / .json 으로 쓴다. * * 판정(verdict) * verified_local 로컬 환경(실제 HAL·포털·Agent 프로세스 + 가짜 Webmaker/AriMail/메일)에서 끝까지 확인됨 * partial 일부 단계만 확인됨(나머지는 실제 인프라가 필요하거나 선행 흐름의 결함으로 막힘) * not_verifiable_locally 로컬에서 확인할 수 없음(실 DNS·메일 왕복·실서버 등) * failed 실제 결함으로 보이는 실패(재현 정보를 bugs 에 기록) * 증거(evidence)에는 HTTP 상태·최종 화면 상태·작업 단계만 남기고 비밀값은 넣지 않는다(lib/redact). */ import { writeFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { redact } from '../../lib/redact.mjs'; export const FLOWS = [ [1, '신규 고객 가입과 이메일 확인'], [2, '개인 사용자 그룹 생성과 다른 사용자 초대'], [3, '상품 주문, 결제 확인, 권리 활성화'], [4, '초안 사이트 생성'], [5, 'Webmaker에서 페이지 제작'], [6, '관리형 배포'], [7, '도메인 연결과 HTTPS 발급'], [8, '외부 주소에서 사이트 확인'], [9, '사이트 정보와 접속·사용량 통계 확인'], [10, '사이트 대표 메일과 메일함 생성'], [11, '외부 송신, 수신, 회신 확인'], [12, '독립 메일 서비스 생성'], [13, '백업 옵션 구매와 첫 백업 확인'], [14, '백업 복원과 외부 사이트 확인'], [15, '관리형 사이트 서버 이전과 외부 확인'], [16, '외부 배포 사이트 생성과 배포'], [17, '사용량 경고와 한도 동작 확인'], [18, '상품 축소 보류와 정리 후 적용'], [19, '사이트 수 반환과 대표 메일의 독립 전환'], [20, '사이트 소유권 이전'], [21, '중지, 해지, 보존 예정일 표시'], [22, 'HAL 우선 배포 후 HAL을 통한 전체 모듈 배포'], [23, '실패 작업의 재시도와 되돌리기'], [24, '운영자 승인, 감사 기록, 고객 알림의 일치'], [25, 'HAL, 로그인, 비밀정보 관리, 인증서 기반의 재해복구 훈련'], [26, '새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지'], [27, '마지막 사이트 수·용량·메일함 동시 요청 시 하나만 성공'], [28, '외부 Linux 서버 Agent 연결·배포·재배포·제거·실패 정리'], [29, 'HAL 장애 중 기존 서비스 유지와 신규 변경 제한 표시'], [30, '모듈 업데이트, 일부 서버 실패, 대상별 되돌리기와 상태 조정'], [31, '긴급 복구의 승인 부족·만료 자격·허용 범위 밖 작업 거부와 정상 복구'], [32, 'Source 옵션 만료 후 30일 read-only, clone·export와 삭제 완료'], [33, '메일 서비스의 사이트 대표→독립 전환 후 항목 유지'], [34, '일시 중지·해지 예약·보존·삭제 단계의 표시 일치'], ]; const LABEL = { verified_local: '로컬 확인', partial: '부분 확인', not_verifiable_locally: '로컬 확인 불가', failed: '실패(결함)' }; export function createReport({ startedAt = new Date() } = {}) { const flows = new Map(); const bugs = []; const infra = new Set(); const env = {}; const api = { env, /** 흐름 판정 기록(같은 번호를 다시 적으면 덮어쓴다 — 나중 판정이 더 정확) */ flow(no, verdict, { evidence = {}, note = '', blockedBy = null, steps = [] } = {}) { flows.set(no, { no, title: (FLOWS.find((f) => f[0] === no) || [])[1] || '', verdict, note, blockedBy, steps, evidence: redact(evidence) }); return api; }, /** 결함 기록: 정확한 재현 정보(파일·경로·기대·실제) */ bug({ title, file, route = '', expected, actual, repro = '', flows: fl = [] }) { if (!bugs.some((b) => b.title === title)) bugs.push({ title, file, route, expected, actual: redact(actual), repro, flows: fl }); return api; }, /** 실제 인프라가 필요한 항목 */ needsInfra(text) { infra.add(text); return api; }, bugs, infra, flows, summary() { const c = { verified_local: 0, partial: 0, not_verifiable_locally: 0, failed: 0, unrecorded: 0 }; for (const [no] of FLOWS) { const f = flows.get(no); if (!f) c.unrecorded++; else c[f.verdict] = (c[f.verdict] || 0) + 1; } return c; }, write(dir, { date = startedAt } = {}) { mkdirSync(dir, { recursive: true }); const stamp = date.toISOString().slice(0, 10); const sum = api.summary(); const rows = FLOWS.map(([no, title]) => { const f = flows.get(no); return f ? f : { no, title, verdict: 'unrecorded', note: '하네스가 이 흐름을 실행하지 못함(앞선 오류)', evidence: {}, steps: [] }; }); const json = { generatedAt: new Date().toISOString(), startedAt: startedAt.toISOString(), env, summary: sum, flows: rows, bugs, needsInfrastructure: [...infra] }; writeFileSync(join(dir, `local-e2e-${stamp}.json`), JSON.stringify(json, null, 1)); let md = `# 로컬 종단 간 검증 기록 — ${stamp}\n\n생성: ${json.generatedAt} · \`node --no-warnings test/e2e.test.mjs\` (test/e2e/)\n\n`; md += `실행 환경: 실제 HAL·고객 포털·Node Agent(명령 stub 모드) 프로세스 ${env.agents || 0}대 + 가짜 Webmaker/AriMail/발신 메일 서버(127.0.0.1). 실 DNS·실 메일 왕복·실 서버는 포함하지 않는다.\n\n`; md += `## 요약\n\n| 판정 | 수 |\n|---|---|\n` + Object.entries(sum).map(([k, v]) => `| ${LABEL[k] || k} | ${v} |`).join('\n') + '\n\n'; md += `## 흐름별 판정(Release 0.1.1 완료 조건 34개)\n\n| # | 흐름 | 판정 | 확인 내용 / 비고 |\n|---|---|---|---|\n`; for (const f of rows) md += `| ${f.no} | ${f.title} | ${LABEL[f.verdict] || f.verdict} | ${(f.note || '').replace(/\|/g, '/')}${f.blockedBy ? ` (선행 흐름 ${f.blockedBy} 에 막힘)` : ''} |\n`; md += `\n## 발견한 결함(재현 정보)\n\n`; if (!bugs.length) md += '없음\n'; for (const b of bugs) md += `- **${b.title}** — \`${b.file}\`${b.route ? ` (${b.route})` : ''}\n - 기대: ${b.expected}\n - 실제: ${typeof b.actual === 'string' ? b.actual : JSON.stringify(b.actual)}\n - 재현: ${b.repro}${b.flows.length ? ` · 영향 흐름 ${b.flows.join(', ')}` : ''}\n`; md += `\n## 실제 인프라가 필요한 항목\n\n`; for (const i of infra) md += `- ${i}\n`; md += `\n## 흐름별 증거(요약)\n\n`; for (const f of rows) { if (!f.steps?.length && !Object.keys(f.evidence || {}).length) continue; md += `### ${f.no}. ${f.title}\n\n`; for (const s of f.steps || []) md += `- ${s.ok === false ? '✘' : '✔'} ${s.name}${s.detail ? ` — ${typeof s.detail === 'string' ? s.detail : JSON.stringify(s.detail).slice(0, 300)}` : ''}\n`; if (Object.keys(f.evidence || {}).length) md += '\n```json\n' + JSON.stringify(f.evidence, null, 1).slice(0, 1800) + '\n```\n'; md += '\n'; } writeFileSync(join(dir, `local-e2e-${stamp}.md`), md); return { md: join(dir, `local-e2e-${stamp}.md`), json: join(dir, `local-e2e-${stamp}.json`), summary: sum }; }, }; return api; } /** 흐름 안에서 단계별 증거를 모으는 도우미: const s = steps(); await s.run('이름', fn) → 결과 기록(예외는 ok:false 로 남기고 다시 던진다) */ export function steps() { const list = []; return { list, async run(name, fn, { soft = false } = {}) { try { const r = await fn(); list.push({ name, ok: true, detail: r === undefined ? undefined : (typeof r === 'string' ? r : redact(r)) }); return r; } catch (e) { list.push({ name, ok: false, detail: e && e.message ? String(e.message).slice(0, 300) : String(e) }); if (soft) return null; throw e; } }, note(name, detail) { list.push({ name, ok: true, detail }); } }; }