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..b52dfce 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,6 @@ on: - 'bun.lock' pull_request: - branches: [main] paths: - 'src/**' - 'tests/**' @@ -52,129 +51,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..63f0892 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..2e696f7 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/resources/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..ea7f970 --- /dev/null +++ b/tests/integration/index.ts @@ -0,0 +1,304 @@ +import * as path from "path"; +import { + buildActionEnv, + assertCommandOk, + createTempDir, + createCommandRunner, + ensureFileExists, + ensureOutputFile, + formatOutput, + formatDuration, + getNodePath, + readJUnitXml, + readOutputsFile, + removeDirIfExists, + removeFileIfExists, + type RunCommandResult, +} from "./utils"; +import { scenarios, type Scenario, type ScenarioResult } from "./scenarios"; + +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", + "resources", + "phpunit-project", +); +const containerName = "phpunit-retry-test"; + +async function runPrechecks(): Promise { + await assertCommandOk( + runCommand, + ["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" }, + ); +} + +function prepareOutputFile(tmpDir: string, scenario: Scenario): string { + const outputFile = path.join(tmpDir, `outputs-${scenario.containerId}.txt`); + removeFileIfExists(outputFile); + ensureOutputFile(outputFile); + + 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, +): 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 retryCount = testcaseNames.length; + if (retryCount !== scenario.expectedRetryTests) { + throw new Error( + `Expected ${scenario.expectedRetryTests} testcases on retry, got ${retryCount}`, + ); + } + + const testcaseSet = new Set(testcaseNames); + + for (const name of scenario.expectedPresent) { + if (!testcaseSet.has(name)) { + throw new Error(`Missing expected test name: ${name}`); + } + } + + for (const name of scenario.expectedAbsent) { + if (testcaseSet.has(name)) { + throw new Error(`Unexpected test name on retry: ${name}`); + } + } + + 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", + logOutput: "on-error", + }); +} + +async function dockerComposeUp(): Promise { + await runCommand(["docker", "compose", "up", "-d", "--build"], { + cwd: projectDir, + label: "docker compose up", + logOutput: "on-error", + }); +} + +function cleanupFiles(tmpDir: string): void { + try { + removeDirIfExists(tmpDir); + } catch (error) { + if (verbose) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`Failed to remove temp dir: ${message}`); + } + } + 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; + + actionResult = await runCommand([nodePath, distEntry], { + cwd: repoRoot, + env, + allowFailure: true, + label: `action:${scenario.name}`, + logOutput: verbose ? "always" : "never", + }); + + const outputs = readOutputsFile(outputFile); + validateOutputs(outputs, scenario); + + const xml = readJUnitXml(junitPath); + const retryCount = validateRetryScope(xml, scenario); + const durationMs = Date.now() - startedAt; + logScenarioSuccess(scenario, retryCount); + return { name: scenario.name, retryCount, durationMs }; + } catch (error) { + logScenarioFailure(scenario, actionResult?.output ?? "", error); + throw error; + } +} + +async function runScenarios( + nodePath: string, + tmpDir: string, +): Promise { + const results: ScenarioResult[] = []; + if (!verbose) { + console.log("Integration tests"); + console.log("-----------------"); + console.log(""); + } + 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; +} + +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`, + ); + 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."); +} + +async function runIntegrationTests(): Promise { + const nodePath = getNodePath(); + ensureFileExists(distEntry, "dist/index.js not found; run bun run build first"); + await runPrechecks(); + + const tmpDir = createTempDir("phpunit-retry-integration-"); + const runStartedAt = Date.now(); + + await dockerComposeDown(); + await dockerComposeUp(); + + try { + const results = await runScenarios(nodePath, tmpDir); + const totalDuration = Date.now() - runStartedAt; + logSummary(results, totalDuration); + } finally { + await dockerComposeDown(); + cleanupFiles(tmpDir); + } +} + +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/.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/resources/phpunit-project/docker-compose.yml b/tests/integration/resources/phpunit-project/docker-compose.yml new file mode 100644 index 0000000..f60147a --- /dev/null +++ b/tests/integration/resources/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/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 new file mode 100644 index 0000000..5872e09 --- /dev/null +++ b/tests/integration/utils.ts @@ -0,0 +1,271 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +export type RunnerOptions = { + verbose: boolean; + rawLogs: boolean; +}; + +export type RunCommandOptions = { + cwd?: string; + env?: Record; + allowFailure?: boolean; + label?: string; + logOutput?: "always" | "on-error" | "never"; +}; + +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 async function assertCommandOk( + runCommand: (cmd: string[], options?: RunCommandOptions) => Promise, + cmd: string[], + label: string, + message: string, + options: RunCommandOptions = {}, +): Promise { + const result = await runCommand(cmd, { allowFailure: true, label, ...options }); + 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 }); + try { + fs.writeFileSync(filePath, "", { flag: "wx" }); + } catch (error) { + const err = error as { code?: string }; + if (err?.code !== "EEXIST") { + throw error; + } + } +} + +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 = {}; + 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 = parseOutputKey(delimiterMatch[1]!); + const delimiter = delimiterMatch[2]!; + const valueLines: string[] = []; + i++; + while (i < lines.length && lines[i] !== delimiter) { + 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) { + 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[] = []; + + 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; + const logMode = runOptions.logOutput ?? (verbose ? "always" : "on-error"); + const formatted = output.trim() ? formatOutput(output).trim() : ""; + + if (formatted) { + if (logMode === "always") { + console.log(formatted); + } else if (logMode === "on-error" && exitCode !== 0) { + console.error(formatted); + } + } + + if (exitCode !== 0 && !runOptions.allowFailure) { + 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();