v0.18.0 G02 — 날짜와 PC 에 따라 흔들리던 테스트에서 재현 가능한 기준선과 행동 검증 도구까지 (2026-09-07)
- 기간: 2026-09-07 ~ 2026-09-07 (세션 1개
36dffd3a, 오너 지시 "#1280 진행해줘" → 분석·계획 게시 → "내 결정 묻지 말고, phase 계획 작성후 마지막 phase까지 중단없이 진행") - 랜딩: PR #1310(Phase 0~7 한 PR, squash) — 마이그레이션·엣지 함수·앱 화면 변경 없음(테스트·스크립트·
.gitattributes·문서). 선행 보수는 PR #1278(초안 보관함 픽스처 고정 시계) - 설계서: 없음 — 분석·Phase 계획은 이슈 #1280 댓글("예상 효과·개선사항" 절 포함)
- 정본:
docs/gates/test-audit.md"줄바꿈(LF) 정책과 결정성 도구",docs/gates/test-baseline-inventory.md(장부), 루트.gitattributes - 도구:
tests/support/(clock·ids·scheduler·storage·clockShift·faultInjection·statsOracle·ownerSwitch·browserClock),tests/db/support/pgSessions.mjs,scripts/normalize-line-endings.mjs,scripts/check-utf8.mjs(CRLF 게이트) - 게이트: 새 단위 테스트 9파일 43건(
testSupportDeterminism·dateIndependence·lineEndingPolicy·faultInjectionTools·statsOracleTools·ownerSwitchFixture·browserClockFixture·tests/db/concurrencyBarrier) +check-utf8의 LF 검사. 마이그레이션·pgTAP·e2e 미접촉(샌드박스 pgTAP 은 도구 실행용으로만 돌렸다: 109파일/1899 assert 통과, 16:55) - 버그리포트: 없음(수리 건 아님 — CI 빨간불 자체는 #1278 이 고쳤다)
- 계약: 새 계약 없음. 줄바꿈 정책은
.gitattributes+check-utf8게이트가 정본
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 기준선 대조 — CI 34042079441 실패 3건 = #1278 수리 확인, Windows 줄바꿈 정책이 로컬 파일에만 있던 것 발견 | ✅ |
| Phase 1 | 고정 시계·순번 UUID·수동 스케줄러·격리 저장소·시계 이동 preload, ±400일 날짜 독립성 증명 | ✅ 35f0894d 9a105fe1 |
| Phase 2 | .gitattributes(LF)·check-utf8 CRLF 게이트·정규화 스크립트·색인 CRLF 1개 정규화·정책 문서 | ✅ 7b60bc76 8b60479b |
| Phase 3 | 저장 실패 주입(quota/abort/hang·답장 유실)과 초안 캐시·대기열 경계 기준선 | ✅ 5f2c5b22 |
| Phase 4 | 두 DB 연결 barrier(tests/db)·자문 잠금/행 잠금 경합 재현 | ✅ 1b6088db |
| Phase 5 | 통계 oracle 코퍼스 기반·owner 전환 픽스처·브라우저 시각 픽스처 | ✅ 437c5cd2 |
| Phase 6 | 기준선 장부(비결정성 분류·소스 모양 검사 전환/유지·검사 자산·주입 지점 인계) | ✅ 4ac4cad1 |
| Phase 7 | npm run check·ci:local·PR·CI·머지·이 기록 | ✅ PR #1310 |
1. 배경
v0.18.0 은 저장·통계 구조를 바꾸는 62개 작업이다. 구조를 바꾼 뒤 테스트가 빨갛게 되면 "진짜 회귀" 인지 "오늘 날짜·이 PC 의 줄바꿈·실제 타이머 때문" 인지 가를 수 있어야 하는데, 2026-09-05 부터 main 의 검증 레인이 날짜 때문에 빨갰다(CI run 34042079441: 2,586개 중 3개 실패). 로컬 Windows 에서는 schema.sql 의 "CRLF 없음" 단언이 PC 에 따라 달랐다. 총괄 문서 §9 는 저장 실패·DB 경합·통계 동치·계정 전환을 실제 행동으로 검증하라고 요구하지만, 그런 실험을 일으킬 공용 도구가 없었다.
2. 문제 제기
시계가 테스트의 숨은 입력이었다
초안 보관함 테스트는 픽스처 저장 시각이 2026-08-29 고정인데 서비스는 실제 Date.now() 를 읽어, 달력이 9/5 를 지나자 임시 보관 TTL(7일)이 픽스처를 만료시켰다. PR #1278 이 Date 전용 mock 으로 고쳤지만, 같은 방식을 다른 테스트에 펴 줄 도구와 "오늘이 달라도 같은 결과" 라는 증명은 없었다. 전수 조사(단위 369 파일·e2e 8·오류 재현 38): 고정 시계를 쓰는 테스트 2 파일, Playwright page.clock/timezoneId 0건, 랜딩 잠금 preflight 테스트는 자식 CLI 의 벽시계와 부모 시계가 결합.
줄바꿈 정책이 한 PC 의 로컬 파일에만 있었다
supabase/**/*.sql eol=lf 규칙이 .git/info/attributes(커밋되지 않는 파일)에만 있어서, 저장소에는 .gitattributes 가 없었다. Windows 체크아웃은 core.autocrlf=true 로 파일 1,536개가 CRLF 로 받아졌고(색인은 LF 1,855·CRLF 1·바이너리 77), 새 클론·다른 PC 에서는 schema.sql 단언이 빨갛다. 색인에도 CRLF 로 들어간 파일이 1개(tests/audit/pending-changes.json) 있었다.
실패를 일으킬 공용 도구가 없었다
tests/support/ 는 SQL 도우미 3개뿐. 저장 실패(용량 초과·중단·멈춤·답장 유실)를 내는 가짜 저장소 없음, 두 DB 연결 경합은 pgTAP 1 파일(dblink)뿐, 계정 전환 픽스처는 테스트마다 손으로 조립, 통계 기대값은 전부 앱 계산 결과의 박제. 소스 문자열을 읽어 함수 이름·JSX 모양을 고정하는 테스트가 72 파일 — 코드를 옮기면 회귀와 무관하게 깨진다.
3. 해결 방안
원칙
오너 결정 없음(2026-09-07 지시: 결정을 묻지 않고 계획대로 끝까지). 전제 = src/**·package.json·.github/** 는 만지지 않는다 (소유 경계 tests/**·scripts/**·.gitattributes); 제품 코드의 Clock/scheduler 주입 지점은 담당 이슈에 목록으로 인계한다.
접근
| 대안 | 판정 |
|---|---|
픽스처 날짜를 매번 최신으로 / 상대 시각(Date.now() 기준)으로 | 기각 — 경계 검증이 안 되고 실행 날짜 의존이 남는다(test-audit 규칙) |
| suite 전체 시각 일괄 동결 | 기각 — 실제 타이머·자식 프로세스·DB 와 어긋나고 규칙상 기본값이 아니다 |
| 테스트 단위 고정 시계 + 공용 도구 + ±400일 증명(preload) | 채택 |
줄바꿈: 테스트 단언 완화 / .git/info/attributes 안내 | 기각 — 정책이 저장소 밖에 남는다 |
.gitattributes 로 LF 고정 + check-utf8 게이트 + 정규화 스크립트 | 채택 |
두 DB 연결: pg 드라이버 추가 | 기각 — 의존성 추가는 HQ 통합 슬롯. psql(docker exec) 두 프로세스로 충분 |
| 통계 oracle: 앱 함수 출력을 코퍼스에 저장 | 기각 — 스냅샷이지 oracle 이 아니다. 출처(table/hand/production)를 강제 |
4. 적용한 내용
Phase 1 — 결정성 도구 (35f0894d, 9a105fe1)
tests/support/clock.mjs(Date 전용 mock 래퍼, 오프셋 없는 시각 거부, advance/set, 타이머까지 잡는 useFixedTimers), ids.mjs(v4 모양 순번 UUID), scheduler.mjs(advance/flush 로 실행 순서를 정하는 타이머), storage.mjs(테스트 동안만 붙는 window/localStorage), clockShift.mjs(실제 시계를 옮기는 preload). dateIndependence.test.mjs 가 +400일·−400일 자식 프로세스에서 초안 보관함 테스트 12/12 를 확인. landingPreflightGate 의 만료 픽스처를 "지금 − 24h" 에서 절대 과거(2000년)로.
Phase 2 — 줄바꿈 정책 (7b60bc76, 8b60479b)
.gitattributes(* text=auto eol=lf + 바이너리 목록), scripts/check-utf8.mjs(git 추적 텍스트 파일의 CRLF 를 실패로, 고치는 명령 안내, 검사 함수 export), scripts/normalize-line-endings.mjs(CRLF→LF 만 바꾸고 색인 stat 을 다시 잡는다; 내용이 바뀐 파일은 스테이징하지 않는다), pending-changes.json 색인 정규화, lineEndingPolicy.test.mjs, test-audit.md 정책 절.
Phase 3 — 저장 실패 주입 (5f2c5b22)
tests/support/faultInjection.mjs: withStoreFaults(메서드·호출 번째·횟수로 quota/abort/hang/throw, 호출 기록), withReplyLoss/withAdapterReplyLoss(요청은 서버에 닿고 답장만 잃는다). 기준선 7건: 용량 초과 시 이전 봉투 보존, 멈춤은 IdbTimeoutError, 답장 유실 뒤 행은 대기열에 남고 다음 플러시가 같은 식별자 로 재전송(S06 이 중복 수행을 막을 지점), 수정 어댑터 답장 유실 뒤 같은 요청 재수행.
Phase 4 — 두 DB 연결 barrier (1b6088db)
tests/db/support/pgSessions.mjs: docker exec … psql 또는 호스트 psql 프로세스를 세션으로, 문장 끝 표식으로 완료를 알고 잠금에 막힌 문장은 "막혀 있음" 으로 관측(start/run/expectBlocked/waitDone). 대상은 BARBELIC_DB_SANDBOX(ci:local 규칙) ·BARBELIC_TEST_DB_CONTAINER·BARBELIC_TEST_DB_URL. concurrencyBarrier.test.mjs: 서로 다른 백엔드, 자문 잠금 대기→커밋 뒤 해제, 실제 표(exercise_synonyms) 행 잠금 대기→커밋된 값 위에서 이어감(롤백으로 원상 복구).
Phase 5 — oracle·owner·브라우저 픽스처 (437c5cd2)
statsOracle.mjs + tests/fixtures/stats-oracle/(기대값마다 provenance 가 table:/hand:/production: 이어야 하고 app: 은 거부; 첫 사례 = 볼륨·세트 수·실측 1RM 손 계산 + 공개 정책 표 (90 %, 4.94회)·(85 %, 7.16회) 선형 보간 111.28 kg 이 정책 함수와 0.05 kg 안). ownerSwitch.mjs(세대 번호 있는 계정 전환, 늦은 응답 관측). browserClock.mjs(page.clock.setFixedTime 로 Date 만 고정, Asia/Seoul test.use, 서울 업무 날짜).
Phase 6 — 장부 (4ac4cad1)
docs/gates/test-baseline-inventory.md: 비결정성 분류 7종·처리, 제품 코드 주입 지점 7곳 인계표(S08·A07·A08·S02·D03), 소스 모양 검사 72 파일의 유지(보안·권한·import 경계 20여 파일)/전환(구현 세부 박제 50여 파일, 담당 이슈 매핑) 목록, native·API·provider· CSS·브라우저·DB 검사 자산표(G05 장부 입력), 도구별 검증 명령.
주요 결정과 그 근거
- Date 만 고정하고 타이머는 흐르게 — IDB·네트워크 대기는 실제 타이머라 함께 멈추면 멈춘 저장이 관측되지 않는다.
- ±400일 증명은 preload 자식 프로세스로 — 부모 suite 의 시계를 건드리지 않고, 고정 시계를 안 쓴 테스트가 실행 날짜에 기대는지 드러난다.
- 줄바꿈 게이트는 추적 파일로 한정 —
.env.local같은 로컬 파일은 정책 밖. - 두 연결은 psql 두 프로세스 — 의존성 추가 없이 CI 의 로컬 스택·ci:local 샌드박스 모두에서 같은 방식.
- oracle 기대값에 출처 강제 — "같은 답" 이 아니라 "맞는 답" 을 대조하기 위해.
작업 중 드러난 것
- git 은 파일 크기가 색인과 다르면 내용을 비교하지 않고 "수정됨" 으로 둔다. CRLF→LF 는 크기가 줄어 정규화 뒤에도 1,534개가
git status에 남았다(diff 는 비어 있음). 정규화 스크립트가 내용 같은 파일만git add --renormalize로 stat 을 다시 잡는다. - 랜딩 잠금 preflight 테스트는 자식 CLI 의 벽시계와 결합 — 부모만 시계를 옮기면 "방금 만든 기록" 이 400일 전 것이 된다. 만료 픽스처는 절대 과거로, 증명 대상에서는 제외(본성상 결합).
- Node 테스트 러너 안에서 손자 프로세스를 띄우면 물려받은
NODE_TEST_CONTEXT때문에 TAP 대신 러너 내부 형식이 나온다 — 지운다.--import는 Windows 절대 경로를 못 받는다(file URL).node --test <디렉터리>/는 안 된다(파일 경로). - Bash 히어독으로 JS 를 넘기면
\0·\\가 망가진다(기존 메모리 함정 재확인) — 스크립트 파일로. sed로 U+FFFD 문자를 바꾸다 검사기 소스가"FFFD"로 망가져 검사기가 자기 자신을 잡았다 — 리터럴 대신\uFFFD이스케이프.
5. 적용 결과
| 항목 | 전 → 후 |
|---|---|
| main verify 레인 날짜 의존 실패 | 3건(9/5~) → 0 (#1278) · ±400일 자식 프로세스 12/12 (이 PR 이 증명 고정) |
| 실제 시계가 기대값에 닿는 단위 테스트 | 미분류 → 분류 7종, 미해소 0 (preflight 는 본성상 결합으로 문서화) |
| 줄바꿈 정책 위치 | 한 PC 의 .git/info/attributes → 저장소 .gitattributes + check-utf8 게이트(추적 텍스트 1,453파일 검사) |
| 이 PC 작업 폴더 CRLF 파일 / 색인 CRLF 파일 | 1,536 / 1 → 0 / 0 |
| 저장 실패 주입 도구 | 0 → 4종 + 답장 유실, 경계 기준선 7건 |
| 두 DB 연결 도구 | 0 → 1, 샌드박스(supabase_db_cile13437d6, main 57fd2bb4 마이그레이션 전체 적용) 3/3 통과 — CI 레인 미연결(skip 집계) |
| 통계 기대값 출처 | 앱 출력 박제 → 출처 강제 코퍼스(첫 사례 1) |
| 소스 모양 검사 | 72 파일 미분류 → 유지/전환 목록 + 담당 매핑 |
| 로컬 게이트 | npm run check 2,626 tests · pass 2,623 · fail 0 · skipped 3(tests/db 대상 없음) · ci:local verify-only · verify 통과(5단계) · 1분 5초 |
| CI | verify 레인 1회 — main 보호 규칙의 필수 체크(verify·landing-queue)라 머지 자체가 초록 증거다(PR #1310 체크 탭) (변경 파일이 full-ci 경로에 없어 migration-smoke·browser·viewport 는 실행되지 않음 — 성공으로 합산하지 않는다) |
미검증: Playwright 시각 픽스처는 가짜 page.clock 으로 계약만 잠갔다(실 브라우저 적용은 e2e/** 담당). tests/db 는 로컬 샌드박스에서만 실행됐다.
6. 이번 개선으로 향상된 것
날짜가 지나도 테스트가 저절로 깨지지 않는다
고정 시계 도구와 ±400일 증명이 있어, 다음에 시계 의존 테스트를 쓰면 같은 방식으로 잠그고 증명 대상에 한 줄 추가하면 된다.
어느 PC 에서 받아도 같은 바이트다
.gitattributes 가 저장소에 있고 게이트가 지킨다. 옛 체크아웃은 명령 하나로 돌아온다.
저장·DB·통계·계정 전환의 "실제 행동" 을 실험할 수 있다
S02/S04/S06(저장 실패), D02/D13(두 연결), D01(oracle), A07/A08(owner) 이 도구를 그대로 가져다 쓴다.
구조적으로 남는 것
줄바꿈 게이트, tests/support 도구 층, tests/db 도구, oracle 코퍼스 형식, 소스 모양 검사 전환 장부.
남은 것
tests/db를 CI 의 로컬 스택 잡에 연결(.github/**= HQ 통합 슬롯) — 지금은 verify 잡에서 skip 으로 집계.- 제품 코드 주입 지점(장부 §1 표) — S08(
withIdbTimeout·자동저장 타이머), A07(appErrorReporter스케줄러·ID), S02/D03(randomUUID공장), A08(매퍼getFullYear기본 인자). - 소스 모양 검사 전환은 코드를 옮기는 담당 이슈의 PR 에서(장부 §2-2),
pending-changes.json신고 동반. - Playwright
page.clock·timezoneId적용(e2e/**),waitForTimeout32곳의 상태 대기 전환(UI 담당). - 총괄 문서 §12 G02 상태 칸·§7 장부 갱신은 HQ(세션
4c81b0f9)가 통합한다.