Requirement, Call, ConstantRequirement, Mirror, Problem, FeatureStatus, CompatibilityMatrix, Manifest, ManifestCheck sowie Fingerprints und LineDiff liegen jetzt im divi-freien Kern. Neu: GlobalRequirement, CompatibilityMatrix::fromProblems(), ManifestCheck-Option signatures, DiviManifestCheck::create() und DiviAreas. Divi-Verhalten unverändert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
370 lines
15 KiB
PHP
370 lines
15 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Cli;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Compatibility\ReleaseState;
|
||
use LIT\MCP\Integrations\Divi\Cli\DiviCheckCommand;
|
||
use LIT\MCP\Integrations\Divi\Release\CheckReport;
|
||
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
|
||
use LIT\MCP\Integrations\Divi\Release\CheckStep;
|
||
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
|
||
use LIT\MCP\Compatibility\CompatibilityMatrix;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviReference;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
|
||
use LIT\MCP\Tests\Support\MatrixFixtures;
|
||
use LIT\MCP\Tests\Support\ReleaseMatrices;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use RuntimeException;
|
||
|
||
final class DiviCheckCommandTest extends TestCase
|
||
{
|
||
/** @var list<string> */
|
||
private array $lines = [];
|
||
|
||
private string $themeDir = '';
|
||
private string $sourceDir = '';
|
||
private string $expectedFile = '';
|
||
|
||
/** @var array<string, string> 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<string, list<string>> $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',
|
||
"<?php\nfunction et_pb_submit_layout( \$args ) {\n\t\$a = 1;\n\treturn 2;\n}\n"
|
||
);
|
||
DiviReference::writeExpected(['library-submit-layout' => 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',
|
||
"<?php\nfunction et_pb_submit_layout( \$args ) {\n\treturn 2;\n}\n"
|
||
);
|
||
|
||
$code = $this->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(
|
||
[
|
||
'customizer-typography', 'font-install', '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 (8 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 eight mirrored Divi functions at their real paths. */
|
||
private function writeDivi(): void
|
||
{
|
||
$files = [
|
||
'functions.php' => "function et_divi_customizer_theme_settings( \$wp_customize ) {\n}",
|
||
'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}",
|
||
'includes/builder-5/server/VisualBuilder/AiAgent/AiAgentImageUtility.php' =>
|
||
"class AiAgentImageUtility {\n\tpublic static function install_to_custom_fonts( \$a, \$b ) {\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, "<?php\n" . $code . "\n");
|
||
}
|
||
}
|
||
|
||
public function testPrintsTheMatrixAfterTheReport(): void
|
||
{
|
||
self::assertSame(0, $this->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);
|
||
}
|
||
}
|