From ec183904598894d1996cca936bacafdf837ab0d9 Mon Sep 17 00:00:00 2001 From: Shang Chieh Tseng Date: Thu, 9 Apr 2026 13:42:02 +0800 Subject: [PATCH 1/2] feat: Add CI pipeline using test-framework-template Install YAML-driven test framework with 7 test cases across 3 suites: - Build: PHP lint (via Docker), Docker image build, composer validation - Integration: container startup, XML-RPC ping, API auth verification - E2E: full CRUD workflow (create project/suite/case, verify, teardown) Includes CI Docker Compose (PostgreSQL + TestLink), DB init script, and GitHub Actions workflows triggered on PRs and pushes. Closes #2 Co-Authored-By: Claude Opus 4.6 (1M context) --- .dockerignore | 2 + .github/workflows/test-pipeline.yml | 41 + .github/workflows/test-suite.yml | 128 ++ .gitignore | 2 + cicd/docker-compose.ci.yml | 47 + cicd/results/.gitignore | 2 + cicd/scripts/ci-config_db.inc.php | 9 + cicd/scripts/ci-custom_config.inc.php | 9 + cicd/scripts/ci-down.sh | 10 + cicd/scripts/ci-up.sh | 36 + cicd/scripts/format-results.sh | 55 + cicd/scripts/init-db.sh | 44 + cicd/tests/package-lock.json | 1410 +++++++++++++++++ cicd/tests/package.json | 40 + cicd/tests/src/cli.ts | 218 +++ cicd/tests/src/config.ts | 77 + cicd/tests/src/executor.ts | 357 +++++ cicd/tests/src/judge/index.ts | 2 + cicd/tests/src/judge/llm-judge.ts | 235 +++ cicd/tests/src/judge/simple-judge.ts | 80 + cicd/tests/src/loader.ts | 221 +++ cicd/tests/src/log-collector.ts | 313 ++++ cicd/tests/src/mcp-client.ts | 37 + cicd/tests/src/reporter/console.ts | 76 + cicd/tests/src/reporter/index.ts | 2 + cicd/tests/src/reporter/json.ts | 132 ++ cicd/tests/src/types.ts | 247 +++ cicd/tests/testcases/build/TC-BUILD-001.yml | 34 + cicd/tests/testcases/build/TC-BUILD-002.yml | 23 + cicd/tests/testcases/build/TC-BUILD-003.yml | 24 + cicd/tests/testcases/e2e/TC-E2E-001.yml | 78 + .../integration/TC-INTEGRATION-001.yml | 24 + .../integration/TC-INTEGRATION-002.yml | 21 + .../integration/TC-INTEGRATION-003.yml | 29 + cicd/tests/tsconfig.json | 20 + 35 files changed, 4085 insertions(+) create mode 100644 .github/workflows/test-pipeline.yml create mode 100644 .github/workflows/test-suite.yml create mode 100644 cicd/docker-compose.ci.yml create mode 100644 cicd/results/.gitignore create mode 100644 cicd/scripts/ci-config_db.inc.php create mode 100644 cicd/scripts/ci-custom_config.inc.php create mode 100755 cicd/scripts/ci-down.sh create mode 100755 cicd/scripts/ci-up.sh create mode 100755 cicd/scripts/format-results.sh create mode 100755 cicd/scripts/init-db.sh create mode 100644 cicd/tests/package-lock.json create mode 100644 cicd/tests/package.json create mode 100644 cicd/tests/src/cli.ts create mode 100644 cicd/tests/src/config.ts create mode 100644 cicd/tests/src/executor.ts create mode 100644 cicd/tests/src/judge/index.ts create mode 100644 cicd/tests/src/judge/llm-judge.ts create mode 100644 cicd/tests/src/judge/simple-judge.ts create mode 100644 cicd/tests/src/loader.ts create mode 100644 cicd/tests/src/log-collector.ts create mode 100644 cicd/tests/src/mcp-client.ts create mode 100644 cicd/tests/src/reporter/console.ts create mode 100644 cicd/tests/src/reporter/index.ts create mode 100644 cicd/tests/src/reporter/json.ts create mode 100644 cicd/tests/src/types.ts create mode 100644 cicd/tests/testcases/build/TC-BUILD-001.yml create mode 100644 cicd/tests/testcases/build/TC-BUILD-002.yml create mode 100644 cicd/tests/testcases/build/TC-BUILD-003.yml create mode 100644 cicd/tests/testcases/e2e/TC-E2E-001.yml create mode 100644 cicd/tests/testcases/integration/TC-INTEGRATION-001.yml create mode 100644 cicd/tests/testcases/integration/TC-INTEGRATION-002.yml create mode 100644 cicd/tests/testcases/integration/TC-INTEGRATION-003.yml create mode 100644 cicd/tests/tsconfig.json diff --git a/.dockerignore b/.dockerignore index d7ad356d73..6cd2b13ae3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,5 @@ .env.example docker-compose.yml Dockerfile +cicd/ +.github/ diff --git a/.github/workflows/test-pipeline.yml b/.github/workflows/test-pipeline.yml new file mode 100644 index 0000000000..0a5dc972a1 --- /dev/null +++ b/.github/workflows/test-pipeline.yml @@ -0,0 +1,41 @@ +name: CI Pipeline + +on: + push: + branches: ["testlink_1_9_20_fixed"] + pull_request: + branches: ["testlink_1_9_20_fixed"] + workflow_dispatch: + inputs: + judge_mode: + description: "Test judge mode" + required: false + default: "simple" + type: choice + options: + - "simple" + - "dual" + +jobs: + build: + name: Build Tests + uses: ./.github/workflows/test-suite.yml + with: + suite: build + judge_mode: ${{ inputs.judge_mode || 'simple' }} + + integration: + name: Integration Tests + needs: build + uses: ./.github/workflows/test-suite.yml + with: + suite: integration + judge_mode: ${{ inputs.judge_mode || 'simple' }} + + e2e: + name: E2E Tests + needs: integration + uses: ./.github/workflows/test-suite.yml + with: + suite: e2e + judge_mode: ${{ inputs.judge_mode || 'simple' }} diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml new file mode 100644 index 0000000000..20cbf861a8 --- /dev/null +++ b/.github/workflows/test-suite.yml @@ -0,0 +1,128 @@ +name: Test Suite + +on: + workflow_dispatch: + inputs: + suite: + description: "Test suite to run" + required: true + type: choice + options: + - "build" + - "integration" + - "e2e" + - "all" + judge_mode: + description: "Test judge mode" + required: false + default: "simple" + type: choice + options: + - "simple" + - "dual" + judge_model: + description: "LLM model for judging (if dual mode)" + required: false + default: "llama3:8b" + type: string + workflow_call: + inputs: + suite: + description: "Test suite to run" + required: true + type: string + judge_mode: + description: "Test judge mode (simple, dual)" + required: false + default: "simple" + type: string + judge_model: + description: "LLM model for judging" + required: false + default: "llama3:8b" + type: string + outputs: + result: + description: "Test result" + value: ${{ jobs.test.outputs.result }} + +jobs: + test: + name: Run ${{ inputs.suite }} Tests + runs-on: ubuntu-latest + outputs: + result: ${{ steps.run-tests.outcome }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install test runner dependencies + run: cd cicd/tests && npm ci + + - name: Start CI environment (integration/e2e) + if: inputs.suite == 'integration' || inputs.suite == 'e2e' || inputs.suite == 'all' + run: | + bash cicd/scripts/ci-up.sh + timeout-minutes: 5 + + - name: Run tests + id: run-tests + run: | + cd cicd/tests + + # Build judge flags based on input + JUDGE_FLAGS="" + if [ "${{ inputs.judge_mode }}" = "simple" ] || [ -z "${{ inputs.judge_mode }}" ]; then + JUDGE_FLAGS="--no-llm" + else + JUDGE_FLAGS="--judge-model ${{ inputs.judge_model || 'llama3:8b' }}" + fi + + # Build suite flag + SUITE_FLAG="" + if [ "${{ inputs.suite }}" != "all" ]; then + SUITE_FLAG="--suite ${{ inputs.suite }}" + fi + + echo "Suite: ${{ inputs.suite }}" + echo "Judge mode: ${{ inputs.judge_mode || 'simple' }}" + echo "Judge flags: $JUDGE_FLAGS" + + # Run tests with JSON output + npx tsx src/cli.ts run $SUITE_FLAG $JUDGE_FLAGS --format json > /tmp/test-results.json || true + + echo "--- JSON Results ---" + cat /tmp/test-results.json + + - name: Check test results + run: | + if [ ! -f /tmp/test-results.json ] || [ ! -s /tmp/test-results.json ]; then + echo "::error::No test results found" + exit 1 + fi + FAILED=$(jq '.summary.failed' /tmp/test-results.json) + echo "Failed tests: $FAILED" + if [ "$FAILED" -gt 0 ]; then + echo "::error::$FAILED test(s) failed" + exit 1 + fi + + - name: Tear down CI environment + if: always() && (inputs.suite == 'integration' || inputs.suite == 'e2e' || inputs.suite == 'all') + run: | + bash cicd/scripts/ci-down.sh || true + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: ${{ inputs.suite }}-test-results + path: | + /tmp/test-results.json + cicd/results/ diff --git a/.gitignore b/.gitignore index 36447cbe5f..bb42967687 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ config_db.inc.php **/._* upload_area/** .env +cicd/tests/node_modules/ +cicd/results/ diff --git a/cicd/docker-compose.ci.yml b/cicd/docker-compose.ci.yml new file mode 100644 index 0000000000..868ec583fa --- /dev/null +++ b/cicd/docker-compose.ci.yml @@ -0,0 +1,47 @@ +# Docker Compose for CI testing +# Spins up TestLink + PostgreSQL with auto-initialization +# +# Usage: +# docker compose -f cicd/docker-compose.ci.yml up -d +# docker compose -f cicd/docker-compose.ci.yml down -v + +networks: + testlink-ci: + name: testlink-ci + +services: + db: + image: postgres:9.6 + networks: + - testlink-ci + environment: + POSTGRES_USER: testlink + POSTGRES_PASSWORD: testlink + POSTGRES_DB: testlink + healthcheck: + test: ["CMD-SHELL", "pg_isready -U testlink"] + interval: 5s + timeout: 5s + retries: 10 + + app: + build: .. + networks: + - testlink-ci + depends_on: + db: + condition: service_healthy + ports: + - "8090:80" + volumes: + - ../logs:/var/testlink/logs:Z + - ../upload_area:/var/testlink/upload_area:Z + - ./scripts/ci-config_db.inc.php:/var/www/html/config_db.inc.php:ro + - ./scripts/ci-custom_config.inc.php:/var/www/html/custom_config.inc.php:ro + - ./scripts/init-db.sh:/var/www/html/cicd/scripts/init-db.sh:ro + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost/login.php || exit 1"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 30s diff --git a/cicd/results/.gitignore b/cicd/results/.gitignore new file mode 100644 index 0000000000..d6b7ef32c8 --- /dev/null +++ b/cicd/results/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/cicd/scripts/ci-config_db.inc.php b/cicd/scripts/ci-config_db.inc.php new file mode 100644 index 0000000000..28a709bda3 --- /dev/null +++ b/cicd/scripts/ci-config_db.inc.php @@ -0,0 +1,9 @@ +api->enabled = TRUE; + +// Disable email in CI +$g_smtp_host = ''; +$tlCfg->smtp_host = ''; diff --git a/cicd/scripts/ci-down.sh b/cicd/scripts/ci-down.sh new file mode 100755 index 0000000000..b2b0c380a1 --- /dev/null +++ b/cicd/scripts/ci-down.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Tear down CI environment +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +COMPOSE_FILE="$SCRIPT_DIR/../docker-compose.ci.yml" + +echo "=== Stopping TestLink CI Environment ===" +docker compose -f "$COMPOSE_FILE" down -v --remove-orphans +echo "=== CI Environment Stopped ===" diff --git a/cicd/scripts/ci-up.sh b/cicd/scripts/ci-up.sh new file mode 100755 index 0000000000..6fb7c0cf79 --- /dev/null +++ b/cicd/scripts/ci-up.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Start CI environment: build image, start services, init DB, verify health +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +COMPOSE_FILE="$SCRIPT_DIR/../docker-compose.ci.yml" + +echo "=== Starting TestLink CI Environment ===" + +echo "Building Docker image..." +docker compose -f "$COMPOSE_FILE" build + +echo "Starting services..." +docker compose -f "$COMPOSE_FILE" up -d + +echo "Waiting for app to be healthy..." +for i in $(seq 1 60); do + if curl -sf http://localhost:8090/login.php > /dev/null 2>&1; then + echo "App is responding." + break + fi + if [ "$i" -eq 60 ]; then + echo "Timeout waiting for app." + docker compose -f "$COMPOSE_FILE" logs app + exit 1 + fi + sleep 2 +done + +echo "Initializing database..." +docker compose -f "$COMPOSE_FILE" exec -T app bash /var/www/html/cicd/scripts/init-db.sh + +echo "=== CI Environment Ready ===" +echo "TestLink URL: http://localhost:8090" +echo "Admin user: admin / admin" +echo "API key: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" diff --git a/cicd/scripts/format-results.sh b/cicd/scripts/format-results.sh new file mode 100755 index 0000000000..35b1525f4b --- /dev/null +++ b/cicd/scripts/format-results.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# format-results.sh - Extract and format test results from JSON files +# Converts escaped \n in stdout/stderr to actual newlines for readability + +set -e + +usage() { + echo "Usage: $0 [output-file]" + echo "" + echo "Extracts test results from JSON and formats stdout/stderr with actual newlines." + echo "If output-file is not specified, prints to stdout." + echo "" + echo "Examples:" + echo " $0 build-results.json" + echo " $0 build-results.json build-results.txt" + exit 1 +} + +if [ -z "$1" ]; then + usage +fi + +if [ ! -f "$1" ]; then + echo "Error: File '$1' not found" + exit 1 +fi + +INPUT_FILE="$1" +OUTPUT_FILE="$2" + +# jq filter to format the results +JQ_FILTER=' +.results[] | +"================================================================================", +"TEST: \(.testId // .test_id) - \(.name)", +"Suite: \(.suite) | Pass: \(.pass) | Duration: \(.duration)ms", +"Reason: \(.reason // "N/A")", +"================================================================================", +(.steps // [] | .[] | + "", + "--- Step: \(.name) ---", + "Command: \(.command)", + "Exit Code: \(.exitCode) | Pass: \(.pass) | Duration: \(.duration)ms", + (if .stdout and .stdout != "" then "STDOUT:\n\(.stdout)" else empty end), + (if .stderr and .stderr != "" then "STDERR:\n\(.stderr)" else empty end) +), +"" +' + +if [ -n "$OUTPUT_FILE" ]; then + jq -r "$JQ_FILTER" "$INPUT_FILE" > "$OUTPUT_FILE" + echo "Formatted output written to: $OUTPUT_FILE" +else + jq -r "$JQ_FILTER" "$INPUT_FILE" +fi diff --git a/cicd/scripts/init-db.sh b/cicd/scripts/init-db.sh new file mode 100755 index 0000000000..f0a959163e --- /dev/null +++ b/cicd/scripts/init-db.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Initialize TestLink database schema for CI +# Runs inside the app container against the CI PostgreSQL instance +set -e + +DB_HOST="${DB_HOST:-db}" +DB_USER="${DB_USER:-testlink}" +DB_PASS="${DB_PASS:-testlink}" +DB_NAME="${DB_NAME:-testlink}" + +export PGPASSWORD="$DB_PASS" + +echo "Waiting for PostgreSQL..." +for i in $(seq 1 30); do + if pg_isready -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -q 2>/dev/null; then + echo "PostgreSQL is ready." + break + fi + sleep 1 +done + +SQL_DIR="/var/www/html/install/sql/postgres" + +echo "Creating tables..." +sed 's|/\*prefix\*/||g' "$SQL_DIR/testlink_create_tables.sql" | \ + psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -q + +echo "Creating UDFs..." +sed 's|/\*prefix\*/||g' "$SQL_DIR/testlink_create_udf0.sql" | \ + psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -q + +echo "Inserting default data..." +sed 's|/\*prefix\*/||g' "$SQL_DIR/testlink_create_default_data.sql" | \ + psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -q + +# Generate an API key for the admin user (user_id=1) +# The key is a known value so test cases can use it +API_KEY="a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" +echo "Setting admin API key..." +psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -q -c \ + "UPDATE users SET script_key='$API_KEY' WHERE id=1;" + +echo "Database initialized successfully." +echo "Admin API key: $API_KEY" diff --git a/cicd/tests/package-lock.json b/cicd/tests/package-lock.json new file mode 100644 index 0000000000..aa730f2683 --- /dev/null +++ b/cicd/tests/package-lock.json @@ -0,0 +1,1410 @@ +{ + "name": "test-framework", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "test-framework", + "version": "1.0.0", + "dependencies": { + "axios": "^1.7.2", + "chalk": "^5.3.0", + "commander": "^12.1.0", + "glob": "^10.3.10", + "js-yaml": "^4.1.0" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^20.14.0", + "tsx": "^4.16.0", + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/cicd/tests/package.json b/cicd/tests/package.json new file mode 100644 index 0000000000..f685a28ca7 --- /dev/null +++ b/cicd/tests/package.json @@ -0,0 +1,40 @@ +{ + "name": "test-framework", + "version": "1.0.0", + "description": "Dual-judge test framework with YAML-based test definitions", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "test": "tsx src/cli.ts run", + "test:build": "tsx src/cli.ts run --suite build", + "test:integration": "tsx src/cli.ts run --suite integration", + "test:e2e": "tsx src/cli.ts run --suite e2e", + "list": "tsx src/cli.ts list", + "dev": "tsx src/cli.ts" + }, + "dependencies": { + "axios": "^1.7.2", + "chalk": "^5.3.0", + "commander": "^12.1.0", + "glob": "^10.3.10", + "js-yaml": "^4.1.0" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^20.14.0", + "tsx": "^4.16.0", + "typescript": "^5.5.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/cicd/tests/src/cli.ts b/cicd/tests/src/cli.ts new file mode 100644 index 0000000000..b9a04c351d --- /dev/null +++ b/cicd/tests/src/cli.ts @@ -0,0 +1,218 @@ +#!/usr/bin/env node +/** + * CLI for the test framework. + * + * Usage: + * npx tsx src/cli.ts run [options] + * npx tsx src/cli.ts list [options] + */ + +import { Command } from 'commander'; +import path from 'path'; +import { mkdirSync, existsSync } from 'fs'; +import { TestLoader } from './loader.js'; +import { TestExecutor } from './executor.js'; +import { SimpleJudge, LLMJudge } from './judge/index.js'; +import { JsonReporter, ConsoleReporter } from './reporter/index.js'; +import { RunConfig, DEFAULT_CONFIG } from './types.js'; +import { CONFIG } from './config.js'; + +const program = new Command(); + +program + .name('test-runner') + .description('Dual-judge test framework with YAML-based test definitions') + .version('1.0.0'); + +/** + * Run command - execute tests + */ +program + .command('run') + .description('Run test cases') + .option('-s, --suite ', 'Run only tests from this suite') + .option('-i, --id ', 'Run only the test with this ID') + .option('--dry-run', 'Show what would run without executing', false) + .option('--no-llm', 'Skip LLM judging (simple judge only)') + .option('--judge-url ', 'Ollama URL for LLM judge', CONFIG.llm.defaultUrl) + .option('--judge-model ', 'Model to use for LLM judging', CONFIG.llm.defaultModel) + .option('-o, --output-dir ', 'Output directory for results') + .option('-f, --format ', 'Output format (console, json)', 'console') + .action(async (options) => { + const startTime = new Date(); + + // Resolve paths + const testsDir = path.dirname(new URL(import.meta.url).pathname); + const projectRoot = path.resolve(testsDir, '..', '..', '..'); + const testcasesDir = path.join(testsDir, '..', 'testcases'); + const dockerDir = path.join(projectRoot, 'docker'); + + // Generate output directory with timestamp + const timestamp = startTime.toISOString().replace(/[:.]/g, '-').substring(0, 19); + const suiteName = options.suite || 'all'; + const outputDir = options.outputDir || path.join(testsDir, '..', '..', 'results', `${timestamp}_${suiteName}`); + + if (!existsSync(outputDir)) { + mkdirSync(outputDir, { recursive: true }); + } + + const config: RunConfig = { + suite: options.suite, + testId: options.id, + dryRun: options.dryRun, + noLlm: !options.llm, + judgeUrl: options.judgeUrl, + judgeModel: options.judgeModel, + outputDir, + outputFormat: options.format as RunConfig['outputFormat'], + workingDir: projectRoot, + dockerComposePath: dockerDir, + }; + + process.stderr.write(`\n[CONFIG] Project root: ${projectRoot}\n`); + process.stderr.write(`[CONFIG] Docker compose: ${dockerDir}\n`); + process.stderr.write(`[CONFIG] Testcases: ${testcasesDir}\n`); + process.stderr.write(`[CONFIG] Output: ${outputDir}\n`); + process.stderr.write(`[CONFIG] LLM Judge: ${config.noLlm ? 'disabled' : config.judgeUrl}\n`); + + // Load test cases + const loader = new TestLoader(testcasesDir); + const allTestCases = await loader.loadAll(); + + if (allTestCases.length === 0) { + process.stderr.write('[ERROR] No test cases found\n'); + process.exit(1); + } + + // Apply user filters + let filteredTestCases = allTestCases; + + if (config.suite) { + filteredTestCases = filteredTestCases.filter((tc) => tc.suite === config.suite); + } + + if (config.testId) { + filteredTestCases = filteredTestCases.filter((tc) => tc.id === config.testId); + } + + if (filteredTestCases.length === 0) { + process.stderr.write('[ERROR] No matching test cases found\n'); + process.exit(1); + } + + // Resolve cross-suite dependencies + const { tests: resolvedTestCases, autoIncluded } = loader.resolveDependencies( + filteredTestCases, + allTestCases + ); + + if (autoIncluded.length > 0) { + process.stderr.write(`[INFO] Auto-included ${autoIncluded.length} dependency test(s): ${autoIncluded.join(', ')}\n`); + } + + // Sort by dependencies + const testCases = loader.sortByDependencies(resolvedTestCases); + + process.stderr.write(`[INFO] Found ${testCases.length} test(s) to run\n`); + + // Dry run + if (config.dryRun) { + process.stderr.write('\n[DRY RUN] Would execute:\n'); + for (const tc of testCases) { + process.stderr.write(` - ${tc.id}: ${tc.name} (${tc.suite})\n`); + for (const step of tc.steps) { + process.stderr.write(` Step: ${step.name}\n`); + } + } + process.exit(0); + } + + // Execute tests + const executor = new TestExecutor(config); + const results = await executor.executeAll(testCases); + + // Run judges + process.stderr.write('\n[JUDGE] Running simple judge...\n'); + const simpleJudge = new SimpleJudge(); + const simpleJudgments = simpleJudge.judgeAll(results); + + let llmJudgments = simpleJudgments.map((j) => ({ + ...j, + reason: config.noLlm ? 'LLM judge disabled' : j.reason, + })); + + if (!config.noLlm) { + process.stderr.write('[JUDGE] Running LLM judge...\n'); + const llmJudge = new LLMJudge(config.judgeUrl, config.judgeModel); + + const available = await llmJudge.isAvailable(); + if (available) { + llmJudgments = await llmJudge.judgeResults(results); + await llmJudge.unloadModel(); + } else { + process.stderr.write('[WARN] LLM judge not available, using simple judge results\n'); + } + } + + // Generate and output reports + const jsonReporter = new JsonReporter(outputDir); + const { summary, reports } = jsonReporter.generateReports( + results, + simpleJudgments, + llmJudgments, + startTime, + suiteName + ); + + jsonReporter.writeReports(summary, reports); + + if (config.outputFormat === 'console') { + const consoleReporter = new ConsoleReporter(); + consoleReporter.report(summary, reports); + } else if (config.outputFormat === 'json') { + jsonReporter.outputSummary(summary, reports); + } + + process.exit(summary.failed > 0 ? 1 : 0); + }); + +/** + * List command - show available tests + */ +program + .command('list') + .description('List available test cases') + .option('-s, --suite ', 'Filter by suite') + .action(async (options) => { + const testsDir = path.dirname(new URL(import.meta.url).pathname); + const testcasesDir = path.join(testsDir, '..', 'testcases'); + + const loader = new TestLoader(testcasesDir); + let testCases = await loader.loadAll(); + + if (options.suite) { + testCases = testCases.filter((tc) => tc.suite === options.suite); + } + + testCases = loader.sortByDependencies(testCases); + const groups = loader.groupBySuite(testCases); + + console.log('\nAvailable Test Cases:'); + console.log('='.repeat(60)); + + for (const [suite, cases] of groups) { + console.log(`\n${suite.toUpperCase()} SUITE (${cases.length} tests):`); + for (const tc of cases) { + console.log(` ${tc.id}: ${tc.name}`); + console.log(` Priority: ${tc.priority}, Timeout: ${tc.timeout}ms`); + if (tc.dependencies.length > 0) { + console.log(` Depends on: ${tc.dependencies.join(', ')}`); + } + } + } + + console.log('\n' + '='.repeat(60)); + console.log(`Total: ${testCases.length} test(s)`); + }); + +program.parse(); diff --git a/cicd/tests/src/config.ts b/cicd/tests/src/config.ts new file mode 100644 index 0000000000..771457eda2 --- /dev/null +++ b/cicd/tests/src/config.ts @@ -0,0 +1,77 @@ +/** + * Project configuration for the test framework. + * + * Customize this file for your project's needs. + */ + +/** + * Available test suites - extend this for your project. + */ +export const SUITES = ['build', 'integration', 'e2e'] as const; +export type Suite = typeof SUITES[number]; + +/** + * Project configuration. + */ +export const CONFIG = { + // Project identification + projectName: 'testlink-code', + + // Session file prefix for log collection + sessionPrefix: 'testlink-code-session', + + // Default timeouts (in milliseconds) + defaultTimeout: 120000, + defaultStepTimeout: 60000, + + // LLM Judge defaults + llm: { + defaultUrl: 'http://localhost:11434', + defaultModel: 'llama3:8b', + timeout: 300000, + stdoutLimit: 1000, + stderrLimit: 500, + logsLimit: 3000, + }, + + // Log collection settings + logs: { + cleanupAge: 24 * 60 * 60 * 1000, // 24 hours + maxBuffer: 50 * 1024 * 1024, // 50MB + }, + + // MCP client settings (for projects using mcp-client.ts) + mcp: { + serverCommand: 'node dist/mcpServer.js', // Override via MCP_SERVER_COMMAND env var + }, +}; + +/** + * Error patterns to detect in logs. + * The Simple Judge will fail tests if any of these patterns are found. + * + * Customize for your project's specific error indicators. + */ +export const ERROR_PATTERNS: RegExp[] = [ + /\berror\b/i, + /\bfailed\b/i, + /\bexception\b/i, + /\bpanic\b/i, + /segmentation fault/i, + /out of memory/i, + /OOM/, +]; + +/** + * Patterns that indicate a test should NOT be failed. + * Use these to exclude false positives from ERROR_PATTERNS. + */ +export const ERROR_EXCLUSIONS: RegExp[] = [ + /error.*handled/i, + /expected.*error/i, + /No syntax errors detected/i, + /error_reporting/i, + /error_handler/i, + /error\.class\.php/i, + /faultCode|faultString/i, // XML-RPC expected response fields +]; diff --git a/cicd/tests/src/executor.ts b/cicd/tests/src/executor.ts new file mode 100644 index 0000000000..9e6a7f0c3e --- /dev/null +++ b/cicd/tests/src/executor.ts @@ -0,0 +1,357 @@ +/** + * Test executor - orchestrates test execution with log collection + * and pattern matching. + */ + +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { TestCase, TestResult, StepResult, PatternMatch, RunConfig } from './types.js'; +import { LogCollector } from './log-collector.js'; +import { CONFIG } from './config.js'; + +const execAsync = promisify(exec); + +/** + * Strip ANSI escape codes from strings. + */ +function stripAnsi(str: string): string { + return str.replace( + /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b[a-zA-Z]/g, + '' + ); +} + +export class TestExecutor { + private config: RunConfig; + private logCollector: LogCollector | null = null; + private totalTests: number = 0; + private currentTest: number = 0; + private currentTestId: string | null = null; + private variables: Record = {}; + + constructor(config: RunConfig) { + this.config = config; + } + + private progress(msg: string): void { + process.stderr.write(msg + '\n'); + } + + private substituteVariables(command: string): string { + return command.replace(/\{\{(\w+)\}\}/g, (match, varName) => { + const value = this.variables[varName]; + if (value === undefined) { + this.progress(` [WARN] Variable {{${varName}}} not found`); + return match; + } + return value; + }); + } + + /** + * Resolve a dot-notation path with optional array find syntax. + * Supports: "field", "nested.field", "data[name=foo].id" + * Array find on field: arrayField[key=value] finds first element where element.key === value + * Array find on root: $[key=value].field finds in top-level array + */ + private resolvePath(obj: any, fieldPath: string): any { + const segments = fieldPath.match(/[^.]+/g) || []; + let current = obj; + + for (const segment of segments) { + if (current === undefined || current === null) return undefined; + + // Top-level array find: $[key=value] + const rootArrayMatch = segment.match(/^\$\[(\w+)=(.+)\]$/); + if (rootArrayMatch) { + const [, matchKey, matchValue] = rootArrayMatch; + if (!Array.isArray(current)) return undefined; + current = current.find((item: any) => String(item[matchKey]) === matchValue); + continue; + } + + // Named array find: fieldName[key=value] + const arrayMatch = segment.match(/^(\w+)\[(\w+)=(.+)\]$/); + if (arrayMatch) { + const [, arrayField, matchKey, matchValue] = arrayMatch; + const arr = current[arrayField]; + if (!Array.isArray(arr)) return undefined; + current = arr.find((item: any) => String(item[matchKey]) === matchValue); + } else { + current = current[segment]; + } + } + + return current; + } + + private captureVariables(step: TestCase['steps'][0], result: StepResult): void { + if (!step.capture || result.exitCode !== 0) return; + + try { + let parsed = JSON.parse(result.stdout); + + // Handle MCP double-encoded responses: content[0].text wrapping + const innerText = parsed?.content?.[0]?.text; + if (innerText) { + try { parsed = JSON.parse(innerText); } catch { /* use outer */ } + } + + for (const [varName, fieldPath] of Object.entries(step.capture)) { + const resolvedPath = this.substituteVariables(fieldPath); + const value = this.resolvePath(parsed, resolvedPath); + if (value !== undefined) { + this.variables[varName] = String(value); + this.progress(` Captured: ${varName} = ${String(value).substring(0, 60)}`); + } else { + this.progress(` [WARN] Capture field '${fieldPath}' not found in response`); + } + } + } catch (e) { + this.progress(` [WARN] Failed to capture variables: ${e}`); + } + } + + private async executeStep( + step: { name: string; command: string; timeout?: number }, + defaultTimeout: number + ): Promise { + const startTime = Date.now(); + let stdout = ''; + let stderr = ''; + let exitCode = 0; + let timedOut = false; + + const timeout = step.timeout || defaultTimeout; + + const env = { ...process.env }; + if (this.currentTestId) { + env.TEST_ID = this.currentTestId; + } + + try { + const result = await execAsync(step.command, { + cwd: this.config.workingDir, + timeout, + maxBuffer: CONFIG.logs.maxBuffer, + shell: '/bin/bash', + env, + }); + stdout = result.stdout; + stderr = result.stderr; + } catch (error: unknown) { + const err = error as { + stdout?: string; + stderr?: string; + message?: string; + code?: number; + killed?: boolean; + }; + stdout = err.stdout || ''; + stderr = err.stderr || err.message || 'Unknown error'; + exitCode = err.code || 1; + timedOut = err.killed === true; + } + + const duration = Date.now() - startTime; + + stdout = stripAnsi(stdout); + stderr = stripAnsi(stderr); + + if (timedOut) { + stderr = `[TIMEOUT] Command killed after ${timeout / 1000}s\n\n${stderr}`; + } + + return { + name: step.name, + command: step.command, + stdout, + stderr, + exitCode, + duration, + }; + } + + private checkPatterns( + result: StepResult, + expectPatterns?: string[], + rejectPatterns?: string[] + ): StepResult['patternMatches'] { + if (!expectPatterns && !rejectPatterns) { + return undefined; + } + + const combined = result.stdout + '\n' + result.stderr; + + const expected: PatternMatch[] = (expectPatterns || []).map((pattern) => ({ + pattern, + found: new RegExp(pattern, 'i').test(combined), + })); + + const rejected: PatternMatch[] = (rejectPatterns || []).map((pattern) => ({ + pattern, + found: new RegExp(pattern, 'i').test(combined), + })); + + return { expected, rejected }; + } + + async executeTestCase(testCase: TestCase): Promise { + const startTime = Date.now(); + const stepResults: StepResult[] = []; + const timestamp = new Date().toISOString().substring(11, 19); + + this.currentTest++; + this.currentTestId = testCase.id; + this.variables = {}; + this.progress( + `[${timestamp}] [${this.currentTest}/${this.totalTests}] ${testCase.id}: ${testCase.name}` + ); + + if (this.logCollector) { + this.logCollector.markTestStart(testCase.id); + } + + for (let i = 0; i < testCase.steps.length; i++) { + const step = testCase.steps[i]; + const stepTimestamp = new Date().toISOString().substring(11, 19); + + this.progress( + ` [${stepTimestamp}] Step ${i + 1}/${testCase.steps.length}: ${step.name}` + ); + + const resolvedCommand = this.substituteVariables(step.command); + const cmdPreview = + resolvedCommand.length > 80 + ? resolvedCommand.substring(0, 80) + '...' + : resolvedCommand; + this.progress(` Command: ${cmdPreview}`); + + const resolvedStep = { ...step, command: resolvedCommand }; + const result = await this.executeStep(resolvedStep, testCase.timeout); + + result.patternMatches = this.checkPatterns( + result, + step.expectPatterns, + step.rejectPatterns + ); + + this.captureVariables(step, result); + + stepResults.push(result); + + const status = result.exitCode === 0 ? '[PASS]' : '[FAIL]'; + const duration = `${(result.duration / 1000).toFixed(1)}s`; + this.progress(` ${status} Exit: ${result.exitCode} (${duration})`); + + if (result.patternMatches) { + const expectedMissing = result.patternMatches.expected.filter( + (p) => !p.found + ); + const rejectedFound = result.patternMatches.rejected.filter( + (p) => p.found + ); + if (expectedMissing.length > 0) { + this.progress( + ` Missing patterns: ${expectedMissing.map((p) => p.pattern).join(', ')}` + ); + } + if (rejectedFound.length > 0) { + this.progress( + ` Rejected patterns found: ${rejectedFound.map((p) => p.pattern).join(', ')}` + ); + } + } + + if (result.exitCode !== 0 && result.stderr) { + const errorPreview = result.stderr.split('\n')[0].substring(0, 100); + this.progress(` Error: ${errorPreview}`); + } + } + + const totalDuration = Date.now() - startTime; + + let logs = ''; + let logFile = ''; + if (this.logCollector) { + this.logCollector.markTestEnd(testCase.id); + logFile = this.logCollector.extractTestLogs(testCase.id); + logs = this.logCollector.getLogsForTest(testCase.id); + } + this.currentTestId = null; + + if (!logs) { + logs = stepResults + .map( + (r) => + `=== Step: ${r.name} === +Command: ${r.command} +Exit Code: ${r.exitCode} +Duration: ${r.duration}ms + +STDOUT: +${r.stdout || '(empty)'} + +STDERR: +${r.stderr || '(empty)'} +` + ) + .join('\n' + '='.repeat(50) + '\n'); + } + + return { + testCase, + steps: stepResults, + totalDuration, + logs, + logFile, + }; + } + + async executeAll(testCases: TestCase[]): Promise { + const results: TestResult[] = []; + + this.totalTests = testCases.length; + this.currentTest = 0; + + const startTimestamp = new Date().toISOString().substring(11, 19); + this.progress(`\n[${startTimestamp}] Starting ${this.totalTests} test(s)...`); + this.progress('-'.repeat(60)); + + // Determine if we need the log collector (for Docker-based tests) + const needsLogCollector = testCases.some( + (tc) => tc.suite === 'integration' || tc.suite === 'e2e' + ); + + if (needsLogCollector) { + this.logCollector = new LogCollector( + this.config.dockerComposePath, + this.config.outputDir + ); + try { + await this.logCollector.start(); + this.progress(`[LOG] Docker log collector started`); + } catch (err) { + this.progress(`[WARN] Failed to start log collector: ${err}`); + this.logCollector = null; + } + } + + for (const tc of testCases) { + const result = await this.executeTestCase(tc); + results.push(result); + } + + if (this.logCollector) { + await this.logCollector.stop(); + this.logCollector.copySessionToOutput(); + this.progress(`[LOG] Docker log collector stopped`); + } + + const endTimestamp = new Date().toISOString().substring(11, 19); + this.progress('-'.repeat(60)); + this.progress(`[${endTimestamp}] Execution complete: ${results.length} test(s)`); + + return results; + } +} diff --git a/cicd/tests/src/judge/index.ts b/cicd/tests/src/judge/index.ts new file mode 100644 index 0000000000..33ecc5f1d6 --- /dev/null +++ b/cicd/tests/src/judge/index.ts @@ -0,0 +1,2 @@ +export { SimpleJudge } from './simple-judge.js'; +export { LLMJudge } from './llm-judge.js'; diff --git a/cicd/tests/src/judge/llm-judge.ts b/cicd/tests/src/judge/llm-judge.ts new file mode 100644 index 0000000000..2bf84d8a93 --- /dev/null +++ b/cicd/tests/src/judge/llm-judge.ts @@ -0,0 +1,235 @@ +/** + * LLM Judge - Semantic analysis of test results using Ollama. + * + * Uses a language model to evaluate test execution logs against criteria. + * Catches silent failures that exit code checking misses. + * + * Judges one test at a time with structured JSON prompts and Ollama JSON mode + * for reliable parsing. Includes observability logging for debugging CI failures. + */ + +import axios from 'axios'; +import { TestResult, Judgment } from '../types.js'; +import { CONFIG } from '../config.js'; + +export class LLMJudge { + private ollamaUrl: string; + private model: string; + + constructor( + ollamaUrl: string = CONFIG.llm.defaultUrl, + model: string = CONFIG.llm.defaultModel + ) { + this.ollamaUrl = ollamaUrl; + this.model = model; + } + + async isAvailable(): Promise { + try { + const response = await axios.get(`${this.ollamaUrl}/api/tags`, { + timeout: 5000, + }); + return response.status === 200; + } catch { + return false; + } + } + + /** + * Truncate a string to a maximum length. + */ + private truncate(text: string, limit: number): string { + if (text.length <= limit) return text; + return text.substring(0, limit) + '... (truncated)'; + } + + /** + * Build structured JSON prompt for LLM evaluation of a single test. + */ + private buildPrompt(result: TestResult): string { + const r = result; + + const steps = r.steps.map((step, j) => { + const stepDef = r.testCase.steps[j]; + return { + name: step.name, + command: step.command.trim(), + exit_code: step.exitCode, + duration_ms: step.duration, + timeout_ms: stepDef?.timeout || r.testCase.timeout, + stdout: this.truncate(step.stdout, CONFIG.llm.stdoutLimit), + stderr: this.truncate(step.stderr, CONFIG.llm.stderrLimit), + }; + }); + + const promptData = { + role: `You are a test result evaluator for ${CONFIG.projectName}. Analyze the test execution data and determine if the test passed or failed.`, + rules: [ + 'Check step stdout for error responses (e.g. {"error":"..."} means FAIL)', + 'Errors with exit code 0 are still FAIL', + 'For AI-generated text, accept reasonable variations', + 'Long durations within timeout are acceptable', + 'Focus on semantic correctness, not formatting differences', + ], + test: { + id: r.testCase.id, + name: r.testCase.name, + suite: r.testCase.suite, + goal: r.testCase.goal || r.testCase.name, + criteria: r.testCase.criteria, + timeout_ms: r.testCase.timeout, + duration_ms: r.totalDuration, + }, + steps, + container_logs: this.truncate(r.logs, CONFIG.llm.logsLimit), + respond: { + format: 'Respond with a single JSON object', + fields: { + testId: r.testCase.id, + pass: 'true if test meets all criteria, false otherwise', + reason: 'Brief explanation of your verdict', + evidence: 'Required if pass is false — the exact stdout content or log line that caused failure', + }, + }, + }; + + const prompt = JSON.stringify(promptData, null, 2); + + // Log prompt stats + const totalStdout = r.steps.reduce((sum, s) => sum + s.stdout.length, 0); + const totalStderr = r.steps.reduce((sum, s) => sum + s.stderr.length, 0); + process.stderr.write(` [LLM] Prompt for ${r.testCase.id}: logs ${r.logs.length} chars, stdout ${totalStdout} chars, stderr ${totalStderr} chars\n`); + process.stderr.write(` [LLM] Prompt size: ${prompt.length} chars\n`); + + return prompt; + } + + /** + * Judge a single test result. + */ + private async judgeOne(result: TestResult): Promise { + const prompt = this.buildPrompt(result); + const testId = result.testCase.id; + + const response = await axios.post( + `${this.ollamaUrl}/api/generate`, + { + model: this.model, + prompt, + stream: false, + format: 'json', + options: { + temperature: 0.1, + num_predict: 1024, + }, + }, + { + timeout: CONFIG.llm.timeout, + } + ); + + const responseText = response.data.response; + const promptTokens = response.data.prompt_eval_count ?? '?'; + const responseTokens = response.data.eval_count ?? '?'; + process.stderr.write(` [LLM] Tokens for ${testId}: prompt=${promptTokens}, response=${responseTokens}\n`); + + // Handle empty response + if (!responseText) { + process.stderr.write(` [LLM] WARNING: Empty response for ${testId}\n`); + return { + testId, + pass: false, + reason: 'LLM returned empty response', + }; + } + + process.stderr.write(` [LLM] Raw response for ${testId} (${responseText.length} chars): ${responseText.substring(0, 500)}\n`); + + try { + const judgment = JSON.parse(responseText) as Judgment; + + // Validate testId matches + if (judgment.testId !== testId) { + process.stderr.write(` [LLM] WARNING: Response testId "${judgment.testId}" doesn't match expected "${testId}"\n`); + judgment.testId = testId; + } + + // Coerce string "true"/"false" to boolean (LLMs often return strings) + if (typeof judgment.pass === 'string') { + judgment.pass = (judgment.pass as unknown as string).toLowerCase() === 'true'; + } + + // Validate required fields + if (typeof judgment.pass !== 'boolean') { + process.stderr.write(` [LLM] WARNING: Response missing "pass" field for ${testId}\n`); + return { + testId, + pass: false, + reason: `LLM response missing "pass" field: ${responseText.substring(0, 200)}`, + }; + } + + if (!judgment.reason) { + judgment.reason = judgment.pass ? 'Passed (no reason provided)' : 'Failed (no reason provided)'; + } + + return judgment; + } catch { + process.stderr.write(` [LLM] WARNING: Failed to parse JSON for ${testId}\n`); + process.stderr.write(` [LLM] Full response: ${responseText}\n`); + return { + testId, + pass: false, + reason: `Failed to parse LLM response: ${responseText.substring(0, 200)}`, + }; + } + } + + /** + * Judge all test results, one at a time. + */ + async judgeResults(results: TestResult[]): Promise { + const allJudgments: Judgment[] = []; + + for (let i = 0; i < results.length; i++) { + const result = results[i]; + process.stderr.write( + ` [LLM] Judging ${i + 1}/${results.length}: ${result.testCase.id}...\n` + ); + + try { + const judgment = await this.judgeOne(result); + allJudgments.push(judgment); + process.stderr.write(` [LLM] ${result.testCase.id}: ${judgment.pass ? 'PASS' : 'FAIL'} — ${judgment.reason}\n`); + } catch (error) { + process.stderr.write(` [LLM] Failed to judge ${result.testCase.id}: ${error}\n`); + allJudgments.push({ + testId: result.testCase.id, + pass: false, + reason: 'LLM judgment failed: ' + String(error), + }); + } + } + + return allJudgments; + } + + async unloadModel(): Promise { + try { + process.stderr.write(` [LLM] Unloading judge model ${this.model}...\n`); + await axios.post( + `${this.ollamaUrl}/api/generate`, + { + model: this.model, + keep_alive: 0, + }, + { + timeout: 30000, + } + ); + process.stderr.write(` [LLM] Judge model unloaded.\n`); + } catch { + process.stderr.write(` [LLM] Warning: Failed to unload judge model\n`); + } + } +} diff --git a/cicd/tests/src/judge/simple-judge.ts b/cicd/tests/src/judge/simple-judge.ts new file mode 100644 index 0000000000..f5f532c99b --- /dev/null +++ b/cicd/tests/src/judge/simple-judge.ts @@ -0,0 +1,80 @@ +/** + * Simple Judge - Fast, deterministic verification based on: + * 1. Exit codes (all steps must return 0) + * 2. Pattern matching (expected patterns found, rejected patterns absent) + * 3. Error pattern detection in logs + */ + +import { TestResult, Judgment } from '../types.js'; +import { ERROR_PATTERNS, ERROR_EXCLUSIONS } from '../config.js'; + +export class SimpleJudge { + /** + * Judge a single test result. + */ + judge(result: TestResult): Judgment { + const reasons: string[] = []; + let pass = true; + + // Check 1: All steps exit code 0 + const failedSteps = result.steps.filter((s) => s.exitCode !== 0); + if (failedSteps.length > 0) { + pass = false; + reasons.push( + `${failedSteps.length} step(s) failed with non-zero exit code: ${failedSteps.map((s) => `${s.name}(${s.exitCode})`).join(', ')}` + ); + } + + // Check 2: Expected patterns found + for (const step of result.steps) { + if (step.patternMatches) { + const missing = step.patternMatches.expected.filter((p) => !p.found); + if (missing.length > 0) { + pass = false; + reasons.push( + `Step "${step.name}" missing expected patterns: ${missing.map((p) => p.pattern).join(', ')}` + ); + } + + // Check 3: Rejected patterns absent + const found = step.patternMatches.rejected.filter((p) => p.found); + if (found.length > 0) { + pass = false; + reasons.push( + `Step "${step.name}" found rejected patterns: ${found.map((p) => p.pattern).join(', ')}` + ); + } + } + } + + // Check 4: No error patterns in logs + const combinedLogs = result.logs + '\n' + result.steps.map((s) => s.stdout + s.stderr).join('\n'); + + for (const pattern of ERROR_PATTERNS) { + if (pattern.test(combinedLogs)) { + // Check if any exclusion pattern matches (false positive) + const isExcluded = ERROR_EXCLUSIONS.some((exc) => exc.test(combinedLogs)); + if (!isExcluded) { + pass = false; + reasons.push(`Error pattern detected: ${pattern.source}`); + break; + } + } + } + + return { + testId: result.testCase.id, + pass, + reason: pass + ? 'All steps passed with exit code 0, patterns matched, no errors' + : reasons.join('; '), + }; + } + + /** + * Judge multiple test results. + */ + judgeAll(results: TestResult[]): Judgment[] { + return results.map((r) => this.judge(r)); + } +} diff --git a/cicd/tests/src/loader.ts b/cicd/tests/src/loader.ts new file mode 100644 index 0000000000..6beadc967d --- /dev/null +++ b/cicd/tests/src/loader.ts @@ -0,0 +1,221 @@ +/** + * Test case loader - reads YAML test definitions and provides + * filtering, sorting, and grouping capabilities. + */ + +import { readFileSync } from 'fs'; +import { glob } from 'glob'; +import yaml from 'js-yaml'; +import path from 'path'; +import { TestCase, TestStep } from './types.js'; +import { SUITES, CONFIG } from './config.js'; + +/** + * Loads and manages test case definitions from YAML files. + */ +export class TestLoader { + private testcasesDir: string; + + constructor(testcasesDir: string) { + this.testcasesDir = testcasesDir; + } + + /** + * Load all test cases from the testcases directory. + */ + async loadAll(): Promise { + const pattern = path.join(this.testcasesDir, '**/*.yml'); + const files = await glob(pattern); + + const testCases: TestCase[] = []; + + for (const file of files) { + try { + const content = readFileSync(file, 'utf-8'); + const raw = yaml.load(content) as Record; + const testCase = this.validateAndNormalize(raw, file); + if (testCase) { + testCases.push(testCase); + } + } catch (error) { + console.error(`Failed to load ${file}:`, error); + } + } + + return testCases; + } + + /** + * Load test cases filtered by suite. + */ + async loadBySuite(suite: string): Promise { + const all = await this.loadAll(); + return all.filter((tc) => tc.suite === suite); + } + + /** + * Load a single test case by ID. + */ + async loadById(id: string): Promise { + const all = await this.loadAll(); + return all.find((tc) => tc.id === id); + } + + /** + * Sort test cases by dependencies using topological sort. + * Tests with lower priority numbers run first within dependency constraints. + */ + sortByDependencies(testCases: TestCase[]): TestCase[] { + const sorted: TestCase[] = []; + const visited = new Set(); + const idMap = new Map(testCases.map((tc) => [tc.id, tc])); + + const visit = (tc: TestCase) => { + if (visited.has(tc.id)) return; + visited.add(tc.id); + + // Visit dependencies first + for (const depId of tc.dependencies) { + const dep = idMap.get(depId); + if (dep) visit(dep); + } + + sorted.push(tc); + }; + + // Sort by priority first, then visit to apply dependency ordering + const byPriority = [...testCases].sort((a, b) => a.priority - b.priority); + for (const tc of byPriority) { + visit(tc); + } + + return sorted; + } + + /** + * Resolve all dependencies for a filtered set of test cases. + * Handles cross-suite dependencies by looking up from the full test set. + */ + resolveDependencies( + filteredTests: TestCase[], + allTests: TestCase[] + ): { tests: TestCase[]; autoIncluded: string[] } { + const filteredIds = new Set(filteredTests.map((tc) => tc.id)); + const allTestsMap = new Map(allTests.map((tc) => [tc.id, tc])); + const result = new Map(); + const autoIncluded: string[] = []; + + const collectWithDeps = (testId: string) => { + if (result.has(testId)) return; + + const test = allTestsMap.get(testId); + if (!test) { + process.stderr.write(`[WARN] Dependency ${testId} not found\n`); + return; + } + + for (const depId of test.dependencies) { + collectWithDeps(depId); + } + + result.set(testId, test); + + if (!filteredIds.has(testId)) { + autoIncluded.push(testId); + } + }; + + for (const test of filteredTests) { + collectWithDeps(test.id); + } + + return { tests: Array.from(result.values()), autoIncluded }; + } + + /** + * Group test cases by suite. + */ + groupBySuite(testCases: TestCase[]): Map { + const groups = new Map(); + + // Initialize groups in defined order + for (const suite of SUITES) { + groups.set(suite, []); + } + + for (const tc of testCases) { + const suite = tc.suite; + if (!groups.has(suite)) { + groups.set(suite, []); + } + groups.get(suite)!.push(tc); + } + + // Remove empty groups + for (const [suite, cases] of groups) { + if (cases.length === 0) { + groups.delete(suite); + } + } + + return groups; + } + + /** + * Validate and normalize a raw YAML object into a TestCase. + */ + private validateAndNormalize( + raw: Record, + filePath: string + ): TestCase | null { + if (!raw.id || typeof raw.id !== 'string') { + console.error(`${filePath}: missing or invalid 'id' field`); + return null; + } + if (!raw.name || typeof raw.name !== 'string') { + console.error(`${filePath}: missing or invalid 'name' field`); + return null; + } + if (!raw.suite || typeof raw.suite !== 'string') { + console.error(`${filePath}: missing or invalid 'suite' field`); + return null; + } + if (!raw.steps || !Array.isArray(raw.steps) || raw.steps.length === 0) { + console.error(`${filePath}: missing or empty 'steps' array`); + return null; + } + + const steps: TestStep[] = []; + for (const step of raw.steps) { + if (!step.name || typeof step.name !== 'string') { + console.error(`${filePath}: step missing 'name' field`); + return null; + } + if (!step.command || typeof step.command !== 'string') { + console.error(`${filePath}: step '${step.name}' missing 'command' field`); + return null; + } + + steps.push({ + name: step.name, + command: step.command, + timeout: typeof step.timeout === 'number' ? step.timeout : undefined, + expectPatterns: Array.isArray(step.expectPatterns) ? step.expectPatterns : undefined, + rejectPatterns: Array.isArray(step.rejectPatterns) ? step.rejectPatterns : undefined, + capture: typeof step.capture === 'object' && step.capture !== null ? step.capture : undefined, + }); + } + + return { + id: raw.id as string, + name: raw.name as string, + suite: raw.suite as string, + priority: typeof raw.priority === 'number' ? raw.priority : 1, + timeout: typeof raw.timeout === 'number' ? raw.timeout : CONFIG.defaultTimeout, + dependencies: Array.isArray(raw.dependencies) ? raw.dependencies : [], + steps, + goal: typeof raw.goal === 'string' ? raw.goal : undefined, + criteria: typeof raw.criteria === 'string' ? raw.criteria : '', + }; + } +} diff --git a/cicd/tests/src/log-collector.ts b/cicd/tests/src/log-collector.ts new file mode 100644 index 0000000000..4167ff1e06 --- /dev/null +++ b/cicd/tests/src/log-collector.ts @@ -0,0 +1,313 @@ +/** + * LogCollector - Captures docker compose logs with text markers for precise + * test boundary extraction. + * + * Marker format: ===TEST:{TEST_ID}:{START|END}:{ISO_TIMESTAMP}=== + */ + +import { spawn, ChildProcess, execSync } from 'child_process'; +import { + createWriteStream, + WriteStream, + writeFileSync, + readFileSync, + existsSync, + unlinkSync, + readdirSync, + mkdirSync, +} from 'fs'; +import path from 'path'; +import { CONFIG } from './config.js'; + +interface TestMarkerState { + startWritten: boolean; + endWritten: boolean; +} + +export class LogCollector { + private process: ChildProcess | null = null; + private sessionFile: string; + private logFileStream: WriteStream | null = null; + private dockerComposeDir: string; + private isRunning: boolean = false; + private testMarkers: Map = new Map(); + private writeQueue: Promise = Promise.resolve(); + private lineBuffer: string = ''; + private outputDir: string; + + constructor(dockerComposeDir: string, outputDir: string) { + this.dockerComposeDir = dockerComposeDir; + this.outputDir = outputDir; + this.sessionFile = `/tmp/${CONFIG.sessionPrefix}-${Date.now()}.log`; + } + + async start(): Promise { + if (this.isRunning) { + return; + } + + this.cleanupOldSessions(); + + return new Promise((resolve, reject) => { + this.logFileStream = createWriteStream(this.sessionFile, { flags: 'a' }); + this.logFileStream.write( + `===SESSION:START:${new Date().toISOString()}===\n` + ); + + this.process = spawn( + 'docker', + ['compose', 'logs', '--follow', '--timestamps'], + { + cwd: this.dockerComposeDir, + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + + this.isRunning = true; + + this.process.stdout?.on('data', (data: Buffer) => { + this.processLogData(data, false); + }); + + this.process.stderr?.on('data', (data: Buffer) => { + this.processLogData(data, true); + }); + + this.process.on('error', (err) => { + this.isRunning = false; + reject(err); + }); + + this.process.on('close', () => { + this.isRunning = false; + }); + + setTimeout(() => { + if (this.isRunning) { + resolve(); + } else { + reject(new Error('Log collector failed to start')); + } + }, 500); + }); + } + + async stop(): Promise { + if (!this.process || !this.isRunning) { + return; + } + + if (this.lineBuffer) { + this.queueWrite(this.lineBuffer + '\n'); + this.lineBuffer = ''; + } + + return new Promise((resolve) => { + let resolved = false; + const doResolve = () => { + if (!resolved) { + resolved = true; + this.isRunning = false; + + if (this.logFileStream && !this.logFileStream.destroyed) { + this.logFileStream.write( + `===SESSION:END:${new Date().toISOString()}===\n` + ); + this.logFileStream.end(); + } + + resolve(); + } + }; + + this.process!.on('close', doResolve); + this.process!.kill('SIGTERM'); + + setTimeout(() => { + if (this.isRunning && this.process) { + this.process.kill('SIGKILL'); + } + doResolve(); + }, 5000); + }); + } + + markTestStart(testId: string): void { + const testLogPath = this.getTestLogPath(testId); + if (existsSync(testLogPath)) { + try { + unlinkSync(testLogPath); + } catch { + // Ignore cleanup errors + } + } + + const timestamp = new Date().toISOString(); + this.testMarkers.set(testId, { startWritten: true, endWritten: false }); + this.queueWrite(`===TEST:${testId}:START:${timestamp}===\n`); + } + + markTestEnd(testId: string): void { + const timestamp = new Date().toISOString(); + const state = this.testMarkers.get(testId); + if (state) { + state.endWritten = true; + } + this.queueWrite(`===TEST:${testId}:END:${timestamp}===\n`); + } + + extractTestLogs(testId: string): string { + const outputPath = this.getTestLogPath(testId); + + const dir = path.dirname(outputPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + this.syncFlush(); + + const markerState = this.testMarkers.get(testId); + if (!markerState?.startWritten) { + writeFileSync(outputPath, ''); + return outputPath; + } + + try { + const escapedTestId = this.escapeRegex(testId); + let sedCmd: string; + + if (markerState.endWritten) { + sedCmd = `sed -n '/===TEST:${escapedTestId}:START:/,/===TEST:${escapedTestId}:END:/{/===TEST:/d;p}' "${this.sessionFile}"`; + } else { + sedCmd = `sed -n '/===TEST:${escapedTestId}:START:/,${'$'}{/===TEST:/d;p}' "${this.sessionFile}"`; + } + + const result = execSync(sedCmd, { + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024, + }); + + const cleaned = this.stripAnsi(result); + writeFileSync(outputPath, cleaned); + } catch { + writeFileSync(outputPath, ''); + } + + return outputPath; + } + + getLogsForTest(testId: string): string { + const logPath = this.extractTestLogs(testId); + try { + return readFileSync(logPath, 'utf-8'); + } catch { + return ''; + } + } + + getAllLogs(): string { + this.syncFlush(); + try { + return readFileSync(this.sessionFile, 'utf-8'); + } catch { + return ''; + } + } + + isActive(): boolean { + return this.isRunning; + } + + getSessionFilePath(): string { + return this.sessionFile; + } + + copySessionToOutput(): string { + const outputPath = path.join(this.outputDir, 'session.log'); + if (!existsSync(this.outputDir)) { + mkdirSync(this.outputDir, { recursive: true }); + } + try { + const content = readFileSync(this.sessionFile, 'utf-8'); + writeFileSync(outputPath, content); + } catch { + writeFileSync(outputPath, ''); + } + return outputPath; + } + + private getTestLogPath(testId: string): string { + return path.join(this.outputDir, `${testId}.log`); + } + + private processLogData(data: Buffer, isStderr: boolean): void { + const text = this.lineBuffer + data.toString(); + const lines = text.split('\n'); + + this.lineBuffer = lines.pop() || ''; + + if (lines.length > 0) { + const prefix = isStderr ? '[stderr] ' : ''; + const formatted = lines.map((l) => prefix + l).join('\n') + '\n'; + this.queueWrite(formatted); + } + } + + private queueWrite(data: string): void { + this.writeQueue = this.writeQueue.then(() => { + return new Promise((resolve) => { + if (this.logFileStream && !this.logFileStream.destroyed) { + this.logFileStream.write(data, () => resolve()); + } else { + resolve(); + } + }); + }); + } + + private syncFlush(): void { + if (this.logFileStream && !this.logFileStream.destroyed) { + const fd = (this.logFileStream as unknown as { fd?: number }).fd; + if (typeof fd === 'number') { + try { + const { fsyncSync } = require('fs'); + fsyncSync(fd); + } catch { + // Ignore fsync errors + } + } + } + } + + private escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + private stripAnsi(str: string): string { + return str.replace(/\x1b\[[0-9;]*m/g, ''); + } + + private cleanupOldSessions(): void { + try { + const files = readdirSync('/tmp').filter((f) => + f.startsWith(CONFIG.sessionPrefix) + ); + const now = Date.now(); + for (const file of files) { + const match = file.match(new RegExp(`${CONFIG.sessionPrefix}-(\\d+)\\.log`)); + if (match) { + const timestamp = parseInt(match[1]); + if (now - timestamp > CONFIG.logs.cleanupAge) { + try { + unlinkSync(`/tmp/${file}`); + } catch { + // Ignore + } + } + } + } + } catch { + // Ignore cleanup errors + } + } +} diff --git a/cicd/tests/src/mcp-client.ts b/cicd/tests/src/mcp-client.ts new file mode 100644 index 0000000000..1621e50574 --- /dev/null +++ b/cicd/tests/src/mcp-client.ts @@ -0,0 +1,37 @@ +#!/usr/bin/env npx tsx +/** + * Lightweight MCP client CLI for integration testing. + * Spawns the MCP server, calls a tool, prints the result as JSON. + * + * Usage: npx tsx cicd/tests/src/mcp-client.ts '' + * + * Configure the server command via MCP_SERVER_COMMAND env var. + * Default: node dist/mcpServer.js + */ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +const [toolName, argsJson] = process.argv.slice(2); +if (!toolName) { + console.error('Usage: mcp-client.ts [json_args]'); + process.exit(1); +} + +const args = argsJson ? JSON.parse(argsJson) : {}; + +const serverCommand = process.env.MCP_SERVER_COMMAND || 'node dist/mcpServer.js'; +const [cmd, ...cmdArgs] = serverCommand.split(' '); + +const transport = new StdioClientTransport({ + command: cmd, + args: cmdArgs, + env: { ...process.env } as Record, +}); + +const client = new Client({ name: 'test-client', version: '1.0.0' }); +await client.connect(transport); + +const result = await client.callTool({ name: toolName, arguments: args }); +console.log(JSON.stringify(result, null, 2)); + +await client.close(); diff --git a/cicd/tests/src/reporter/console.ts b/cicd/tests/src/reporter/console.ts new file mode 100644 index 0000000000..984e2ec8a3 --- /dev/null +++ b/cicd/tests/src/reporter/console.ts @@ -0,0 +1,76 @@ +/** + * Console Reporter - Outputs test results to terminal with formatting. + */ + +import chalk from 'chalk'; +import { TestReport, TestSummary } from '../types.js'; + +export class ConsoleReporter { + private formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + return `${(ms / 60000).toFixed(1)}min`; + } + + report(summary: TestSummary, reports: TestReport[]): void { + console.log('\n' + '='.repeat(60)); + console.log(chalk.bold('Test Results')); + console.log('='.repeat(60)); + + for (const report of reports) { + const status = report.pass + ? chalk.green.bold('[PASS]') + : chalk.red.bold('[FAIL]'); + + console.log(`\n${status} ${report.testId}: ${report.name}`); + console.log(` Suite: ${report.suite}`); + console.log(` Duration: ${this.formatDuration(report.duration)}`); + + const simpleStatus = report.simpleJudge.pass + ? chalk.green('PASS') + : chalk.red('FAIL'); + const llmStatus = report.llmJudge.pass + ? chalk.green('PASS') + : chalk.red('FAIL'); + + console.log(` Simple Judge: ${simpleStatus} - ${report.simpleJudge.reason}`); + console.log(` LLM Judge: ${llmStatus} - ${report.llmJudge.reason}`); + if (!report.llmJudge.pass && report.llmJudge.evidence) { + console.log(` ${chalk.yellow('Evidence:')} ${report.llmJudge.evidence}`); + } + + if (report.logFile) { + console.log(` Log file: ${report.logFile}`); + } + } + + console.log('\n' + '='.repeat(60)); + console.log(chalk.bold('Summary')); + console.log('='.repeat(60)); + + const passColor = summary.passed === summary.total ? chalk.green : chalk.yellow; + console.log( + `Total: ${summary.total} | ` + + passColor(`Passed: ${summary.passed}`) + + ` | ` + + chalk.red(`Failed: ${summary.failed}`) + ); + + console.log( + ` Simple Judge: ${summary.simple.passed}/${summary.total} passed` + ); + console.log(` LLM Judge: ${summary.llm.passed}/${summary.total} passed`); + console.log(`Duration: ${this.formatDuration(summary.duration)}`); + console.log(`Output: ${summary.runId}`); + + console.log('\n' + '='.repeat(60)); + if (summary.failed === 0) { + console.log(chalk.green.bold('All tests passed!')); + } else { + console.log( + chalk.red.bold(`${summary.failed} test(s) failed.`) + ); + } + console.log('='.repeat(60) + '\n'); + } +} diff --git a/cicd/tests/src/reporter/index.ts b/cicd/tests/src/reporter/index.ts new file mode 100644 index 0000000000..e388307878 --- /dev/null +++ b/cicd/tests/src/reporter/index.ts @@ -0,0 +1,2 @@ +export { JsonReporter } from './json.js'; +export { ConsoleReporter } from './console.js'; diff --git a/cicd/tests/src/reporter/json.ts b/cicd/tests/src/reporter/json.ts new file mode 100644 index 0000000000..02070ce6aa --- /dev/null +++ b/cicd/tests/src/reporter/json.ts @@ -0,0 +1,132 @@ +/** + * JSON Reporter - Outputs test results as JSON files. + */ + +import { writeFileSync, mkdirSync, existsSync } from 'fs'; +import { hostname } from 'os'; +import path from 'path'; +import { TestResult, TestReport, TestSummary, Judgment, StepReportEntry } from '../types.js'; + +export class JsonReporter { + private outputDir: string; + + constructor(outputDir: string) { + this.outputDir = outputDir; + if (!existsSync(outputDir)) { + mkdirSync(outputDir, { recursive: true }); + } + } + + generateReports( + results: TestResult[], + simpleJudgments: Judgment[], + llmJudgments: Judgment[], + startTime: Date, + suite: string + ): { summary: TestSummary; reports: TestReport[] } { + const endTime = new Date(); + const duration = endTime.getTime() - startTime.getTime(); + + const simpleMap = new Map(simpleJudgments.map((j) => [j.testId, j])); + const llmMap = new Map(llmJudgments.map((j) => [j.testId, j])); + + const reports: TestReport[] = results.map((result) => { + const simple = simpleMap.get(result.testCase.id) || { + testId: result.testCase.id, + pass: false, + reason: 'No judgment', + }; + const llm = llmMap.get(result.testCase.id) || { + testId: result.testCase.id, + pass: false, + reason: 'No judgment', + }; + + const pass = simple.pass && llm.pass; + + const steps: StepReportEntry[] = result.steps.map((step) => ({ + name: step.name, + command: step.command, + exitCode: step.exitCode, + duration: step.duration, + stdout: step.stdout, + stderr: step.stderr, + pass: step.exitCode === 0, + })); + + return { + testId: result.testCase.id, + name: result.testCase.name, + suite: result.testCase.suite, + pass, + reason: pass + ? 'Both judges passed' + : `Simple: ${simple.reason}; LLM: ${llm.reason}`, + duration: result.totalDuration, + steps, + logFile: result.logFile, + simpleJudge: simple, + llmJudge: llm, + }; + }); + + const simplePassed = simpleJudgments.filter((j) => j.pass).length; + const llmPassed = llmJudgments.filter((j) => j.pass).length; + const passed = reports.filter((r) => r.pass).length; + + const summary: TestSummary = { + runId: startTime.toISOString(), + suite, + timestamp: startTime.toISOString(), + duration, + total: results.length, + passed, + failed: results.length - passed, + simple: { + passed: simplePassed, + failed: results.length - simplePassed, + }, + llm: { + passed: llmPassed, + failed: results.length - llmPassed, + }, + environment: { + hostname: hostname(), + nodeVersion: process.version, + }, + tests: results.map((r) => r.testCase.id), + }; + + return { summary, reports }; + } + + writeReports(summary: TestSummary, reports: TestReport[]): void { + const summaryPath = path.join(this.outputDir, 'summary.json'); + writeFileSync(summaryPath, JSON.stringify(summary, null, 2)); + + for (const report of reports) { + const reportPath = path.join(this.outputDir, `${report.testId}.json`); + writeFileSync(reportPath, JSON.stringify(report, null, 2)); + } + + process.stderr.write(`\n[JSON] Results written to ${this.outputDir}/\n`); + } + + outputSummary(summary: TestSummary, reports: TestReport[]): void { + const output = { + summary, + results: reports.map((r) => ({ + testId: r.testId, + name: r.name, + suite: r.suite, + pass: r.pass, + reason: r.reason, + duration: r.duration, + steps: r.steps, + simpleJudge: r.simpleJudge, + llmJudge: r.llmJudge, + })), + }; + console.log(JSON.stringify(output, null, 2)); + } +} diff --git a/cicd/tests/src/types.ts b/cicd/tests/src/types.ts new file mode 100644 index 0000000000..f91d2242c3 --- /dev/null +++ b/cicd/tests/src/types.ts @@ -0,0 +1,247 @@ +/** + * TypeScript interfaces for the test framework. + */ + +// ============================================ +// Test Case Definitions (from YAML) +// ============================================ + +/** + * A single step within a test case. + */ +export interface TestStep { + /** Human-readable step name */ + name: string; + /** Shell command to execute */ + command: string; + /** Step-specific timeout in ms (overrides test case timeout) */ + timeout?: number; + /** Regex patterns that MUST appear in stdout/stderr */ + expectPatterns?: string[]; + /** Regex patterns that must NOT appear in stdout/stderr */ + rejectPatterns?: string[]; + /** Capture variables from step output for use in later steps */ + capture?: Record; +} + +/** + * A complete test case definition. + */ +export interface TestCase { + /** Unique test case ID (e.g., TC-BUILD-001) */ + id: string; + /** Human-readable test name */ + name: string; + /** Test suite */ + suite: string; + /** Execution priority (lower = runs first) */ + priority: number; + /** Default timeout for all steps in ms */ + timeout: number; + /** Test IDs that must pass before this test runs */ + dependencies: string[]; + /** Test steps to execute */ + steps: TestStep[]; + /** Human-readable criteria for LLM judge evaluation */ + criteria: string; + /** Short goal statement for LLM judge context (optional) */ + goal?: string; +} + +// ============================================ +// Execution Results +// ============================================ + +/** + * Pattern matching result for a single pattern. + */ +export interface PatternMatch { + pattern: string; + found: boolean; +} + +/** + * Result of executing a single test step. + */ +export interface StepResult { + /** Step name */ + name: string; + /** Command that was executed */ + command: string; + /** Captured stdout */ + stdout: string; + /** Captured stderr */ + stderr: string; + /** Process exit code */ + exitCode: number; + /** Execution duration in ms */ + duration: number; + /** Pattern matching results (if patterns were defined) */ + patternMatches?: { + expected: PatternMatch[]; + rejected: PatternMatch[]; + }; +} + +/** + * Result of executing an entire test case. + */ +export interface TestResult { + /** The test case that was executed */ + testCase: TestCase; + /** Results for each step */ + steps: StepResult[]; + /** Total execution duration in ms */ + totalDuration: number; + /** Extracted logs for this test (from LogCollector) */ + logs: string; + /** Path to the full log file */ + logFile: string; +} + +// ============================================ +// Judge System +// ============================================ + +/** + * A judge's verdict on a test result. + */ +export interface Judgment { + /** Test case ID */ + testId: string; + /** Pass/fail verdict */ + pass: boolean; + /** Explanation of the verdict */ + reason: string; + /** Evidence log line (required when pass=false) */ + evidence?: string; +} + +// ============================================ +// Reports +// ============================================ + +/** + * Structured step result for JSON output. + */ +export interface StepReportEntry { + /** Step name */ + name: string; + /** Command executed */ + command: string; + /** Exit code */ + exitCode: number; + /** Duration in ms */ + duration: number; + /** Captured stdout */ + stdout: string; + /** Captured stderr */ + stderr: string; + /** Whether step passed */ + pass: boolean; +} + +/** + * Complete report for a single test. + */ +export interface TestReport { + /** Test case ID */ + testId: string; + /** Test name */ + name: string; + /** Test suite */ + suite: string; + /** Final pass/fail (both judges must pass in dual mode) */ + pass: boolean; + /** Final reason (combined from both judges) */ + reason: string; + /** Execution duration in ms */ + duration: number; + /** Structured step results */ + steps: StepReportEntry[]; + /** Path to full log file */ + logFile: string; + /** Simple judge verdict */ + simpleJudge: Judgment; + /** LLM judge verdict */ + llmJudge: Judgment; +} + +/** + * Summary of a test run. + */ +export interface TestSummary { + /** Run identifier (ISO timestamp) */ + runId: string; + /** Suite that was run (or 'all') */ + suite: string; + /** When the run started */ + timestamp: string; + /** Total duration in ms */ + duration: number; + /** Total number of tests */ + total: number; + /** Number of passing tests */ + passed: number; + /** Number of failing tests */ + failed: number; + /** Simple judge breakdown */ + simple: { + passed: number; + failed: number; + }; + /** LLM judge breakdown */ + llm: { + passed: number; + failed: number; + }; + /** Environment info */ + environment: { + hostname: string; + nodeVersion: string; + dockerVersion?: string; + }; + /** List of test IDs in execution order */ + tests: string[]; +} + +// ============================================ +// Configuration +// ============================================ + +/** + * CLI run configuration. + */ +export interface RunConfig { + /** Filter by suite */ + suite?: string; + /** Filter by specific test ID */ + testId?: string; + /** Show what would run without executing */ + dryRun: boolean; + /** Skip LLM judging (simple judge only) */ + noLlm: boolean; + /** URL of the LLM judge Ollama instance */ + judgeUrl: string; + /** Model to use for LLM judging */ + judgeModel: string; + /** Output directory for results */ + outputDir: string; + /** Output format */ + outputFormat: 'console' | 'json'; + /** Working directory (project root) */ + workingDir: string; + /** Path to docker-compose directory for log collection */ + dockerComposePath: string; +} + +/** + * Default configuration values. + */ +export const DEFAULT_CONFIG: Partial = { + dryRun: false, + noLlm: false, + judgeUrl: 'http://localhost:11434', + judgeModel: 'llama3:8b', + outputFormat: 'console', +}; diff --git a/cicd/tests/testcases/build/TC-BUILD-001.yml b/cicd/tests/testcases/build/TC-BUILD-001.yml new file mode 100644 index 0000000000..989e49c269 --- /dev/null +++ b/cicd/tests/testcases/build/TC-BUILD-001.yml @@ -0,0 +1,34 @@ +id: TC-BUILD-001 +name: PHP Syntax Check +suite: build +goal: Verify PHP files have no syntax errors using the Docker image +priority: 1 +timeout: 180000 +dependencies: + - TC-BUILD-002 + +steps: + - name: Lint PHP files in lib/ + command: | + docker run --rm testlink-ci-test find /var/www/html/lib -name '*.php' -exec php -l {} + 2>&1 | tail -5 + timeout: 120000 + expectPatterns: + - "No syntax errors detected" + rejectPatterns: + - "Parse error" + - "Fatal error" + + - name: Lint PHP files in cfg/ + command: | + docker run --rm testlink-ci-test find /var/www/html/cfg -name '*.php' -exec php -l {} + 2>&1 | tail -5 + timeout: 30000 + expectPatterns: + - "No syntax errors detected" + rejectPatterns: + - "Parse error" + - "Fatal error" + +criteria: | + Verify PHP source files have no syntax errors: + - php -l runs inside the Docker container on lib/ and cfg/ files + - No parse errors or fatal errors detected diff --git a/cicd/tests/testcases/build/TC-BUILD-002.yml b/cicd/tests/testcases/build/TC-BUILD-002.yml new file mode 100644 index 0000000000..d88f178d4b --- /dev/null +++ b/cicd/tests/testcases/build/TC-BUILD-002.yml @@ -0,0 +1,23 @@ +id: TC-BUILD-002 +name: Docker Image Build +suite: build +priority: 2 +timeout: 300000 +dependencies: [] + +steps: + - name: Build Docker image + command: docker build -t testlink-ci-test . + timeout: 240000 + rejectPatterns: + - "^ERROR" + + - name: Verify image exists + command: docker image inspect testlink-ci-test --format '{{.Id}}' + expectPatterns: + - "sha256:" + +criteria: | + Verify the Docker image builds successfully: + - docker build completes without errors + - The resulting image is available locally diff --git a/cicd/tests/testcases/build/TC-BUILD-003.yml b/cicd/tests/testcases/build/TC-BUILD-003.yml new file mode 100644 index 0000000000..ae9499e1f9 --- /dev/null +++ b/cicd/tests/testcases/build/TC-BUILD-003.yml @@ -0,0 +1,24 @@ +id: TC-BUILD-003 +name: Composer Dependencies +suite: build +priority: 3 +timeout: 120000 +dependencies: [] + +steps: + - name: Validate composer.json + command: | + cd . && python3 -c "import json; json.load(open('composer.json')); print('valid')" + expectPatterns: + - "valid" + + - name: Check vendor directory exists + command: | + test -f ./vendor/autoload.php && echo "vendor present" || echo "vendor missing" + expectPatterns: + - "vendor present|vendor missing" + +criteria: | + Verify composer configuration is valid: + - composer.json is valid JSON + - Vendor directory state is reported diff --git a/cicd/tests/testcases/e2e/TC-E2E-001.yml b/cicd/tests/testcases/e2e/TC-E2E-001.yml new file mode 100644 index 0000000000..17940376ac --- /dev/null +++ b/cicd/tests/testcases/e2e/TC-E2E-001.yml @@ -0,0 +1,78 @@ +id: TC-E2E-001 +name: Full CRUD Workflow via XML-RPC +suite: e2e +priority: 1 +timeout: 120000 +dependencies: + - TC-INTEGRATION-003 + +steps: + - name: Create test project + command: | + curl -sf -X POST http://localhost:8090/lib/api/xmlrpc/v1/xmlrpc.php \ + -H "Content-Type: text/xml" \ + -d 'tl.createTestProject + devKeya1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 + testprojectnameCI Test Project + testcaseprefixCIT + notesCreated by CI pipeline + ' + expectPatterns: + - "Success|id" + + - name: Create test suite + command: | + # Extract project ID from previous response — get the first integer id value + curl -sf -X POST http://localhost:8090/lib/api/xmlrpc/v1/xmlrpc.php \ + -H "Content-Type: text/xml" \ + -d 'tl.createTestSuite + devKeya1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 + testprojectid1 + testsuitenameCI Suite + detailsSuite created by CI + ' + expectPatterns: + - "id|name_already_exists" + + - name: Create test case + command: | + curl -sf -X POST http://localhost:8090/lib/api/xmlrpc/v1/xmlrpc.php \ + -H "Content-Type: text/xml" \ + -d 'tl.createTestCase + devKeya1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 + testcasenameCI Smoke Test + testsuiteid2 + testprojectid1 + authorloginadmin + summaryAutomated CI smoke test + steps + step_number1 + actionsVerify system is operational + expected_resultsSystem responds correctly + + ' + expectPatterns: + - "id|Success" + + - name: Verify test case was created + command: | + curl -sf -X POST http://localhost:8090/lib/api/xmlrpc/v1/xmlrpc.php \ + -H "Content-Type: text/xml" \ + -d 'tl.getTestCase + devKeya1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 + testcaseexternalidCIT-1 + ' + expectPatterns: + - "CI Smoke Test" + + - name: Tear down CI environment + command: bash ./cicd/scripts/ci-down.sh + timeout: 30000 + +criteria: | + Verify full CRUD workflow through XML-RPC API: + - Create a test project with prefix CIT + - Create a test suite under the project + - Create a test case in the suite + - Retrieve and verify the test case + - Clean up the environment diff --git a/cicd/tests/testcases/integration/TC-INTEGRATION-001.yml b/cicd/tests/testcases/integration/TC-INTEGRATION-001.yml new file mode 100644 index 0000000000..db71a152aa --- /dev/null +++ b/cicd/tests/testcases/integration/TC-INTEGRATION-001.yml @@ -0,0 +1,24 @@ +id: TC-INTEGRATION-001 +name: TestLink Container Startup +suite: integration +priority: 1 +timeout: 180000 +dependencies: + - TC-BUILD-002 + +steps: + - name: Start CI environment + command: bash ./cicd/scripts/ci-up.sh + timeout: 150000 + + - name: Verify app is responding + command: | + curl -sf http://localhost:8090/login.php | grep -o "Login" | head -1 + expectPatterns: + - "Login" + +criteria: | + Verify TestLink starts and serves the login page: + - Docker containers start (app + postgres) + - Database is initialized with schema and default data + - Login page is accessible on port 8090 diff --git a/cicd/tests/testcases/integration/TC-INTEGRATION-002.yml b/cicd/tests/testcases/integration/TC-INTEGRATION-002.yml new file mode 100644 index 0000000000..e777407d22 --- /dev/null +++ b/cicd/tests/testcases/integration/TC-INTEGRATION-002.yml @@ -0,0 +1,21 @@ +id: TC-INTEGRATION-002 +name: XML-RPC API Ping +suite: integration +priority: 2 +timeout: 30000 +dependencies: + - TC-INTEGRATION-001 + +steps: + - name: Call tl.ping via XML-RPC + command: | + curl -sf -X POST http://localhost:8090/lib/api/xmlrpc/v1/xmlrpc.php \ + -H "Content-Type: text/xml" \ + -d 'tl.ping' + expectPatterns: + - "Hello!" + +criteria: | + Verify the XML-RPC API is accessible: + - tl.ping endpoint responds + - Response contains "Hello!" greeting diff --git a/cicd/tests/testcases/integration/TC-INTEGRATION-003.yml b/cicd/tests/testcases/integration/TC-INTEGRATION-003.yml new file mode 100644 index 0000000000..4a7b222040 --- /dev/null +++ b/cicd/tests/testcases/integration/TC-INTEGRATION-003.yml @@ -0,0 +1,29 @@ +id: TC-INTEGRATION-003 +name: XML-RPC API Authentication +suite: integration +priority: 3 +timeout: 30000 +dependencies: + - TC-INTEGRATION-002 + +steps: + - name: Call tl.checkDevKey with valid API key + command: | + curl -sf -X POST http://localhost:8090/lib/api/xmlrpc/v1/xmlrpc.php \ + -H "Content-Type: text/xml" \ + -d 'tl.checkDevKeydevKeya1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4' + expectPatterns: + - "1|true" + + - name: Call tl.checkDevKey with invalid API key + command: | + curl -sf -X POST http://localhost:8090/lib/api/xmlrpc/v1/xmlrpc.php \ + -H "Content-Type: text/xml" \ + -d 'tl.checkDevKeydevKeyinvalid_key_12345' + expectPatterns: + - "faultCode|invalid|5000" + +criteria: | + Verify API authentication works: + - Valid API key returns true/success + - Invalid API key returns error/fault diff --git a/cicd/tests/tsconfig.json b/cicd/tests/tsconfig.json new file mode 100644 index 0000000000..199a9b2c55 --- /dev/null +++ b/cicd/tests/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/mcp-client.ts"] +} From 182d2d66f9f94e3e8cfe93b49b6feca1ecc8c2c0 Mon Sep 17 00:00:00 2001 From: Shang Chieh Tseng Date: Thu, 9 Apr 2026 13:49:39 +0800 Subject: [PATCH 2/2] fix: Remove duplicate CI environment startup/teardown from workflow Test cases manage their own lifecycle (TC-INTEGRATION-001 starts, TC-E2E-001 tears down), so the workflow should not duplicate this. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/test-suite.yml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 20cbf861a8..71f4268e91 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -65,12 +65,6 @@ jobs: - name: Install test runner dependencies run: cd cicd/tests && npm ci - - name: Start CI environment (integration/e2e) - if: inputs.suite == 'integration' || inputs.suite == 'e2e' || inputs.suite == 'all' - run: | - bash cicd/scripts/ci-up.sh - timeout-minutes: 5 - - name: Run tests id: run-tests run: | @@ -113,11 +107,6 @@ jobs: exit 1 fi - - name: Tear down CI environment - if: always() && (inputs.suite == 'integration' || inputs.suite == 'e2e' || inputs.suite == 'all') - run: | - bash cicd/scripts/ci-down.sh || true - - name: Upload test results uses: actions/upload-artifact@v4 if: always()