diff --git a/src/composer.lock b/src/composer.lock index 2f998e95..b56342ca 100644 --- a/src/composer.lock +++ b/src/composer.lock @@ -330,16 +330,16 @@ "packages-dev": [ { "name": "dealerdirect/phpcodesniffer-composer-installer", - "version": "v1.2.0", + "version": "v1.2.1", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/composer-installer.git", - "reference": "845eb62303d2ca9b289ef216356568ccc075ffd1" + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/845eb62303d2ca9b289ef216356568ccc075ffd1", - "reference": "845eb62303d2ca9b289ef216356568ccc075ffd1", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd", "shasum": "" }, "require": { @@ -422,7 +422,7 @@ "type": "thanks_dev" } ], - "time": "2025-11-11T04:32:07+00:00" + "time": "2026-05-06T08:26:05+00:00" }, { "name": "doctrine/instantiator", diff --git a/src/php/Plugin.php b/src/php/Plugin.php index 24598976..ade1ecd3 100644 --- a/src/php/Plugin.php +++ b/src/php/Plugin.php @@ -22,6 +22,7 @@ use Code_Snippets\UnifiedSnippets\Scan_Results_Store; use Code_Snippets\UnifiedSnippets\REST\Scan_REST_Controller; use Code_Snippets\UnifiedSnippets\Scanners\Additional_CSS_Scanner; +use Code_Snippets\UnifiedSnippets\Scanners\Divi_Theme_Options_Scanner; use Code_Snippets\UnifiedSnippets\Scanners\Functions_Php_Scanner; use Code_Snippets\UnifiedSnippets\Scanners\Header_Footer_Code_Manager_Scanner; use Code_Snippets\UnifiedSnippets\Scanners\Htaccess_Scanner; @@ -197,6 +198,7 @@ private function init_unified_snippets(): void { $this->unified_snippets->register( new Insert_Headers_And_Footers_Scanner() ); $this->unified_snippets->register( new Header_Footer_Code_Manager_Scanner() ); $this->unified_snippets->register( new Insert_PHP_Code_Snippet_Scanner() ); + $this->unified_snippets->register( new Divi_Theme_Options_Scanner() ); new Scan_REST_Controller( $this->unified_snippets, $this->unified_snippets_store ); } diff --git a/src/php/UnifiedSnippets/Scanners/Divi_Theme_Options_Scanner.php b/src/php/UnifiedSnippets/Scanners/Divi_Theme_Options_Scanner.php new file mode 100644 index 00000000..864133f8 --- /dev/null +++ b/src/php/UnifiedSnippets/Scanners/Divi_Theme_Options_Scanner.php @@ -0,0 +1,369 @@ +` array (one-row mode) or per-key options like `et__custom_css` + * (per-row mode). This scanner mirrors Divi's own `et_get_option()` read path so it works in + * either layout, and surfaces each populated field as a {@see Discovered_Snippet}. + * + * The four Integration fields each have a companion enable toggle (e.g. `_integrate_header_enable`) + * that Divi checks before emitting the code at runtime. We honour those toggles via `is_active` + * so the Unified view reflects what is actually running. + * + * Wrapping logic for the post-scoped fields (which fire on the Divi-only `et_before_post` / + * `et_after_post` hooks) is intentionally left to a later Phase 3 importer; the scanner stays + * read-only and records the relevant caveat in `import_notes`. + * + * @package Code_Snippets + */ +class Divi_Theme_Options_Scanner extends Scanner_Base { + + /** + * Templates whose option storage this scanner understands. + */ + private const SUPPORTED_TEMPLATES = [ 'Divi', 'Extra' ]; + + /** + * Fallback shortname when the active template is not Extra. Production only reaches this + * branch when running against Divi itself (the parent theme). + */ + private const DEFAULT_SHORTNAME = 'divi'; + + /** + * {@inheritDoc} + */ + public function get_id(): string { + return 'divi-theme-options'; + } + + /** + * {@inheritDoc} + */ + public function get_label(): string { + return __( 'Divi Theme Options', 'code-snippets' ); + } + + /** + * {@inheritDoc} + * + * Available only when Divi (or Extra) is the active theme template. We deliberately do + * not scan when Divi is merely installed: its hooks are not running, so the code is + * dormant and the namespaced option keys belong to an inactive theme. + */ + public function is_available(): bool { + if ( ! function_exists( 'get_template' ) ) { + return false; + } + + return in_array( get_template(), self::SUPPORTED_TEMPLATES, true ); + } + + /** + * {@inheritDoc} + * + * Raw HTML/JS is injected unfiltered into every page (head/footer) or every single post + * (top/bottom); CSS overrides the active theme's styles. Medium captures that risk without + * implying that wp-config-level damage is possible. + */ + public function get_risk_level(): string { + return 'medium'; + } + + /** + * {@inheritDoc} + */ + public function supports_import(): bool { + return true; + } + + /** + * {@inheritDoc} + */ + public function scan(): array { + $shortname = $this->resolve_shortname(); + $source_name = ucfirst( $shortname ); + + $snippets = [ + $this->scan_custom_css( $shortname, $source_name ), + $this->scan_integration_head( $shortname, $source_name ), + $this->scan_integration_body( $shortname, $source_name ), + $this->scan_integration_single_top( $shortname, $source_name ), + $this->scan_integration_single_bottom( $shortname, $source_name ), + ]; + + return array_values( array_filter( $snippets ) ); + } + + /** + * Resolve the Divi option shortname for the active template. + * + * @return string 'divi' or 'extra'. + */ + private function resolve_shortname(): string { + if ( ! function_exists( 'get_template' ) ) { + return self::DEFAULT_SHORTNAME; + } + + $template = get_template(); + + if ( 'Extra' === $template ) { + return 'extra'; + } + + return self::DEFAULT_SHORTNAME; + } + + /** + * Read a Divi option, mirroring `et_get_option()`'s two storage modes. + * + * Divi may store its theme options either as a single serialised array under `et_` + * (one-row mode) or as individual options keyed by the full field id (per-row mode). The + * actual mode is decided per-install by `et_options_stored_in_one_row()`. Crucially, the + * field IDs in `options_divi.php` are already prefixed with the shortname (e.g. + * `divi_custom_css`, `divi_integration_head`), and that full id is what `et_get_option()` + * uses as both the array key (one-row) and the option name (per-row). Rather than load + * Divi's helper, we check both locations and return whichever yields a value; reads against + * an empty install harmlessly return ''. + * + * @param string $shortname The resolved Divi shortname (e.g. `divi`). + * @param string $key Option key without the shortname prefix (e.g. `custom_css`). + * + * @return string The stored value, or '' when unset. + */ + private function read_option( string $shortname, string $key ): string { + if ( ! function_exists( 'get_option' ) ) { + return ''; + } + + $full_key = $shortname . '_' . $key; + + $bundle = get_option( 'et_' . $shortname ); + if ( is_array( $bundle ) && isset( $bundle[ $full_key ] ) && '' !== (string) $bundle[ $full_key ] ) { + return (string) $bundle[ $full_key ]; + } + + $value = get_option( $full_key, '' ); + + return is_scalar( $value ) ? (string) $value : ''; + } + + /** + * Read one of Divi's `_integrate_*_enable` toggles. + * + * Mirrors Divi's runtime check (see `integration_head()` in custom_functions.php), which + * emits code only when the toggle value is strictly equal to `'on'`. Other states the + * scanner may encounter: + * + * - Stored as `'false'` (string) when the checkbox was explicitly unchecked at save. + * Divi's save handler writes the literal string `'false'` for unchecked checkboxes + * (see `epanel_save_data()`). + * - Missing entirely on a never-saved install. `et_get_option()` returns boolean false + * in that case, and `false === 'on'` is also false, so Divi does NOT emit. We match + * that here — the checkbox `std => 'on'` in options_divi.php controls the admin UI's + * default-checked state, not the runtime default. + * + * @param string $shortname The resolved Divi shortname. + * @param string $key Toggle key without the `_` prefix. + * + * @return bool + */ + private function read_toggle( string $shortname, string $key ): bool { + return 'on' === $this->read_option( $shortname, $key ); + } + + /** + * Build the Custom CSS snippet, if populated. + * + * @param string $shortname The Divi option shortname. + * @param string $source_name Human-readable theme name. + * + * @return Discovered_Snippet|null + */ + private function scan_custom_css( string $shortname, string $source_name ): ?Discovered_Snippet { + $code = $this->read_option( $shortname, 'custom_css' ); + + if ( '' === trim( $code ) ) { + return null; + } + + return $this->build_field_snippet( + [ + 'name' => sprintf( + /* translators: %s: theme name (e.g. Divi). */ + __( '%s Custom CSS', 'code-snippets' ), + $source_name + ), + 'code' => $code, + 'type' => 'css', + 'source_name' => $source_name, + 'source_path' => 'divi://theme-options/custom_css', + 'is_active' => true, + 'import_notes' => __( 'Imports cleanly as a site-wide CSS snippet (scope: site-css).', 'code-snippets' ), + ] + ); + } + + /** + * Build the Integration > Head snippet, if populated. + * + * @param string $shortname The Divi option shortname. + * @param string $source_name Human-readable theme name. + * + * @return Discovered_Snippet|null + */ + private function scan_integration_head( string $shortname, string $source_name ): ?Discovered_Snippet { + $code = $this->read_option( $shortname, 'integration_head' ); + + if ( '' === trim( $code ) ) { + return null; + } + + return $this->build_field_snippet( + [ + 'name' => sprintf( + /* translators: %s: theme name (e.g. Divi). */ + __( '%s Head Code', 'code-snippets' ), + $source_name + ), + 'code' => $code, + 'type' => 'html', + 'source_name' => $source_name, + 'source_path' => 'divi://theme-options/integration_head', + 'is_active' => $this->read_toggle( $shortname, 'integrate_header_enable' ), + 'import_notes' => __( 'Divi renders this on wp_head at priority 12. Importing as head-content runs it on wp_head at priority 10, so the imported snippet executes slightly earlier than Divi did. Usually harmless, but may matter for order-sensitive scripts.', 'code-snippets' ), + ] + ); + } + + /** + * Build the Integration > Body snippet, if populated. + * + * Despite Divi's UI labelling this "body" code, the theme actually emits the content on + * `wp_footer` at priority 12 (see Divi/epanel/custom_functions.php). Importing into + * Code Snippets' `footer-content` scope (also wp_footer, default priority 10 per + * `Evaluate_Content::init()`) reproduces the runtime behaviour, just executed slightly + * earlier. The priority delta is surfaced in `import_notes` for users with order-sensitive + * scripts. + * + * @param string $shortname The Divi option shortname. + * @param string $source_name Human-readable theme name. + * + * @return Discovered_Snippet|null + */ + private function scan_integration_body( string $shortname, string $source_name ): ?Discovered_Snippet { + $code = $this->read_option( $shortname, 'integration_body' ); + + if ( '' === trim( $code ) ) { + return null; + } + + return $this->build_field_snippet( + [ + 'name' => sprintf( + /* translators: %s: theme name (e.g. Divi). */ + __( '%s Body Code', 'code-snippets' ), + $source_name + ), + 'code' => $code, + 'type' => 'html', + 'source_name' => $source_name, + 'source_path' => 'divi://theme-options/integration_body', + 'is_active' => $this->read_toggle( $shortname, 'integrate_body_enable' ), + 'import_notes' => __( 'Divi renders this on wp_footer at priority 12. Importing as footer-content runs it on wp_footer at priority 10, so the imported snippet executes slightly earlier than Divi did. Usually harmless, but may matter for order-sensitive scripts.', 'code-snippets' ), + ] + ); + } + + /** + * Build the Integration > Single Top snippet, if populated. + * + * @param string $shortname The Divi option shortname. + * @param string $source_name Human-readable theme name. + * + * @return Discovered_Snippet|null + */ + private function scan_integration_single_top( string $shortname, string $source_name ): ?Discovered_Snippet { + $code = $this->read_option( $shortname, 'integration_single_top' ); + + if ( '' === trim( $code ) ) { + return null; + } + + return $this->build_field_snippet( + [ + 'name' => sprintf( + /* translators: %s: theme name (e.g. Divi). */ + __( '%s Single Top Code', 'code-snippets' ), + $source_name + ), + 'code' => $code, + 'type' => 'html', + 'source_name' => $source_name, + 'source_path' => 'divi://theme-options/integration_single_top', + 'is_active' => $this->read_toggle( $shortname, 'integrate_singletop_enable' ), + 'import_notes' => __( 'Fires on Divi\'s et_before_post hook. Import wraps the code in an add_action() targeting that hook, so the snippet will only run while the Divi theme is active.', 'code-snippets' ), + ] + ); + } + + /** + * Build the Integration > Single Bottom snippet, if populated. + * + * @param string $shortname The Divi option shortname. + * @param string $source_name Human-readable theme name. + * + * @return Discovered_Snippet|null + */ + private function scan_integration_single_bottom( string $shortname, string $source_name ): ?Discovered_Snippet { + $code = $this->read_option( $shortname, 'integration_single_bottom' ); + + if ( '' === trim( $code ) ) { + return null; + } + + return $this->build_field_snippet( + [ + 'name' => sprintf( + /* translators: %s: theme name (e.g. Divi). */ + __( '%s Single Bottom Code', 'code-snippets' ), + $source_name + ), + 'code' => $code, + 'type' => 'html', + 'source_name' => $source_name, + 'source_path' => 'divi://theme-options/integration_single_bottom', + 'is_active' => $this->read_toggle( $shortname, 'integrate_singlebottom_enable' ), + 'import_notes' => __( 'Fires on Divi\'s et_after_post hook. Import wraps the code in an add_action() targeting that hook, so the snippet will only run while the Divi theme is active.', 'code-snippets' ), + ] + ); + } + + /** + * Common field defaults applied to every Divi snippet before delegating to build_snippet(). + * + * @param array $fields Per-field overrides. + * + * @return Discovered_Snippet + */ + private function build_field_snippet( array $fields ): Discovered_Snippet { + return $this->build_snippet( + array_merge( + [ + 'source_type' => 'theme', + 'line_start' => 0, + 'line_end' => 0, + ], + $fields + ) + ); + } +} diff --git a/tests/phpunit/test-divi-scanner.php b/tests/phpunit/test-divi-scanner.php new file mode 100644 index 00000000..a90afb31 --- /dev/null +++ b/tests/phpunit/test-divi-scanner.php @@ -0,0 +1,373 @@ + + */ + private array $registered_filters = []; + + /** + * Tear down per-test fixtures: remove any `pre_option_*` filters this test registered, and + * delete the wp_options rows the per-storage tests seed. + */ + public function tear_down() { + foreach ( $this->registered_filters as $entry ) { + remove_filter( $entry['hook'], $entry['callback'] ); + } + + $this->registered_filters = []; + + delete_option( 'et_divi' ); + delete_option( 'divi_custom_css' ); + delete_option( 'divi_integration_head' ); + delete_option( 'divi_integration_body' ); + delete_option( 'divi_integration_single_top' ); + delete_option( 'divi_integration_single_bottom' ); + delete_option( 'divi_integrate_header_enable' ); + delete_option( 'divi_integrate_body_enable' ); + delete_option( 'divi_integrate_singletop_enable' ); + delete_option( 'divi_integrate_singlebottom_enable' ); + + parent::tear_down(); + } + + /** + * Force `get_template()` (and its companion `get_stylesheet()`) to report a chosen theme + * slug for the duration of the test, without activating that theme on disk. Uses WordPress's + * own `pre_option_*` short-circuit filters so we don't poke at scanner internals. + * + * @param string $template Slug to return from get_template(), e.g. 'Divi' or 'twentytwentyfour'. + * + * @return void + */ + private function pretend_active_template( string $template ): void { + $callback = static function () use ( $template ) { + return $template; + }; + + foreach ( [ 'pre_option_template', 'pre_option_stylesheet' ] as $hook ) { + add_filter( $hook, $callback ); + + $this->registered_filters[] = [ + 'hook' => $hook, + 'callback' => $callback, + ]; + } + } + + /** + * Index a scan result by snippet name for easier per-field assertions. + * + * @param array $results Scanner output. + * + * @return array + */ + private function index_by_name( array $results ): array { + $indexed = []; + + foreach ( $results as $snippet ) { + $indexed[ $snippet->name ] = $snippet; + } + + return $indexed; + } + + /** + * Write a fully populated `et_divi` bundle (one-row storage mode) covering every field + * the scanner knows about, with all enable toggles set to `'on'`. + * + * @return void + */ + private function seed_full_divi_bundle(): void { + update_option( + 'et_divi', + [ + 'divi_custom_css' => '.divi-test { color: red; }', + 'divi_integration_head' => '', + 'divi_integration_body' => '', + 'divi_integration_single_top' => '
Top
', + 'divi_integration_single_bottom' => '
Bottom
', + 'divi_integrate_header_enable' => 'on', + 'divi_integrate_body_enable' => 'on', + 'divi_integrate_singletop_enable' => 'on', + 'divi_integrate_singlebottom_enable' => 'on', + ] + ); + } + + /** + * A fully populated Divi install produces one Discovered_Snippet per field with the + * expected type, source metadata, and active flags. Uses one-row storage mode (the modern + * Divi default). + */ + public function test_scan_returns_one_snippet_per_populated_field() { + $this->pretend_active_template( 'Divi' ); + $this->seed_full_divi_bundle(); + + $scanner = new Divi_Theme_Options_Scanner(); + + $this->assertTrue( $scanner->is_available() ); + + $results = $scanner->scan(); + $this->assertCount( 5, $results ); + + $by_name = $this->index_by_name( $results ); + + $this->assertArrayHasKey( 'Divi Custom CSS', $by_name ); + $this->assertSame( 'css', $by_name['Divi Custom CSS']->type ); + $this->assertSame( '.divi-test { color: red; }', $by_name['Divi Custom CSS']->code ); + $this->assertSame( 'divi://theme-options/custom_css', $by_name['Divi Custom CSS']->source_path ); + $this->assertTrue( $by_name['Divi Custom CSS']->is_active ); + + $this->assertArrayHasKey( 'Divi Head Code', $by_name ); + $this->assertSame( 'html', $by_name['Divi Head Code']->type ); + $this->assertSame( 'divi://theme-options/integration_head', $by_name['Divi Head Code']->source_path ); + $this->assertTrue( $by_name['Divi Head Code']->is_active ); + + $this->assertArrayHasKey( 'Divi Body Code', $by_name ); + $this->assertSame( 'html', $by_name['Divi Body Code']->type ); + $this->assertStringContainsString( 'wp_footer', $by_name['Divi Body Code']->import_notes ); + $this->assertTrue( $by_name['Divi Body Code']->is_active ); + + $this->assertArrayHasKey( 'Divi Single Top Code', $by_name ); + $this->assertStringContainsString( 'et_before_post', $by_name['Divi Single Top Code']->import_notes ); + $this->assertTrue( $by_name['Divi Single Top Code']->is_active ); + + $this->assertArrayHasKey( 'Divi Single Bottom Code', $by_name ); + $this->assertStringContainsString( 'et_after_post', $by_name['Divi Single Bottom Code']->import_notes ); + $this->assertTrue( $by_name['Divi Single Bottom Code']->is_active ); + + foreach ( $results as $snippet ) { + $this->assertSame( 'theme', $snippet->source_type ); + $this->assertSame( 'Divi', $snippet->source_name ); + $this->assertSame( 'divi-theme-options', $snippet->scanner_id ); + $this->assertSame( 'medium', $snippet->risk_level ); + $this->assertTrue( $snippet->is_importable ); + $this->assertSame( 0, $snippet->line_start ); + $this->assertSame( 0, $snippet->line_end ); + } + } + + /** + * Empty and whitespace-only fields are skipped, so a sparsely populated install only + * surfaces the fields the user has actually filled in. + */ + public function test_scan_skips_empty_fields() { + $this->pretend_active_template( 'Divi' ); + + update_option( + 'et_divi', + [ + 'divi_custom_css' => '.only-css {}', + 'divi_integration_head' => ' ', + 'divi_integration_body' => '', + ] + ); + + $results = ( new Divi_Theme_Options_Scanner() )->scan(); + + $this->assertCount( 1, $results ); + $this->assertSame( 'Divi Custom CSS', $results[0]->name ); + $this->assertSame( 'css', $results[0]->type ); + } + + /** + * Disabling Divi's `_integrate_header_enable` toggle (stored as `'off'`) is reflected as + * `is_active = false` on the discovered snippet so the Unified view does not falsely + * report it as running. + */ + public function test_disabled_toggle_marks_snippet_inactive() { + $this->pretend_active_template( 'Divi' ); + + update_option( + 'et_divi', + [ + 'divi_integration_head' => '', + 'divi_integrate_header_enable' => 'off', + ] + ); + + $results = ( new Divi_Theme_Options_Scanner() )->scan(); + + $this->assertCount( 1, $results ); + $this->assertSame( 'Divi Head Code', $results[0]->name ); + $this->assertFalse( $results[0]->is_active ); + } + + /** + * Divi's runtime check uses strict equality against `'on'`, so a populated Integration field + * with no stored toggle value (which `et_get_option()` returns as boolean false) is NOT + * actually emitted by Divi. The scanner must mirror that. + */ + public function test_missing_toggle_reports_inactive() { + $this->pretend_active_template( 'Divi' ); + + update_option( + 'et_divi', + [ + 'divi_integration_body' => '', + ] + ); + + $results = ( new Divi_Theme_Options_Scanner() )->scan(); + + $this->assertCount( 1, $results ); + $this->assertSame( 'Divi Body Code', $results[0]->name ); + $this->assertFalse( $results[0]->is_active ); + } + + /** + * Divi's save handler writes the literal string `'false'` when a checkbox is unchecked at + * save time. That is not strictly equal to `'on'`, so the snippet must report inactive. + */ + public function test_literal_false_toggle_reports_inactive() { + $this->pretend_active_template( 'Divi' ); + + update_option( + 'et_divi', + [ + 'divi_integration_head' => '', + 'divi_integrate_header_enable' => 'false', + ] + ); + + $results = ( new Divi_Theme_Options_Scanner() )->scan(); + + $this->assertCount( 1, $results ); + $this->assertSame( 'Divi Head Code', $results[0]->name ); + $this->assertFalse( $results[0]->is_active ); + } + + /** + * `source_path` is deterministic (no volatile line numbers) so the snippet hash remains + * stable across repeated scans. This is what change detection in Phase 5 relies on. + */ + public function test_hash_is_stable_across_scans() { + $this->pretend_active_template( 'Divi' ); + $this->seed_full_divi_bundle(); + + $hashes_a = wp_list_pluck( ( new Divi_Theme_Options_Scanner() )->scan(), 'hash' ); + $hashes_b = wp_list_pluck( ( new Divi_Theme_Options_Scanner() )->scan(), 'hash' ); + + sort( $hashes_a ); + sort( $hashes_b ); + + $this->assertSame( $hashes_a, $hashes_b ); + $this->assertCount( 5, array_unique( $hashes_a ) ); + } + + /** + * Editing the code of one field changes its `checksum` (so change detection fires) but not + * its `hash` (so the snippet is recognised as the same source location). + */ + public function test_checksum_changes_with_code_but_hash_stays() { + $this->pretend_active_template( 'Divi' ); + $this->seed_full_divi_bundle(); + + $head_before = $this->index_by_name( ( new Divi_Theme_Options_Scanner() )->scan() )['Divi Head Code']; + + $bundle = get_option( 'et_divi' ); + $bundle['divi_integration_head'] = ''; + update_option( 'et_divi', $bundle ); + + $head_after = $this->index_by_name( ( new Divi_Theme_Options_Scanner() )->scan() )['Divi Head Code']; + + $this->assertSame( $head_before->hash, $head_after->hash ); + $this->assertNotSame( $head_before->checksum, $head_after->checksum ); + } + + /** + * Without Divi (or Extra) as the active template, the scanner reports itself unavailable + * even if the wp_options rows still exist. Divi's hooks are not running, so the code is + * dormant and should not be surfaced. + */ + public function test_is_available_false_without_divi_theme() { + $this->pretend_active_template( 'twentytwentyfour' ); + + $this->assertFalse( ( new Divi_Theme_Options_Scanner() )->is_available() ); + } + + /** + * The sibling theme Extra shares Divi's option storage layout under a different shortname, + * so the scanner reports itself available when Extra is the active template. + */ + public function test_is_available_true_for_extra_theme() { + $this->pretend_active_template( 'Extra' ); + + $this->assertTrue( ( new Divi_Theme_Options_Scanner() )->is_available() ); + } + + /** + * Static scanner identity surfaces correctly to the registry / REST layer. + */ + public function test_scanner_identity() { + $scanner = new Divi_Theme_Options_Scanner(); + + $this->assertSame( 'divi-theme-options', $scanner->get_id() ); + $this->assertSame( 'Divi Theme Options', $scanner->get_label() ); + $this->assertSame( 'medium', $scanner->get_risk_level() ); + $this->assertTrue( $scanner->supports_import() ); + $this->assertFalse( $scanner->supports_editing() ); + } + + /** + * One-row storage: Divi packs every option into `et_divi`, keyed by the full field id + * (e.g. `divi_custom_css`). The scanner must look up `_` inside that array + * rather than the bare key. + */ + public function test_reads_real_one_row_storage() { + $this->pretend_active_template( 'Divi' ); + + update_option( + 'et_divi', + [ + 'divi_custom_css' => '.real-one-row { color: green; }', + 'divi_integration_head' => '', + ] + ); + + $results = $this->index_by_name( ( new Divi_Theme_Options_Scanner() )->scan() ); + + $this->assertArrayHasKey( 'Divi Custom CSS', $results ); + $this->assertSame( '.real-one-row { color: green; }', $results['Divi Custom CSS']->code ); + $this->assertArrayHasKey( 'Divi Head Code', $results ); + $this->assertSame( '', $results['Divi Head Code']->code ); + } + + /** + * Per-row storage: each field is stored as its own option named after the full field id + * (e.g. `divi_integration_body`), with no `et_` prefix. The scanner falls back to this read + * path when the `et_` bundle is missing the key. + */ + public function test_reads_real_per_row_storage() { + $this->pretend_active_template( 'Divi' ); + + update_option( 'divi_integration_body', '' ); + + $results = $this->index_by_name( ( new Divi_Theme_Options_Scanner() )->scan() ); + + $this->assertArrayHasKey( 'Divi Body Code', $results ); + $this->assertSame( + '', + $results['Divi Body Code']->code + ); + } +}