*/ private array $lines = []; private string $themeDir = ''; private string $sourceDir = ''; private string $expectedFile = ''; /** @var array What the command reads as the signatures of the running Divi. */ private array $signatures = []; /** Set to true to make the signature read fail like a Divi call that is not loaded. */ private bool $signaturesUnreadable = false; protected function setUp(): void { parent::setUp(); $this->lines = []; $this->signatures = ['sig:et_pb_is_allowed' => str_repeat('a', 64), 'sig:A::b' => str_repeat('b', 64)]; $this->signaturesUnreadable = false; $base = sys_get_temp_dir() . '/lit-cli-' . uniqid(); $this->themeDir = $base . '/Divi'; $this->sourceDir = $base . '/reference'; mkdir($this->themeDir . '/includes/builder', 0777, true); mkdir($this->sourceDir, 0777, true); $this->expectedFile = $base . '/fingerprints.json'; Functions\when('current_user_can')->justReturn(true); Functions\when('get_current_user_id')->justReturn(1); Functions\when('delete_transient')->justReturn(true); } protected function tearDown(): void { exec('rm -rf ' . escapeshellarg(dirname($this->themeDir))); parent::tearDown(); } /** * @param array> $problems Step key => problems. * @param bool $expectSave True asserts save() is called exactly once (the green happy path); otherwise it is * merely allowed, since some tests (e.g. no admin) never reach the save() call. */ private function command( array $problems = [], bool $expectSave = false, bool $noSourceDir = false, ?CompatibilityMatrix $matrix = null ): DiviCheckCommand { $steps = array_map( static fn (string $key): CheckStep => new CheckStep($key, $problems[$key] ?? []), [CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE] ); $check = Mockery::mock(CompatibilityCheck::class); $check->allows('run')->andReturn(new CheckReport('r1', 'd', 1, '5.3.0', '0.6.0', 'k', $steps)); $reports = Mockery::mock(CheckReportStore::class); if ($expectSave) { $reports->expects('save')->once(); } else { $reports->allows('save'); } return new DiviCheckCommand( static fn () => $check, $reports, function (string $line): void { $this->lines[] = $line; }, fn (): ?string => $this->themeDir, $this->expectedFile, $noSourceDir ? static fn (): ?string => null : fn (): ?string => $this->sourceDir, function (): array { if ($this->signaturesUnreadable) { throw new RuntimeException('signature of et_pb_is_allowed(): symbol is not loaded'); } return $this->signatures; }, $matrix === null ? null : static fn (): CompatibilityMatrix => $matrix ); } public function testNeedsAnAdministrator(): void { Functions\when('current_user_can')->justReturn(false); self::assertSame(1, $this->command()->run([])); self::assertStringContainsString('--user=', implode("\n", $this->lines)); } public function testPrintsTheStepsAndSucceedsWhenGreen(): void { self::assertSame(0, $this->command(expectSave: true)->run([])); self::assertContains('✓ Schreibtest auf einer Wegwerfseite', $this->lines); } public function testFailsWhenAStepIsRed(): void { self::assertSame(1, $this->command([CheckStep::CATALOG => ['divi/text: unbekanntes Modul']])->run([])); self::assertContains('✗ Modulkatalog (Kurzform-Pfade)', $this->lines); self::assertContains(' divi/text: unbekanntes Modul', $this->lines); } public function testUpdateReferenceRefusesWhenMoreThanFingerprintsIsRed(): void { $code = $this->command([CheckStep::FORMAT => ['kaputt']])->run(['update-reference' => true]); self::assertSame(1, $code); self::assertFileDoesNotExist($this->expectedFile); } public function testUpdateReferenceRefusesWithoutADevelopmentSourceDir(): void { $code = $this->command(noSourceDir: true)->run(['update-reference' => true]); self::assertSame(1, $code); self::assertStringContainsString( 'Referenz nicht aktualisiert: nur in einer Entwicklungskopie mit tests/ möglich.', implode("\n", $this->lines) ); self::assertFileDoesNotExist($this->expectedFile); } public function testDiffShowsWhatChangedInAMirroredFunction(): void { file_put_contents( $this->themeDir . '/includes/builder/shortcode-core.php', " str_repeat('0', 64)], $this->expectedFile); DiviReference::writeSource( 'library-submit-layout', "function et_pb_submit_layout( \$args ) {\n\t\$a = 1;\n\treturn 1;\n}", $this->sourceDir ); $this->command([CheckStep::MIRRORS => ['Bibliothek: Nachbau von et_pb_submit_layout() – Terme']]) ->run(['diff' => true]); $output = implode("\n", $this->lines); self::assertStringContainsString('library-submit-layout', $output); self::assertStringContainsString("- \treturn 1;", $output); self::assertStringContainsString("+ \treturn 2;", $output); } public function testUpdateReferenceWritesNothingWhenAMirrorIsUnreadable(): void { file_put_contents( $this->themeDir . '/includes/builder/shortcode-core.php', "command([CheckStep::MIRRORS => ['geändert']])->run(['update-reference' => true]); // The other mirrored Divi files are missing in the temp theme. self::assertSame(1, $code); self::assertStringContainsString('nicht lesbar', implode("\n", $this->lines)); self::assertFileDoesNotExist($this->expectedFile, 'all or nothing'); } public function testUpdateReferenceWritesNothingWhenASourceIsUnwritable(): void { $this->writeDivi(); chmod($this->sourceDir, 0500); try { $code = $this->command([CheckStep::MIRRORS => ['geändert']])->run(['update-reference' => true]); self::assertSame(1, $code); self::assertStringContainsString( 'Referenz nicht vollständig aktualisiert:', implode("\n", $this->lines) ); self::assertFileDoesNotExist($this->expectedFile, 'sources are written before fingerprints.json'); } finally { chmod($this->sourceDir, 0777); } } public function testUpdateReferenceWritesNothingWhenASignatureIsUnreadable(): void { $this->writeDivi(); $this->signaturesUnreadable = true; $code = $this->command([CheckStep::MIRRORS => ['geändert']])->run(['update-reference' => true]); self::assertSame(1, $code); self::assertStringContainsString( 'Referenz nicht aktualisiert – die Signatur eines Divi-Aufrufs ist nicht lesbar: ' . 'signature of et_pb_is_allowed(): symbol is not loaded', implode("\n", $this->lines) ); self::assertFileDoesNotExist($this->expectedFile, 'all or nothing'); self::assertSame([], glob($this->sourceDir . '/*.txt'), 'no source written either'); } public function testUpdateReferenceWritesFingerprintsSignaturesAndSources(): void { $this->writeDivi(); $code = $this->command([CheckStep::MIRRORS => ['geändert']])->run(['update-reference' => true]); self::assertSame(0, $code); self::assertSame( [ 'library-submit-layout', 'preset-sync', 'sig:A::b', 'sig:et_pb_is_allowed', 'tb-api-save', 'tb-load-setting-options', 'tb-store-template', 'vb-sync-flags', ], array_keys(DiviReference::expected($this->expectedFile)) ); self::assertSame(str_repeat('b', 64), DiviReference::expected($this->expectedFile)['sig:A::b']); self::assertContains( 'Referenz aktualisiert (6 Fingerabdrücke, 2 Signaturen). Smoke-Skripte laufen lassen, dann committen.', $this->lines ); self::assertStringStartsWith( 'function et_pb_submit_layout(', (string) DiviReference::readSource('library-submit-layout', $this->sourceDir) ); } /** Minimal stand-ins for the six mirrored Divi functions at their real paths. */ private function writeDivi(): void { $files = [ 'includes/builder/shortcode-core.php' => "function et_pb_submit_layout( \$args ) {\n\treturn 1;\n}", 'includes/builder/frontend-builder/theme-builder/api.php' => "function et_theme_builder_api_save() {\n}", 'includes/builder/frontend-builder/theme-builder/theme-builder.php' => "function et_theme_builder_store_template( \$a, \$b, \$c ) {\n}\n" . "function et_theme_builder_load_template_setting_options( \$ids ) {\n}", 'includes/builder-5/server/VisualBuilder/REST/SyncToServer/SyncToServerController.php' => "class SyncToServerController {\n\tpublic static function update( \$request ) {\n\t}\n}", 'includes/builder-5/server/Packages/GlobalData/GlobalPresetController.php' => "class GlobalPresetController {\n\tpublic static function sync( \$request ) {\n\t}\n}", ]; foreach ($files as $path => $code) { $file = $this->themeDir . '/' . $path; if (! is_dir(dirname($file))) { mkdir(dirname($file), 0777, true); } file_put_contents($file, "command()->run([])); $schreibtest = array_search('✓ Schreibtest auf einer Wegwerfseite', $this->lines, true); $header = array_search('Fähigkeiten:', $this->lines, true); self::assertIsInt($schreibtest); self::assertIsInt($header); self::assertGreaterThan($schreibtest, $header); self::assertContains('✓ Seiten schreiben – freigegeben (code)', $this->lines); self::assertContains('✓ Theme Builder lesen – freigegeben (code)', $this->lines); } public function testEveryFeatureGetsOneLine(): void { $this->command()->run([]); $matrixLines = array_filter( $this->lines, static fn (string $line): bool => str_contains($line, ' – freigegeben') ); self::assertCount(count(DiviFeature::cases()), $matrixLines); } public function testAnIncompatibleFeatureFailsWithItsProblemsIndented(): void { $matrix = MatrixFixtures::with( DiviFeature::PageWrite, ReleaseState::Incompatible, 'auto', 'Seiten: et_pb_x() fehlt' ); $code = $this->command(matrix: $matrix)->run([]); self::assertSame(2, $code); self::assertContains('✗ Seiten schreiben – inkompatibel', $this->lines); self::assertContains(' Seiten: et_pb_x() fehlt', $this->lines); self::assertContains('✓ Seiten lesen – freigegeben (auto)', $this->lines); } public function testAnUntestedFeatureFailsWithExitTwoAlthoughTheReportIsGreen(): void { $matrix = MatrixFixtures::with(DiviFeature::Catalog, ReleaseState::Untested, 'auto'); $code = $this->command(expectSave: true, matrix: $matrix)->run([]); self::assertSame(2, $code); self::assertContains('– Modulkatalog – ungetestet', $this->lines); } public function testARedReportKeepsExitOneWhateverTheMatrixSays(): void { $red = [CheckStep::CATALOG => ['divi/text: unbekanntes Modul']]; $incompatible = MatrixFixtures::with(DiviFeature::Catalog, ReleaseState::Incompatible, 'auto', 'x'); self::assertSame(1, $this->command($red)->run([])); self::assertSame(1, $this->command($red, matrix: $incompatible)->run([])); } public function testUpdateReferenceKeepsItsOwnExitCodeAndStillPrintsTheMatrix(): void { $this->writeDivi(); $matrix = MatrixFixtures::with( DiviFeature::PageWrite, ReleaseState::Incompatible, 'auto', 'Seiten: Nachbau geändert' ); $code = $this->command([CheckStep::MIRRORS => ['geändert']], matrix: $matrix) ->run(['update-reference' => true]); self::assertSame(0, $code); self::assertContains('✗ Seiten schreiben – inkompatibel', $this->lines); } public function testWithoutAnInjectedMatrixTheStateIsComputedAfterTheCheck(): void { $order = []; Functions\when('delete_transient')->alias(static function (string $key) use (&$order): bool { $order[] = 'delete:' . $key; return true; }); ReleaseGate::useResolver(static function () use (&$order): ReleaseStatus { $order[] = 'resolve'; return ReleaseMatrices::released('5.3.0'); }); $this->command()->run([]); self::assertSame(['delete:' . ReleaseResolver::CACHE, 'resolve'], $order); } }