From 2fbd956cb5920efef934515eb67d1224ea022f58 Mon Sep 17 00:00:00 2001 From: EJClaw Date: Thu, 8 Oct 2026 10:48:47 +0900 Subject: [PATCH] =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8:=20bun=20run=20test?= =?UTF-8?q?=EB=8A=94=20=EB=B0=94=EB=80=90=20=EB=B6=80=EB=B6=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A7=8C=20(=EC=A0=84=EC=B2=B4=EB=8A=94=20te?= =?UTF-8?q?st:all)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/test-changed.ts가 origin/main 이후 변경(없으면 마지막 커밋)을 보고 고른다. 게임 하나만 바뀌면 그 게임 테스트 + 모든 게임을 도는 파일에서 그 게임 항목만(-t), 문서·E2E·배포는 없음, 엔진·shared·루트 설정·모르는 파일은 전체. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- docs/10-testing.md | 9 +++- package.json | 3 +- scripts/test-changed.ts | 113 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 scripts/test-changed.ts diff --git a/README.md b/README.md index 857ff9a..539914d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ bun run --filter '@bg/web' dev # 웹 :5173 (API/WS는 :3000으로 프록 ## 확인 ```bash bun run typecheck -bun run test # 규칙·서버 통합·방 단위 테스트 (4개 병렬, 무작위 대국은 시드 1/10만, 1분 안쪽) +bun run test # 바뀐 부분 테스트만 (origin/main 이후 + 커밋 안 한 변경, 없으면 마지막 커밋 기준) + # 예: 체스만 고쳤으면 체스 테스트만, 문서만 고쳤으면 없음. -- --dry-run 으로 고른 것만 보기 +bun run test:all # 전체 테스트 (4개 병렬, 무작위 대국은 시드 1/10만, 1분 안쪽) bun run test:full # 배포 전 전체: 무작위 대국 시드 전부(1,000개 등), 순차 실행이라 10분쯤 (cd apps/web && bunx vite build) # 웹 빌드 bun e2e/omok.e2e.ts # 브라우저 두 개로 오목 한 판 (Chrome 필요) diff --git a/docs/10-testing.md b/docs/10-testing.md index eb98a70..289b5aa 100644 --- a/docs/10-testing.md +++ b/docs/10-testing.md @@ -3,13 +3,18 @@ ## 1. 원칙 - "될 것 같다"가 아니라 테스트 결과로 완료를 판단한다. - 큰 테스트(부하, 장애 주입, E2E)를 돌리기 전에는 **먼저 Gitea 저장소에 커밋·푸시**하고 진행한다(사용자 요구사항). 결과는 커밋 해시와 함께 기록. -- 모든 PR/커밋 전: `bun run typecheck && bun run lint && bun test`. +- 모든 PR/커밋 전: `bun run typecheck && bun run test`. `bun run test`는 바뀐 부분 테스트만 돈다(`scripts/test-changed.ts`). + - 바뀐 파일 = `origin/main` 이후 커밋 + 커밋 안 한 변경(없으면 마지막 커밋). `TEST_BASE=`로 기준 변경. + - 게임 하나(`packages/games/src/`, `apps/web/src/games/`) → 그 게임 테스트 + 모든 게임을 도는 테스트 파일에서 그 게임 항목만(`-t `). + - `bots/` → 봇 테스트 전부, `apps/server/src` → 서버 테스트 전부, 게임 화면 공통(`components`·`lib`·`net`) → 웹 테스트 전부, 그 밖의 화면(홈 등) → 웹 공통 테스트. + - 문서·E2E·배포·스크립트 → 없음. 엔진·shared·게임 목록·루트 설정·그 밖의 파일 → 전체. + - 전체가 필요하면 `bun run test:all`(배포 전, 큰 변경), 무작위 대국 시드까지 전부면 `bun run test:full`. ## 2. 테스트 종류 | 종류 | 도구 | 대상 | 언제 | |---|---|---|---| | 규칙 단위 | `bun test` | `packages/games/*` | 매 커밋 | -| 무작위 대국/불변식 | `bun test` + `engine/testing.ts` | 모든 게임. 평소 `bun run test`는 `playoutSeeds()`로 시드 1/10(예: 1,000 → 100)만, `bun run test:full`(`FULL_TESTS=1`)은 전부 | 매 커밋(전체는 큰 변경·배포 전) | +| 무작위 대국/불변식 | `bun test` + `engine/testing.ts` | 모든 게임. 평소 `bun run test`·`test:all`은 `playoutSeeds()`로 시드 1/10(예: 1,000 → 100)만, `bun run test:full`(`FULL_TESTS=1`)은 전부 | 매 커밋(전체는 큰 변경·배포 전) | | 정보 유출 | 같은 도구 | 숨김 정보 게임 | 매 커밋 | | 서버 통합 | `bun test` + 실제 WS 클라이언트 | 인증, 방 만들기/입장, 행동, 재접속, 강퇴, 한 판 더 | 매 커밋 | | 복구 | 통합 테스트 | 서버 인스턴스 종료 → 같은 DB로 재시작 → 상태 동일 | 매 커밋 | diff --git a/package.json b/package.json index b32d745..93a3305 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "dev": "bun run --filter '@bg/server' dev & bun run --filter '@bg/web' dev", "build": "bun run --filter '@bg/web' build", "typecheck": "tsc -p packages/engine && tsc -p packages/shared && tsc -p packages/games && tsc -p apps/server && tsc -p apps/web", - "test": "bun test --parallel=4 --timeout 60000", + "test": "bun scripts/test-changed.ts", + "test:all": "bun test --parallel=4 --timeout 60000", "test:full": "FULL_TESTS=1 bun test --timeout 60000" }, "devDependencies": { diff --git a/scripts/test-changed.ts b/scripts/test-changed.ts new file mode 100644 index 0000000..eada597 --- /dev/null +++ b/scripts/test-changed.ts @@ -0,0 +1,113 @@ +/** + * `bun run test`: run only the tests related to the files that changed. + * + * Changed files = everything since origin/main (commits not pushed yet + uncommitted + untracked). + * If that is empty (already pushed), the last commit is used. `TEST_BASE=` overrides the base. + * + * - one game (packages/games/src/, apps/web/src/games/) → that game's test files, plus that + * game's cases in the all-games test files (`-t `) + * - bots/ → every bot test · apps/server → server tests · web screens → web tests + * - docs, e2e, deploy, scripts → nothing + * - engine, shared, game registry, root config, anything unknown → every test (`bun run test:all`) + * + * `bun run test -- --dry-run` prints the choice without running. + */ +import { Glob } from 'bun'; + +const BUN_TEST = ['bun', 'test', '--parallel=4', '--timeout', '60000']; +/** Test files that loop over every game; for a game-only change they run just that game's cases. */ +const ALL_GAMES_TESTS = [ + 'packages/games/src/bots/bots.test.ts', + 'apps/web/src/games/help.test.ts', + 'apps/server/src/rooms/replay.test.ts', + 'apps/server/src/rooms/bots.room.test.ts', +]; +const IGNORED = [/\.md$/, /^docs\//, /^e2e\//, /^deploy\//, /^scripts\//, /^test-results\//, /^\.(gitignore|editorconfig|dockerignore)$/]; + +function git(...args: string[]): string[] | null { + const r = Bun.spawnSync(['git', ...args], { stderr: 'ignore' }); + if (r.exitCode !== 0) return null; + return r.stdout.toString().split('\n').filter(Boolean); +} + +function changedFiles(): { files: string[]; from: string } | null { + const base = process.env.TEST_BASE ?? (git('rev-parse', '--verify', '-q', 'origin/main') ? 'origin/main' : null); + if (base) { + const mb = git('merge-base', 'HEAD', base)?.[0]; + const diff = mb ? git('diff', '--name-only', '--no-renames', mb) : null; + const untracked = git('ls-files', '--others', '--exclude-standard'); + if (!diff || !untracked) return null; + const files = [...new Set([...diff, ...untracked])]; + if (files.length > 0 || process.env.TEST_BASE) return { files, from: `${base} 이후 + 커밋 안 한 변경` }; + } + const last = git('diff', '--name-only', '--no-renames', 'HEAD~1', 'HEAD'); + return last ? { files: last, from: '마지막 커밋' } : null; +} + +const allTests = [...new Glob('{apps,packages}/**/*.test.{ts,tsx}').scanSync('.')].filter((f) => !f.includes('node_modules')).sort(); +const gameIds = new Set([...new Glob('packages/games/src/*/index.ts').scanSync('.')].map((f) => f.split('/')[3]!).filter((id) => id !== 'bots')); + +type Plan = { all: true; why: string } | { all: false; files: Set; games: Set }; + +function plan(changed: string[]): Plan { + const files = new Set(); + const games = new Set(); + const add = (pred: (t: string) => boolean) => allTests.filter(pred).forEach((t) => files.add(t)); + for (const p of changed) { + if (IGNORED.some((re) => re.test(p))) continue; + const game = p.match(/^packages\/games\/src\/([^/]+)\//)?.[1] ?? p.match(/^apps\/web\/src\/games\/([^/]+)\//)?.[1] ?? p.match(/^apps\/server\/src\/rooms\/([^/.]+)\.room\.test\.ts$/)?.[1]; + if (game && gameIds.has(game)) { + games.add(game); + add((t) => t.startsWith(`packages/games/src/${game}/`) || t.startsWith(`apps/web/src/games/${game}/`) || t === `apps/server/src/rooms/${game}.room.test.ts`); + if (p.startsWith('apps/web/')) add((t) => t === 'apps/web/src/no-money-wording.test.ts'); + } else if (p.startsWith('packages/games/src/bots/')) { + add((t) => /^packages\/games\/src\/[^/]+\/bots?\.test\.ts$/.test(t) || t === 'apps/server/src/rooms/bots.room.test.ts' || t === 'apps/server/src/bots.test.ts'); + } else if (p.startsWith('apps/server/src/')) { + add((t) => t.startsWith('apps/server/')); + } else if (/^apps\/web\/src\/(components|lib|net)\//.test(p) || /^apps\/web\/src\/games\/[^/]+$/.test(p)) { + add((t) => t.startsWith('apps/web/')); // shared by every game screen + } else if (/^apps\/web\/(src\/|public\/|index\.html$|sw\.js$|vite\.config\.ts$)/.test(p)) { + add((t) => /^apps\/web\/src\/[^/]+\.test\.tsx?$/.test(t)); // other screens: home, pages, store, styles + } else { + return { all: true, why: p }; + } + } + return { all: false, files, games }; +} + +const shown = (cmd: string[]) => cmd.map((a) => (/^[\w./=-]+$/.test(a) ? a : `'${a}'`)).join(' '); + +function run(cmd: string[]): number { + console.log(`\n$ ${shown(cmd)}`); + return Bun.spawnSync(cmd, { stdio: ['inherit', 'inherit', 'inherit'] }).exitCode ?? 1; +} + +const dry = process.argv.includes('--dry-run'); +const changed = changedFiles(); +const p: Plan = changed ? plan(changed.files) : { all: true, why: 'git으로 바뀐 파일을 알 수 없음' }; +if (changed) console.log(`바뀐 파일 ${changed.files.length}개 (${changed.from})`); + +if (p.all) { + console.log(`전체 테스트: ${p.why}`); + process.exit(dry ? 0 : run(BUN_TEST)); +} + +const cross = ALL_GAMES_TESTS.filter((t) => !p.files.has(t) && allTests.includes(t)); +const runs: string[][] = []; +if (p.files.size > 0) runs.push([...BUN_TEST, ...[...p.files].map((f) => `./${f}`)]); +if (p.games.size > 0 && cross.length > 0) { + // Whole id only: "poker" must not pick "cockroach-poker" cases. + runs.push([...BUN_TEST, '-t', `(? `./${f}`)]); +} +if (runs.length === 0) { + console.log('관련 테스트 없음 (문서·E2E·배포 파일만 바뀜)'); + process.exit(0); +} +if (p.games.size > 0) console.log(`게임: ${[...p.games].join(', ')}`); +if (dry) { + for (const r of runs) console.log(`$ ${shown(r)}`); + process.exit(0); +} +let code = 0; +for (const r of runs) code = run(r) || code; +process.exit(code);