From 9b93b1ca75b26728dfa9e40989307d3ae791c173 Mon Sep 17 00:00:00 2001 From: Darshan Date: Wed, 24 Dec 2025 20:19:26 +0530 Subject: [PATCH 1/3] update: test suite. --- .github/workflows/test-local.yml | 136 -------- .github/workflows/test.yml | 131 +------- .github/workflows/update-tag.yml | 5 +- package.json | 2 +- tests/README.md | 13 +- tests/integration/index.ts | 290 ++++++++++++++++++ .../phpunit-project/docker-compose.yml | 5 + tests/integration/utils.ts | 196 ++++++++++++ tests/test-integration.ts | 99 ------ 9 files changed, 514 insertions(+), 363 deletions(-) delete mode 100644 .github/workflows/test-local.yml create mode 100644 tests/integration/index.ts create mode 100644 tests/integration/phpunit-project/docker-compose.yml create mode 100644 tests/integration/utils.ts delete mode 100644 tests/test-integration.ts diff --git a/.github/workflows/test-local.yml b/.github/workflows/test-local.yml deleted file mode 100644 index ad34787..0000000 --- a/.github/workflows/test-local.yml +++ /dev/null @@ -1,136 +0,0 @@ -name: Local Test - -on: - workflow_dispatch: - -jobs: - unit-tests: - name: Unit Tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v1 - with: - bun-version: latest - - - name: Install dependencies - run: bun install - - - name: Run unit tests - run: bun test tests/unit/ - - - name: Build action - run: bun run build - - test-action: - name: Test Action - ${{ matrix.scenario }} - runs-on: ubuntu-latest - needs: unit-tests - strategy: - matrix: - include: - - scenario: "Simple Dependencies" - test_path: "tests/SampleTest.php" - container_id: "simple" - test_dir: "tests/integration/phpunit-project/tests" - - - scenario: "Complex Dependencies" - test_path: "tests/ProjectTest.php" - container_id: "complex" - test_dir: "tests/integration/phpunit-project/tests" - - - scenario: "Full Test Suite" - container_id: "full" - test_dir: "tests/integration/phpunit-project/tests" - steps: - - uses: actions/checkout@v4 - - - name: Build Docker image - working-directory: tests/integration/phpunit-project - run: docker build -t phpunit-retry-test:latest . - - - name: Start container - run: docker run -d --name phpunit-retry-${{ matrix.container_id }} phpunit-retry-test:latest tail -f /dev/null - - - name: Test action with failing tests - id: test-retry - uses: ./ - with: - command: docker exec phpunit-retry-${{ matrix.container_id }} vendor/bin/phpunit ${{ matrix.test_path || '' }} - test_dir: ${{ matrix.test_dir }} - max_attempts: 3 - retry_wait_seconds: 5 - continue-on-error: true - - - name: Cleanup - if: always() - run: docker rm -f phpunit-retry-${{ matrix.container_id }} || true - - - name: Verify action executed - run: | - echo "Action completed with outcome: ${{ steps.test-retry.outcome }}" - - test-action-with-env: - name: Test Action with Environment Variables - runs-on: ubuntu-latest - needs: unit-tests - steps: - - uses: actions/checkout@v4 - - - name: Build Docker image - working-directory: tests/integration/phpunit-project - run: docker build -t phpunit-retry-test:latest . - - - name: Start container - run: docker run -d --name phpunit-retry-env phpunit-retry-test:latest tail -f /dev/null - - - name: Test action with env vars in command - id: test-retry - uses: ./ - with: - command: _DATABASE_CONFIG=shared_tables _STORAGE_PATH=/tmp/storage docker exec phpunit-retry-env vendor/bin/phpunit tests/SampleTest.php - test_dir: tests/integration/phpunit-project/tests - max_attempts: 3 - retry_wait_seconds: 5 - continue-on-error: true - - - name: Verify action executed - run: | - echo "Action completed with outcome: ${{ steps.test-retry.outcome }}" - - - name: Test action with env vars and vendor path - id: test-retry-vendor - uses: ./ - with: - command: _DATABASE_CONFIG=shared_tables _STORAGE_PATH=/tmp/storage docker exec phpunit-retry-env vendor/bin/phpunit vendor/sample/library/tests/VendorTest.php - test_dir: tests/integration/phpunit-project/vendor/sample/library/tests - max_attempts: 3 - retry_wait_seconds: 5 - continue-on-error: true - - - name: Verify vendor test executed - run: | - echo "Vendor test completed with outcome: ${{ steps.test-retry-vendor.outcome }}" - - - name: Cleanup - if: always() - run: docker rm -f phpunit-retry-env || true - - lint: - name: Lint & Type Check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v1 - with: - bun-version: latest - - - name: Install dependencies - run: bun install - - - name: Run checks - run: bun check diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 44fca00..6d99206 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,7 @@ name: Test Action on: push: - branches: [main] + branches: [ main ] paths: - 'src/**' - 'tests/**' @@ -12,7 +12,7 @@ on: - 'bun.lock' pull_request: - branches: [main] + branches: [ main ] paths: - 'src/**' - 'tests/**' @@ -52,129 +52,26 @@ jobs: exit 1 fi - build-test-image: - name: Build Test Image + integration-tests: + name: Integration Tests runs-on: ubuntu-latest + needs: unit-tests steps: - uses: actions/checkout@v4 - - name: Build Docker image - working-directory: tests/integration/phpunit-project - run: docker build -t phpunit-retry-test:latest . - - - name: Save image - run: docker save phpunit-retry-test:latest | gzip > /tmp/phpunit-retry-test.tar.gz - - - name: Upload image artifact - uses: actions/upload-artifact@v4 - with: - name: phpunit-retry-test-image - path: /tmp/phpunit-retry-test.tar.gz - retention-days: 1 - - test-action: - name: Test Action - ${{ matrix.scenario }} - runs-on: ubuntu-latest - needs: build-test-image - strategy: - matrix: - include: - - scenario: "Simple Dependencies" - test_path: "tests/SampleTest.php" - container_id: "simple" - test_dir: "tests/integration/phpunit-project/tests" - - - scenario: "Complex Dependencies" - test_path: "tests/ProjectTest.php" - container_id: "complex" - test_dir: "tests/integration/phpunit-project/tests" - - - scenario: "Full Test Suite" - container_id: "full" - test_dir: "tests/integration/phpunit-project/tests" - steps: - - uses: actions/checkout@v4 - - - name: Download image artifact - uses: actions/download-artifact@v4 - with: - name: phpunit-retry-test-image - path: /tmp - - - name: Load Docker image - run: docker load < /tmp/phpunit-retry-test.tar.gz - - - name: Start container - run: docker run -d --name phpunit-retry-${{ matrix.container_id }} phpunit-retry-test:latest tail -f /dev/null - - - name: Test action with failing tests - id: test-retry - uses: ./ - with: - command: docker exec phpunit-retry-${{ matrix.container_id }} vendor/bin/phpunit ${{ matrix.test_path || '' }} - test_dir: ${{ matrix.test_dir }} - max_attempts: 3 - retry_wait_seconds: 5 - continue-on-error: true - - - name: Cleanup - if: always() - run: docker rm -f phpunit-retry-${{ matrix.container_id }} || true - - - name: Verify action executed - run: | - echo "Action completed with outcome: ${{ steps.test-retry.outcome }}" - - test-action-with-env: - name: Test Action with Environment Variables - runs-on: ubuntu-latest - needs: build-test-image - steps: - - uses: actions/checkout@v4 - - - name: Download image artifact - uses: actions/download-artifact@v4 - with: - name: phpunit-retry-test-image - path: /tmp - - - name: Load Docker image - run: docker load < /tmp/phpunit-retry-test.tar.gz - - - name: Start container - run: docker run -d --name phpunit-retry-env phpunit-retry-test:latest tail -f /dev/null - - - name: Test action with env vars in command - id: test-retry - uses: ./ + - name: Setup Bun + uses: oven-sh/setup-bun@v1 with: - command: _DATABASE_CONFIG=shared_tables _STORAGE_PATH=/tmp/storage docker exec phpunit-retry-env vendor/bin/phpunit tests/SampleTest.php - test_dir: tests/integration/phpunit-project/tests - max_attempts: 3 - retry_wait_seconds: 5 - continue-on-error: true - - - name: Verify action executed - run: | - echo "Action completed with outcome: ${{ steps.test-retry.outcome }}" + bun-version: latest - - name: Test action with env vars and vendor path - id: test-retry-vendor - uses: ./ - with: - command: _DATABASE_CONFIG=shared_tables _STORAGE_PATH=/tmp/storage docker exec phpunit-retry-env vendor/bin/phpunit vendor/sample/library/tests/VendorTest.php - test_dir: tests/integration/phpunit-project/vendor/sample/library/tests - max_attempts: 3 - retry_wait_seconds: 5 - continue-on-error: true + - name: Install dependencies + run: bun install - - name: Verify vendor test executed - run: | - echo "Vendor test completed with outcome: ${{ steps.test-retry-vendor.outcome }}" + - name: Build action + run: bun run build - - name: Cleanup - if: always() - run: docker rm -f phpunit-retry-env || true + - name: Run integration tests + run: bun run test:integration lint: name: Lint & Type Check diff --git a/.github/workflows/update-tag.yml b/.github/workflows/update-tag.yml index f2b2287..1122621 100644 --- a/.github/workflows/update-tag.yml +++ b/.github/workflows/update-tag.yml @@ -2,8 +2,9 @@ name: Update Latest Tag on: push: - branches: - - main + branches: [main] + paths: + - 'dist/**' jobs: update-tag: diff --git a/package.json b/package.json index 6ac5fb8..5cc2965 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "lint": "prettier --check 'src/**/*.ts'", "test": "bun test", "test:unit": "bun test tests/unit/", - "test:integration": "bun run tests/test-integration.ts", + "test:integration": "bun run tests/integration/index.ts", "check": "bun lint && tsc --noEmit" }, "devDependencies": { diff --git a/tests/README.md b/tests/README.md index b09fd45..33c9443 100644 --- a/tests/README.md +++ b/tests/README.md @@ -12,13 +12,13 @@ Fast, isolated tests for individual components: Run: `bun test tests/unit/` ### Integration Tests -End-to-end test using GitHub Actions locally: -- **test-integration.ts** - Runs the full action via `act` (requires Docker) - - Uses `.github/workflows/test.yml` workflow (test-action job) +End-to-end test using the action bundle and Docker: +- **integration/index.ts** - Runs the action against a Dockerized PHPUnit project + - Uses `tests/integration/phpunit-project/docker-compose.yml` - Tests complete retry flow with real PHPUnit runs - Validates JUnit parsing, dependency resolution, and retry logic -Run: `bun test:integration` (requires `act` CLI tool) +Run: `bun test:integration` (requires Docker and Docker Compose) ## Running Tests @@ -26,9 +26,6 @@ Run: `bun test:integration` (requires `act` CLI tool) # Unit tests (fast) bun test -# Integration test (slower, requires Docker and act CLI) +# Integration test (slower, requires Docker and Docker Compose) bun test:integration - -# Install act (if not already installed) -brew install act ``` diff --git a/tests/integration/index.ts b/tests/integration/index.ts new file mode 100644 index 0000000..55b361b --- /dev/null +++ b/tests/integration/index.ts @@ -0,0 +1,290 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + buildActionEnv, + createCommandRunner, + ensureOutputFile, + formatOutput, + formatDuration, + parseOutputs, +} from "./utils"; + +type Scenario = { + name: string; + containerId: string; + testPath?: string; + testDir: string; + maxAttempts: number; + expectedAttempts: number; + expectedSuccess: "true" | "false"; + expectedRetryTests: number; + expectedPresent: string[]; + expectedAbsent: string[]; +}; + +const args = new Set(process.argv.slice(2)); +const verbose = args.has("--verbose"); +const rawLogs = args.has("--raw"); +const { runCommand } = createCommandRunner({ verbose, rawLogs }); + +const repoRoot = path.resolve(__dirname, "..", ".."); +const distEntry = path.join(repoRoot, "dist", "index.js"); +const junitPath = path.join(repoRoot, "phpunit-junit.xml"); +const projectDir = path.join( + repoRoot, + "tests", + "integration", + "phpunit-project", +); +const testDirInput = "tests/integration/phpunit-project/tests"; +const containerName = "phpunit-retry-test"; + +const scenarios: Scenario[] = [ + { + name: "Simple Dependencies", + containerId: "simple", + testPath: "tests/SampleTest.php", + testDir: testDirInput, + maxAttempts: 2, + expectedAttempts: 2, + expectedSuccess: "false", + expectedRetryTests: 6, + expectedPresent: [ + 'name="testCreate"', + 'name="testUpdate"', + 'name="testRead"', + 'name="testDelete"', + 'name="testMultipleDeps"', + 'name="testAnotherFailure"', + ], + expectedAbsent: ['name="testIndependent"'], + }, + { + name: "Complex Dependencies", + containerId: "complex", + testPath: "tests/ProjectTest.php", + testDir: testDirInput, + maxAttempts: 2, + expectedAttempts: 2, + expectedSuccess: "false", + expectedRetryTests: 3, + expectedPresent: [ + 'name="testCreateProject"', + 'name="testUpdateProject"', + 'name="testDeleteProject"', + ], + expectedAbsent: ['name="testProjectValidation"', 'name="testListProjects"'], + }, + { + name: "Full Test Suite", + containerId: "full", + testDir: testDirInput, + maxAttempts: 2, + expectedAttempts: 2, + expectedSuccess: "false", + expectedRetryTests: 9, + expectedPresent: [ + 'name="testCreate"', + 'name="testUpdate"', + 'name="testRead"', + 'name="testDelete"', + 'name="testMultipleDeps"', + 'name="testAnotherFailure"', + 'name="testCreateProject"', + 'name="testUpdateProject"', + 'name="testDeleteProject"', + ], + expectedAbsent: [ + 'name="testIndependent"', + 'name="testProjectValidation"', + 'name="testListProjects"', + ], + }, +]; + +type ScenarioResult = { + name: string; + retryCount: number; + durationMs: number; +}; + +async function runScenario( + scenario: Scenario, + nodePath: string, + tmpDir: string, +): Promise { + const defaultOutputFile = path.join( + tmpDir, + `outputs-${scenario.containerId}.txt`, + ); + const outputFile = process.env.GITHUB_OUTPUT || defaultOutputFile; + let actionOutput = ""; + const startedAt = Date.now(); + + if (fs.existsSync(junitPath)) { + fs.unlinkSync(junitPath); + } + if (outputFile === defaultOutputFile && fs.existsSync(outputFile)) { + fs.unlinkSync(outputFile); + } + ensureOutputFile(outputFile); + + try { + if (verbose) { + console.log(`Scenario: ${scenario.name}`); + } + const baseCommand = `docker exec ${containerName} vendor/bin/phpunit`; + const command = scenario.testPath + ? `${baseCommand} ${scenario.testPath}` + : baseCommand; + + const env = buildActionEnv(command, { + repoRoot, + outputPath: outputFile, + testDir: scenario.testDir, + maxAttempts: scenario.maxAttempts, + }); + + const actionResult = await runCommand([nodePath, distEntry], { + cwd: repoRoot, + env, + allowFailure: true, + label: `action:${scenario.name}`, + }); + actionOutput = actionResult.output; + + if (!fs.existsSync(outputFile)) { + throw new Error(`Expected action outputs file at ${outputFile}`); + } + + const outputs = parseOutputs(outputFile); + if (!outputs.total_attempts) { + throw new Error("Missing output: total_attempts"); + } + if (outputs.total_attempts !== String(scenario.expectedAttempts)) { + throw new Error( + `Expected total_attempts=${scenario.expectedAttempts}, got ${outputs.total_attempts}`, + ); + } + if (outputs.success !== scenario.expectedSuccess) { + throw new Error( + `Expected success=${scenario.expectedSuccess}, got ${outputs.success}`, + ); + } + + if (!fs.existsSync(junitPath)) { + throw new Error("Expected JUnit file to exist"); + } + + const xml = fs.readFileSync(junitPath, "utf8"); + const retryCount = (xml.match(/ { + const nodePath = Bun.which("node"); + if (!nodePath) { + throw new Error("node not found in PATH"); + } + + if (!fs.existsSync(distEntry)) { + throw new Error("dist/index.js not found; run bun run build first"); + } + + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "phpunit-retry-integration-"), + ); + const runStartedAt = Date.now(); + + await runCommand(["docker", "compose", "down", "--remove-orphans"], { + cwd: projectDir, + allowFailure: true, + label: "docker compose down", + }); + + await runCommand(["docker", "compose", "up", "-d", "--build"], { + cwd: projectDir, + label: "docker compose up", + }); + + try { + const results: ScenarioResult[] = []; + if (!verbose) { + console.log(`Running integration tests (${scenarios.length} scenarios)`); + } + for (const scenario of scenarios) { + const result = await runScenario(scenario, nodePath, tmpDir); + results.push(result); + if (!verbose) { + console.log( + `✓ ${result.name} (${result.retryCount} tests, ${formatDuration(result.durationMs)})`, + ); + } + } + const totalDuration = Date.now() - runStartedAt; + if (!verbose) { + const totalTests = results.reduce((sum, r) => sum + r.retryCount, 0); + console.log( + `All passed (${results.length}/${scenarios.length}, ${totalTests} tests) in ${formatDuration(totalDuration)}`, + ); + console.log("Tip: re-run with --verbose for full logs."); + } else { + console.log("Integration test passed"); + } + } finally { + await runCommand(["docker", "compose", "down", "--remove-orphans"], { + cwd: projectDir, + allowFailure: true, + label: "docker compose down", + }); + } +} + +async function main(): Promise { + try { + await runIntegrationTests(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Integration test failed: ${message}`); + process.exit(1); + } +} + +void main(); diff --git a/tests/integration/phpunit-project/docker-compose.yml b/tests/integration/phpunit-project/docker-compose.yml new file mode 100644 index 0000000..f60147a --- /dev/null +++ b/tests/integration/phpunit-project/docker-compose.yml @@ -0,0 +1,5 @@ +services: + phpunit-retry: + build: . + container_name: phpunit-retry-test + command: ["tail", "-f", "/dev/null"] diff --git a/tests/integration/utils.ts b/tests/integration/utils.ts new file mode 100644 index 0000000..cdeb703 --- /dev/null +++ b/tests/integration/utils.ts @@ -0,0 +1,196 @@ +import * as fs from "fs"; +import * as path from "path"; + +export type RunnerOptions = { + verbose: boolean; + rawLogs: boolean; +}; + +export type RunCommandOptions = { + cwd?: string; + env?: Record; + allowFailure?: boolean; + label?: string; +}; + +export type RunCommandResult = { + exitCode: number; + output: string; +}; + +export type ActionEnvDefaults = { + repoRoot: string; + outputPath: string; + testDir: string; + maxAttempts: number; + retryWaitSeconds?: number; + timeoutMinutes?: number; + shell?: string; +}; + +async function readStream( + stream: ReadableStream | number | null | undefined, +): Promise { + if (!stream || typeof stream === "number") { + return ""; + } + return new Response(stream).text(); +} + +type SpawnedProcess = ReturnType; + +async function collectOutput(proc: SpawnedProcess): Promise { + const [stdout, stderr] = await Promise.all([ + readStream(proc.stdout), + readStream(proc.stderr), + ]); + return `${stdout}${stderr}`; +} + +function setDefaultEnv( + env: Record, + key: string, + value: string, +): void { + if (!env[key]) { + env[key] = value; + } +} + +export function buildActionEnv( + command: string, + defaults: ActionEnvDefaults, +): Record { + const env = { ...process.env } as Record; + + setDefaultEnv(env, "GITHUB_WORKSPACE", defaults.repoRoot); + setDefaultEnv(env, "GITHUB_ACTIONS", "true"); + setDefaultEnv(env, "GITHUB_OUTPUT", defaults.outputPath); + setDefaultEnv(env, "INPUT_COMMAND", command); + setDefaultEnv(env, "INPUT_TEST_DIR", defaults.testDir); + setDefaultEnv(env, "INPUT_MAX_ATTEMPTS", String(defaults.maxAttempts)); + setDefaultEnv( + env, + "INPUT_RETRY_WAIT_SECONDS", + String(defaults.retryWaitSeconds ?? 0), + ); + setDefaultEnv( + env, + "INPUT_TIMEOUT_MINUTES", + String(defaults.timeoutMinutes ?? 5), + ); + setDefaultEnv(env, "INPUT_SHELL", defaults.shell ?? "bash"); + + return env; +} + +export function ensureOutputFile(filePath: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, ""); + } +} + +export function parseOutputs(filePath: string): Record { + const content = fs.readFileSync(filePath, "utf8"); + const outputs: Record = {}; + const lines = content.split(/\r?\n/); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line) continue; + + const delimiterMatch = line.match(/^([^<]+)<<(.+)$/); + if (delimiterMatch) { + const key = delimiterMatch[1]!; + const delimiter = delimiterMatch[2]!; + const valueLines: string[] = []; + i++; + while (i < lines.length && lines[i] !== delimiter) { + valueLines.push(lines[i]!); + i++; + } + outputs[key] = valueLines.join("\n"); + continue; + } + + const equalsIndex = line.indexOf("="); + if (equalsIndex === -1) continue; + const key = line.slice(0, equalsIndex); + outputs[key] = line.slice(equalsIndex + 1); + } + + return outputs; +} + +export function formatOutput(output: string): string { + const lines = output.split(/\r?\n/); + const formatted: string[] = []; + + for (const line of lines) { + if (!line) continue; + if (line.startsWith("::group::")) { + formatted.push(`== ${line.slice("::group::".length)} ==`); + continue; + } + if (line.startsWith("::endgroup::")) { + continue; + } + const cmdMatch = line.match(/^::(debug|notice|warning|error)::(.*)$/); + if (cmdMatch) { + formatted.push(`[${cmdMatch[1]}] ${cmdMatch[2]}`); + continue; + } + formatted.push(line); + } + + return formatted.join("\n"); +} + +export function formatDuration(ms: number): string { + const seconds = ms / 1000; + return `${seconds.toFixed(2)}s`; +} + +export function createCommandRunner(options: RunnerOptions): { + runCommand: (cmd: string[], options?: RunCommandOptions) => Promise; +} { + const { verbose, rawLogs } = options; + const captureOutput = !rawLogs; + + async function runCommand( + cmd: string[], + runOptions: RunCommandOptions = {}, + ): Promise { + const proc = Bun.spawn(cmd, { + cwd: runOptions.cwd, + env: runOptions.env, + stdout: captureOutput ? "pipe" : "inherit", + stderr: captureOutput ? "pipe" : "inherit", + }); + const output = captureOutput ? await collectOutput(proc) : ""; + const exitCode = await proc.exited; + + if (verbose && captureOutput && output.trim()) { + const formatted = formatOutput(output).trim(); + if (formatted) { + console.log(formatted); + } + } + + if (exitCode !== 0 && !runOptions.allowFailure) { + if (!verbose && output.trim()) { + const formatted = formatOutput(output).trim(); + if (formatted) { + console.error(formatted); + } + } + const label = runOptions.label || cmd.join(" "); + throw new Error(`Command failed: ${label} (exit ${exitCode})`); + } + + return { exitCode, output }; + } + + return { runCommand }; +} diff --git a/tests/test-integration.ts b/tests/test-integration.ts deleted file mode 100644 index d69a3c2..0000000 --- a/tests/test-integration.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { $ } from "bun"; - -async function runIntegrationTests() { - try { - await $`command -v act`.quiet(); - } catch { - console.error("Error: act not installed"); - console.error("Install with: brew install act"); - process.exit(1); - } - - console.log("Running integration test with act (matrix workflow)...\n"); - - try { - // Run both jobs and combine output - const result1 = - await $`act -j test-action -W .github/workflows/test-local.yml -P ubuntu-latest=catthehacker/ubuntu:act-latest --container-architecture linux/amd64`.nothrow(); - - const result2 = - await $`act -j test-action-with-env -W .github/workflows/test-local.yml -P ubuntu-latest=catthehacker/ubuntu:act-latest --container-architecture linux/amd64`.nothrow(); - - const output = result1.text() + "\n" + result2.text(); - - // Verify expected behavior across all matrix jobs - const checks = [ - { - pattern: /scenario:Simple Dependencies/, - name: "Simple Dependencies matrix job runs", - }, - { - pattern: /scenario:Complex Dependencies/, - name: "Complex Dependencies matrix job runs", - }, - { - pattern: /scenario:Full Test Suite/, - name: "Full Test Suite matrix job runs", - }, - { - pattern: /Test Action with Environment Variables/, - name: "Environment variables job runs", - }, - { - pattern: /Test action with env vars and vendor path/, - name: "Environment variables with vendor path runs", - }, - { - pattern: /Attempt 1/, - name: "First attempt runs", - }, - { - pattern: /phpunit-retry-simple|phpunit-retry-complex|phpunit-retry-full|phpunit-retry-env/, - name: "Docker containers are created", - }, - { - pattern: /SampleTest|ProjectTest/, - name: "Tests are executed", - }, - { - pattern: /Dependency analysis:/, - name: "Action shows test execution status", - }, - { - pattern: /Retrying.*failed test/, - name: "Action retries failed tests", - }, - ]; - - let passed = 0; - let failed = 0; - - console.log("Verification:\n"); - for (const check of checks) { - if (check.pattern.test(output)) { - console.log(`✅ ${check.name}`); - passed++; - } else { - console.log(`❌ ${check.name}`); - failed++; - } - } - - console.log(`\nResults: ${passed}/${checks.length} checks passed\n`); - - if (failed > 0) { - console.error("Integration test failed"); - process.exit(1); - } - - console.log("Integration test passed"); - } catch (error) { - console.error("Integration test failed"); - if (error instanceof Error) { - console.error(error.message); - } - process.exit(1); - } -} - -void runIntegrationTests(); From 5d1c6e419a859e33332119e43f24cfe3cfc84924 Mon Sep 17 00:00:00 2001 From: Darshan Date: Thu, 25 Dec 2025 13:08:21 +0530 Subject: [PATCH 2/3] refactor: test suite more with better organizing. --- .github/workflows/test.yml | 1 - .github/workflows/update-tag.yml | 2 +- tests/README.md | 2 +- tests/integration/index.ts | 427 +++++++++--------- .../phpunit-project/.gitignore | 0 .../phpunit-project/Dockerfile | 0 .../phpunit-project/composer.json | 0 .../phpunit-project/docker-compose.yml | 0 .../phpunit-project/phpunit.xml | 0 .../phpunit-project/tests/ProjectTest.php | 0 .../phpunit-project/tests/SampleTest.php | 0 tests/integration/scenarios.ts | 83 ++++ tests/integration/utils.ts | 86 +++- 13 files changed, 384 insertions(+), 217 deletions(-) rename tests/integration/{ => resources}/phpunit-project/.gitignore (100%) rename tests/integration/{ => resources}/phpunit-project/Dockerfile (100%) rename tests/integration/{ => resources}/phpunit-project/composer.json (100%) rename tests/integration/{ => resources}/phpunit-project/docker-compose.yml (100%) rename tests/integration/{ => resources}/phpunit-project/phpunit.xml (100%) rename tests/integration/{ => resources}/phpunit-project/tests/ProjectTest.php (100%) rename tests/integration/{ => resources}/phpunit-project/tests/SampleTest.php (100%) create mode 100644 tests/integration/scenarios.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6d99206..b52dfce 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,6 @@ on: - 'bun.lock' pull_request: - branches: [ main ] paths: - 'src/**' - 'tests/**' diff --git a/.github/workflows/update-tag.yml b/.github/workflows/update-tag.yml index 1122621..63f0892 100644 --- a/.github/workflows/update-tag.yml +++ b/.github/workflows/update-tag.yml @@ -2,7 +2,7 @@ name: Update Latest Tag on: push: - branches: [main] + branches: [ main ] paths: - 'dist/**' diff --git a/tests/README.md b/tests/README.md index 33c9443..2e696f7 100644 --- a/tests/README.md +++ b/tests/README.md @@ -14,7 +14,7 @@ Run: `bun test tests/unit/` ### Integration Tests End-to-end test using the action bundle and Docker: - **integration/index.ts** - Runs the action against a Dockerized PHPUnit project - - Uses `tests/integration/phpunit-project/docker-compose.yml` + - Uses `tests/integration/resources/phpunit-project/docker-compose.yml` - Tests complete retry flow with real PHPUnit runs - Validates JUnit parsing, dependency resolution, and retry logic diff --git a/tests/integration/index.ts b/tests/integration/index.ts index 55b361b..83d56ab 100644 --- a/tests/integration/index.ts +++ b/tests/integration/index.ts @@ -1,27 +1,21 @@ -import * as fs from "fs"; -import * as os from "os"; import * as path from "path"; import { buildActionEnv, + assertCommandOk, + createTempDir, createCommandRunner, + ensureFileExists, ensureOutputFile, formatOutput, formatDuration, - parseOutputs, + getNodePath, + readJUnitXml, + readOutputsFile, + removeDirIfExists, + removeFileIfExists, + type RunCommandResult, } from "./utils"; - -type Scenario = { - name: string; - containerId: string; - testPath?: string; - testDir: string; - maxAttempts: number; - expectedAttempts: number; - expectedSuccess: "true" | "false"; - expectedRetryTests: number; - expectedPresent: string[]; - expectedAbsent: string[]; -}; +import { scenarios, type Scenario, type ScenarioResult } from "./scenarios"; const args = new Set(process.argv.slice(2)); const verbose = args.has("--verbose"); @@ -35,245 +29,260 @@ const projectDir = path.join( repoRoot, "tests", "integration", + "resources", "phpunit-project", ); -const testDirInput = "tests/integration/phpunit-project/tests"; const containerName = "phpunit-retry-test"; -const scenarios: Scenario[] = [ - { - name: "Simple Dependencies", - containerId: "simple", - testPath: "tests/SampleTest.php", - testDir: testDirInput, - maxAttempts: 2, - expectedAttempts: 2, - expectedSuccess: "false", - expectedRetryTests: 6, - expectedPresent: [ - 'name="testCreate"', - 'name="testUpdate"', - 'name="testRead"', - 'name="testDelete"', - 'name="testMultipleDeps"', - 'name="testAnotherFailure"', - ], - expectedAbsent: ['name="testIndependent"'], - }, - { - name: "Complex Dependencies", - containerId: "complex", - testPath: "tests/ProjectTest.php", - testDir: testDirInput, - maxAttempts: 2, - expectedAttempts: 2, - expectedSuccess: "false", - expectedRetryTests: 3, - expectedPresent: [ - 'name="testCreateProject"', - 'name="testUpdateProject"', - 'name="testDeleteProject"', - ], - expectedAbsent: ['name="testProjectValidation"', 'name="testListProjects"'], - }, - { - name: "Full Test Suite", - containerId: "full", - testDir: testDirInput, - maxAttempts: 2, - expectedAttempts: 2, - expectedSuccess: "false", - expectedRetryTests: 9, - expectedPresent: [ - 'name="testCreate"', - 'name="testUpdate"', - 'name="testRead"', - 'name="testDelete"', - 'name="testMultipleDeps"', - 'name="testAnotherFailure"', - 'name="testCreateProject"', - 'name="testUpdateProject"', - 'name="testDeleteProject"', - ], - expectedAbsent: [ - 'name="testIndependent"', - 'name="testProjectValidation"', - 'name="testListProjects"', - ], - }, -]; - -type ScenarioResult = { - name: string; - retryCount: number; - durationMs: number; -}; +async function runPrechecks(): Promise { + await assertCommandOk( + runCommand, + ["docker", "info"], + "docker info", + "Docker daemon is not available. Start Docker and try again.", + ); + await assertCommandOk( + runCommand, + ["docker", "compose", "version"], + "docker compose version", + "Docker Compose is not available. Install Docker Compose and try again.", + ); +} + +function prepareOutputFile(tmpDir: string, scenario: Scenario): string { + const outputFile = path.join(tmpDir, `outputs-${scenario.containerId}.txt`); + removeFileIfExists(outputFile); + ensureOutputFile(outputFile); -async function runScenario( + return outputFile; +} + +function buildScenarioCommand(scenario: Scenario): string { + const baseCommand = `docker exec ${containerName} vendor/bin/phpunit`; + if (!scenario.testPath) { + return baseCommand; + } + if (!/^[A-Za-z0-9_./-]+$/.test(scenario.testPath)) { + throw new Error(`Invalid test path: ${scenario.testPath}`); + } + return `${baseCommand} ${scenario.testPath}`; +} + +function validateOutputs( + outputs: Record, scenario: Scenario, - nodePath: string, - tmpDir: string, -): Promise { - const defaultOutputFile = path.join( - tmpDir, - `outputs-${scenario.containerId}.txt`, +): void { + if (!outputs.total_attempts) { + throw new Error("Missing output: total_attempts"); + } + if (outputs.total_attempts !== String(scenario.expectedAttempts)) { + throw new Error( + `Expected total_attempts=${scenario.expectedAttempts}, got ${outputs.total_attempts}`, + ); + } + if (outputs.success !== scenario.expectedSuccess) { + throw new Error( + `Expected success=${scenario.expectedSuccess}, got ${outputs.success}`, + ); + } +} + +function validateRetryScope(xml: string, scenario: Scenario): number { + const testcaseNames = Array.from( + xml.matchAll(/]*\bname="([^"]+)"/g), + (match) => match[1]!, ); - const outputFile = process.env.GITHUB_OUTPUT || defaultOutputFile; - let actionOutput = ""; - const startedAt = Date.now(); + const retryCount = testcaseNames.length; + if (retryCount !== scenario.expectedRetryTests) { + throw new Error( + `Expected ${scenario.expectedRetryTests} testcases on retry, got ${retryCount}`, + ); + } + + const testcaseSet = new Set(testcaseNames); - if (fs.existsSync(junitPath)) { - fs.unlinkSync(junitPath); + for (const name of scenario.expectedPresent) { + if (!testcaseSet.has(name)) { + throw new Error(`Missing expected test name: ${name}`); + } } - if (outputFile === defaultOutputFile && fs.existsSync(outputFile)) { - fs.unlinkSync(outputFile); + + for (const name of scenario.expectedAbsent) { + if (testcaseSet.has(name)) { + throw new Error(`Unexpected test name on retry: ${name}`); + } } - ensureOutputFile(outputFile); + return retryCount; +} + +function logScenarioStart(scenario: Scenario): void { + if (verbose) { + console.log(`Scenario: ${scenario.name}`); + } +} + +function logScenarioSuccess(scenario: Scenario, retryCount: number): void { + if (verbose) { + console.log(`OK: ${scenario.name} (${retryCount} testcases)`); + } +} + +function logScenarioFailure( + scenario: Scenario, + actionOutput: string, + error: unknown, +): void { + if (verbose) { + return; + } + const message = error instanceof Error ? error.message : String(error); + console.error(`✗ ${scenario.name}: ${message}`); + if (actionOutput.trim()) { + const formatted = formatOutput(actionOutput).trim(); + if (formatted) { + console.error(formatted); + } + } + console.error("Tip: re-run with --verbose for full logs."); +} + +async function dockerComposeDown(): Promise { + await runCommand(["docker", "compose", "down", "--remove-orphans"], { + cwd: projectDir, + allowFailure: true, + label: "docker compose down", + }); +} + +async function dockerComposeUp(): Promise { + await runCommand(["docker", "compose", "up", "-d", "--build"], { + cwd: projectDir, + label: "docker compose up", + }); +} + +function cleanupFiles(tmpDir: string): void { try { + removeDirIfExists(tmpDir); + } catch (error) { if (verbose) { - console.log(`Scenario: ${scenario.name}`); + const message = error instanceof Error ? error.message : String(error); + console.warn(`Failed to remove temp dir: ${message}`); } - const baseCommand = `docker exec ${containerName} vendor/bin/phpunit`; - const command = scenario.testPath - ? `${baseCommand} ${scenario.testPath}` - : baseCommand; + } + try { + removeFileIfExists(junitPath); + } catch (error) { + if (verbose) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`Failed to remove JUnit file: ${message}`); + } + } +} +async function runScenario( + scenario: Scenario, + nodePath: string, + tmpDir: string, +): Promise { + const outputFile = prepareOutputFile(tmpDir, scenario); + let actionResult: RunCommandResult | undefined; + const startedAt = Date.now(); + + removeFileIfExists(junitPath); + + try { + logScenarioStart(scenario); + const command = buildScenarioCommand(scenario); const env = buildActionEnv(command, { repoRoot, outputPath: outputFile, testDir: scenario.testDir, maxAttempts: scenario.maxAttempts, }); + env.GITHUB_OUTPUT = outputFile; - const actionResult = await runCommand([nodePath, distEntry], { + actionResult = await runCommand([nodePath, distEntry], { cwd: repoRoot, env, allowFailure: true, label: `action:${scenario.name}`, }); - actionOutput = actionResult.output; - - if (!fs.existsSync(outputFile)) { - throw new Error(`Expected action outputs file at ${outputFile}`); - } - const outputs = parseOutputs(outputFile); - if (!outputs.total_attempts) { - throw new Error("Missing output: total_attempts"); - } - if (outputs.total_attempts !== String(scenario.expectedAttempts)) { - throw new Error( - `Expected total_attempts=${scenario.expectedAttempts}, got ${outputs.total_attempts}`, - ); - } - if (outputs.success !== scenario.expectedSuccess) { - throw new Error( - `Expected success=${scenario.expectedSuccess}, got ${outputs.success}`, - ); - } - - if (!fs.existsSync(junitPath)) { - throw new Error("Expected JUnit file to exist"); - } - - const xml = fs.readFileSync(junitPath, "utf8"); - const retryCount = (xml.match(/ { - const nodePath = Bun.which("node"); - if (!nodePath) { - throw new Error("node not found in PATH"); +async function runScenarios( + nodePath: string, + tmpDir: string, +): Promise { + const results: ScenarioResult[] = []; + if (!verbose) { + console.log("Integration tests"); + console.log("-----------------"); + console.log(""); } - - if (!fs.existsSync(distEntry)) { - throw new Error("dist/index.js not found; run bun run build first"); + for (const scenario of scenarios) { + const result = await runScenario(scenario, nodePath, tmpDir); + results.push(result); + if (!verbose) { + console.log(`Scenario: ${result.name}`); + console.log(" Result: PASS"); + console.log(` Retry scope: ${result.retryCount} tests`); + console.log(` Duration: ${formatDuration(result.durationMs)}`); + console.log(""); + } } + return results; +} - const tmpDir = fs.mkdtempSync( - path.join(os.tmpdir(), "phpunit-retry-integration-"), +function logSummary(results: ScenarioResult[], durationMs: number): void { + if (verbose) { + console.log("Integration test passed"); + return; + } + const totalTests = results.reduce((sum, r) => sum + r.retryCount, 0); + console.log("Summary"); + console.log("-------"); + console.log( + ` Scenarios: ${results.length}/${scenarios.length} passed`, ); - const runStartedAt = Date.now(); + console.log(` Retry scope total: ${totalTests} tests`); + console.log(` Total time: ${formatDuration(durationMs)}`); + console.log(""); + console.log("Tip: use --verbose for action logs; use --raw for raw output."); +} - await runCommand(["docker", "compose", "down", "--remove-orphans"], { - cwd: projectDir, - allowFailure: true, - label: "docker compose down", - }); +async function runIntegrationTests(): Promise { + const nodePath = getNodePath(); + ensureFileExists(distEntry, "dist/index.js not found; run bun run build first"); + await runPrechecks(); - await runCommand(["docker", "compose", "up", "-d", "--build"], { - cwd: projectDir, - label: "docker compose up", - }); + const tmpDir = createTempDir("phpunit-retry-integration-"); + const runStartedAt = Date.now(); + + await dockerComposeDown(); + await dockerComposeUp(); try { - const results: ScenarioResult[] = []; - if (!verbose) { - console.log(`Running integration tests (${scenarios.length} scenarios)`); - } - for (const scenario of scenarios) { - const result = await runScenario(scenario, nodePath, tmpDir); - results.push(result); - if (!verbose) { - console.log( - `✓ ${result.name} (${result.retryCount} tests, ${formatDuration(result.durationMs)})`, - ); - } - } + const results = await runScenarios(nodePath, tmpDir); const totalDuration = Date.now() - runStartedAt; - if (!verbose) { - const totalTests = results.reduce((sum, r) => sum + r.retryCount, 0); - console.log( - `All passed (${results.length}/${scenarios.length}, ${totalTests} tests) in ${formatDuration(totalDuration)}`, - ); - console.log("Tip: re-run with --verbose for full logs."); - } else { - console.log("Integration test passed"); - } + logSummary(results, totalDuration); } finally { - await runCommand(["docker", "compose", "down", "--remove-orphans"], { - cwd: projectDir, - allowFailure: true, - label: "docker compose down", - }); + await dockerComposeDown(); + cleanupFiles(tmpDir); } } diff --git a/tests/integration/phpunit-project/.gitignore b/tests/integration/resources/phpunit-project/.gitignore similarity index 100% rename from tests/integration/phpunit-project/.gitignore rename to tests/integration/resources/phpunit-project/.gitignore diff --git a/tests/integration/phpunit-project/Dockerfile b/tests/integration/resources/phpunit-project/Dockerfile similarity index 100% rename from tests/integration/phpunit-project/Dockerfile rename to tests/integration/resources/phpunit-project/Dockerfile diff --git a/tests/integration/phpunit-project/composer.json b/tests/integration/resources/phpunit-project/composer.json similarity index 100% rename from tests/integration/phpunit-project/composer.json rename to tests/integration/resources/phpunit-project/composer.json diff --git a/tests/integration/phpunit-project/docker-compose.yml b/tests/integration/resources/phpunit-project/docker-compose.yml similarity index 100% rename from tests/integration/phpunit-project/docker-compose.yml rename to tests/integration/resources/phpunit-project/docker-compose.yml diff --git a/tests/integration/phpunit-project/phpunit.xml b/tests/integration/resources/phpunit-project/phpunit.xml similarity index 100% rename from tests/integration/phpunit-project/phpunit.xml rename to tests/integration/resources/phpunit-project/phpunit.xml diff --git a/tests/integration/phpunit-project/tests/ProjectTest.php b/tests/integration/resources/phpunit-project/tests/ProjectTest.php similarity index 100% rename from tests/integration/phpunit-project/tests/ProjectTest.php rename to tests/integration/resources/phpunit-project/tests/ProjectTest.php diff --git a/tests/integration/phpunit-project/tests/SampleTest.php b/tests/integration/resources/phpunit-project/tests/SampleTest.php similarity index 100% rename from tests/integration/phpunit-project/tests/SampleTest.php rename to tests/integration/resources/phpunit-project/tests/SampleTest.php diff --git a/tests/integration/scenarios.ts b/tests/integration/scenarios.ts new file mode 100644 index 0000000..1759571 --- /dev/null +++ b/tests/integration/scenarios.ts @@ -0,0 +1,83 @@ +export type Scenario = { + name: string; + containerId: string; + testPath?: string; + testDir: string; + maxAttempts: number; + expectedAttempts: number; + expectedSuccess: "true" | "false"; + expectedRetryTests: number; + expectedPresent: string[]; + expectedAbsent: string[]; +}; + +export type ScenarioResult = { + name: string; + retryCount: number; + durationMs: number; +}; + +const testDirInput = "tests/integration/resources/phpunit-project/tests"; + +export const scenarios: Scenario[] = [ + { + name: "Simple Dependencies", + containerId: "simple", + testPath: "tests/SampleTest.php", + testDir: testDirInput, + maxAttempts: 2, + expectedAttempts: 2, + expectedSuccess: "false", + expectedRetryTests: 6, + expectedPresent: [ + "testCreate", + "testUpdate", + "testRead", + "testDelete", + "testMultipleDeps", + "testAnotherFailure", + ], + expectedAbsent: ["testIndependent"], + }, + { + name: "Complex Dependencies", + containerId: "complex", + testPath: "tests/ProjectTest.php", + testDir: testDirInput, + maxAttempts: 2, + expectedAttempts: 2, + expectedSuccess: "false", + expectedRetryTests: 3, + expectedPresent: [ + "testCreateProject", + "testUpdateProject", + "testDeleteProject", + ], + expectedAbsent: ["testProjectValidation", "testListProjects"], + }, + { + name: "Full Test Suite", + containerId: "full", + testDir: testDirInput, + maxAttempts: 2, + expectedAttempts: 2, + expectedSuccess: "false", + expectedRetryTests: 9, + expectedPresent: [ + "testCreate", + "testUpdate", + "testRead", + "testDelete", + "testMultipleDeps", + "testAnotherFailure", + "testCreateProject", + "testUpdateProject", + "testDeleteProject", + ], + expectedAbsent: [ + "testIndependent", + "testProjectValidation", + "testListProjects", + ], + }, +]; diff --git a/tests/integration/utils.ts b/tests/integration/utils.ts index cdeb703..a255558 100644 --- a/tests/integration/utils.ts +++ b/tests/integration/utils.ts @@ -1,4 +1,5 @@ import * as fs from "fs"; +import * as os from "os"; import * as path from "path"; export type RunnerOptions = { @@ -84,13 +85,70 @@ export function buildActionEnv( return env; } +export async function assertCommandOk( + runCommand: (cmd: string[], options?: RunCommandOptions) => Promise, + cmd: string[], + label: string, + message: string, +): Promise { + const result = await runCommand(cmd, { allowFailure: true, label }); + if (result.exitCode !== 0) { + throw new Error(message); + } +} + +export function getNodePath(): string { + const nodePath = Bun.which("node"); + if (!nodePath) { + throw new Error("node not found in PATH"); + } + return nodePath; +} + +export function createTempDir(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +export function ensureFileExists(filePath: string, message: string): void { + if (!fs.existsSync(filePath)) { + throw new Error(message); + } +} + +export function removeFileIfExists(filePath: string): void { + if (fs.existsSync(filePath)) { + fs.rmSync(filePath, { force: true }); + } +} + +export function removeDirIfExists(dirPath: string): void { + if (fs.existsSync(dirPath)) { + fs.rmSync(dirPath, { recursive: true, force: true }); + } +} + export function ensureOutputFile(filePath: string): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); - if (!fs.existsSync(filePath)) { - fs.writeFileSync(filePath, ""); + try { + fs.writeFileSync(filePath, "", { flag: "wx" }); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== "EEXIST") { + throw err; + } } } +export function readOutputsFile(filePath: string): Record { + ensureFileExists(filePath, `Expected action outputs file at ${filePath}`); + return parseOutputs(filePath); +} + +export function readJUnitXml(filePath: string): string { + ensureFileExists(filePath, "Expected JUnit file to exist"); + return fs.readFileSync(filePath, "utf8"); +} + export function parseOutputs(filePath: string): Record { const content = fs.readFileSync(filePath, "utf8"); const outputs: Record = {}; @@ -102,7 +160,7 @@ export function parseOutputs(filePath: string): Record { const delimiterMatch = line.match(/^([^<]+)<<(.+)$/); if (delimiterMatch) { - const key = delimiterMatch[1]!; + const key = parseOutputKey(delimiterMatch[1]!); const delimiter = delimiterMatch[2]!; const valueLines: string[] = []; i++; @@ -110,19 +168,37 @@ export function parseOutputs(filePath: string): Record { valueLines.push(lines[i]!); i++; } + if (i >= lines.length) { + throw new Error( + `Missing delimiter "${delimiter}" for output key "${key}"`, + ); + } outputs[key] = valueLines.join("\n"); continue; } const equalsIndex = line.indexOf("="); - if (equalsIndex === -1) continue; - const key = line.slice(0, equalsIndex); + if (equalsIndex === -1) { + throw new Error(`Malformed output line: ${line}`); + } + const key = parseOutputKey(line.slice(0, equalsIndex)); outputs[key] = line.slice(equalsIndex + 1); } return outputs; } +function parseOutputKey(rawKey: string): string { + const key = rawKey.trim(); + if (!key || key !== rawKey) { + throw new Error(`Invalid output key: "${rawKey}"`); + } + if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key)) { + throw new Error(`Invalid output key: "${rawKey}"`); + } + return key; +} + export function formatOutput(output: string): string { const lines = output.split(/\r?\n/); const formatted: string[] = []; From 2af018044c2056c6fab82176733073062a2f8008 Mon Sep 17 00:00:00 2001 From: Darshan Date: Thu, 25 Dec 2025 13:15:30 +0530 Subject: [PATCH 3/3] update: logging. --- tests/integration/index.ts | 5 +++++ tests/integration/utils.ts | 25 ++++++++++++------------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/tests/integration/index.ts b/tests/integration/index.ts index 83d56ab..ea7f970 100644 --- a/tests/integration/index.ts +++ b/tests/integration/index.ts @@ -40,12 +40,14 @@ async function runPrechecks(): Promise { ["docker", "info"], "docker info", "Docker daemon is not available. Start Docker and try again.", + { logOutput: "never" }, ); await assertCommandOk( runCommand, ["docker", "compose", "version"], "docker compose version", "Docker Compose is not available. Install Docker Compose and try again.", + { logOutput: "never" }, ); } @@ -152,6 +154,7 @@ async function dockerComposeDown(): Promise { cwd: projectDir, allowFailure: true, label: "docker compose down", + logOutput: "on-error", }); } @@ -159,6 +162,7 @@ async function dockerComposeUp(): Promise { await runCommand(["docker", "compose", "up", "-d", "--build"], { cwd: projectDir, label: "docker compose up", + logOutput: "on-error", }); } @@ -208,6 +212,7 @@ async function runScenario( env, allowFailure: true, label: `action:${scenario.name}`, + logOutput: verbose ? "always" : "never", }); const outputs = readOutputsFile(outputFile); diff --git a/tests/integration/utils.ts b/tests/integration/utils.ts index a255558..5872e09 100644 --- a/tests/integration/utils.ts +++ b/tests/integration/utils.ts @@ -12,6 +12,7 @@ export type RunCommandOptions = { env?: Record; allowFailure?: boolean; label?: string; + logOutput?: "always" | "on-error" | "never"; }; export type RunCommandResult = { @@ -90,8 +91,9 @@ export async function assertCommandOk( cmd: string[], label: string, message: string, + options: RunCommandOptions = {}, ): Promise { - const result = await runCommand(cmd, { allowFailure: true, label }); + const result = await runCommand(cmd, { allowFailure: true, label, ...options }); if (result.exitCode !== 0) { throw new Error(message); } @@ -132,9 +134,9 @@ export function ensureOutputFile(filePath: string): void { try { fs.writeFileSync(filePath, "", { flag: "wx" }); } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err.code !== "EEXIST") { - throw err; + const err = error as { code?: string }; + if (err?.code !== "EEXIST") { + throw error; } } } @@ -246,21 +248,18 @@ export function createCommandRunner(options: RunnerOptions): { }); const output = captureOutput ? await collectOutput(proc) : ""; const exitCode = await proc.exited; + const logMode = runOptions.logOutput ?? (verbose ? "always" : "on-error"); + const formatted = output.trim() ? formatOutput(output).trim() : ""; - if (verbose && captureOutput && output.trim()) { - const formatted = formatOutput(output).trim(); - if (formatted) { + if (formatted) { + if (logMode === "always") { console.log(formatted); + } else if (logMode === "on-error" && exitCode !== 0) { + console.error(formatted); } } if (exitCode !== 0 && !runOptions.allowFailure) { - if (!verbose && output.trim()) { - const formatted = formatOutput(output).trim(); - if (formatted) { - console.error(formatted); - } - } const label = runOptions.label || cmd.join(" "); throw new Error(`Command failed: ${label} (exit ${exitCode})`); }