From 94f5288adb5323d383258c897d6074b59e260935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Fr=C3=A8rejean?= Date: Wed, 8 Jul 2026 07:50:37 +0200 Subject: [PATCH] feat: add declarative config_overrides to FixtureLoader Consumers that suppress a write-time side effect keyed on a config static (auto-scaffolding, auto-publishing, denormalisation hooks) previously needed a bespoke onBeforeLoad Extension. Expose a config_overrides map so those statics can be forced per class in YAML instead. Each override is applied from a FixtureBlueprint beforeCreate callback, which runs inside FixtureBlueprint's own Config::nest()/unnest() window, so the value is live only for that record's write and reverts immediately afterwards. It is applied before the onBeforeLoad hook so a consumer's dynamic extension can still override the same class. The onBeforeLoad hook stays for anything a static value cannot express (dynamic values, non-config side effects). --- README.md | 26 +++++- src/Fixtures/FixtureLoader.php | 48 +++++++++++ .../Fixtures/FixtureLoaderTest.php | 80 +++++++++++++++++++ tests/Support/E2eConfigProbeObject.php | 52 ++++++++++++ tests/Support/E2eScaffoldingObject.php | 58 ++++++++++++++ tests/Support/fixtures/config-overrides.yml | 10 +++ 6 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 tests/Support/E2eConfigProbeObject.php create mode 100644 tests/Support/E2eScaffoldingObject.php create mode 100644 tests/Support/fixtures/config-overrides.yml diff --git a/README.md b/README.md index 029829d..fe08393 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,30 @@ WeDevelop\E2e\Fixtures\FixtureLoader: to by walking these classes in the order listed. `fixtures` maps the name used by the Playwright client to a module-relative path of the fixture YAML. +### Config overrides during load +The most common write-time customisation is suppressing a side effect whose +trigger is a config static — e.g. a container model that auto-scaffolds children +in `onAfterWrite()` and would duplicate children the fixture declares itself. +For that case declare the statics to force per class; each is applied only while +the record is written and reverts immediately afterwards (it never leaks into +normal app code): + +```yaml +WeDevelop\E2e\Fixtures\FixtureLoader: + config_overrides: + My\Module\Model\Section: + auto_scaffold: false + My\Module\Model\Row: + auto_scaffold: false +``` + +Reach for an `onBeforeLoad` extension (below) only when a static value can't +express it — dynamic values or non-config side effects. + ### Extension hooks -To inject domain behavior around a load — for example suppressing model -auto-scaffolding or publishing extra records — add an `Extension` implementing -either hook and wire it via `WeDevelop\E2e\Fixtures\FixtureLoader.extensions`: +To inject domain behavior around a load — for example computing dynamic values or +publishing extra records — add an `Extension` implementing either hook and wire +it via `WeDevelop\E2e\Fixtures\FixtureLoader.extensions`: ```php public function onBeforeLoad(string $name, FixtureFactory $factory): void diff --git a/src/Fixtures/FixtureLoader.php b/src/Fixtures/FixtureLoader.php index 72968d9..c63e03f 100644 --- a/src/Fixtures/FixtureLoader.php +++ b/src/Fixtures/FixtureLoader.php @@ -8,10 +8,12 @@ use RuntimeException; use SilverStripe\CMS\Model\SiteTree; use SilverStripe\Control\Director; +use SilverStripe\Core\Config\Config; use SilverStripe\Core\Config\Configurable; use SilverStripe\Core\Extensible; use SilverStripe\Core\Injector\Injectable; use SilverStripe\Core\Manifest\ModuleResourceLoader; +use SilverStripe\Dev\FixtureBlueprint; use SilverStripe\Dev\FixtureFactory; use SilverStripe\Dev\YamlFixture; use SilverStripe\ORM\DataObject; @@ -65,6 +67,24 @@ class FixtureLoader */ private static array $fixtures = []; + /** + * Config statics to force on specific classes while fixtures are written. + * + * For each class, the given key/value pairs are applied via a + * FixtureBlueprint `beforeCreate` callback, so the override is set inside + * FixtureBlueprint's own Config::nest()/unnest() window and reverts once the + * record is written — it never leaks into normal app code. + * + * This is the declarative form of the common `onBeforeLoad` use case: + * suppressing write-time side effects (auto-scaffolding, auto-publishing, + * denormalisation hooks) whose trigger is a config static. Anything a static + * value cannot express (dynamic values, non-config side effects) still + * belongs in an `onBeforeLoad` extension. + * + * @var array> + */ + private static array $config_overrides = []; + /** * Load a single named fixture into the database. * @@ -137,6 +157,10 @@ public function loadAll(): array private function loadFixtureFromPath(string $name, string $path): FixtureResult { $factory = new FixtureFactory(); + // Apply declarative config overrides before the onBeforeLoad hook so a + // consumer's dynamic extension can still override the same class (a later + // FixtureFactory::define() replaces an earlier blueprint for that class). + $this->applyConfigOverrides($factory); // Extensible::extend() takes its arguments by reference (&...$arguments), // which makes PHPStan widen every passed variable to the union of all of // them for the rest of the scope. Pass throwaway aliases so the typed @@ -319,6 +343,30 @@ private function resolveFixturePath(string $name): string return $absolutePath; } + /** + * Register blueprints that force {@see $config_overrides} statics during the + * write of each targeted class. + * + * Each override is set from a `beforeCreate` callback, which FixtureBlueprint + * runs inside its own Config::nest()/unnest() window — so the value is live + * for that record's write only and is restored immediately afterwards. + */ + private function applyConfigOverrides(FixtureFactory $factory): void + { + /** @var array> $overrides */ + $overrides = static::config()->get('config_overrides'); + + foreach ($overrides as $class => $settings) { + $blueprint = new FixtureBlueprint($class); + $blueprint->addCallback('beforeCreate', static function () use ($class, $settings): void { + foreach ($settings as $key => $value) { + Config::modify()->set($class, $key, $value); + } + }); + $factory->define($class, $blueprint); + } + } + /** * @return list */ diff --git a/tests/Integration/Fixtures/FixtureLoaderTest.php b/tests/Integration/Fixtures/FixtureLoaderTest.php index 97ad571..a9c1fc1 100644 --- a/tests/Integration/Fixtures/FixtureLoaderTest.php +++ b/tests/Integration/Fixtures/FixtureLoaderTest.php @@ -12,8 +12,10 @@ use SilverStripe\Dev\SapphireTest; use SilverStripe\Versioned\Versioned; use WeDevelop\E2e\Fixtures\FixtureLoader; +use WeDevelop\E2e\Tests\Support\E2eConfigProbeObject; use WeDevelop\E2e\Tests\Support\E2eFixtureTestPage; use WeDevelop\E2e\Tests\Support\E2eOtherTestPage; +use WeDevelop\E2e\Tests\Support\E2eScaffoldingObject; use WeDevelop\E2e\Tests\Support\E2eVersionedObject; use WeDevelop\E2e\Tests\Support\LoaderHookSpy; @@ -24,6 +26,8 @@ final class FixtureLoaderTest extends SapphireTest E2eFixtureTestPage::class, E2eOtherTestPage::class, E2eVersionedObject::class, + E2eScaffoldingObject::class, + E2eConfigProbeObject::class, ]; protected function setUp(): void @@ -37,6 +41,7 @@ protected function setUp(): void E2eFixtureTestPage::class, ]); LoaderHookSpy::reset(); + E2eConfigProbeObject::reset(); } public function testLoadWritesFixtureAndReturnsResult(): void @@ -327,6 +332,81 @@ public function testLoadAllDoesNotResetWhenAFixturePathIsInvalid(): void self::assertCount($simplePageCountBefore, $this->draftPagesWithSegmentPrefix('e2e-simple')); } + public function testConfigOverrideReachesRecordDuringWrite(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'config-overrides' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/config-overrides.yml', + ]); + Config::modify()->set(FixtureLoader::class, 'config_overrides', [ + E2eConfigProbeObject::class => ['probe_alpha' => 'overridden'], + ]); + + FixtureLoader::create()->load('config-overrides'); + + self::assertSame('overridden', E2eConfigProbeObject::$observed['probe_alpha']); + } + + public function testConfigOverrideDoesNotLeakPastLoad(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'config-overrides' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/config-overrides.yml', + ]); + Config::modify()->set(FixtureLoader::class, 'config_overrides', [ + E2eConfigProbeObject::class => ['probe_alpha' => 'overridden'], + ]); + + FixtureLoader::create()->load('config-overrides'); + + // The override is scoped to each record's write by FixtureBlueprint's + // Config::nest()/unnest(); normal app code sees the declared default. + self::assertSame('default', Config::inst()->get(E2eConfigProbeObject::class, 'probe_alpha')); + } + + public function testConfigOverridesApplyMultipleClassesAndKeys(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'config-overrides' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/config-overrides.yml', + ]); + Config::modify()->set(FixtureLoader::class, 'config_overrides', [ + E2eScaffoldingObject::class => ['auto_scaffold' => false], + E2eConfigProbeObject::class => [ + 'probe_alpha' => 'alpha-override', + 'probe_beta' => 'beta-override', + ], + ]); + + $childrenBefore = $this->countScaffoldChildren(); + + FixtureLoader::create()->load('config-overrides'); + + // auto_scaffold=false suppressed the child on E2eScaffoldingObject... + self::assertSame($childrenBefore, $this->countScaffoldChildren()); + // ...and both keys on the second class took effect at write time. + self::assertSame('alpha-override', E2eConfigProbeObject::$observed['probe_alpha']); + self::assertSame('beta-override', E2eConfigProbeObject::$observed['probe_beta']); + } + + public function testAbsentConfigOverridesIsNoOp(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'config-overrides' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/config-overrides.yml', + ]); + // No config_overrides configured (default []): the model's own + // auto_scaffold default (true) stands and its config statics are untouched. + $childrenBefore = $this->countScaffoldChildren(); + + FixtureLoader::create()->load('config-overrides'); + + self::assertSame($childrenBefore + 1, $this->countScaffoldChildren()); + self::assertSame('default', E2eConfigProbeObject::$observed['probe_alpha']); + self::assertSame('default', E2eConfigProbeObject::$observed['probe_beta']); + } + + private function countScaffoldChildren(): int + { + return (int) E2eScaffoldingObject::get()->filter('Title', 'scaffolded-container')->count(); + } + /** * @return array */ diff --git a/tests/Support/E2eConfigProbeObject.php b/tests/Support/E2eConfigProbeObject.php new file mode 100644 index 0000000..0bc9355 --- /dev/null +++ b/tests/Support/E2eConfigProbeObject.php @@ -0,0 +1,52 @@ + */ + private static array $db = [ + 'Title' => 'Varchar(255)', + ]; + + private static string $probe_alpha = 'default'; + + private static string $probe_beta = 'default'; + + /** + * Config values seen during writes since the last reset, keyed by config + * name. Overwritten per write, so it reflects the most recent probe. + * + * @var array + */ + public static array $observed = []; + + public static function reset(): void + { + self::$observed = []; + } + + protected function onBeforeWrite(): void + { + parent::onBeforeWrite(); + + self::$observed = [ + 'probe_alpha' => (string) static::config()->get('probe_alpha'), + 'probe_beta' => (string) static::config()->get('probe_beta'), + ]; + } +} diff --git a/tests/Support/E2eScaffoldingObject.php b/tests/Support/E2eScaffoldingObject.php new file mode 100644 index 0000000..47f768a --- /dev/null +++ b/tests/Support/E2eScaffoldingObject.php @@ -0,0 +1,58 @@ + */ + private static array $db = [ + 'Title' => 'Varchar(255)', + ]; + + /** + * When true, writing this object scaffolds one child. Consumers suppress + * this during fixture loads via FixtureLoader.config_overrides. + */ + private static bool $auto_scaffold = true; + + /** Marks factory-created children so they never scaffold recursively. */ + private bool $isScaffoldChild = false; + + public function markAsScaffoldChild(): void + { + $this->isScaffoldChild = true; + } + + protected function onAfterWrite(): void + { + parent::onAfterWrite(); + + if ($this->isScaffoldChild) { + return; + } + + if (!static::config()->get('auto_scaffold')) { + return; + } + + $child = new self(); + $child->markAsScaffoldChild(); + $child->Title = 'scaffolded-' . $this->Title; + $child->write(); + } +} diff --git a/tests/Support/fixtures/config-overrides.yml b/tests/Support/fixtures/config-overrides.yml new file mode 100644 index 0000000..b095497 --- /dev/null +++ b/tests/Support/fixtures/config-overrides.yml @@ -0,0 +1,10 @@ +WeDevelop\E2e\Tests\Support\E2eFixtureTestPage: + page1: + Title: 'E2E Config Overrides Page' + URLSegment: 'e2e-config-overrides' +WeDevelop\E2e\Tests\Support\E2eScaffoldingObject: + container1: + Title: 'container' +WeDevelop\E2e\Tests\Support\E2eConfigProbeObject: + probe1: + Title: 'probe'