diff --git a/src/Integrations/Core/EnvironmentProbe.php b/src/Integrations/Core/EnvironmentProbe.php index b2984ec..c5b5e23 100644 --- a/src/Integrations/Core/EnvironmentProbe.php +++ b/src/Integrations/Core/EnvironmentProbe.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Core; use LIT\MCP\Integrations\Divi\DiviCompatibility; +use LIT\MCP\Integrations\Divi\Release\DiviEnvironment; defined('ABSPATH') || exit; @@ -21,10 +22,8 @@ class EnvironmentProbe /** @return list}> */ public function components(): array { - $theme = wp_get_theme(); - $isDivi = $theme->get_template() === 'Divi'; - $parent = $theme->parent(); - $diviVersion = $isDivi ? (string) ($parent ? $parent->get('Version') : $theme->get('Version')) : null; + $diviVersion = DiviEnvironment::version(); + $isDivi = $diviVersion !== null; $tecActive = class_exists('Tribe__Events__Main'); diff --git a/src/Integrations/Divi/DiviCompatibility.php b/src/Integrations/Divi/DiviCompatibility.php index 7884369..4ac6411 100644 --- a/src/Integrations/Divi/DiviCompatibility.php +++ b/src/Integrations/Divi/DiviCompatibility.php @@ -4,21 +4,14 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi; -use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance; -use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; -use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; -use LIT\MCP\Integrations\Divi\Release\Call; -use LIT\MCP\Integrations\Divi\Schema\DiviModules; -use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; -use Throwable; +use LIT\MCP\Integrations\Divi\Release\ManifestCheck; defined('ABSPATH') || exit; /** - * Live check of the Divi internals LIT MCP relies on (decisions note §5, E15): classes, - * functions, the library post type and taxonomies, the instance block type. lit-site-status shows - * the failed checks, so a Divi update that moves one of them is visible before a tool reports - * "unsupported". + * Live check of the Divi internals LIT MCP relies on (spec §4.3): every check of every adapter manifest – calls + * with their argument count, constants, registrations and the fingerprints of the mirrors. lit-site-status shows + * the failed checks, so a Divi update that moves one of them is visible before a tool reports "unsupported". */ final class DiviCompatibility { @@ -30,49 +23,14 @@ final class DiviCompatibility /** @return list Labels of the checks that failed or threw. */ public function problems(): array { - $problems = []; - foreach ($this->checks ?? self::defaults() as $label => $check) { - try { - $ok = (bool) $check(); - } catch (Throwable) { - $ok = false; - } - if (! $ok) { - $problems[] = (string) $label; - } - } - - return $problems; + return ManifestCheck::failed($this->checks ?? self::defaults()); } /** @return array */ - public static function defaults(): array + public static function defaults(?ManifestCheck $check = null): array { - return [ - 'ModuleRegistration (Modul-Katalog)' => static fn (): bool => DiviModules::isAvailable(), - 'DynamicContentOptions (Dynamic Content)' => - static fn (): bool => class_exists(DynamicContentMap::REGISTRY), - 'et_pb_is_allowed (Divi-Rollen)' => static fn (): bool => function_exists('et_pb_is_allowed'), - 'Post-Typ et_pb_layout (Bibliothek)' => - static fn (): bool => post_type_exists(DiviLibraryStore::POST_TYPE), - 'Taxonomien scope, layout_type, module_width (Bibliothek)' => - static fn (): bool => (new DiviLibraryStore())->missingTaxonomies() === [], - 'GlobalLayout::is_global_layout_template (globale Module)' => - static fn (): bool => method_exists(DiviLibraryStore::GLOBAL_LAYOUT, 'is_global_layout_template'), - 'ET_Core_PageResource (CSS-Cache)' => static fn (): bool => class_exists('ET_Core_PageResource'), - 'Block-Typ divi/global-layout (Instanzen globaler Module)' => - static fn (): bool => DiviModules::knowsBlock(GlobalInstance::BLOCK), - 'Taxonomien layout_category, layout_tag (Bibliothek)' => - static fn (): bool => taxonomy_exists('layout_category') && taxonomy_exists('layout_tag'), - // Includes et_theme_builder_clear_wp_cache (Theme Builder cache of every site-wide clear, review M-2). - 'Theme Builder: Funktionen et_theme_builder_* und et_()' => static fn (): bool => array_filter( - DiviThemeBuilder::manifest()->calls, - static fn (Call $call): bool => $call->className() === null && ! function_exists($call->member()) - ) === [], - 'Theme Builder: Post-Typen et_template und Layouts' => static fn (): bool => array_filter( - DiviThemeBuilder::postTypes(), - static fn (string $type): bool => ! post_type_exists($type) - ) === [], - ]; + $check ??= new ManifestCheck(); + + return array_merge($check->codeChecks(), $check->registrationChecks(), $check->mirrorChecks()); } } diff --git a/src/Integrations/Divi/Release/DiviEnvironment.php b/src/Integrations/Divi/Release/DiviEnvironment.php new file mode 100644 index 0000000..d0010fc --- /dev/null +++ b/src/Integrations/Divi/Release/DiviEnvironment.php @@ -0,0 +1,32 @@ +get_template() !== self::TEMPLATE) { + return null; + } + $parent = $theme->parent(); + + return (string) ($parent ? $parent->get('Version') : $theme->get('Version')); + } + + public static function themeDir(): ?string + { + return wp_get_theme()->get_template() === self::TEMPLATE ? (string) get_template_directory() : null; + } +} diff --git a/src/Integrations/Divi/Release/DiviReference.php b/src/Integrations/Divi/Release/DiviReference.php new file mode 100644 index 0000000..ac7893b --- /dev/null +++ b/src/Integrations/Divi/Release/DiviReference.php @@ -0,0 +1,99 @@ +.txt, development only – not in the plugin package). Written by + * wp lit-mcp divi-check --update-reference. + */ +final class DiviReference +{ + public const FILE = __DIR__ . '/fingerprints.json'; + + /** @return array Mirror ID => sha256; [] when the file is missing or broken. */ + public static function expected(?string $file = null): array + { + $file ??= self::FILE; + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local plugin file. + $raw = is_file($file) && is_readable($file) ? file_get_contents($file) : false; + $data = is_string($raw) ? json_decode($raw, true) : null; + if (! is_array($data)) { + return []; + } + + $expected = []; + foreach ($data as $id => $hash) { + if (is_string($id) && is_string($hash)) { + $expected[$id] = $hash; + } + } + ksort($expected); + + return $expected; + } + + /** + * @param array $hashes Mirror ID => sha256. + * @throws RuntimeException + */ + public static function writeExpected(array $hashes, ?string $file = null): void + { + ksort($hashes); + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP, no WordPress. + $json = json_encode($hashes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- dev fixture. + if (! is_string($json) || file_put_contents($file ?? self::FILE, $json . "\n") === false) { + throw new RuntimeException('fingerprints.json not written'); + } + } + + /** tests/Fixtures/divi/reference of a development checkout; null in an installed plugin. */ + public static function sourceDir(): ?string + { + $dir = dirname(__DIR__, 4) . '/tests/Fixtures/divi/reference'; + + return is_dir($dir) ? $dir : null; + } + + public static function readSource(string $id, string $dir): ?string + { + $file = $dir . '/' . $id . '.txt'; + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local dev fixture. + $source = is_file($file) ? file_get_contents($file) : false; + + return is_string($source) ? $source : null; + } + + /** @throws RuntimeException */ + public static function writeSource(string $id, string $source, string $dir): void + { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- dev fixture. + if (file_put_contents($dir . '/' . $id . '.txt', $source . "\n") === false) { + throw new RuntimeException(sprintf('reference source %s not written', $id)); + } + } + + /** + * Fingerprints of every mirror in the installed Divi. + * + * @return array + * @throws RuntimeException When a mirrored function cannot be read. + */ + public static function current(string $themeDir): array + { + $prints = []; + foreach (DiviAdapters::mirrors() as $mirror) { + $file = $themeDir . '/' . $mirror->file; + $prints[$mirror->id] = SourceFingerprint::of($file, $mirror->function, $mirror->class); + } + + return $prints; + } +} diff --git a/src/Integrations/Divi/Release/ManifestCheck.php b/src/Integrations/Divi/Release/ManifestCheck.php new file mode 100644 index 0000000..a2b6ecf --- /dev/null +++ b/src/Integrations/Divi/Release/ManifestCheck.php @@ -0,0 +1,153 @@ + check. Code checks (calls with the argument count + * the adapter passes, constants) and mirror checks decide the release gate; registration checks (post types, + * taxonomies, block types) depend on the request context (stage3 finding 6) and only show in reports. + */ +final class ManifestCheck +{ + /** @var array */ + private array $manifests; + + /** @var Closure(Mirror): ?string */ + private Closure $current; + + /** @var Closure(string): bool */ + private Closure $knowsBlock; + + /** + * @param array|null $manifests null = DiviAdapters::manifests(). + * @param array|null $expected Mirror ID => sha256; null = DiviReference::expected(). + * @param (Closure(Mirror): ?string)|null $current Hash of the installed mirror, null = unreadable; + * null = SourceFingerprint in the Divi theme directory. + * @param (Closure(string): bool)|null $knowsBlock null = DiviModules::knowsBlock(). + */ + public function __construct( + ?array $manifests = null, + private ?array $expected = null, + ?Closure $current = null, + ?Closure $knowsBlock = null + ) { + $this->manifests = $manifests ?? DiviAdapters::manifests(); + $this->current = $current ?? self::installed(...); + $this->knowsBlock = $knowsBlock ?? DiviModules::knowsBlock(...); + } + + /** @return array */ + public function codeChecks(): array + { + $checks = []; + foreach ($this->manifests as $manifest) { + foreach ($manifest->calls as $call) { + $checks[$manifest->area . ': ' . $call->label()] = static fn (): bool => self::accepts($call); + } + foreach ($manifest->constants as $constant) { + $checks[$manifest->area . ': Konstante ' . $constant] = static fn (): bool => defined($constant); + } + } + + return $checks; + } + + /** @return array */ + public function mirrorChecks(): array + { + $checks = []; + foreach ($this->manifests as $manifest) { + foreach ($manifest->mirrors as $mirror) { + $label = sprintf('%s: Nachbau von %s – %s', $manifest->area, $mirror->label(), $mirror->what); + $checks[$label] = function () use ($mirror): bool { + $expected = ($this->expected ??= DiviReference::expected())[$mirror->id] ?? null; + + return $expected !== null && ($this->current)($mirror) === $expected; + }; + } + } + + return $checks; + } + + /** @return array */ + public function registrationChecks(): array + { + $checks = []; + foreach ($this->manifests as $manifest) { + foreach ($manifest->postTypes as $type) { + $checks[$manifest->area . ': Post-Typ ' . $type] = static fn (): bool => post_type_exists($type); + } + foreach ($manifest->taxonomies as $taxonomy) { + $checks[$manifest->area . ': Taxonomie ' . $taxonomy] = + static fn (): bool => taxonomy_exists($taxonomy); + } + foreach ($manifest->blockTypes as $block) { + $checks[$manifest->area . ': Block-Typ ' . $block] = fn (): bool => ($this->knowsBlock)($block); + } + } + + return $checks; + } + + /** + * @param array $checks + * @return list Labels of the checks that failed or threw. + */ + public static function failed(array $checks): array + { + $failed = []; + foreach ($checks as $label => $check) { + try { + $ok = (bool) $check(); + } catch (Throwable) { + $ok = false; + } + if (! $ok) { + $failed[] = (string) $label; + } + } + + return $failed; + } + + /** The function or method exists and takes the number of arguments the adapter passes. */ + public static function accepts(Call $call): bool + { + $class = $call->className(); + if ($class === null) { + if (! function_exists($call->member())) { + return false; + } + $reflection = new ReflectionFunction($call->member()); + } else { + if (! method_exists($class, $call->member())) { + return false; + } + $reflection = new ReflectionMethod($class, $call->member()); + } + + return $call->args === null + || ($reflection->getNumberOfRequiredParameters() <= $call->args + && ($reflection->isVariadic() || $reflection->getNumberOfParameters() >= $call->args)); + } + + private static function installed(Mirror $mirror): ?string + { + $dir = DiviEnvironment::themeDir(); + + return $dir === null + ? null + : SourceFingerprint::of($dir . '/' . $mirror->file, $mirror->function, $mirror->class)->hash; + } +} diff --git a/src/Integrations/Divi/Release/fingerprints.json b/src/Integrations/Divi/Release/fingerprints.json new file mode 100644 index 0000000..8782303 --- /dev/null +++ b/src/Integrations/Divi/Release/fingerprints.json @@ -0,0 +1,7 @@ +{ + "library-submit-layout": "594b3843d4c42e1a91e5eda06f108c79583a0036e46c92dd749f4f541e362d02", + "tb-api-save": "7ba452f4d73c9b676281ca6ddcfddd669502965db868bf478ef7804937e5bfc4", + "tb-load-setting-options": "2ea0a6925a1da5522489a417104b384b2bd77a16b25f4122b9503873a4c8a24e", + "tb-store-template": "3bf4ac6028ed99ed8ac3523c659392a5fce7818ef8a80ea226e01f5404eb441d", + "vb-sync-flags": "f8e85421197b0f823219dad6081af7e47f9b4e388c45e162e9188f892766fde9" +} diff --git a/tests/Fixtures/divi/reference/library-submit-layout.txt b/tests/Fixtures/divi/reference/library-submit-layout.txt new file mode 100644 index 0000000..18a44aa --- /dev/null +++ b/tests/Fixtures/divi/reference/library-submit-layout.txt @@ -0,0 +1,69 @@ +function et_pb_submit_layout( $args ) { + /** + * Filters the layout data passed to {@see et_pb_submit_layout()}. + * + * @since 3.0.99 + * + * @param string[] $args See {@see et_pb_submit_layout()} for array structure definition. + */ + $args = apply_filters( 'et_pb_submit_layout_args', $args ); + + if ( empty( $args ) ) { + return ''; + } + + $layout_cats_processed = array(); + $layout_tags_processed = array(); + + if ( '' !== $args['layout_selected_cats'] ) { + $layout_cats_array = explode( ',', $args['layout_selected_cats'] ); + $layout_cats_processed = array_map( 'intval', $layout_cats_array ); + } + + if ( '' !== $args['layout_selected_tags'] ) { + $layout_tags_array = explode( ',', $args['layout_selected_tags'] ); + $layout_tags_processed = array_map( 'intval', $layout_tags_array ); + } + + $meta = array(); + + if ( 'row' === $args['layout_type'] && '0' !== $args['columns_layout'] ) { + $meta = array_merge( $meta, array( '_et_pb_row_layout' => $args['columns_layout'] ) ); + } + + if ( 'module' === $args['layout_type'] ) { + $meta = array_merge( $meta, array( '_et_pb_module_type' => $args['module_type'] ) ); + + // save unsynced options for global modules. Always empty for new modules. + if ( 'global' === $args['layout_scope'] ) { + $meta = array_merge( $meta, array( '_et_pb_excluded_global_options' => wp_json_encode( array() ) ) ); + } + } + + // et_layouts_built_for_post_type. + $meta = array_merge( + $meta, + array( + '_et_pb_built_for_post_type' => $args['built_for_post_type'], + '_et_pb_template_type' => $args['template_type'] ?? '', + ) + ); + + $tax_input = array( + 'scope' => $args['layout_scope'], + 'layout_type' => $args['layout_type'], + 'module_width' => $args['module_width'], + 'layout_category' => $layout_cats_processed, + 'layout_tag' => $layout_tags_processed, + 'layout_location' => et_()->array_get_sanitized( $args, 'layout_location', 'local' ), + ); + + $post_date = isset( $args['post_date'] ) && ! empty( $args['post_date'] ) ? $args['post_date'] : null; + $new_layout_id = et_pb_create_layout( $args['layout_name'], $args['layout_content'], $meta, $tax_input, $args['layout_new_cat'], $args['layout_new_tag'], 'publish', $post_date ); + $new_post_data['post_id'] = (int) $new_layout_id; + + $new_post_data['edit_link'] = esc_url_raw( get_edit_post_link( $new_layout_id ) ); + $json_post_data = wp_json_encode( $new_post_data ); + + return $json_post_data; + } diff --git a/tests/Fixtures/divi/reference/tb-api-save.txt b/tests/Fixtures/divi/reference/tb-api-save.txt new file mode 100644 index 0000000..2d0c219 --- /dev/null +++ b/tests/Fixtures/divi/reference/tb-api-save.txt @@ -0,0 +1,113 @@ +function et_theme_builder_api_save() { + et_builder_security_check( 'theme_builder', 'edit_others_posts', 'et_theme_builder_api_save', 'nonce' ); + + // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce is done in `et_builder_security_check`. + $_ = et_(); + $live = '1' === $_->array_get( $_POST, 'live', '0' ); + $first_request = '1' === $_->array_get( $_POST, 'first_request', '1' ); + $last_request = '1' === $_->array_get( $_POST, 'last_request', '1' ); + $templates = wp_unslash( $_->array_get( $_POST, 'templates', array() ) ); + $processed_templates = wp_unslash( $_->array_get( $_POST, 'processed_templates', array() ) ); + $library_tb_id = (int) $_->array_get( $_POST, 'library_theme_builder_id', 0 ); + $library_item_id = (int) $_->array_get( $_POST, 'library_item_id', 0 ); + $theme_builder_id = $library_tb_id ? $library_tb_id : et_theme_builder_get_theme_builder_post_id( $live, true ); + $has_default = '1' === $_->array_get( $_POST, 'hasDefault', '0' ); + $updated_ids = array(); + // phpcs:enable + + // Remove this action as it not necessary when we're saving entire TB. + // save_post_cb is a heavy operation and significanlty slows down the saving of TB. + // We remove static page resources after TB save below in this function. + remove_action( 'save_post', array( 'ET_Core_PageResource', 'save_post_cb' ), 10, 3 ); + + $templates_to_process = array(); + + // Populate the templates. + foreach ( $templates as $index => $template ) { + $templates_to_process[ $_->array_get( $template, 'id', 'unsaved_' . $index ) ] = $template; + } + + $affected_templates = array(); + + // Update or insert templates. + foreach ( $templates_to_process as $template ) { + $raw_post_id = $_->array_get( $template, 'id', 0 ); + $post_id = is_numeric( $raw_post_id ) ? (int) $raw_post_id : 0; + $new_post_id = et_theme_builder_store_template( $theme_builder_id, $template, ! $has_default ); + + if ( ! $new_post_id ) { + continue; + } + + $is_default = get_post_meta( $new_post_id, '_et_default', true ) === '1'; + + if ( $is_default ) { + $has_default = true; + } + + // Add template ID into $affected_templates for later use + // to Add mapping template ID to theme builder ID + // and delete existing template mapping. + $affected_templates[] = array( + 'raw' => $raw_post_id, + 'normalized' => $post_id, + 'new_post_id' => $new_post_id, + ); + } + + foreach ( $affected_templates as $template_pair ) { + if ( $template_pair['normalized'] !== $template_pair['new_post_id'] ) { + $updated_ids[ $template_pair['raw'] ] = $template_pair['new_post_id']; + } + } + + if ( $last_request ) { + $existing_templates = get_post_meta( $theme_builder_id, '_et_template', false ); + + if ( $existing_templates ) { + // Store existing template mapping as backup to avoid data lost + // when user interrupting the saving process before completed. + update_option( 'et_tb_templates_backup_' . $theme_builder_id, $existing_templates ); + } + + // Delete existing template mapping. + delete_post_meta( $theme_builder_id, '_et_template' ); + + $processed_templates = array_merge( $processed_templates, $affected_templates ); + + // Insert new template mapping. + foreach ( $processed_templates as $template_pair ) { + add_post_meta( $theme_builder_id, '_et_template', $template_pair['new_post_id'] ); + } + + // Delete existing template mapping backup. + delete_option( 'et_tb_templates_backup_' . $theme_builder_id ); + + if ( $live ) { + et_theme_builder_trash_draft_and_unused_posts(); + } + + et_theme_builder_clear_wp_cache( 'all' ); + + // Clear all CSS cache files when Theme Builder templates are saved. + // Theme builder CSS files include layout IDs in the filename (e.g., et-core-unified-tb-285804-tb-285806-279784.min), + // so we need to clear all CSS files to ensure files with these layout IDs are removed. + ET_Core_PageResource::remove_static_resources( 'all', 'all' ); + } + + // Edit Template and Edit Preset: Save the templates into local library. + if ( $library_tb_id && $library_item_id ) { + et_theme_builder_update_library_item( $library_item_id, $templates ); + } + + // Add this action back. + add_action( 'save_post', array( 'ET_Core_PageResource', 'save_post_cb' ), 10, 3 ); + + wp_send_json_success( + array( + 'updatedTemplateIds' => (object) $updated_ids, + 'processedTemplatesData' => (object) $affected_templates, + 'hasDefault' => $has_default ? '1' : '0', + ) + ); +} diff --git a/tests/Fixtures/divi/reference/tb-load-setting-options.txt b/tests/Fixtures/divi/reference/tb-load-setting-options.txt new file mode 100644 index 0000000..670afe8 --- /dev/null +++ b/tests/Fixtures/divi/reference/tb-load-setting-options.txt @@ -0,0 +1,36 @@ +function et_theme_builder_load_template_setting_options( $setting_ids ) { + $flat_parent_settings = et_theme_builder_get_flat_template_settings_options(); + $groups = array(); + + foreach ( $setting_ids as $setting_id ) { + $parent_id = explode( ET_THEME_BUILDER_SETTING_SEPARATOR, $setting_id ); + $entity_id = implode( '', array_slice( $parent_id, -1 ) ); + $parent_id = array_slice( $parent_id, 0, -1 ); + $parent_id = implode( ET_THEME_BUILDER_SETTING_SEPARATOR, $parent_id ) . ET_THEME_BUILDER_SETTING_SEPARATOR; + + if ( ! isset( $flat_parent_settings[ $parent_id ] ) ) { + // Top-level, invalid or unknown setting. + continue; + } + + if ( ! isset( $groups[ $parent_id ] ) ) { + $groups[ $parent_id ] = array( + 'parent' => $flat_parent_settings[ $parent_id ], + 'settings' => array(), + ); + } + + $groups[ $parent_id ]['settings'][ $setting_id ] = $entity_id; + } + + $settings = array(); + + foreach ( $groups as $parent_id => $group ) { + $settings = array_merge( + $settings, + et_theme_builder_get_template_setting_child_options( $group['parent'], $group['settings'] ) + ); + } + + return $settings; +} diff --git a/tests/Fixtures/divi/reference/tb-store-template.txt b/tests/Fixtures/divi/reference/tb-store-template.txt new file mode 100644 index 0000000..eda5a35 --- /dev/null +++ b/tests/Fixtures/divi/reference/tb-store-template.txt @@ -0,0 +1,124 @@ +function et_theme_builder_store_template( $theme_builder_id, $template, $allow_default ) { + if ( ! current_user_can( 'edit_others_posts' ) ) { + wp_die(); + } + + $_ = et_(); + $raw_post_id = $_->array_get( $template, 'id', 0 ); + $post_id = is_numeric( $raw_post_id ) ? (int) $raw_post_id : 0; + $title = sanitize_text_field( $_->array_get( $template, 'title', '' ) ); + $default = $allow_default && '1' === $_->array_get( $template, 'default', '1' ); + $enabled = '1' === $_->array_get( $template, 'enabled', '1' ); + $header_id = (int) $_->array_get( $template, 'layouts.header.id', 0 ); + $header_enabled = (bool) $_->array_get( $template, 'layouts.header.enabled', true ); + $body_id = (int) $_->array_get( $template, 'layouts.body.id', 0 ); + $body_enabled = (bool) $_->array_get( $template, 'layouts.body.enabled', true ); + $footer_id = (int) $_->array_get( $template, 'layouts.footer.id', 0 ); + $footer_enabled = (bool) $_->array_get( $template, 'layouts.footer.enabled', true ); + $use_on = array_map( 'sanitize_text_field', $_->array_get( $template, 'use_on', array() ) ); + $exclude_from = array_map( 'sanitize_text_field', $_->array_get( $template, 'exclude_from', array() ) ); + $exists = $post_id > 0 && ET_THEME_BUILDER_TEMPLATE_POST_TYPE === get_post_type( $post_id ) && 'publish' === get_post_status( $post_id ); + $autogenerated_title = '1' === $_->array_get( $template, 'autogenerated_title', '1' ); + $onboarding = (bool) $_->array_get( $template, 'onboarding', '0' ); + + if ( ET_THEME_BUILDER_HEADER_LAYOUT_POST_TYPE !== get_post_type( $header_id ) || 'publish' !== get_post_status( $header_id ) ) { + $header_id = 0; + } + + if ( ET_THEME_BUILDER_BODY_LAYOUT_POST_TYPE !== get_post_type( $body_id ) || 'publish' !== get_post_status( $body_id ) ) { + $body_id = 0; + } + + if ( ET_THEME_BUILDER_FOOTER_LAYOUT_POST_TYPE !== get_post_type( $footer_id ) || 'publish' !== get_post_status( $footer_id ) ) { + $footer_id = 0; + } + + if ( $exists ) { + // Preform update only when needed. + if ( get_post_field( 'post_title', $post_id, 'raw' ) !== $title ) { + wp_update_post( + array( + 'ID' => $post_id, + 'post_title' => $title, + ) + ); + + // Update layout title for each template. + et_theme_builder_update_layout_title( $template ); + } + + update_post_meta( $post_id, '_et_onboarding_created', '0' ); + + } else { + $post_id = wp_insert_post( + array( + 'post_type' => ET_THEME_BUILDER_TEMPLATE_POST_TYPE, + 'post_status' => 'publish', + 'post_title' => $title, + ) + ); + + if ( $onboarding ) { + update_post_meta( $post_id, '_et_onboarding_created', '1' ); + } + } + + if ( 0 === $post_id || is_wp_error( $post_id ) ) { + return false; + } + + $metas = array( + '_et_autogenerated_title' => $autogenerated_title ? '1' : '0', + '_et_default' => $default ? '1' : '0', + '_et_enabled' => $enabled ? '1' : '0', + '_et_header_layout_id' => $header_id, + '_et_header_layout_enabled' => $header_enabled ? '1' : '0', + '_et_body_layout_id' => $body_id, + '_et_body_layout_enabled' => $body_enabled ? '1' : '0', + '_et_footer_layout_id' => $footer_id, + '_et_footer_layout_enabled' => $footer_enabled ? '1' : '0', + ); + + foreach ( $metas as $key => $value ) { + if ( strval( $value ) === strval( get_post_meta( $post_id, $key, true ) ) ) { + continue; + } + + update_post_meta( $post_id, $key, $value ); + } + + if ( $onboarding ) { + $onboarding_use_on = get_post_meta( $post_id, '_et_use_on' ); + $onboarding_exclude_from = get_post_meta( $post_id, '_et_exclude_from' ); + + foreach ( $onboarding_use_on as $condition ) { + add_post_meta( $post_id, '_et_old_use_on', $condition ); + } + + foreach ( $onboarding_exclude_from as $condition ) { + add_post_meta( $post_id, '_et_old_exclude_from', $condition ); + } + } + + // Handle _et_use_on meta. + delete_post_meta( $post_id, '_et_use_on' ); + if ( $use_on ) { + $use_on_unique = array_unique( $use_on ); + + foreach ( $use_on_unique as $condition ) { + add_post_meta( $post_id, '_et_use_on', $condition ); + } + } + + // Handle _et_exclude_from meta. + delete_post_meta( $post_id, '_et_exclude_from' ); + if ( $exclude_from ) { + $exclude_from_unique = array_unique( $exclude_from ); + + foreach ( $exclude_from_unique as $condition ) { + add_post_meta( $post_id, '_et_exclude_from', $condition ); + } + } + + return $post_id; +} diff --git a/tests/Fixtures/divi/reference/vb-sync-flags.txt b/tests/Fixtures/divi/reference/vb-sync-flags.txt new file mode 100644 index 0000000..43d9259 --- /dev/null +++ b/tests/Fixtures/divi/reference/vb-sync-flags.txt @@ -0,0 +1,444 @@ +function update( WP_REST_Request $request ) { + $post_id = (int) $request->get_param( 'post_id' ); + $post_status = $request->get_param( 'post_status' ); + $content = $request->get_param( 'content' ); + $preferences = $request->get_param( 'preferences' ); + $page_settings_by_layout = $request->get_param( 'pageSettingsByLayout' ); + $sync_type = $request->get_param( 'syncType' ); + $options = $request->get_param( 'options' ); + $workspace = $request->get_param( 'workspace' ); + $prime_cache_url = $request->get_param( 'prime_cache_url' ); + $main_loop_type = $request->get_param( 'mainLoopType' ); + $main_loop_type = is_string( $main_loop_type ) ? $main_loop_type : 'singular'; + $loop_settings_data = $request->get_param( 'mainLoopSettingsData' ); + $layout_post_ids = $request->get_param( 'layout_post_ids' ); + $layout_post_ids = is_array( $layout_post_ids ) ? $layout_post_ids : []; + $layout_post_ids = self::sanitize_layout_post_ids( $layout_post_ids ); + $page_settings_by_layout = is_array( $page_settings_by_layout ) ? $page_settings_by_layout : []; + $layout_post_ids = self::_validate_and_normalize_layout_post_ids( $layout_post_ids, $post_id, $main_loop_type ); + + if ( is_wp_error( $layout_post_ids ) ) { + return self::response_error( + $layout_post_ids->get_error_code(), + $layout_post_ids->get_error_message(), + $layout_post_ids->get_error_data() + ); + } + + foreach ( $page_settings_by_layout as $layout => $layout_page_settings ) { + if ( ! is_array( $layout_page_settings ) || ! isset( $layout_page_settings['customCss'] ) ) { + continue; + } + + $page_settings_by_layout[ $layout ]['customCss'] = str_replace( '\\', '\\\\', $layout_page_settings['customCss'] ); + } + + $post_content = isset( $content['post_content'] ) ? $content['post_content'] : ''; + $header_content = isset( $content['header'] ) ? $content['header'] : ''; + $body_content = isset( $content['body'] ) ? $content['body'] : ''; + $footer_content = isset( $content['footer'] ) ? $content['footer'] : ''; + $has_post_content_payload = '' !== $post_content; + + $post_content_post_id = isset( $layout_post_ids['postContent'] ) ? absint( $layout_post_ids['postContent'] ) : $post_id; + $header_post_id = isset( $layout_post_ids['header'] ) ? absint( $layout_post_ids['header'] ) : 0; + $body_post_id = isset( $layout_post_ids['body'] ) ? absint( $layout_post_ids['body'] ) : 0; + $footer_post_id = isset( $layout_post_ids['footer'] ) ? absint( $layout_post_ids['footer'] ) : 0; + $post_content_page_settings = isset( $page_settings_by_layout['postContent'] ) && is_array( $page_settings_by_layout['postContent'] ) + ? $page_settings_by_layout['postContent'] + : []; + + // Guardrail: never allow empty placeholder-only payloads to overwrite Theme Builder layouts. + $header_has_content = self::_has_meaningful_layout_content( $header_content ); + $body_has_content = self::_has_meaningful_layout_content( $body_content ); + $footer_has_content = self::_has_meaningful_layout_content( $footer_content ); + + if ( ! $header_has_content ) { + $header_post_id = 0; + } + + if ( ! $body_has_content ) { + $body_post_id = 0; + } + + if ( ! $footer_has_content ) { + $footer_post_id = 0; + } + + // Extract off-canvas data and make it available for hooks. + $off_canvas_data = $request->get_param( 'off_canvas_data' ); + if ( $off_canvas_data ) { + // Normalize to array format - decode JSON string if needed. + $normalized_canvas_data = self::_normalize_off_canvas_data( $off_canvas_data ); + if ( $normalized_canvas_data ) { + // Store off-canvas data globally so hooks can access it. + $GLOBALS['divi_off_canvas_data'] = $normalized_canvas_data; + } + } + + // Check if there is an autosave that is newer. + $post_author = get_current_user_id(); + + // Store one autosave per author. If there is already an autosave, overwrite it. + $autosave = wp_get_post_autosave( $post_id, $post_author ); + + if ( ! empty( $autosave ) ) { + wp_delete_post_revision( $autosave->ID ); + } + + // Check if what is being synced to server is preview post. + $is_saving_preview = 'preview' === $sync_type; + + // Preview post aims to have similar post previewing experience as WordPress block editor. + // How preview post works in WordPress block editor: + // 1. Preview button is clicked. + // 2. REST request is sent to `/wp-json/wp/v2/pages/POST_ID/autosaves?_locale=user` endpoint. + // 3. An autosave post for current post is created. Autosave post is another record on `wp_post` table that: + // - has post_status: `inherit` + // - has post_type: `revision` + // - has post_parent pointing to the published / draft post ID + // 4. Basically the autosave post is the most recent revision of the post. A user commonly can have only have + // one autosave per post. + // 5. Once autosave post is created, the preview post URL is generated and returned to the client. + // 6. User is being redirected to the preview post URL + // For WordPress block editor's preview post mechanism, see: `WP_REST_Autosaves_Controller->create_item()`. + + if ( $is_saving_preview ) { + // Create new autosave as post revision that will be used for preview. + $revision_id = Revision::put_post_revision( + [ + 'ID' => $post_id, + 'post_title' => $post_content_page_settings['postTitle'] ?? '', + + // `_wp_put_post_revision()` already does the `wp_slash()` for the content. No need apply `wp_slash()` here. + 'post_content' => $post_content, + ], + true + ); + + // Create preview post link. + // Equivalent of this on WordPress block editor can be seen at WP_REST_Autosaves_Controller->prepare_item_for_response(). + $preview_url = get_preview_post_link( + $post_id, + [ + 'preview_id' => $post_id, + 'preview_nonce' => wp_create_nonce( 'post_preview_' . $post_id ), + ] + ); + + return self::response_success( + [ + 'previewId' => $revision_id, + 'previewUrl' => $preview_url, + 'syncType' => 'preview', + 'post_status' => $post_status, + 'save_verification' => $revision_id ? true : false, + ] + ); + } + + /** + * Filters whether to proceed with the post save operation. + * + * This filter allows modules to intercept the save process and prevent + * the default wp_update_post() call. If the filter returns an array, + * that array will be returned as the success response and no save will occur. + * If the filter returns null, the save will proceed normally. + * + * @since ?? + * + * @param array|null $abort_save_response Response array to return if save should be aborted, or null to proceed. + * @param int $post_id The post ID being saved. + * @param string $post_content The post content being saved. + * @param string $post_status The post status being saved. + * @param array $options The options array containing conditional_tags and other metadata. + * @param array $post_content_page_settings The post content page settings array (if any). + */ + $abort_save_response = apply_filters( + 'divi_visual_builder_rest_before_save_post', + null, + $post_id, + $post_content, + $post_status, + $options, + $post_content_page_settings + ); + + if ( is_array( $abort_save_response ) ) { + // A filter has intercepted the save and provided a custom response. + return self::response_success( $abort_save_response ); + } + + // Build the post update array. + $post_update_data = [ + 'ID' => $post_id, + 'post_status' => $post_status, + ]; + + // Ignore invalid empty-string post content payloads, while still allowing status/title/excerpt updates. + if ( $has_post_content_payload ) { + $post_update_data['post_content'] = wp_slash( $post_content ); + } + + // Include post_title and post_excerpt in this update to avoid a second wp_update_post() call. + // This prevents duplicate revisions and duplicate filter processing. + // Note: wp_update_post() internally calls sanitize_post() which sanitizes both fields, + // but we sanitize here for consistency with the existing approach in et_builder_update_settings(). + if ( $post_content_page_settings ) { + if ( isset( $post_content_page_settings['postTitle'] ) ) { + $post_update_data['post_title'] = sanitize_text_field( $post_content_page_settings['postTitle'] ); + } + + if ( isset( $post_content_page_settings['postExcerpt'] ) ) { + // Use wp_kses_post for excerpt to allow certain HTML tags (same as et_builder_update_settings). + $post_update_data['post_excerpt'] = wp_kses_post( $post_content_page_settings['postExcerpt'] ); + } + } + + $main_post = get_post( $post_id ); + $has_main_post_context = $main_post instanceof \WP_Post; + $update = $has_main_post_context ? wp_update_post( $post_update_data ) : 0; + + $layout_updates = [ + [ + 'layout' => 'postContent', + 'id' => $post_content_post_id, + 'content' => $post_content, + ], + [ + 'layout' => 'header', + 'id' => $header_post_id, + 'content' => $header_content, + ], + [ + 'layout' => 'body', + 'id' => $body_post_id, + 'content' => $body_content, + ], + [ + 'layout' => 'footer', + 'id' => $footer_post_id, + 'content' => $footer_content, + ], + ]; + $updated_theme_builder_layout_post_ids = []; + + foreach ( $layout_updates as $layout_update ) { + $target_layout = $layout_update['layout'] ?? 'postContent'; + $target_post_id = absint( $layout_update['id'] ?? 0 ); + $target_content = $layout_update['content'] ?? ''; + + // Main post content is already updated above. + if ( $post_id === $target_post_id ) { + continue; + } + + $layout_update_result = self::_update_layout_post_content( $target_post_id, $target_content ); + + if ( is_wp_error( $layout_update_result ) ) { + return self::response_error( + $layout_update_result->get_error_code(), + $layout_update_result->get_error_message(), + $layout_update_result->get_error_data() + ); + } + + if ( 0 !== (int) $layout_update_result && in_array( $target_layout, [ 'header', 'body', 'footer' ], true ) ) { + $updated_theme_builder_layout_post_ids[] = $target_post_id; + } + } + + // Ensure Theme Builder layout saves clear template-targeted frontend caches. + // In non-singular Visual Builder contexts, the main request post ID is not a layout post, + // so cache clearing must also run for explicitly saved layout post IDs. + if ( ! empty( $updated_theme_builder_layout_post_ids ) ) { + foreach ( array_unique( $updated_theme_builder_layout_post_ids ) as $updated_layout_post_id ) { + et_theme_builder_clear_wp_post_cache( $updated_layout_post_id ); + } + } + + // Save page settings by layout post ID. + if ( ! empty( $page_settings_by_layout ) ) { + self::_save_page_settings_by_layout( + $page_settings_by_layout, + [ + 'postContent' => $post_content_post_id, + 'header' => $header_post_id, + 'body' => $body_post_id, + 'footer' => $footer_post_id, + ], + $post_id + ); + } + + // Save non-singular page settings (term properties, author properties, etc.). + // The frontend syncs field edits back into `mainLoopSettingsData`, so the + // updated values arrive here directly — no separate property needed. + if ( 'singular' !== $main_loop_type && is_array( $loop_settings_data ) ) { + $expected_main_loop_owner_context = self::_get_expected_main_loop_owner_context( $main_loop_type ); + + SavingUtility::save_non_singular_page_settings( + $loop_settings_data, + $main_loop_type, + $expected_main_loop_owner_context + ); + } + + /** + * Action hook to fire when the Post is being saved. + * + * This is for backward compatibility with hooks written for Divi version <5.0.0. + * + * @since 3.20 + * @deprecated 5.0.0 Use `divi_visual_builder_rest_save_post` hook instead. + * + * @param int $post_id Post ID. + */ + do_action( + 'et_save_post', + $post_id + ); + + /** + * Action hook to fire when the Post is being saved. + * + * @since ?? + * + * @param int $post_id Post ID. + */ + // Pass request context into save hooks. + // This avoids relying on globals and is extendable for future non-singular owners. + do_action( + 'divi_visual_builder_rest_save_post', + $post_id, + [ + 'mainLoopType' => $main_loop_type, + 'mainLoopSettingsData' => $loop_settings_data, + ] + ); + + // Prime page cache AFTER off-canvas data and other post meta is saved. + // This ensures that when the cache priming request triggers early detection, + // all post meta (including appended canvas content) is available. + SavingUtility::prime_page_cache_on_save( $post_id, $prime_cache_url ); + + if ( ( $update && ! is_wp_error( $update ) ) || ( ! $has_main_post_context && ! is_wp_error( $update ) ) ) { + // Update post meta so we know D5 is used and Readiness migrator will skip it. + if ( $has_main_post_context ) { + update_post_meta( $post_id, '_et_pb_use_divi_5', 'on' ); + // Also set the legacy meta for backward compatibility with D4 components. + update_post_meta( $post_id, '_et_pb_use_builder', 'on' ); + + // Clear the page creation flow flag so it doesn't show again on subsequent loads. + update_post_meta( $post_id, '_et_pb_show_page_creation', 'off' ); + } + + $layout_ids_to_mark = [ + $post_content_post_id, + $header_post_id, + $body_post_id, + $footer_post_id, + ]; + + foreach ( $layout_ids_to_mark as $layout_id ) { + $layout_id = absint( $layout_id ); + + if ( 0 === $layout_id || $post_id === $layout_id ) { + continue; + } + + update_post_meta( $layout_id, '_et_pb_use_divi_5', 'on' ); + update_post_meta( $layout_id, '_et_pb_use_builder', 'on' ); + } + + $preferences = SavingUtility::sanitize_app_preferences( $preferences ); + foreach ( $preferences as $preference_key => $preference_value ) { + $option_name = 'et_fb_pref_' . $preference_key; + et_update_option( $option_name, $preference_value ); + } + + // Updated last used workspace. + $last_used_workspace = $workspace['last-used'] ?? []; + + Workspace::update_item( 'builtIn', 'last-used', $last_used_workspace ); + + /** + * Action hook to fire when the Post is updated. + * + * This is for backward compatibility with hooks written for Divi version <5.0.0. + * + * @param int $post_id Post ID. + * + * @since 3.29 + * @deprecated 5.0.0 Use `divi_visual_builder_rest_update_post` hook instead. + */ + do_action( + 'et_update_post', + $post_id + ); + + /** + * Action hook to fire when the Post is updated. + * + * @param int $post_id Post ID. + * + * @since ?? + */ + do_action( 'divi_visual_builder_rest_update_post', $post_id ); + + $saved_post_content = $has_main_post_context ? get_post_field( 'post_content', $update ) : $post_content; + $verification = $has_main_post_context + ? self::_verify_post_content_matches_after_sanitization( $post_content, $saved_post_content ) + : true; + + /** + * Filter to modify the save verification result. + * + * @since ?? + * @deprecated 5.0.0 Use the {@see 'divi_visual_builder_rest_save_verification'} filter instead. + * + * @param bool $verification Whether to save the verification result. + */ + $verification = apply_filters( + 'et_fb_ajax_save_verification_result', + $verification + ); + + /** + * Filter to modify the save verification result. + * + * @since ?? + * + * @param bool $verification Whether to save the verification result. + */ + $save_verification_filtered = apply_filters( + 'divi_visual_builder_rest_save_post_save_verification', + $verification + ); + + $return_rendered_content = $options['return_rendered_content'] ?? false; + + if ( $return_rendered_content && $has_main_post_context ) { + // Replace dynamic data in the content with the actual value. + $normalized_content = DynamicData::get_processed_dynamic_data( $saved_post_content, $post_id, true ); + + // Apply the_content filter to the content. + $rendered_content = apply_filters( 'the_content', $normalized_content ); + } else { + $rendered_content = $saved_post_content; + } + + return self::response_success( + [ + 'post_status' => $has_main_post_context ? get_post_status( $update ) : $post_status, + 'save_verification' => $save_verification_filtered, + 'rendered_content' => $rendered_content, + ] + ); + } + + if ( is_wp_error( $update ) ) { + return self::response_error( $update->get_error_code(), $update->get_error_message(), $update->get_error_data() ); + } + + return self::response_error( 'unknown_error', esc_html__( 'Unknown error.', 'et_builder_5' ) ); + } diff --git a/tests/Unit/Integrations/Divi/DiviCompatibilityTest.php b/tests/Unit/Integrations/Divi/DiviCompatibilityTest.php index 85960c8..b3ea59f 100644 --- a/tests/Unit/Integrations/Divi/DiviCompatibilityTest.php +++ b/tests/Unit/Integrations/Divi/DiviCompatibilityTest.php @@ -5,6 +5,10 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi; use LIT\MCP\Integrations\Divi\DiviCompatibility; +use LIT\MCP\Integrations\Divi\Release\Call; +use LIT\MCP\Integrations\Divi\Release\Manifest; +use LIT\MCP\Integrations\Divi\Release\ManifestCheck; +use LIT\MCP\Integrations\Divi\Release\Mirror; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -23,34 +27,35 @@ final class DiviCompatibilityTest extends TestCase self::assertSame(['B', 'C'], $compatibility->problems()); } - public function testDefaultsCoverTheDiviInternalsInUse(): void + public function testDefaultsAreCodeRegistrationsAndFingerprints(): void { + $check = new ManifestCheck( + ['X' => new Manifest( + Manifest::LIBRARY, + calls: [new Call('strlen', 1)], + postTypes: ['et_pb_layout'], + mirrors: [new Mirror('m', 'a.php', 'a', null, 'Terme')] + )], + [], + static fn (): ?string => null, + static fn (): bool => true + ); + self::assertSame( - [ - 'ModuleRegistration (Modul-Katalog)', - 'DynamicContentOptions (Dynamic Content)', - 'et_pb_is_allowed (Divi-Rollen)', - 'Post-Typ et_pb_layout (Bibliothek)', - 'Taxonomien scope, layout_type, module_width (Bibliothek)', - 'GlobalLayout::is_global_layout_template (globale Module)', - 'ET_Core_PageResource (CSS-Cache)', - 'Block-Typ divi/global-layout (Instanzen globaler Module)', - 'Taxonomien layout_category, layout_tag (Bibliothek)', - 'Theme Builder: Funktionen et_theme_builder_* und et_()', - 'Theme Builder: Post-Typen et_template und Layouts', - ], - array_keys(DiviCompatibility::defaults()) + ['Bibliothek: strlen()', 'Bibliothek: Post-Typ et_pb_layout', 'Bibliothek: Nachbau von a() – Terme'], + array_keys(DiviCompatibility::defaults($check)) ); } - /** Review M-2: et_theme_builder_clear_wp_cache is one of DiviThemeBuilder::FUNCTIONS – reported once. */ - public function testChecksTheThemeBuilderInternals(): void + public function testRealDefaultsCoverEveryAdapterArea(): void { - $labels = array_keys(DiviCompatibility::defaults()); + $labels = implode("\n", array_keys(DiviCompatibility::defaults())); - self::assertNotContains('et_theme_builder_clear_wp_cache (Theme-Builder-Cache)', $labels); - - self::assertContains('Theme Builder: Funktionen et_theme_builder_* und et_()', $labels); - self::assertContains('Theme Builder: Post-Typen et_template und Layouts', $labels); + $areas = [Manifest::PAGES, Manifest::LIBRARY, Manifest::THEME_BUILDER, Manifest::DESIGN, Manifest::CATALOG]; + foreach ($areas as $area) { + self::assertStringContainsString($area . ': ', $labels); + } + self::assertStringContainsString('Theme Builder: Nachbau von et_theme_builder_api_save()', $labels); + self::assertStringContainsString('Katalog: Block-Typ divi/global-layout', $labels); } } diff --git a/tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php b/tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php index c0699fe..35b739e 100644 --- a/tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php +++ b/tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php @@ -19,21 +19,12 @@ use ReflectionClass; */ final class DiviBoundaryTest extends TestCase { - /** - * Files that still touch Divi outside an adapter (paths below src/). Tasks 3–7 - * shrink this list to DiviCompatibility.php; Task 8 removes it together with - * testLegacyFilesStillTouchDivi. - */ - private const LEGACY = [ - 'Integrations/Divi/DiviCompatibility.php', - ]; - public function testOnlyAdaptersTouchDivi(): void { $offenders = []; foreach (self::sourceFiles() as $relative => $path) { $touches = DiviTouches::inFile($path); - if ($touches === [] || in_array($relative, self::LEGACY, true)) { + if ($touches === []) { continue; } if (! in_array(self::classOf($relative), DiviAdapters::CLASSES, true)) { @@ -45,16 +36,6 @@ final class DiviBoundaryTest extends TestCase . implode("\n", $offenders)); } - public function testLegacyFilesStillTouchDivi(): void - { - $files = self::sourceFiles(); - foreach (self::LEGACY as $relative) { - self::assertArrayHasKey($relative, $files, $relative . ' gibt es nicht mehr – aus LEGACY streichen'); - self::assertNotSame([], DiviTouches::inFile($files[$relative]), $relative . ' ist sauber – aus LEGACY streichen'); - self::assertNotContains(self::classOf($relative), DiviAdapters::CLASSES, $relative . ' ist ein Adapter'); - } - } - public function testAdaptersDeclareExactlyTheDiviNamesTheyUse(): void { foreach (DiviAdapters::CLASSES as $class) { diff --git a/tests/Unit/Integrations/Divi/Release/DiviReferenceTest.php b/tests/Unit/Integrations/Divi/Release/DiviReferenceTest.php new file mode 100644 index 0000000..72d4a84 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/DiviReferenceTest.php @@ -0,0 +1,44 @@ + $mirror->id, DiviAdapters::mirrors()); + sort($ids); + $expected = DiviReference::expected(); + + self::assertSame($ids, array_keys($expected)); + foreach ($expected as $id => $hash) { + self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $hash, $id); + self::assertNotNull(DiviReference::readSource($id, (string) DiviReference::sourceDir()), $id); + } + } + + public function testWritesAndReadsExpectedFingerprintsSorted(): void + { + $file = (string) tempnam(sys_get_temp_dir(), 'lit'); + + try { + DiviReference::writeExpected(['b' => str_repeat('b', 64), 'a' => str_repeat('a', 64)], $file); + + self::assertSame(['a' => str_repeat('a', 64), 'b' => str_repeat('b', 64)], DiviReference::expected($file)); + self::assertStringEndsWith("\n", (string) file_get_contents($file)); + } finally { + unlink($file); + } + } + + public function testMissingOrBrokenFileMeansNoExpectations(): void + { + self::assertSame([], DiviReference::expected('/nonexistent/fingerprints.json')); + } +} diff --git a/tests/Unit/Integrations/Divi/Release/ManifestCheckTest.php b/tests/Unit/Integrations/Divi/Release/ManifestCheckTest.php new file mode 100644 index 0000000..dbcea6c --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/ManifestCheckTest.php @@ -0,0 +1,134 @@ + new Manifest( + Manifest::PAGES, + calls: [ + new Call('strlen', 1), + new Call('lit_mcp_missing_function', 0), + new Call(ArrayObject::class . '::count', 0), + new Call('Missing\Klass::run', 0), + new Call('str_replace'), + ], + constants: ['PHP_VERSION', 'LIT_MCP_MISSING_CONSTANT'], + postTypes: ['et_pb_layout'], + taxonomies: ['scope'], + blockTypes: ['divi/global-layout'], + mirrors: [ + new Mirror('same', 'a.php', 'a', null, 'gleich'), + new Mirror('changed', 'b.php', 'b', null, 'geändert'), + new Mirror('unreadable', 'c.php', 'c', null, 'fehlt'), + new Mirror('no-expectation', 'd.php', 'd', null, 'ohne Soll'), + ] + ), + ]; + + $currentHashes = ['same' => 'h1', 'changed' => 'hX', 'no-expectation' => 'h4']; + + return new ManifestCheck( + $manifests, + ['same' => 'h1', 'changed' => 'h2', 'unreadable' => 'h3'], + $current ?? static fn (Mirror $mirror): ?string => $currentHashes[$mirror->id] ?? null, + static fn (string $name): bool => false + ); + } + + public function testCodeChecksCoverExistenceAndArgumentCount(): void + { + self::assertSame( + [ + 'Seiten: strlen()', + 'Seiten: lit_mcp_missing_function()', + 'Seiten: ArrayObject::count()', + 'Seiten: Klass::run()', + 'Seiten: str_replace()', + 'Seiten: Konstante PHP_VERSION', + 'Seiten: Konstante LIT_MCP_MISSING_CONSTANT', + ], + array_keys($this->check()->codeChecks()) + ); + self::assertSame( + [ + 'Seiten: lit_mcp_missing_function()', + 'Seiten: Klass::run()', + 'Seiten: Konstante LIT_MCP_MISSING_CONSTANT', + ], + ManifestCheck::failed($this->check()->codeChecks()) + ); + } + + public function testAcceptsChecksTheArgumentCountTheAdapterPasses(): void + { + self::assertTrue(ManifestCheck::accepts(new Call('strlen', 1))); + self::assertFalse(ManifestCheck::accepts(new Call('strlen', 2)), 'strlen takes one argument'); + self::assertFalse(ManifestCheck::accepts(new Call('str_replace', 2)), 'str_replace needs three'); + self::assertTrue(ManifestCheck::accepts(new Call('sprintf', 5)), 'variadic'); + self::assertTrue(ManifestCheck::accepts(new Call('str_replace')), 'existence only'); + } + + public function testMirrorChecksCompareWithTheExpectedFingerprints(): void + { + $checks = $this->check()->mirrorChecks(); + + self::assertSame( + [ + 'Seiten: Nachbau von a() – gleich', + 'Seiten: Nachbau von b() – geändert', + 'Seiten: Nachbau von c() – fehlt', + 'Seiten: Nachbau von d() – ohne Soll', + ], + array_keys($checks) + ); + self::assertSame( + [ + 'Seiten: Nachbau von b() – geändert', + 'Seiten: Nachbau von c() – fehlt', + 'Seiten: Nachbau von d() – ohne Soll', + ], + ManifestCheck::failed($checks) + ); + } + + public function testUnreadableMirrorIsAFailedCheck(): void + { + $check = $this->check(static function (): ?string { + throw new RuntimeException('Divi file gone'); + }); + + self::assertCount(4, ManifestCheck::failed($check->mirrorChecks())); + } + + public function testRegistrationChecksAskWordPressAndDivi(): void + { + Functions\when('post_type_exists')->justReturn(true); + Functions\when('taxonomy_exists')->justReturn(false); + + $checks = $this->check()->registrationChecks(); + + self::assertSame( + ['Seiten: Post-Typ et_pb_layout', 'Seiten: Taxonomie scope', 'Seiten: Block-Typ divi/global-layout'], + array_keys($checks) + ); + self::assertSame( + ['Seiten: Taxonomie scope', 'Seiten: Block-Typ divi/global-layout'], + ManifestCheck::failed($checks) + ); + } +}