/** * `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);