From 2378bfee921a48174db169ee9dd9bdcde71ea43f Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 07:59:48 +0000 Subject: [PATCH 1/4] test: isolate conditional integration runner --- .../nationHtmlTransport.integration.test.ts | 16 +- .../securityTransport.integration.test.ts | 16 +- docs/integration-tests.md | 27 +- tools/conditional-integration-registry.tsv | 13 + tools/run-conditional-integration.sh | 417 +++++++++++++++--- 5 files changed, 419 insertions(+), 70 deletions(-) create mode 100644 tools/conditional-integration-registry.tsv diff --git a/app/game-api/test/nationHtmlTransport.integration.test.ts b/app/game-api/test/nationHtmlTransport.integration.test.ts index 4f24067..c124fc5 100644 --- a/app/game-api/test/nationHtmlTransport.integration.test.ts +++ b/app/game-api/test/nationHtmlTransport.integration.test.ts @@ -19,7 +19,8 @@ import { createGameApiServer } from '../src/server.js'; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const integration = describe.skipIf(!databaseUrl || !process.env.REDIS_URL); const profileId = process.env.POSTGRES_SCHEMA ?? 'conditional_integration'; -const profileName = `che:nation-html-${process.pid}`; +const runId = process.env.CONDITIONAL_INTEGRATION_RUN_ID ?? String(process.pid); +const profileName = `che:nation-html-${runId}`; const userId = `nation-html-user-${process.pid}`; const fixtureId = 900_000 + (process.pid % 50_000); const secret = 'nation-html-http-secret'; @@ -57,6 +58,18 @@ const restoreEnv = (): void => { } }; +const deleteProfileRedisKeys = async (): Promise => { + if (!redis) { + return; + } + for await (const key of redis.client.scanIterator({ + MATCH: `sammo:game:*:${profileName}:*`, + COUNT: 100, + })) { + await redis.client.del(key); + } +}; + integration('nation HTML purification over HTTP transport', () => { beforeAll(async () => { uploadDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-nation-html-http-')); @@ -150,6 +163,7 @@ integration('nation HTML purification over HTTP transport', () => { await db?.nation.deleteMany({ where: { id: fixtureId } }); await db?.worldState.deleteMany({ where: { id: fixtureId } }); await disconnectDb?.(); + await deleteProfileRedisKeys(); await redis?.disconnect(); if (uploadDir) { await fs.rm(uploadDir, { recursive: true, force: true }); diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index f368e66..0d5b873 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -19,7 +19,8 @@ import { createGameApiServer } from '../src/server.js'; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const integration = describe.skipIf(!databaseUrl || !process.env.REDIS_HOST || !process.env.REDIS_PORT); const profileId = process.env.POSTGRES_SCHEMA ?? 'conditional_integration'; -const profileName = `che:security-http-${process.pid}`; +const runId = process.env.CONDITIONAL_INTEGRATION_RUN_ID ?? String(process.pid); +const profileName = `che:security-http-${runId}`; const userId = `security-http-user-${process.pid}`; const generalId = 990_001; const secret = 'security-http-e2e-secret'; @@ -56,6 +57,18 @@ const restoreEnv = (): void => { } }; +const deleteProfileRedisKeys = async (): Promise => { + if (!redis) { + return; + } + for await (const key of redis.client.scanIterator({ + MATCH: `sammo:game:*:${profileName}:*`, + COUNT: 100, + })) { + await redis.client.del(key); + } +}; + const buildPayload = (suffix: string, sanctions: GameSessionTokenPayload['sanctions']): GameSessionTokenPayload => ({ version: 1, profile: profileName, @@ -144,6 +157,7 @@ integration('game API security over HTTP transport', () => { await server?.app.close(); await db?.general.deleteMany({ where: { id: generalId } }); await disconnectDb?.(); + await deleteProfileRedisKeys(); await redis?.disconnect(); if (uploadDir) { await fs.rm(uploadDir, { recursive: true, force: true }); diff --git a/docs/integration-tests.md b/docs/integration-tests.md index 31cc1a9..30bbec3 100644 --- a/docs/integration-tests.md +++ b/docs/integration-tests.md @@ -15,8 +15,14 @@ pnpm test:integration:conditional ``` 조건부 runner는 환경 변수 존재 여부만으로 안전성을 보장하지 않습니다. -대상 host, port, database, schema와 Redis prefix가 해당 worktree 전용인지 -확인해 주세요. +대상 host, port, database와 Redis prefix가 해당 worktree 전용인지 확인해 +주세요. Runner는 실행 ID가 포함된 game schema를 생성하고 성공, 테스트 실패, +`HUP`, `INT`, `TERM` 종료에서 자신이 생성한 schema만 삭제합니다. 사용자가 +schema 환경 변수를 지정한 경우에도 이미 존재하는 schema는 거부합니다. +HTTP transport fixture의 Redis key도 실행 ID를 profile namespace에 포함하고, +runner 종료 시 그 실행 ID에 속한 key만 삭제합니다. 공유 Redis 전체에 +`FLUSHDB`나 `FLUSHALL`을 실행하지 않습니다. +`SIGKILL`은 cleanup trap을 실행할 수 없으므로 자동 정리를 보장하지 않습니다. ## 준비 @@ -61,14 +67,19 @@ runtime role을 삭제하고 PID와 명령행 및 daemon 종료를 확인한 뒤 경매 timer race 실제 포함 suite는 `tools/run-conditional-integration.sh`, 각 package의 -`package.json`, `*.integration.test.ts`를 기준으로 확인합니다. +`package.json`, `*.integration.test.ts`를 기준으로 확인합니다. DB 조건부 +환경 변수는 `tools/conditional-integration-registry.tsv`에서 명시적으로 +관리합니다. 새 `*_DATABASE_URL` gate가 registry에 없거나 registry 항목이 +더 이상 테스트에 존재하지 않으면 runner가 테스트 실행 전에 실패합니다. 관리자 시간 조정의 PostgreSQL 경계는 `runtimeClockShiftPersistence.integration.test.ts`, gateway action `PARTIAL → APPLIED` 경계는 `gatewayRuntimeAction.integration.test.ts`입니다. -후자는 `GATEWAY_RUNTIME_ACTION_DATABASE_URL`, 전자는 -`INPUT_EVENT_DATABASE_URL`이 없으면 skip되므로 결과에서 실제 실행 여부를 -따로 확인해 주세요. +Runner는 후자에 `GATEWAY_RUNTIME_ACTION_DATABASE_URL`, 전자에 +`INPUT_EVENT_DATABASE_URL`을 같은 격리 schema URL로 주입합니다. 장수 생성, +선택 pool, 즉시 장수 action처럼 별도 marker를 사용하는 테스트도 registry를 +통해 포함합니다. DB와 Redis가 함께 필요한 파일은 DB group에서 한 번만 +실행하고 Redis-only 파일만 별도 group에서 실행합니다. ## 안전 경계 @@ -85,5 +96,9 @@ runtime role을 삭제하고 PID와 명령행 및 daemon 종료를 확인한 뒤 PostgreSQL·Redis 경계를 증명하지 않습니다. Full suite 실패는 변경 worktree와 변경 없는 `main`에서 각각 재현해 회귀와 baseline을 구분합니다. +조건부 runner는 마지막 줄에 통과, skip, 실패한 test file 수를 출력하며, +하나라도 file 전체가 skip되면 성공으로 처리하지 않습니다. 실행이 중단된 경우도 +이미 완료된 group의 집계와 schema cleanup 결과를 확인해 주세요. + Ref 호환성 판정은 [차등 검증](architecture/turn-state-differential-testing.md), UI는 [프론트엔드 호환 검증](frontend-legacy-parity.md)을 함께 사용합니다. diff --git a/tools/conditional-integration-registry.tsv b/tools/conditional-integration-registry.tsv new file mode 100644 index 0000000..579247e --- /dev/null +++ b/tools/conditional-integration-registry.tsv @@ -0,0 +1,13 @@ +# Environment variable Execution mode +CREATE_GENERAL_DATABASE_URL create_general +GATEWAY_RUNTIME_ACTION_DATABASE_URL gateway_runtime +GENERAL_LIFECYCLE_DATABASE_URL core +IMMEDIATE_ACTION_DATABASE_URL immediate_action +INPUT_EVENT_DATABASE_URL core +LIVE_SORTIE_PERSISTENCE_DATABASE_URL reference_live_sortie +NPC_POSSESSION_DATABASE_URL npc_possession +NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL reference_npc_possession +RESERVED_TURN_DATABASE_URL core +SELECT_POOL_DATABASE_URL select_pool +TURN_DAEMON_LEASE_DATABASE_URL core +TURN_DIFFERENTIAL_DATABASE_URL core diff --git a/tools/run-conditional-integration.sh b/tools/run-conditional-integration.sh index c10b766..54c5eb9 100755 --- a/tools/run-conditional-integration.sh +++ b/tools/run-conditional-integration.sh @@ -10,12 +10,17 @@ usage() { script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) workspace_root=$(CDPATH= cd -- "$script_dir/.." && pwd) +registry_file="$script_dir/conditional-integration-registry.tsv" env_file=${1:-"$workspace_root/.env.ci"} if [ ! -f "$env_file" ]; then echo "missing integration environment file: $env_file" >&2 exit 66 fi +if [ ! -f "$registry_file" ]; then + echo "missing integration marker registry: $registry_file" >&2 + exit 66 +fi env_file=$(CDPATH= cd -- "$(dirname -- "$env_file")" && pwd)/$(basename -- "$env_file") # .env.ci is generated by the sam_rebuild development Compose helper and is a @@ -32,20 +37,46 @@ set +a : "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required in $env_file}" : "${REDIS_URL:?REDIS_URL is required in $env_file}" -integration_schema=${CONDITIONAL_INTEGRATION_SCHEMA:-conditional_integration} -scenario_schema=${SCENARIO_SEED_INTEGRATION_SCHEMA:-conditional_scenario_seed} -npc_possession_schema=${NPC_POSSESSION_INTEGRATION_SCHEMA:-conditional_$(date +%s)_$$_npc_possession_integration} -npc_possession_differential_schema=conditional_$(date +%s)_$$_npc_possession_differential +node_tag=$(printf '%s' "${CI_NODE_INDEX:-local}" | tr -cd 'a-zA-Z0-9_' | tr 'A-Z' 'a-z' | cut -c1-8) +run_id=$(date -u +%m%d%H%M%S)_$$_${node_tag} +export CONDITIONAL_INTEGRATION_RUN_ID=$run_id +integration_schema=${CONDITIONAL_INTEGRATION_SCHEMA:-ci_${run_id}_integration} +scenario_schema=${SCENARIO_SEED_INTEGRATION_SCHEMA:-ci_${run_id}_scenario_seed} +npc_possession_schema=${NPC_POSSESSION_INTEGRATION_SCHEMA:-ci_${run_id}_npc_possession_integration} +create_general_schema=${CREATE_GENERAL_INTEGRATION_SCHEMA:-ci_${run_id}_create_general_integration} +select_pool_schema=${SELECT_POOL_INTEGRATION_SCHEMA:-ci_${run_id}_select_pool_integration} +immediate_action_schema=${IMMEDIATE_ACTION_INTEGRATION_SCHEMA:-ci_${run_id}_immediate_action_integration} +gateway_runtime_schema=${GATEWAY_RUNTIME_INTEGRATION_SCHEMA:-ci_${run_id}_gateway_runtime_integration} +npc_possession_differential_schema=${NPC_POSSESSION_DIFFERENTIAL_SCHEMA:-ci_${run_id}_npc_possession_differential} +live_sortie_schema=${LIVE_SORTIE_PERSISTENCE_SCHEMA:-ci_${run_id}_live_sortie_persistence} -for schema in "$integration_schema" "$scenario_schema" "$npc_possession_schema" "$npc_possession_differential_schema"; do +for schema in \ + "$integration_schema" \ + "$scenario_schema" \ + "$npc_possession_schema" \ + "$create_general_schema" \ + "$select_pool_schema" \ + "$immediate_action_schema" \ + "$gateway_runtime_schema" \ + "$npc_possession_differential_schema" \ + "$live_sortie_schema"; do case "$schema" in ''|[!a-z_]*|*[!a-z0-9_]*) echo "integration schema must be a lowercase PostgreSQL identifier: $schema" >&2 exit 64 ;; esac + if [ "${#schema}" -gt 63 ]; then + echo "integration schema exceeds PostgreSQL's 63-byte identifier limit: $schema" >&2 + exit 64 + fi done +report_dir=$(mktemp -d) +summary_file="$report_dir/summary.tsv" +owned_schemas= +active_process_group= + build_database_url() { SCHEMA_NAME=$1 node --input-type=module -e ' const required = [ @@ -72,29 +103,260 @@ build_database_url() { ' } +base_database_url=$(build_database_url public) + +create_owned_schema() { + schema=$1 + SCHEMA_NAME=$schema DATABASE_URL=$base_database_url \ + pnpm --filter @sammo-ts/infra exec node --input-type=module -e ' + import pg from "pg"; + const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + try { + await client.query(`CREATE SCHEMA "${process.env.SCHEMA_NAME}"`); + } finally { + await client.end(); + } + ' + owned_schemas="${owned_schemas}${schema} +" +} + +drop_owned_schemas() { + cleanup_failed=0 + for schema in $owned_schemas; do + if ! SCHEMA_NAME=$schema DATABASE_URL=$base_database_url \ + pnpm --filter @sammo-ts/infra exec node --input-type=module -e ' + import pg from "pg"; + const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + try { + await client.query(`DROP SCHEMA IF EXISTS "${process.env.SCHEMA_NAME}" CASCADE`); + } finally { + await client.end(); + } + ' >/dev/null; then + cleanup_failed=1 + fi + done + return "$cleanup_failed" +} + +delete_owned_redis_keys() { + CONDITIONAL_INTEGRATION_RUN_ID=$run_id REDIS_URL=$REDIS_URL \ + pnpm --filter @sammo-ts/infra exec node --input-type=module -e ' + import { createClient } from "redis"; + const client = createClient({ url: process.env.REDIS_URL }); + const runId = process.env.CONDITIONAL_INTEGRATION_RUN_ID; + const patterns = [ + `sammo:game:*:che:security-http-${runId}:*`, + `sammo:game:*:che:nation-html-${runId}:*`, + ]; + await client.connect(); + try { + for (const pattern of patterns) { + for await (const key of client.scanIterator({ MATCH: pattern, COUNT: 100 })) { + await client.del(key); + } + } + } finally { + await client.quit(); + } + ' >/dev/null +} + +terminate_active_process_group() { + [ -n "$active_process_group" ] || return 0 + kill -TERM "-$active_process_group" 2>/dev/null || true + wait "$active_process_group" 2>/dev/null || true + active_process_group= +} + +print_summary() { + if [ ! -s "$summary_file" ]; then + echo "conditional integration summary: 0 passed files, 0 skipped files, 0 failed files" + return + fi + awk -F '\t' ' + { + passed += $2; + skipped += $3; + failed += $4; + } + END { + printf "conditional integration summary: %d passed files, %d skipped files, %d failed files\n", + passed, skipped, failed; + } + ' "$summary_file" +} + +handle_exit() { + exit_status=$? + trap - EXIT HUP INT TERM + terminate_active_process_group + if ! drop_owned_schemas && [ "$exit_status" -eq 0 ]; then + exit_status=1 + fi + if ! delete_owned_redis_keys && [ "$exit_status" -eq 0 ]; then + exit_status=1 + fi + print_summary + rm -rf "$report_dir" + exit "$exit_status" +} + +trap handle_exit EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +validate_marker_registry() { + discovered_file="$report_dir/discovered-markers.txt" + registered_file="$report_dir/registered-markers.txt" + missing_file="$report_dir/unregistered-markers.txt" + stale_file="$report_dir/stale-markers.txt" + + ( + cd "$workspace_root" + rg -o --no-filename \ + 'process\.env\.[A-Z0-9_]+_DATABASE_URL' \ + app/game-api/test \ + app/game-engine/test \ + tools/integration-tests/test \ + -g '*.integration.test.ts' | + sed 's/process\.env\.//' | + sort -u + ) >"$discovered_file" + awk -F '\t' '!/^#/ && NF == 2 { print $1 }' "$registry_file" | sort -u >"$registered_file" + + comm -23 "$discovered_file" "$registered_file" >"$missing_file" + if [ -s "$missing_file" ]; then + echo "unregistered conditional database marker(s):" >&2 + sed 's/^/ /' "$missing_file" >&2 + exit 65 + fi + + comm -13 "$discovered_file" "$registered_file" >"$stale_file" + if [ -s "$stale_file" ]; then + echo "stale conditional database marker(s):" >&2 + sed 's/^/ /' "$stale_file" >&2 + exit 65 + fi +} + +markers_for_mode() { + mode=$1 + awk -F '\t' -v mode="$mode" '!/^#/ && $2 == mode { print $1 }' "$registry_file" | + paste -sd '|' - +} + +record_vitest_result() { + label=$1 + result_file=$2 + counts=$(node --input-type=module -e ' + import fs from "node:fs"; + const result = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const files = Array.isArray(result.testResults) ? result.testResults : []; + const isSkipped = ({ assertionResults = [], status }) => + status === "pending" || + (assertionResults.length > 0 && + assertionResults.every(({ status: assertionStatus }) => + assertionStatus === "pending" || assertionStatus === "todo" + )); + const skipped = files.filter(isSkipped).length; + const failed = files.filter(({ status }) => status === "failed").length; + const passed = files.length - skipped - failed; + process.stdout.write(`${passed}\t${skipped}\t${failed}`); + ' "$result_file") + printf '%s\t%s\n' "$label" "$counts" >>"$summary_file" + skipped=$(printf '%s' "$counts" | cut -f2) + if [ "$skipped" -ne 0 ]; then + echo "$label left $skipped integration test file(s) skipped" >&2 + return 1 + fi +} + +run_vitest() { + package_dir=$1 + label=$2 + shift 2 + result_file="$report_dir/$(printf '%s' "$label" | tr -cd 'a-zA-Z0-9_' | tr 'A-Z' 'a-z').json" + + echo "running $label tests in $package_dir" + ( + cd "$workspace_root/$package_dir" + exec setsid pnpm exec vitest run \ + --no-file-parallelism \ + --maxWorkers=1 \ + --reporter=default \ + --reporter=json \ + --outputFile.json="$result_file" \ + "$@" + ) & + active_process_group=$! + test_status=0 + wait "$active_process_group" || test_status=$? + active_process_group= + + result_status=0 + if [ -f "$result_file" ]; then + record_vitest_result "$label" "$result_file" || result_status=$? + else + printf '%s\t0\t0\t1\n' "$label" >>"$summary_file" + result_status=1 + fi + if [ "$test_status" -eq 0 ] && [ "$result_status" -ne 0 ]; then + test_status=$result_status + fi + return "$test_status" +} + run_marked_tests() { package_dir=$1 marker=$2 label=$3 - test_files=$(cd "$workspace_root/$package_dir" && rg -l "$marker" test -g '*.test.ts' | sort) + test_files=$(cd "$workspace_root/$package_dir" && rg -l "$marker" test -g '*.integration.test.ts' | sort) if [ -z "$test_files" ]; then echo "no $label tests found under $package_dir" >&2 exit 65 fi - echo "running $label tests in $package_dir" - ( - cd "$workspace_root/$package_dir" - printf '%s\n' "$test_files" | - xargs -r pnpm exec vitest run --no-file-parallelism --maxWorkers=1 - ) + # Integration test paths cannot contain whitespace in this repository. + # shellcheck disable=SC2086 + run_vitest "$package_dir" "$label" $test_files } +run_redis_only_tests() { + package_dir=app/game-api + database_marker=$1 + test_files=$( + cd "$workspace_root/$package_dir" + redis_files=$(rg -l 'process\.env\.REDIS_URL' test -g '*.integration.test.ts' | sort) + # Files already selected through a database marker run once in that + # database group, where Redis is also available. + # shellcheck disable=SC2086 + rg --files-without-match "$database_marker" $redis_files + ) + if [ -z "$test_files" ]; then + echo "no Redis-only integration tests found under $package_dir" >&2 + exit 65 + fi + # shellcheck disable=SC2086 + run_vitest "$package_dir" "game_api_redis" $test_files +} + +export PATH cd "$workspace_root" pnpm install --frozen-lockfile pnpm --filter @sammo-ts/infra prisma:generate pnpm --filter @sammo-ts/common build pnpm --filter @sammo-ts/infra build +validate_marker_registry + +create_owned_schema "$integration_schema" +create_owned_schema "$npc_possession_schema" +create_owned_schema "$scenario_schema" + database_url=$(build_database_url "$integration_schema") export POSTGRES_SCHEMA=$integration_schema export DATABASE_URL=$database_url @@ -106,24 +368,74 @@ export RESERVED_TURN_DATABASE_URL=$database_url pnpm --filter @sammo-ts/infra prisma:db:push:game -database_markers='INPUT_EVENT_DATABASE_URL|GENERAL_LIFECYCLE_DATABASE_URL|TURN_DAEMON_LEASE_DATABASE_URL|RESERVED_TURN_DATABASE_URL' -run_marked_tests app/game-api "$database_markers" "PostgreSQL" -run_marked_tests app/game-engine "$database_markers" "PostgreSQL" -run_marked_tests tools/integration-tests \ - 'TURN_DIFFERENTIAL_DATABASE_URL|INPUT_EVENT_DATABASE_URL' \ - "PostgreSQL snapshot" +core_database_markers=$(markers_for_mode core) +run_marked_tests app/game-api "$core_database_markers" "game_api_postgresql" +run_marked_tests app/game-engine "$core_database_markers" "game_engine_postgresql" +run_marked_tests tools/integration-tests "$core_database_markers" "snapshot_postgresql" + +create_owned_schema "$create_general_schema" +create_general_database_url=$(build_database_url "$create_general_schema") +( + export POSTGRES_SCHEMA=$create_general_schema + export DATABASE_URL=$create_general_database_url + pnpm --filter @sammo-ts/infra prisma:db:push:game +) +export CREATE_GENERAL_DATABASE_URL=$create_general_database_url +run_marked_tests app/game-api \ + "$(markers_for_mode create_general)" \ + "create_general_postgresql" + +create_owned_schema "$select_pool_schema" +select_pool_database_url=$(build_database_url "$select_pool_schema") +( + export POSTGRES_SCHEMA=$select_pool_schema + export DATABASE_URL=$select_pool_database_url + pnpm --filter @sammo-ts/infra prisma:db:push:game +) +export SELECT_POOL_DATABASE_URL=$select_pool_database_url +run_marked_tests app/game-api \ + "$(markers_for_mode select_pool)" \ + "select_pool_api_postgresql" +run_marked_tests app/game-engine \ + "$(markers_for_mode select_pool)" \ + "select_pool_engine_postgresql" + +create_owned_schema "$immediate_action_schema" +immediate_action_database_url=$(build_database_url "$immediate_action_schema") +( + export POSTGRES_SCHEMA=$immediate_action_schema + export DATABASE_URL=$immediate_action_database_url + pnpm --filter @sammo-ts/infra prisma:db:push:game +) +export IMMEDIATE_ACTION_DATABASE_URL=$immediate_action_database_url +run_marked_tests app/game-engine \ + "$(markers_for_mode immediate_action)" \ + "immediate_action_postgresql" + +create_owned_schema "$gateway_runtime_schema" +gateway_runtime_database_url=$(build_database_url "$gateway_runtime_schema") +( + export POSTGRES_SCHEMA=$gateway_runtime_schema + export DATABASE_URL=$gateway_runtime_database_url + export GATEWAY_DATABASE_URL=$gateway_runtime_database_url + pnpm --filter @sammo-ts/infra prisma:db:push:gateway +) +export GATEWAY_RUNTIME_ACTION_DATABASE_URL=$gateway_runtime_database_url +run_marked_tests app/game-engine \ + "$(markers_for_mode gateway_runtime)" \ + "gateway_runtime_postgresql" npc_possession_database_url=$(build_database_url "$npc_possession_schema") ( export POSTGRES_SCHEMA=$npc_possession_schema export DATABASE_URL=$npc_possession_database_url - export NPC_POSSESSION_DATABASE_URL=$npc_possession_database_url pnpm --filter @sammo-ts/infra prisma:migrate:deploy:game pnpm --filter @sammo-ts/infra prisma:migrate:deploy:game - run_marked_tests app/game-api \ - 'NPC_POSSESSION_DATABASE_URL' \ - "NPC possession PostgreSQL" ) +export NPC_POSSESSION_DATABASE_URL=$npc_possession_database_url +run_marked_tests app/game-api \ + "$(markers_for_mode npc_possession)" \ + "npc_possession_postgresql" if [ "${TURN_DIFFERENTIAL_REFERENCE:-}" = "1" ]; then reference_workspace_root=${TURN_DIFFERENTIAL_WORKSPACE_ROOT:-"$workspace_root/.."} @@ -161,64 +473,45 @@ if [ "${TURN_DIFFERENTIAL_REFERENCE:-}" = "1" ]; then echo "reference root/HWE databases are not bootstrapped" >&2 exit 69 fi + + create_owned_schema "$npc_possession_differential_schema" npc_possession_differential_database_url=$(build_database_url "$npc_possession_differential_schema") ( - cleanup_npc_possession_differential_schema() { - printf 'DROP SCHEMA IF EXISTS "%s" CASCADE;\n' "$npc_possession_differential_schema" | - DATABASE_URL=$npc_possession_differential_database_url \ - pnpm --filter @sammo-ts/infra exec prisma db execute --stdin >/dev/null - } - handle_npc_possession_differential_exit() { - exit_status=$? - trap - EXIT HUP INT TERM - if ! cleanup_npc_possession_differential_schema && [ "$exit_status" -eq 0 ]; then - exit_status=1 - fi - exit "$exit_status" - } - trap handle_npc_possession_differential_exit EXIT - trap 'exit 129' HUP - trap 'exit 130' INT - trap 'exit 143' TERM export POSTGRES_SCHEMA=$npc_possession_differential_schema export DATABASE_URL=$npc_possession_differential_database_url export NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL=$npc_possession_differential_database_url - export TURN_DIFFERENTIAL_WORKSPACE_ROOT=$reference_workspace_root - export TURN_DIFFERENTIAL_STACK_DIR=$reference_stack pnpm --filter @sammo-ts/infra prisma:db:push:game - cd "$workspace_root/tools/integration-tests" - pnpm exec vitest run --no-file-parallelism --maxWorkers=1 \ - test/npcPossessionSelectionReference.integration.test.ts ) + export NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL=$npc_possession_differential_database_url + export TURN_DIFFERENTIAL_WORKSPACE_ROOT=$reference_workspace_root + export TURN_DIFFERENTIAL_STACK_DIR=$reference_stack + run_marked_tests tools/integration-tests \ + "$(markers_for_mode reference_npc_possession)" \ + "npc_possession_reference" - live_sortie_schema=${LIVE_SORTIE_PERSISTENCE_SCHEMA:-conditional_live_sortie_persistence} - case "$live_sortie_schema" in - ''|[!a-z_]*|*[!a-z0-9_]*) - echo "live sortie persistence schema must be a lowercase PostgreSQL identifier" >&2 - exit 64 - ;; - esac + create_owned_schema "$live_sortie_schema" live_sortie_database_url=$(build_database_url "$live_sortie_schema") + ( + export POSTGRES_SCHEMA=$live_sortie_schema + export DATABASE_URL=$live_sortie_database_url + pnpm --filter @sammo-ts/infra prisma:db:push:game + ) export POSTGRES_SCHEMA=$live_sortie_schema export DATABASE_URL=$live_sortie_database_url export LIVE_SORTIE_PERSISTENCE_DATABASE_URL=$live_sortie_database_url - pnpm --filter @sammo-ts/infra prisma:db:push:game run_marked_tests tools/integration-tests \ - 'LIVE_SORTIE_PERSISTENCE_DATABASE_URL' \ - "live sortie PostgreSQL persistence" + "$(markers_for_mode reference_live_sortie)" \ + "live_sortie_postgresql" export POSTGRES_SCHEMA=$integration_schema export DATABASE_URL=$database_url fi -run_marked_tests app/game-api 'process\.env\.REDIS_URL' "Redis" +run_redis_only_tests "$core_database_markers" scenario_database_url=$(build_database_url "$scenario_schema") -( - export POSTGRES_SCHEMA=$scenario_schema - export DATABASE_URL=$scenario_database_url - pnpm --filter @sammo-ts/infra prisma:db:push:game - cd "$workspace_root/app/game-engine" - pnpm exec vitest run --no-file-parallelism --maxWorkers=1 test/scenarioSeeder.test.ts -) +export POSTGRES_SCHEMA=$scenario_schema +export DATABASE_URL=$scenario_database_url +pnpm --filter @sammo-ts/infra prisma:db:push:game +run_vitest app/game-engine "scenario_seed_postgresql" test/scenarioSeeder.test.ts echo "conditional PostgreSQL and Redis integration tests passed" From a568a8e74770aa0cf830b611f48f45830f044226 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 08:02:19 +0000 Subject: [PATCH 2/4] test: clean conditional Redis keys safely --- app/game-api/test/nationHtmlTransport.integration.test.ts | 6 ++++-- app/game-api/test/securityTransport.integration.test.ts | 6 ++++-- tools/run-conditional-integration.sh | 6 ++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/app/game-api/test/nationHtmlTransport.integration.test.ts b/app/game-api/test/nationHtmlTransport.integration.test.ts index c124fc5..0d83c39 100644 --- a/app/game-api/test/nationHtmlTransport.integration.test.ts +++ b/app/game-api/test/nationHtmlTransport.integration.test.ts @@ -62,11 +62,13 @@ const deleteProfileRedisKeys = async (): Promise => { if (!redis) { return; } - for await (const key of redis.client.scanIterator({ + for await (const keys of redis.client.scanIterator({ MATCH: `sammo:game:*:${profileName}:*`, COUNT: 100, })) { - await redis.client.del(key); + if (keys.length > 0) { + await redis.client.del(keys); + } } }; diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 0d5b873..112b1e5 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -61,11 +61,13 @@ const deleteProfileRedisKeys = async (): Promise => { if (!redis) { return; } - for await (const key of redis.client.scanIterator({ + for await (const keys of redis.client.scanIterator({ MATCH: `sammo:game:*:${profileName}:*`, COUNT: 100, })) { - await redis.client.del(key); + if (keys.length > 0) { + await redis.client.del(keys); + } } }; diff --git a/tools/run-conditional-integration.sh b/tools/run-conditional-integration.sh index 54c5eb9..6f53128 100755 --- a/tools/run-conditional-integration.sh +++ b/tools/run-conditional-integration.sh @@ -155,8 +155,10 @@ delete_owned_redis_keys() { await client.connect(); try { for (const pattern of patterns) { - for await (const key of client.scanIterator({ MATCH: pattern, COUNT: 100 })) { - await client.del(key); + for await (const keys of client.scanIterator({ MATCH: pattern, COUNT: 100 })) { + if (keys.length > 0) { + await client.del(keys); + } } } } finally { From 4359d8dadddd8d080bdde059d3f66a3dbcc5c90b Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 08:25:08 +0000 Subject: [PATCH 3/4] test: harden conditional cleanup boundaries --- .../test/battleSimWorker.integration.test.ts | 3 +- docs/integration-tests.md | 10 ++ tools/run-conditional-integration.sh | 148 ++++++++++++++---- 3 files changed, 128 insertions(+), 33 deletions(-) diff --git a/app/game-api/test/battleSimWorker.integration.test.ts b/app/game-api/test/battleSimWorker.integration.test.ts index 87469ba..0ab872a 100644 --- a/app/game-api/test/battleSimWorker.integration.test.ts +++ b/app/game-api/test/battleSimWorker.integration.test.ts @@ -20,7 +20,8 @@ afterEach(() => { liveDescribe('battle simulator worker with live Redis', () => { it('consumes an isolated queue, produces a result, and stops cleanly', { timeout: 30_000 }, async () => { - const scenario = `battle-sim-e2e-${randomUUID()}`; + const namespace = process.env.CONDITIONAL_INTEGRATION_RUN_ID ?? randomUUID(); + const scenario = `battle-sim-e2e-${namespace}-${randomUUID()}`; const profileName = `che:${scenario}`; const requesterUserId = 'worker-e2e-user'; vi.stubEnv('PROFILE', 'che'); diff --git a/docs/integration-tests.md b/docs/integration-tests.md index 30bbec3..f540d3a 100644 --- a/docs/integration-tests.md +++ b/docs/integration-tests.md @@ -22,6 +22,13 @@ schema 환경 변수를 지정한 경우에도 이미 존재하는 schema는 거 HTTP transport fixture의 Redis key도 실행 ID를 profile namespace에 포함하고, runner 종료 시 그 실행 ID에 속한 key만 삭제합니다. 공유 Redis 전체에 `FLUSHDB`나 `FLUSHALL`을 실행하지 않습니다. +Battle simulator fixture도 실행 ID가 포함된 queue/result/notify namespace를 +사용합니다. 실행 중단 시 테스트의 `finally`가 실행되지 않아도 runner가 같은 +실행 ID의 key만 찾아 삭제합니다. +Runner는 test process group에 종료 신호를 전달하고 기본 10초 안에 종료되지 +않으면 `SIGKILL`로 전환한 뒤 schema와 Redis cleanup을 계속합니다. 이 유예 +시간은 중단 경계 검증에서만 `CONDITIONAL_INTEGRATION_TERM_GRACE_SECONDS` +(1~60초)로 줄일 수 있습니다. `SIGKILL`은 cleanup trap을 실행할 수 없으므로 자동 정리를 보장하지 않습니다. ## 준비 @@ -71,6 +78,9 @@ runtime role을 삭제하고 PID와 명령행 및 daemon 종료를 확인한 뒤 환경 변수는 `tools/conditional-integration-registry.tsv`에서 명시적으로 관리합니다. 새 `*_DATABASE_URL` gate가 registry에 없거나 registry 항목이 더 이상 테스트에 존재하지 않으면 runner가 테스트 실행 전에 실패합니다. +Registry는 marker 존재 여부뿐 아니라 중복, 형식과 지원 execution mode도 +검사합니다. 지원하지 않는 mode로 인해 test가 실행 group에서 빠지는 경우에도 +runner는 test 시작 전에 실패합니다. 관리자 시간 조정의 PostgreSQL 경계는 `runtimeClockShiftPersistence.integration.test.ts`, gateway action diff --git a/tools/run-conditional-integration.sh b/tools/run-conditional-integration.sh index 6f53128..c3c639a 100755 --- a/tools/run-conditional-integration.sh +++ b/tools/run-conditional-integration.sh @@ -40,6 +40,19 @@ set +a node_tag=$(printf '%s' "${CI_NODE_INDEX:-local}" | tr -cd 'a-zA-Z0-9_' | tr 'A-Z' 'a-z' | cut -c1-8) run_id=$(date -u +%m%d%H%M%S)_$$_${node_tag} export CONDITIONAL_INTEGRATION_RUN_ID=$run_id +schema_ownership_token="sammo-conditional-integration:$run_id" +supported_registry_modes="core create_general gateway_runtime immediate_action npc_possession reference_live_sortie reference_npc_possession select_pool" +term_grace_seconds=${CONDITIONAL_INTEGRATION_TERM_GRACE_SECONDS:-10} +case "$term_grace_seconds" in + ''|*[!0-9]*) + echo "CONDITIONAL_INTEGRATION_TERM_GRACE_SECONDS must be an integer from 1 to 60" >&2 + exit 64 + ;; +esac +if [ "$term_grace_seconds" -lt 1 ] || [ "$term_grace_seconds" -gt 60 ]; then + echo "CONDITIONAL_INTEGRATION_TERM_GRACE_SECONDS must be an integer from 1 to 60" >&2 + exit 64 +fi integration_schema=${CONDITIONAL_INTEGRATION_SCHEMA:-ci_${run_id}_integration} scenario_schema=${SCENARIO_SEED_INTEGRATION_SCHEMA:-ci_${run_id}_scenario_seed} npc_possession_schema=${NPC_POSSESSION_INTEGRATION_SCHEMA:-ci_${run_id}_npc_possession_integration} @@ -74,8 +87,9 @@ done report_dir=$(mktemp -d) summary_file="$report_dir/summary.tsv" -owned_schemas= +validated_registry_file="$report_dir/validated-registry.tsv" active_process_group= +cleanup_resources_started=0 build_database_url() { SCHEMA_NAME=$1 node --input-type=module -e ' @@ -107,39 +121,56 @@ base_database_url=$(build_database_url public) create_owned_schema() { schema=$1 - SCHEMA_NAME=$schema DATABASE_URL=$base_database_url \ + SCHEMA_NAME=$schema SCHEMA_OWNERSHIP_TOKEN=$schema_ownership_token \ + DATABASE_URL=$base_database_url \ pnpm --filter @sammo-ts/infra exec node --input-type=module -e ' import pg from "pg"; const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`; + const apostrophe = String.fromCharCode(39); + const quoteLiteral = (value) => + `${apostrophe}${value.replaceAll(apostrophe, apostrophe.repeat(2))}${apostrophe}`; await client.connect(); try { - await client.query(`CREATE SCHEMA "${process.env.SCHEMA_NAME}"`); + await client.query("BEGIN"); + await client.query(`CREATE SCHEMA ${quoteIdentifier(process.env.SCHEMA_NAME)}`); + await client.query( + `COMMENT ON SCHEMA ${quoteIdentifier(process.env.SCHEMA_NAME)} IS ${quoteLiteral( + process.env.SCHEMA_OWNERSHIP_TOKEN + )}` + ); + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; } finally { await client.end(); } ' - owned_schemas="${owned_schemas}${schema} -" } drop_owned_schemas() { - cleanup_failed=0 - for schema in $owned_schemas; do - if ! SCHEMA_NAME=$schema DATABASE_URL=$base_database_url \ - pnpm --filter @sammo-ts/infra exec node --input-type=module -e ' - import pg from "pg"; - const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); - await client.connect(); - try { - await client.query(`DROP SCHEMA IF EXISTS "${process.env.SCHEMA_NAME}" CASCADE`); - } finally { - await client.end(); + SCHEMA_OWNERSHIP_TOKEN=$schema_ownership_token DATABASE_URL=$base_database_url \ + pnpm --filter @sammo-ts/infra exec node --input-type=module -e ' + import pg from "pg"; + const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`; + await client.connect(); + try { + const result = await client.query( + `SELECT nspname + FROM pg_namespace + WHERE obj_description(oid, $$pg_namespace$$) = $1 + ORDER BY nspname`, + [process.env.SCHEMA_OWNERSHIP_TOKEN] + ); + for (const { nspname } of result.rows) { + await client.query(`DROP SCHEMA ${quoteIdentifier(nspname)} CASCADE`); } - ' >/dev/null; then - cleanup_failed=1 - fi - done - return "$cleanup_failed" + } finally { + await client.end(); + } + ' >/dev/null } delete_owned_redis_keys() { @@ -151,6 +182,7 @@ delete_owned_redis_keys() { const patterns = [ `sammo:game:*:che:security-http-${runId}:*`, `sammo:game:*:che:nation-html-${runId}:*`, + `sammo:che:battle-sim-e2e-${runId}-*:battle-sim:*`, ]; await client.connect(); try { @@ -169,8 +201,19 @@ delete_owned_redis_keys() { terminate_active_process_group() { [ -n "$active_process_group" ] || return 0 - kill -TERM "-$active_process_group" 2>/dev/null || true - wait "$active_process_group" 2>/dev/null || true + process_group=$active_process_group + kill -TERM "-$process_group" 2>/dev/null || true + waited_seconds=0 + while kill -0 "-$process_group" 2>/dev/null && + [ "$waited_seconds" -lt "$term_grace_seconds" ]; do + sleep 1 + waited_seconds=$((waited_seconds + 1)) + done + if kill -0 "-$process_group" 2>/dev/null; then + echo "conditional integration process group did not stop after ${term_grace_seconds}s; sending SIGKILL" >&2 + kill -KILL "-$process_group" 2>/dev/null || true + fi + wait "$process_group" 2>/dev/null || true active_process_group= } @@ -196,11 +239,13 @@ handle_exit() { exit_status=$? trap - EXIT HUP INT TERM terminate_active_process_group - if ! drop_owned_schemas && [ "$exit_status" -eq 0 ]; then - exit_status=1 - fi - if ! delete_owned_redis_keys && [ "$exit_status" -eq 0 ]; then - exit_status=1 + if [ "$cleanup_resources_started" -eq 1 ]; then + if ! drop_owned_schemas && [ "$exit_status" -eq 0 ]; then + exit_status=1 + fi + if ! delete_owned_redis_keys && [ "$exit_status" -eq 0 ]; then + exit_status=1 + fi fi print_summary rm -rf "$report_dir" @@ -215,6 +260,7 @@ trap 'exit 143' TERM validate_marker_registry() { discovered_file="$report_dir/discovered-markers.txt" registered_file="$report_dir/registered-markers.txt" + registry_errors_file="$report_dir/registry-errors.txt" missing_file="$report_dir/unregistered-markers.txt" stale_file="$report_dir/stale-markers.txt" @@ -229,7 +275,44 @@ validate_marker_registry() { sed 's/process\.env\.//' | sort -u ) >"$discovered_file" - awk -F '\t' '!/^#/ && NF == 2 { print $1 }' "$registry_file" | sort -u >"$registered_file" + : >"$validated_registry_file" + awk -F '\t' -v supported_modes="$supported_registry_modes" \ + -v validated_registry_file="$validated_registry_file" ' + BEGIN { + split(supported_modes, mode_names, " "); + for (mode_index in mode_names) { + allowed_modes[mode_names[mode_index]] = 1; + } + } + /^#/ || /^[[:space:]]*$/ { next } + NF != 2 { + printf "line %d must contain exactly one tab-separated marker and mode\n", NR; + failed = 1; + next; + } + $1 !~ /^[A-Z][A-Z0-9_]*_DATABASE_URL$/ { + printf "line %d has invalid marker: %s\n", NR, $1; + failed = 1; + } + !($2 in allowed_modes) { + printf "line %d has unsupported execution mode: %s\n", NR, $2; + failed = 1; + } + seen[$1]++ { + printf "line %d duplicates marker: %s\n", NR, $1; + failed = 1; + } + { + print $1 "\t" $2 > validated_registry_file; + } + END { exit failed } + ' "$registry_file" >"$registry_errors_file" || { + echo "invalid conditional integration marker registry:" >&2 + sed 's/^/ /' "$registry_errors_file" >&2 + exit 65 + } + sort -u "$validated_registry_file" -o "$validated_registry_file" + cut -f1 "$validated_registry_file" >"$registered_file" comm -23 "$discovered_file" "$registered_file" >"$missing_file" if [ -s "$missing_file" ]; then @@ -248,7 +331,7 @@ validate_marker_registry() { markers_for_mode() { mode=$1 - awk -F '\t' -v mode="$mode" '!/^#/ && $2 == mode { print $1 }' "$registry_file" | + awk -F '\t' -v mode="$mode" '$2 == mode { print $1 }' "$validated_registry_file" | paste -sd '|' - } @@ -348,13 +431,14 @@ run_redis_only_tests() { export PATH cd "$workspace_root" +validate_marker_registry + pnpm install --frozen-lockfile pnpm --filter @sammo-ts/infra prisma:generate pnpm --filter @sammo-ts/common build pnpm --filter @sammo-ts/infra build -validate_marker_registry - +cleanup_resources_started=1 create_owned_schema "$integration_schema" create_owned_schema "$npc_possession_schema" create_owned_schema "$scenario_schema" From 3cf3adee7c55e2d055a1d625da0e3ac788a96eb0 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 08:30:00 +0000 Subject: [PATCH 4/4] test: reject empty conditional test modes --- docs/integration-tests.md | 4 +++- tools/run-conditional-integration.sh | 17 ++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/integration-tests.md b/docs/integration-tests.md index f540d3a..7fd88c7 100644 --- a/docs/integration-tests.md +++ b/docs/integration-tests.md @@ -80,7 +80,9 @@ runtime role을 삭제하고 PID와 명령행 및 daemon 종료를 확인한 뒤 더 이상 테스트에 존재하지 않으면 runner가 테스트 실행 전에 실패합니다. Registry는 marker 존재 여부뿐 아니라 중복, 형식과 지원 execution mode도 검사합니다. 지원하지 않는 mode로 인해 test가 실행 group에서 빠지는 경우에도 -runner는 test 시작 전에 실패합니다. +runner는 test 시작 전에 실패합니다. 지원 mode에 marker가 하나도 없거나 +실행 group의 marker 정규식이 비어도 전체 파일로 선택 범위를 넓히지 않고 +실패합니다. 관리자 시간 조정의 PostgreSQL 경계는 `runtimeClockShiftPersistence.integration.test.ts`, gateway action diff --git a/tools/run-conditional-integration.sh b/tools/run-conditional-integration.sh index c3c639a..d18d212 100755 --- a/tools/run-conditional-integration.sh +++ b/tools/run-conditional-integration.sh @@ -298,6 +298,9 @@ validate_marker_registry() { printf "line %d has unsupported execution mode: %s\n", NR, $2; failed = 1; } + ($2 in allowed_modes) { + mode_counts[$2]++; + } seen[$1]++ { printf "line %d duplicates marker: %s\n", NR, $1; failed = 1; @@ -305,7 +308,15 @@ validate_marker_registry() { { print $1 "\t" $2 > validated_registry_file; } - END { exit failed } + END { + for (mode_name in allowed_modes) { + if (!mode_counts[mode_name]) { + printf "supported execution mode has no marker: %s\n", mode_name; + failed = 1; + } + } + exit failed; + } ' "$registry_file" >"$registry_errors_file" || { echo "invalid conditional integration marker registry:" >&2 sed 's/^/ /' "$registry_errors_file" >&2 @@ -400,6 +411,10 @@ run_marked_tests() { package_dir=$1 marker=$2 label=$3 + if [ -z "$marker" ]; then + echo "no conditional database marker configured for $label" >&2 + exit 65 + fi test_files=$(cd "$workspace_root/$package_dir" && rg -l "$marker" test -g '*.integration.test.ts' | sort) if [ -z "$test_files" ]; then echo "no $label tests found under $package_dir" >&2