diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..5397f59
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,38 @@
+name: Tests
+
+on:
+ push:
+ branches: [ main, develop ]
+ pull_request:
+ branches: [ main, develop ]
+
+jobs:
+ test:
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest]
+ php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
+
+ name: PHP ${{ matrix.php }} - ${{ matrix.os }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: dom, curl, libxml, mbstring, zip
+ coverage: none
+
+ - name: Install xmlstarlet
+ run: sudo apt-get update && sudo apt-get install -y xmlstarlet
+
+ - name: Install dependencies
+ run: composer update --prefer-dist --no-progress
+
+ - name: Run test suite
+ run: ./vendor/bin/phpunit --testdox
diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml
new file mode 100644
index 0000000..277821c
--- /dev/null
+++ b/.github/workflows/update-changelog.yml
@@ -0,0 +1,32 @@
+name: "Update Changelog"
+
+on:
+ release:
+ types: [released]
+
+permissions:
+ contents: write
+
+jobs:
+ update:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v5
+ with:
+ ref: main
+
+ - name: Update Changelog
+ uses: stefanzweifel/changelog-updater-action@v1
+ with:
+ latest-version: ${{ github.event.release.name }}
+ release-notes: ${{ github.event.release.body }}
+
+ - name: Commit updated CHANGELOG
+ uses: stefanzweifel/git-auto-commit-action@v7
+ with:
+ branch: main
+ commit_message: Update CHANGELOG
+ file_pattern: CHANGELOG.md
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..dddbc6a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+vendor
+.phpunit.cache
+composer.lock
diff --git a/README.md b/README.md
index 3635a44..ba6b668 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,16 @@ For XmlStarlet on other platforms, see [here](http://xmlstar.sourceforge.net/doc
./vendor/bin/phpunit-failed-runner
```
+## Testing
+
+This package includes a comprehensive test suite that demonstrates the incremental test-fixing workflow.
+
+Run the test suite:
+
+```bash
+composer test
+```
+
### Changelog
Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently.
diff --git a/bin/phpunit-failed-runner b/bin/phpunit-failed-runner
index 564a529..7bc4eb4 100755
--- a/bin/phpunit-failed-runner
+++ b/bin/phpunit-failed-runner
@@ -8,10 +8,22 @@ else
runner="./vendor/bin/phpunit"
fi
+# Determine XSL path - check if we're in the package itself or using it as a dependency
+if [ -f "./prune.xsl" ]; then
+ # We're in the package root
+ xsl_path="."
+elif [ -f "./vendor/chrisdicarlo/phpunit-failed-runner/prune.xsl" ]; then
+ # We're using the package as a dependency
+ xsl_path="./vendor/chrisdicarlo/phpunit-failed-runner"
+else
+ echo "Error: Cannot find XSL transformation files"
+ exit 1
+fi
+
if test -f "$logfile"; then
echo -e "Logfile found. Searching for previously failing tests... \U23F3"
- count_failed_tests="$(xmlstarlet tr ./vendor/chrisdicarlo/phpunit-failed-runner/prune.xsl junit.xml | xmlstarlet tr --omit-decl ./vendor/chrisdicarlo/phpunit-failed-runner/count-failed.xsl)"
+ count_failed_tests="$(xmlstarlet tr "$xsl_path/prune.xsl" junit.xml | xmlstarlet tr --omit-decl "$xsl_path/count-failed.xsl")"
if [ "$count_failed_tests" = "0" ]; then
echo -e "No failed tests! Great job! \U1F44D \U1F389"
@@ -19,7 +31,7 @@ if test -f "$logfile"; then
exit 0
else
echo -e "Found $count_failed_tests previously failing tests, filtering... \U1F97A"
- filter=$(xmlstarlet tr ./vendor/chrisdicarlo/phpunit-failed-runner/prune.xsl junit.xml | xmlstarlet tr --omit-decl ./vendor/chrisdicarlo/phpunit-failed-runner/failed-tests.xsl); "$runner" --filter "$filter" --log-junit junit.xml
+ filter=$(xmlstarlet tr "$xsl_path/prune.xsl" junit.xml | xmlstarlet tr --omit-decl "$xsl_path/failed-tests.xsl"); "$runner" --filter "$filter" --log-junit junit.xml
fi
else
echo -e "Logfile not found. Running the test suite... \U1F91E"
@@ -29,7 +41,7 @@ else
fi
if test -f "$logfile"; then
- count_failed_tests="$(xmlstarlet tr ./vendor/chrisdicarlo/phpunit-failed-runner/prune.xsl junit.xml | xmlstarlet tr --omit-decl ./vendor/chrisdicarlo/phpunit-failed-runner/count-failed.xsl)"
+ count_failed_tests="$(xmlstarlet tr "$xsl_path/prune.xsl" junit.xml | xmlstarlet tr --omit-decl "$xsl_path/count-failed.xsl")"
if [ "$count_failed_tests" = "0" ]; then
echo -e "No failed tests! Great job! \U1F44D \U1F389"
diff --git a/composer.json b/composer.json
index dc23a5c..b5986dd 100644
--- a/composer.json
+++ b/composer.json
@@ -19,5 +19,16 @@
],
"bin": [
"bin/phpunit-failed-runner"
- ]
+ ],
+ "require-dev": {
+ "phpunit/phpunit": "^8.5|^9.6|^10.5|^11.0"
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Tests\\": "tests/"
+ }
+ },
+ "scripts": {
+ "test": "phpunit"
+ }
}
diff --git a/phpunit-integration-fixtures.xml b/phpunit-integration-fixtures.xml
new file mode 100644
index 0000000..cbd0c63
--- /dev/null
+++ b/phpunit-integration-fixtures.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ tests/AlwaysPassingTest.php
+ tests/InitiallyFailingTest.php
+ tests/AnotherFailingTest.php
+
+
+
+
+ bin
+
+
+
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..fede57b
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,20 @@
+
+
+
+
+ tests
+ tests/Fixtures
+
+
+
+
+ bin
+
+
+
diff --git a/tests/AlwaysPassingTest.php b/tests/AlwaysPassingTest.php
new file mode 100644
index 0000000..8251285
--- /dev/null
+++ b/tests/AlwaysPassingTest.php
@@ -0,0 +1,26 @@
+assertTrue(
+ $fixture->success(),
+ 'AlwaysPassingFixture should always return true'
+ );
+ }
+
+ public function test_another_always_passing_test(): void
+ {
+ $fixture = new AlwaysPassingFixture();
+
+ $this->assertTrue($fixture->success());
+ }
+}
diff --git a/tests/AnotherFailingTest.php b/tests/AnotherFailingTest.php
new file mode 100644
index 0000000..3404e85
--- /dev/null
+++ b/tests/AnotherFailingTest.php
@@ -0,0 +1,19 @@
+assertTrue(
+ $fixture->success(),
+ 'This test will also fail initially'
+ );
+ }
+}
diff --git a/tests/Fixtures/AlwaysPassingFixture.php b/tests/Fixtures/AlwaysPassingFixture.php
new file mode 100644
index 0000000..f24b4c3
--- /dev/null
+++ b/tests/Fixtures/AlwaysPassingFixture.php
@@ -0,0 +1,14 @@
+assertTrue(
+ $fixture->success(),
+ 'This test will fail initially but pass after the fixture is fixed'
+ );
+ }
+
+ public function test_another_initially_failing_test(): void
+ {
+ $fixture = new InitiallyFailingFixture();
+
+ $this->assertTrue(
+ $fixture->success(),
+ 'Another test that depends on the same fixture'
+ );
+ }
+}
diff --git a/tests/Integration/ScriptBehaviorTest.php b/tests/Integration/ScriptBehaviorTest.php
new file mode 100644
index 0000000..80c8631
--- /dev/null
+++ b/tests/Integration/ScriptBehaviorTest.php
@@ -0,0 +1,293 @@
+projectRoot = dirname(__DIR__, 2);
+ $this->junitPath = $this->projectRoot . '/junit.xml';
+ $this->scriptPath = $this->projectRoot . '/bin/phpunit-failed-runner';
+ $this->fixtureFilePath = $this->projectRoot . '/tests/Fixtures/InitiallyFailingFixture.php';
+
+ // Clean up any existing junit.xml before each test
+ if (file_exists($this->junitPath)) {
+ unlink($this->junitPath);
+ }
+
+ // Backup the fixture file
+ $this->originalFixtureContent = file_get_contents($this->fixtureFilePath);
+ }
+
+ protected function tearDown(): void
+ {
+ // Restore the fixture file
+ if ($this->originalFixtureContent !== null) {
+ file_put_contents($this->fixtureFilePath, $this->originalFixtureContent);
+ }
+
+ // Clean up junit.xml after each test
+ if (file_exists($this->junitPath)) {
+ unlink($this->junitPath);
+ }
+ }
+
+ /**
+ * Execute the script and capture output
+ * Uses phpunit-integration-fixtures.xml to only run fixture tests
+ */
+ private function runScript(?string &$output = null): int
+ {
+ $output = [];
+ $exitCode = 0;
+
+ // Use the fixtures config to only run fixture tests (not the full suite)
+ exec("cd {$this->projectRoot} && {$this->scriptPath} -c phpunit-integration-fixtures.xml 2>&1", $output, $exitCode);
+ $output = implode("\n", $output);
+
+ return $exitCode;
+ }
+
+ /**
+ * Make the fixture fail by changing return true to return false
+ */
+ private function makeFixtureFail(): void
+ {
+ $content = file_get_contents($this->fixtureFilePath);
+ $content = str_replace('return true;', 'return false;', $content);
+ file_put_contents($this->fixtureFilePath, $content);
+ }
+
+ /**
+ * Make the fixture pass by changing return false to return true
+ */
+ private function makeFixturePass(): void
+ {
+ $content = file_get_contents($this->fixtureFilePath);
+ $content = str_replace('return false;', 'return true;', $content);
+ file_put_contents($this->fixtureFilePath, $content);
+ }
+
+ public function test_script_runs_full_suite_when_no_logfile_exists(): void
+ {
+ // Arrange: No junit.xml exists, make fixture fail so junit.xml persists
+ $this->assertFileDoesNotExist($this->junitPath);
+ $this->makeFixtureFail();
+
+ // Act: Run the script
+ $exitCode = $this->runScript($output);
+
+ // Assert: Should run full suite
+ $this->assertEquals(0, $exitCode, 'Script should exit successfully');
+ $this->assertStringContainsString('Logfile not found', $output);
+ $this->assertStringContainsString('Running the test suite', $output);
+
+ // junit.xml should be created and persist (because test failed)
+ $this->assertFileExists($this->junitPath);
+ }
+
+ public function test_script_removes_logfile_when_all_tests_pass(): void
+ {
+ // Arrange: Make fixture fail first, run script to create junit.xml
+ $this->makeFixtureFail();
+ $this->runScript($firstOutput);
+ $this->assertFileExists($this->junitPath, 'junit.xml should exist after failed run');
+
+ // Now fix the fixture
+ $this->makeFixturePass();
+
+ // Act: Run script again (it should find the logfile with no failures)
+ $exitCode = $this->runScript($output);
+
+ // Assert: Should clean up the logfile
+ $this->assertEquals(0, $exitCode);
+ $this->assertStringContainsString('Logfile found', $output);
+ $this->assertStringContainsString('No failed tests', $output);
+ $this->assertStringContainsString('Great job', $output);
+
+ // junit.xml should be removed
+ $this->assertFileDoesNotExist($this->junitPath);
+ }
+
+ public function test_script_filters_and_reruns_only_failing_tests(): void
+ {
+ // Arrange: Make fixture fail, run script to create junit.xml with failures
+ $this->makeFixtureFail();
+ $this->runScript($firstOutput);
+
+ $this->assertFileExists($this->junitPath);
+ $this->assertStringContainsString('Logfile not found', $firstOutput);
+
+ // Act: Run script again (should detect failures and rerun only those tests)
+ $exitCode = $this->runScript($secondOutput);
+
+ // Assert: Should find and filter failed tests
+ $this->assertEquals(0, $exitCode);
+ $this->assertStringContainsString('Logfile found', $secondOutput);
+ $this->assertStringContainsString('Searching for previously failing tests', $secondOutput);
+ $this->assertStringContainsString('previously failing tests, filtering', $secondOutput);
+
+ // junit.xml should still exist (tests still failing)
+ $this->assertFileExists($this->junitPath);
+ }
+
+ public function test_full_workflow_fail_then_fix_then_cleanup(): void
+ {
+ // Phase 1: Run with failing test
+ $this->makeFixtureFail();
+ $exitCode1 = $this->runScript($output1);
+
+ $this->assertEquals(0, $exitCode1, 'Script should exit successfully even with failures');
+ $this->assertStringContainsString('Logfile not found', $output1);
+ $this->assertFileExists($this->junitPath);
+
+ // Phase 2: Run again (should detect and rerun failures)
+ $exitCode2 = $this->runScript($output2);
+
+ $this->assertEquals(0, $exitCode2);
+ $this->assertStringContainsString('Logfile found', $output2);
+ $this->assertStringContainsString('previously failing tests', $output2);
+ $this->assertFileExists($this->junitPath);
+
+ // Phase 3: Fix the test and run again
+ $this->makeFixturePass();
+ $exitCode3 = $this->runScript($output3);
+
+ // Assert: After fixing, should clean up
+ $this->assertEquals(0, $exitCode3);
+ $this->assertStringContainsString('Logfile found', $output3);
+ $this->assertStringContainsString('No failed tests', $output3);
+ $this->assertFileDoesNotExist($this->junitPath, 'junit.xml should be cleaned up after all tests pass');
+ }
+
+ public function test_script_counts_failed_tests_correctly(): void
+ {
+ // Arrange: Make fixture fail
+ $this->makeFixtureFail();
+ $this->runScript();
+
+ // Act: Run again and check the count in output
+ $this->runScript($output);
+
+ // Assert: Output should mention the count of failed tests
+ $this->assertStringContainsString('Logfile found', $output);
+
+ // The script should report finding failed tests
+ $this->assertMatchesRegularExpression(
+ '/Found \d+ previously failing tests/',
+ $output,
+ 'Should report count of failed tests'
+ );
+ }
+
+ public function test_script_exits_with_zero_on_success(): void
+ {
+ // Arrange: All tests passing
+ $this->makeFixturePass();
+
+ // Act: Run script
+ $exitCode = $this->runScript();
+
+ // Assert: Should exit with 0
+ $this->assertEquals(0, $exitCode, 'Script should always exit with code 0');
+ }
+
+ public function test_script_creates_valid_junit_xml(): void
+ {
+ // Arrange: Make fixture fail so junit.xml persists
+ $this->makeFixtureFail();
+
+ // Act: Run script
+ $this->runScript();
+
+ // Assert: junit.xml should be valid XML
+ $this->assertFileExists($this->junitPath);
+
+ $xml = @simplexml_load_file($this->junitPath);
+ $this->assertNotFalse($xml, 'junit.xml should be valid XML');
+ $this->assertEquals('testsuites', $xml->getName());
+ }
+
+ public function test_script_handles_no_failures_on_first_run(): void
+ {
+ // Arrange: All tests passing
+ $this->makeFixturePass();
+
+ // Act: Run script (first run, no logfile)
+ $exitCode = $this->runScript($output);
+
+ // Assert: Should run full suite and clean up immediately if all pass
+ $this->assertEquals(0, $exitCode);
+ $this->assertStringContainsString('Logfile not found', $output);
+
+ // The logfile might be cleaned up immediately after first run if all tests pass
+ // Check if we see success message
+ if (!file_exists($this->junitPath)) {
+ $this->assertStringContainsString('No failed tests', $output);
+ }
+ }
+
+ public function test_script_output_contains_expected_messages(): void
+ {
+ // Test that the script outputs the expected emoji/messages
+ $this->makeFixtureFail();
+ $this->runScript($firstOutput);
+
+ // First run: should see "Logfile not found"
+ $this->assertStringContainsString('Logfile not found', $firstOutput);
+ $this->assertStringContainsString('Running the test suite', $firstOutput);
+
+ // Second run: should see "Logfile found"
+ $this->runScript($secondOutput);
+ $this->assertStringContainsString('Logfile found', $secondOutput);
+ $this->assertStringContainsString('Searching for previously failing tests', $secondOutput);
+ }
+
+ public function test_script_persists_junit_xml_when_tests_fail(): void
+ {
+ // Arrange: Make fixture fail
+ $this->makeFixtureFail();
+
+ // Act: Run script
+ $this->runScript();
+
+ // Assert: junit.xml should persist (not be deleted)
+ $this->assertFileExists($this->junitPath, 'junit.xml should not be deleted when tests fail');
+ }
+
+ public function test_script_uses_xmlstarlet_for_transformations(): void
+ {
+ // This test verifies xmlstarlet is available (required dependency)
+ // Act: Run script
+ $exitCode = $this->runScript($output);
+
+ // Assert: Should not contain errors about xmlstarlet
+ $this->assertStringNotContainsString('xmlstarlet: command not found', $output);
+ $this->assertStringNotContainsString('xmlstarlet: not found', $output);
+ }
+
+ public function test_script_detects_xsl_files_in_project_root(): void
+ {
+ // Act: Run script
+ $exitCode = $this->runScript($output);
+
+ // Assert: Should not contain XSL path detection errors
+ $this->assertStringNotContainsString('Cannot find XSL transformation files', $output);
+ $this->assertEquals(0, $exitCode);
+ }
+}
diff --git a/tests/Unit/XslTransformationTest.php b/tests/Unit/XslTransformationTest.php
new file mode 100644
index 0000000..bfb5da9
--- /dev/null
+++ b/tests/Unit/XslTransformationTest.php
@@ -0,0 +1,195 @@
+projectRoot = dirname(__DIR__, 1);
+ }
+
+ public function test_prune_xsl_extracts_failed_tests_from_junit_xml(): void
+ {
+ $junitXml = <<
+
+
+
+
+
+ Failed
+
+
+ Failed
+
+
+
+XML;
+
+ $tempFile = tempnam(sys_get_temp_dir(), 'junit');
+ file_put_contents($tempFile, $junitXml);
+
+ $output = shell_exec("xmlstarlet tr {$this->projectRoot}/../prune.xsl {$tempFile}");
+
+ $this->assertStringContainsString('', $output);
+ $this->assertStringContainsString('Tests\FailingTest::test_failing_one', $output);
+ $this->assertStringContainsString('Tests\AnotherTest::test_failing_two', $output);
+ $this->assertStringNotContainsString('PassingTest', $output);
+
+ unlink($tempFile);
+ }
+
+ public function test_prune_xsl_handles_errors_as_failures(): void
+ {
+ $junitXml = <<
+
+
+
+ Error occurred
+
+
+ Failed
+
+
+
+XML;
+
+ $tempFile = tempnam(sys_get_temp_dir(), 'junit');
+ file_put_contents($tempFile, $junitXml);
+
+ $output = shell_exec("xmlstarlet tr {$this->projectRoot}/../prune.xsl {$tempFile}");
+
+ $this->assertStringContainsString('ErrorTest::test_with_error', $output);
+ $this->assertStringContainsString('FailureTest::test_with_failure', $output);
+
+ unlink($tempFile);
+ }
+
+ public function test_count_failed_xsl_counts_tests_correctly(): void
+ {
+ $prunedXml = <<
+
+ Tests\FailingTest::test_one
+ Tests\FailingTest::test_two
+ Tests\AnotherTest::test_three
+
+XML;
+
+ $tempFile = tempnam(sys_get_temp_dir(), 'pruned');
+ file_put_contents($tempFile, $prunedXml);
+
+ $count = trim(shell_exec("xmlstarlet tr --omit-decl {$this->projectRoot}/../count-failed.xsl {$tempFile}"));
+
+ $this->assertEquals('3', $count);
+
+ unlink($tempFile);
+ }
+
+ public function test_count_failed_xsl_returns_zero_for_no_failures(): void
+ {
+ $prunedXml = <<
+
+
+XML;
+
+ $tempFile = tempnam(sys_get_temp_dir(), 'pruned');
+ file_put_contents($tempFile, $prunedXml);
+
+ $count = trim(shell_exec("xmlstarlet tr --omit-decl {$this->projectRoot}/../count-failed.xsl {$tempFile}"));
+
+ $this->assertEquals('0', $count);
+
+ unlink($tempFile);
+ }
+
+ public function test_failed_tests_xsl_creates_phpunit_filter(): void
+ {
+ $prunedXml = <<
+
+ Tests\FailingTest::test_one
+ Tests\FailingTest::test_two
+ Tests\AnotherTest::test_three
+
+XML;
+
+ $tempFile = tempnam(sys_get_temp_dir(), 'pruned');
+ file_put_contents($tempFile, $prunedXml);
+
+ $filter = trim(shell_exec("xmlstarlet tr --omit-decl {$this->projectRoot}/../failed-tests.xsl {$tempFile}"));
+
+ $this->assertStringContainsString('Tests\\\\FailingTest::test_one', $filter);
+ $this->assertStringContainsString('Tests\\\\FailingTest::test_two', $filter);
+ $this->assertStringContainsString('Tests\\\\AnotherTest::test_three', $filter);
+ $this->assertStringContainsString('|', $filter); // Tests are separated by |
+
+ unlink($tempFile);
+ }
+
+ public function test_failed_tests_xsl_escapes_backslashes(): void
+ {
+ $prunedXml = <<
+
+ Tests\Namespace\ClassName::testMethod
+
+XML;
+
+ $tempFile = tempnam(sys_get_temp_dir(), 'pruned');
+ file_put_contents($tempFile, $prunedXml);
+
+ $filter = trim(shell_exec("xmlstarlet tr --omit-decl {$this->projectRoot}/../failed-tests.xsl {$tempFile}"));
+
+ // Backslashes should be escaped for PHPUnit filter
+ $this->assertStringContainsString('Tests\\\\Namespace\\\\ClassName::testMethod', $filter);
+
+ unlink($tempFile);
+ }
+
+ public function test_full_transformation_pipeline(): void
+ {
+ $junitXml = <<
+
+
+
+
+
+ Failed
+
+
+
+ Failed
+
+
+
+XML;
+
+ $tempFile = tempnam(sys_get_temp_dir(), 'junit');
+ file_put_contents($tempFile, $junitXml);
+
+ // Run full pipeline: prune -> count
+ $count = trim(shell_exec("xmlstarlet tr {$this->projectRoot}/../prune.xsl {$tempFile} | xmlstarlet tr --omit-decl {$this->projectRoot}/../count-failed.xsl"));
+ $this->assertEquals('2', $count);
+
+ // Run full pipeline: prune -> failed-tests
+ $filter = trim(shell_exec("xmlstarlet tr {$this->projectRoot}/../prune.xsl {$tempFile} | xmlstarlet tr --omit-decl {$this->projectRoot}/../failed-tests.xsl"));
+ $this->assertStringContainsString('Tests\\\\FailingTest::test_fail_1', $filter);
+ $this->assertStringContainsString('Tests\\\\FailingTest::test_fail_2', $filter);
+ $this->assertStringContainsString('|', $filter);
+
+ unlink($tempFile);
+ }
+}