Merge branch test/conditional-integration-runner-20260731

This commit is contained in:
2026-07-31 08:30:44 +00:00
6 changed files with 538 additions and 71 deletions
@@ -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');
@@ -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,20 @@ const restoreEnv = (): void => {
}
};
const deleteProfileRedisKeys = async (): Promise<void> => {
if (!redis) {
return;
}
for await (const keys of redis.client.scanIterator({
MATCH: `sammo:game:*:${profileName}:*`,
COUNT: 100,
})) {
if (keys.length > 0) {
await redis.client.del(keys);
}
}
};
integration('nation HTML purification over HTTP transport', () => {
beforeAll(async () => {
uploadDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-nation-html-http-'));
@@ -150,6 +165,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 });
@@ -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,20 @@ const restoreEnv = (): void => {
}
};
const deleteProfileRedisKeys = async (): Promise<void> => {
if (!redis) {
return;
}
for await (const keys of redis.client.scanIterator({
MATCH: `sammo:game:*:${profileName}:*`,
COUNT: 100,
})) {
if (keys.length > 0) {
await redis.client.del(keys);
}
}
};
const buildPayload = (suffix: string, sanctions: GameSessionTokenPayload['sanctions']): GameSessionTokenPayload => ({
version: 1,
profile: profileName,
@@ -144,6 +159,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 });
+33 -6
View File
@@ -15,8 +15,21 @@ 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`을 실행하지 않습니다.
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을 실행할 수 없으므로 자동 정리를 보장하지 않습니다.
## 준비
@@ -61,14 +74,24 @@ 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가 테스트 실행 전에 실패합니다.
Registry는 marker 존재 여부뿐 아니라 중복, 형식과 지원 execution mode도
검사합니다. 지원하지 않는 mode로 인해 test가 실행 group에서 빠지는 경우에도
runner는 test 시작 전에 실패합니다. 지원 mode에 marker가 하나도 없거나
실행 group의 marker 정규식이 비어도 전체 파일로 선택 범위를 넓히지 않고
실패합니다.
관리자 시간 조정의 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 +108,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)을 함께 사용합니다.
@@ -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
1 # Environment variable Execution mode
2 CREATE_GENERAL_DATABASE_URL create_general
3 GATEWAY_RUNTIME_ACTION_DATABASE_URL gateway_runtime
4 GENERAL_LIFECYCLE_DATABASE_URL core
5 IMMEDIATE_ACTION_DATABASE_URL immediate_action
6 INPUT_EVENT_DATABASE_URL core
7 LIVE_SORTIE_PERSISTENCE_DATABASE_URL reference_live_sortie
8 NPC_POSSESSION_DATABASE_URL npc_possession
9 NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL reference_npc_possession
10 RESERVED_TURN_DATABASE_URL core
11 SELECT_POOL_DATABASE_URL select_pool
12 TURN_DAEMON_LEASE_DATABASE_URL core
13 TURN_DIFFERENTIAL_DATABASE_URL core
+456 -62
View File
@@ -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,60 @@ 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
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}
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"
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 '
const required = [
@@ -72,29 +117,347 @@ build_database_url() {
'
}
base_database_url=$(build_database_url public)
create_owned_schema() {
schema=$1
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("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();
}
'
}
drop_owned_schemas() {
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`);
}
} finally {
await client.end();
}
' >/dev/null
}
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}:*`,
`sammo:che:battle-sim-e2e-${runId}-*:battle-sim:*`,
];
await client.connect();
try {
for (const pattern of patterns) {
for await (const keys of client.scanIterator({ MATCH: pattern, COUNT: 100 })) {
if (keys.length > 0) {
await client.del(keys);
}
}
}
} finally {
await client.quit();
}
' >/dev/null
}
terminate_active_process_group() {
[ -n "$active_process_group" ] || return 0
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=
}
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 [ "$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"
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"
registry_errors_file="$report_dir/registry-errors.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"
: >"$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;
}
($2 in allowed_modes) {
mode_counts[$2]++;
}
seen[$1]++ {
printf "line %d duplicates marker: %s\n", NR, $1;
failed = 1;
}
{
print $1 "\t" $2 > validated_registry_file;
}
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
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
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 }' "$validated_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)
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
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"
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
cleanup_resources_started=1
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 +469,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 +574,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"