diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..a6e38be
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,78 @@
+name: CI
+
+on:
+ push:
+
+ pull_request:
+ branches:
+ - '*'
+
+permissions:
+ contents: read
+
+jobs:
+ testsuite:
+ name: PHP ${{ matrix.php-version }}
+ runs-on: ubuntu-24.04
+ strategy:
+ fail-fast: false
+ matrix:
+ # composer.json declares php >=8.1 and cakephp/cakephp ^5.0.
+ # CakePHP 5.0-5.2 allow PHP 8.1; 5.3+ requires PHP 8.2, so on 8.1
+ # composer resolves 5.2.x and on 8.2+ the latest 5.x. Test the whole
+ # declared range: 8.1 (min) -> 8.5 (newest).
+ php-version: ['8.1', '8.2', '8.3', '8.4', '8.5']
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ extensions: mbstring, intl, apcu, sqlite3, pdo_sqlite
+ ini-values: apc.enable_cli=1
+ coverage: none
+
+ - name: Get composer cache directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
+
+ - name: Cache composer dependencies
+ uses: actions/cache@v4
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ matrix.php-version }}-${{ hashFiles('composer.json') }}
+ restore-keys: ${{ runner.os }}-composer-${{ matrix.php-version }}-
+
+ - name: Install dependencies
+ # composer.lock is gitignored, so resolve dependencies on each run.
+ run: composer update --prefer-dist --no-interaction --no-progress
+
+ - name: Run PHPUnit
+ run: vendor/bin/phpunit
+
+ cs-stan:
+ name: Coding Standard & Static Analysis
+ runs-on: ubuntu-24.04
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.2'
+ extensions: mbstring, intl, apcu
+ coverage: none
+
+ - name: Install dependencies
+ run: composer update --prefer-dist --no-interaction --no-progress
+
+ - name: Run PHP CodeSniffer
+ run: composer cs-check
+
+ # NOTE: phpstan/psalm are pinned to 1.10.14 / 5.10.0 in .phive/phars.xml;
+ # those versions do not run on PHP 8.2+ (fatal in better-reflection) and
+ # report pre-existing findings. Modernize the pins in a separate change
+ # before wiring them back into CI as a blocking step.
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..bd68de9
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+ tests/TestCase/
+
+
+
+
+
+ src/
+
+
+
diff --git a/src/TestSuite/Fixture/Extension/PHPUnitBeforeTestMethodCalledSubscriber.php b/src/TestSuite/Fixture/Extension/PHPUnitBeforeTestMethodCalledSubscriber.php
deleted file mode 100644
index 9761215..0000000
--- a/src/TestSuite/Fixture/Extension/PHPUnitBeforeTestMethodCalledSubscriber.php
+++ /dev/null
@@ -1,35 +0,0 @@
-begin();
- }
-}
diff --git a/src/TestSuite/Fixture/Extension/PHPUnitExtension.php b/src/TestSuite/Fixture/Extension/PHPUnitExtension.php
index 978bd0d..81203ca 100644
--- a/src/TestSuite/Fixture/Extension/PHPUnitExtension.php
+++ b/src/TestSuite/Fixture/Extension/PHPUnitExtension.php
@@ -37,7 +37,7 @@ public function bootstrap(Configuration $configuration, Facade $facade, Paramete
$facade->registerSubscribers(
new PHPUnitStartedSubscriber($fixtureManager),
- new PHPUnitBeforeTestMethodCalledSubscriber(),
+ new PHPUnitPreparationStartedSubscriber(),
new PHPUnitAfterTestMethodFinishedSubscriber()
);
}
diff --git a/src/TestSuite/Fixture/Extension/PHPUnitPreparationStartedSubscriber.php b/src/TestSuite/Fixture/Extension/PHPUnitPreparationStartedSubscriber.php
new file mode 100644
index 0000000..fc4a38f
--- /dev/null
+++ b/src/TestSuite/Fixture/Extension/PHPUnitPreparationStartedSubscriber.php
@@ -0,0 +1,45 @@
+inTransaction()) {
+ $connection->rollback(true);
+ }
+ $connection->begin();
+ }
+}
diff --git a/tests/TestCase/TestSuite/Fixture/Extension/PHPUnitPreparationStartedSubscriberTest.php b/tests/TestCase/TestSuite/Fixture/Extension/PHPUnitPreparationStartedSubscriberTest.php
new file mode 100644
index 0000000..082c9a5
--- /dev/null
+++ b/tests/TestCase/TestSuite/Fixture/Extension/PHPUnitPreparationStartedSubscriberTest.php
@@ -0,0 +1,167 @@
+connection = new class extends Connection {
+ /**
+ * @var array
+ */
+ public array $calls = [];
+
+ /**
+ * @var bool
+ */
+ public bool $inTransaction = false;
+
+ /**
+ * Skip the parent constructor, the test never touches the driver.
+ */
+ public function __construct()
+ {
+ }
+
+ /**
+ * @return bool
+ */
+ public function inTransaction(): bool
+ {
+ return $this->inTransaction;
+ }
+
+ /**
+ * @return void
+ */
+ public function begin(): void
+ {
+ $this->calls[] = 'begin';
+ $this->inTransaction = true;
+ }
+
+ /**
+ * @param bool|null $toBeginning Whether the transaction should be rolled back to the beginning.
+ * @return bool
+ */
+ public function rollback(?bool $toBeginning = null): bool
+ {
+ $this->calls[] = 'rollback';
+ $this->inTransaction = false;
+
+ return true;
+ }
+ };
+
+ ConnectionManager::drop('test');
+ ConnectionManager::setConfig('test', $this->connection);
+ }
+
+ /**
+ * The subscriber opens the per-test transaction on the PreparationStarted event.
+ *
+ * @return void
+ */
+ public function testOpensTransactionOnPreparationStarted(): void
+ {
+ $subscriber = new PHPUnitPreparationStartedSubscriber();
+ $this->assertInstanceOf(PreparationStartedSubscriber::class, $subscriber);
+
+ $subscriber->notify($this->preparationStartedEvent());
+
+ $this->assertSame(['begin'], $this->connection->calls);
+ }
+
+ /**
+ * A transaction left open by a previous test is rolled back before beginning.
+ *
+ * @return void
+ */
+ public function testRollsBackAbandonedTransactionBeforeBeginning(): void
+ {
+ $this->connection->inTransaction = true;
+
+ (new PHPUnitPreparationStartedSubscriber())->notify($this->preparationStartedEvent());
+
+ $this->assertSame(['rollback', 'begin'], $this->connection->calls);
+ }
+
+ /**
+ * Builds a PreparationStarted event; the subscriber ignores the payload.
+ *
+ * @return \PHPUnit\Event\Test\PreparationStarted
+ */
+ protected function preparationStartedEvent(): PreparationStarted
+ {
+ $snapshot = new Snapshot(
+ HRTime::fromSecondsAndNanoseconds(0, 0),
+ MemoryUsage::fromBytes(0),
+ MemoryUsage::fromBytes(0),
+ new GarbageCollectorStatus(0, 0, 0, 0, null, null, null, null, null, null, null, null),
+ );
+ $telemetry = new Info(
+ $snapshot,
+ Duration::fromSecondsAndNanoseconds(0, 0),
+ MemoryUsage::fromBytes(0),
+ Duration::fromSecondsAndNanoseconds(0, 0),
+ MemoryUsage::fromBytes(0),
+ );
+ $test = new class ('PreparationStarted') extends EventTest {
+ /**
+ * @return string
+ */
+ public function id(): string
+ {
+ return 'CakeDC\DbTest\Test\TestCase\PreparationStarted::test';
+ }
+
+ /**
+ * @return string
+ */
+ public function name(): string
+ {
+ return 'test';
+ }
+ };
+
+ return new PreparationStarted($telemetry, $test);
+ }
+}