diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index a267e9c2..d195658d 100755 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -13,6 +13,9 @@ jobs: strategy: fail-fast: false runs-on: ubuntu-22.04 + env: + # PHP 7.4 images depend on the expired Debian Bullseye package index. + WP_ENV_PHP_VERSION: "8.2" steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/classes/Visualizer/Gutenberg/Block.php b/classes/Visualizer/Gutenberg/Block.php index be55c623..5a152351 100644 --- a/classes/Visualizer/Gutenberg/Block.php +++ b/classes/Visualizer/Gutenberg/Block.php @@ -202,8 +202,25 @@ public function enqueue_gutenberg_scripts() { * Hook server side rendering into render callback */ public function register_block_type() { + $asset_path = VISUALIZER_ABSPATH . '/classes/Visualizer/Gutenberg/build/index.asset.php'; + $version = $this->version; + if ( file_exists( $asset_path ) ) { + // @phpstan-ignore-next-line + $asset = require $asset_path; + $version = isset( $asset['version'] ) ? $asset['version'] : $version; + } + if ( ! wp_style_is( 'visualizer-datatables', 'registered' ) ) { + wp_register_style( 'visualizer-datatables', VISUALIZER_ABSURL . 'css/lib/datatables.min.css', array(), Visualizer_Plugin::VERSION ); + } + if ( ! wp_style_is( 'visualizer-gutenberg-block', 'registered' ) ) { + wp_register_style( 'visualizer-gutenberg-block', VISUALIZER_ABSURL . 'classes/Visualizer/Gutenberg/build/style-index.css', array( 'visualizer-datatables' ), $version ); + } register_block_type( 'visualizer/chart', array( + // The editor_style registration is what gets the stylesheet into the + // iframed editor canvas; styles enqueued via enqueue_block_editor_assets + // only reach the parent document. + 'editor_style' => 'visualizer-gutenberg-block', 'render_callback' => array( $this, 'gutenberg_block_callback' ), 'attributes' => array( 'id' => array( @@ -297,6 +314,9 @@ public function get_visualizer_data( $post ) { // faetch and update settings $data['visualizer-settings'] = get_post_meta( $post_id, Visualizer_Plugin::CF_SETTINGS, true ); + if ( ! is_array( $data['visualizer-settings'] ) ) { + $data['visualizer-settings'] = array(); + } if ( empty( $data['visualizer-settings']['pagination'] ) ) { $data['visualizer-settings']['pageSize'] = ''; } diff --git a/classes/Visualizer/Module/Admin.php b/classes/Visualizer/Module/Admin.php index e003f2e6..e7ef36e5 100644 --- a/classes/Visualizer/Module/Admin.php +++ b/classes/Visualizer/Module/Admin.php @@ -81,7 +81,6 @@ public function __construct( Visualizer_Plugin $plugin ) { $this->_addFilter( 'media_view_strings', 'setupMediaViewStrings' ); $this->_addFilter( 'plugin_action_links', 'getPluginActionLinks', 10, 2 ); $this->_addFilter( 'plugin_row_meta', 'getPluginMetaLinks', 10, 2 ); - $this->_addFilter( 'visualizer_logger_data', 'getLoggerData' ); $this->_addFilter( 'visualizer_feedback_review_trigger', 'feedbackReviewTrigger' ); $this->_addFilter( 'themeisle_sdk_blackfriday_data', 'add_black_friday_data' ); diff --git a/classes/Visualizer/Module/Frontend.php b/classes/Visualizer/Module/Frontend.php index eca006a9..9d589da5 100644 --- a/classes/Visualizer/Module/Frontend.php +++ b/classes/Visualizer/Module/Frontend.php @@ -750,7 +750,7 @@ private function getChartData( $cache_key = '', $chart_id = 0 ) { $series = get_post_meta( $chart->ID, Visualizer_Plugin::CF_SERIES, true ); $is_woocommerce_report = get_post_meta( $chart->ID, Visualizer_Plugin::CF_IS_WOOCOMMERCE_SOURCE, true ); - if ( isset( $settings['series'] ) && ! ( count( $settings['series'] ) - count( $series ) > 1 ) ) { + if ( isset( $settings['series'] ) && is_array( $settings['series'] ) && is_array( $series ) && ! ( count( $settings['series'] ) - count( $series ) > 1 ) ) { $diff_total_series = abs( count( $settings['series'] ) - count( $series ) ); if ( $diff_total_series ) { foreach ( range( 1, $diff_total_series ) as $k => $diff_series ) { diff --git a/classes/Visualizer/Module/Setup.php b/classes/Visualizer/Module/Setup.php index f0cc81f4..2ef61c60 100644 --- a/classes/Visualizer/Module/Setup.php +++ b/classes/Visualizer/Module/Setup.php @@ -113,7 +113,7 @@ public function getUsage( $data, $meta_keys = array() ) { $lib = get_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_LIBRARY, true ); $charts['library'][ $lib ] = isset( $charts['library'][ $lib ] ) ? $charts['library'][ $lib ] + 1 : 1; $settings = get_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, true ); - if ( array_key_exists( 'manual', $settings ) && ! empty( $settings['manual'] ) ) { + if ( is_array( $settings ) && ! empty( $settings['manual'] ) ) { $charts['manual_config'] = $charts['manual_config'] + 1; } @@ -124,7 +124,7 @@ public function getUsage( $data, $meta_keys = array() ) { if ( Visualizer_Module::is_pro() ) { $permissions = get_post_meta( $chart_id, Visualizer_Pro::CF_PERMISSIONS, true ); - if ( empty( $permissions ) ) { + if ( ! is_array( $permissions ) || empty( $permissions['permissions'] ) || ! is_array( $permissions['permissions'] ) ) { continue; } $permissions = $permissions['permissions']; @@ -132,7 +132,7 @@ public function getUsage( $data, $meta_keys = array() ) { foreach ( $default_perms as $key => $val ) { if ( ! is_array( $val ) && ! is_null( $val ) && isset( $permissions[ $key ] ) && $permissions[ $key ] !== $val ) { $customized = true; - } elseif ( is_array( $val ) && ! is_null( $val ) && isset( $permissions[ $key ] ) && count( $permissions[ $key ] ) !== count( $val ) ) { + } elseif ( is_array( $val ) && ! is_null( $val ) && isset( $permissions[ $key ] ) && is_array( $permissions[ $key ] ) && count( $permissions[ $key ] ) !== count( $val ) ) { $customized = true; } } diff --git a/classes/Visualizer/Remote/Fetch.php b/classes/Visualizer/Remote/Fetch.php index 03040c4c..b1923cae 100644 --- a/classes/Visualizer/Remote/Fetch.php +++ b/classes/Visualizer/Remote/Fetch.php @@ -257,6 +257,15 @@ private static function validate_url( $url, &$ips = array() ) { $ips = array(); $validated_url = wp_http_validate_url( $url ); if ( false === $validated_url ) { + // WordPress 7.1+ rejects non-public IP literals inside wp_http_validate_url() + // itself; older cores let them through to our is_global_ip() check below. Keep + // the distinct "unsafe destination" error on every core version so callers can + // tell a policy block from a malformed URL. + $scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) ); + $host = (string) wp_parse_url( $url, PHP_URL_HOST ); + if ( in_array( $scheme, array( 'http', 'https' ), true ) && filter_var( $host, FILTER_VALIDATE_IP ) && ! self::is_global_ip( $host ) ) { + return new WP_Error( 'visualizer_unsafe_remote_url', 'The remote URL resolves to a non-public address.' ); + } return new WP_Error( 'visualizer_invalid_remote_url', 'The remote URL is not allowed.' ); } diff --git a/composer.lock b/composer.lock index df2b7b77..dd538ab7 100644 --- a/composer.lock +++ b/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "codeinwp/themeisle-sdk", - "version": "3.3.57", + "version": "3.3.61", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "3c761b0bddda8d5963a47d14a40811869131030b" + "reference": "9fe698b52dec768a0dd8b500fb51efe40962ee99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/3c761b0bddda8d5963a47d14a40811869131030b", - "reference": "3c761b0bddda8d5963a47d14a40811869131030b", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/9fe698b52dec768a0dd8b500fb51efe40962ee99", + "reference": "9fe698b52dec768a0dd8b500fb51efe40962ee99", "shasum": "" }, "require-dev": { @@ -43,9 +43,9 @@ ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.57" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.61" }, - "time": "2026-07-23T13:31:25+00:00" + "time": "2026-08-24T15:59:27+00:00" }, { "name": "neitanod/forceutf8", diff --git a/tests/e2e/config/mu-plugins/plant-chart-settings.php b/tests/e2e/config/mu-plugins/plant-chart-settings.php index 5222e9de..5a07d4a0 100644 --- a/tests/e2e/config/mu-plugins/plant-chart-settings.php +++ b/tests/e2e/config/mu-plugins/plant-chart-settings.php @@ -38,5 +38,21 @@ function () { }, ) ); + + // Runs the SDK usage logger on demand, so specs can verify it + // tolerates whatever chart meta they planted (issue #1359). + register_rest_route( + 'visualizer-e2e/v1', + '/usage', + array( + 'methods' => 'GET', + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'callback' => function () { + return apply_filters( 'visualizer_logger_data', array() ); + }, + ) + ); } ); diff --git a/tests/e2e/specs/gutenberg-editor.spec.js b/tests/e2e/specs/gutenberg-editor.spec.js index c5ad0157..efb91693 100644 --- a/tests/e2e/specs/gutenberg-editor.spec.js +++ b/tests/e2e/specs/gutenberg-editor.spec.js @@ -18,34 +18,37 @@ test.describe( 'Charts with Gutenberg Editor', () => { page.setDefaultTimeout( 5000 ); } ); - test('check available action on block creation', async ( { admin, editor, page } ) => { + test('check available action on block creation', async ( { admin, editor } ) => { await admin.createNewPost(); await editor.insertBlock( { name: 'visualizer/chart'} ); - // Check chart selection options are available. - await expect( page.getByText('Make a new chart or display') ).toBeVisible(); - await expect( page.getByLabel('Editor content').locator('a') ).toBeVisible(); - await expect( page.locator('div').filter({ hasText: /^Display an existing chart$/ }) ).toBeVisible(); + // The block renders inside the editor canvas, which WordPress serves in + // an iframe, so every locator must go through editor.canvas. + await expect( editor.canvas.getByText('Make a new chart or display') ).toBeVisible(); + await expect( editor.canvas.locator('a.visualizer-settings__content-option').filter({ hasText: 'Create a new chart' }) ).toBeVisible(); + await expect( editor.canvas.locator('div').filter({ hasText: /^Display an existing chart$/ }) ).toBeVisible(); } ); test('new chart creation', async ( { admin, editor, page } ) => { await admin.createNewPost(); await editor.insertBlock( { name: 'visualizer/chart'} ); - await expect( page.getByText('Make a new chart or display') ).toBeVisible(); - await expect( page.getByLabel('Editor content').locator('a') ).toBeVisible(); + await expect( editor.canvas.getByText('Make a new chart or display') ).toBeVisible(); + const createOption = editor.canvas.locator('a.visualizer-settings__content-option').filter({ hasText: 'Create a new chart' }); + await expect( createOption ).toBeVisible(); - await page.getByLabel('Editor content').locator('a').click({ force: true}); + await createOption.click({ force: true }); - // Create chart via popup. - await page.frameLocator('iframe').getByRole('button', { name: 'Next' }).click(); - await page.frameLocator('iframe').getByRole('button', { name: 'Create Chart' }).click(); + // Create chart via popup; target the wizard frame, not the canvas iframe. + const wizard = page.frameLocator('iframe[src*="visualizer-create-chart"]'); + await wizard.getByRole('button', { name: 'Next' }).click(); + await wizard.getByRole('button', { name: 'Create Chart' }).click(); - await expect( page.getByRole('button', { name: 'Done' }) ).toBeVisible(); - await page.getByRole('button', { name: 'Done' }).click(); + await expect( editor.canvas.getByRole('button', { name: 'Done' }) ).toBeVisible(); + await editor.canvas.getByRole('button', { name: 'Done' }).click(); - await expect( page.locator('.wp-block-visualizer-chart').count() ).resolves.toBe( 1 ); - await expect( page.getByRole('button', { name: 'Done' }) ).toBeHidden(); + await expect( editor.canvas.locator('.wp-block-visualizer-chart') ).toHaveCount( 1 ); + await expect( editor.canvas.getByRole('button', { name: 'Done' }) ).toBeHidden(); } ); @@ -55,15 +58,15 @@ test.describe( 'Charts with Gutenberg Editor', () => { // Create a new post and insert the first available chart. await admin.createNewPost(); await editor.insertBlock( { name: 'visualizer/chart'} ); - await page.locator('div').filter({ hasText: /^Display an existing chart$/ }).click(); - await page.locator('.visualizer-settings__charts-controls').first().click(); + await editor.canvas.locator('div').filter({ hasText: /^Display an existing chart$/ }).click(); + await editor.canvas.locator('.visualizer-settings__charts-controls').first().click(); // Check if it was inserted correctly then enter view mode for the block. - expect( page.getByLabel('Block: Visualizer Chart').getByText('Visualizer') ).not.toBeNull(); - await page.getByRole('button', { name: 'Done' }).click(); + await expect( editor.canvas.getByLabel('Block: Visualizer Chart') ).toBeVisible(); + await editor.canvas.getByRole('button', { name: 'Done' }).click(); // Check if the Chart did not crash the editor. - expect( page.locator('.wp-block-visualizer-chart').count() ).resolves.toBe( 1 ); + await expect( editor.canvas.locator('.wp-block-visualizer-chart') ).toHaveCount( 1 ); } ); test( 'check block Edit new button', async ( { admin, editor, page } ) => { @@ -73,13 +76,15 @@ test.describe( 'Charts with Gutenberg Editor', () => { await admin.createNewPost(); await editor.insertBlock( { name: 'visualizer/chart'} ); - await page.locator('div').filter({ hasText: /^Display an existing chart$/ }).click(); - await page.locator('.visualizer-settings__charts-controls').first().click(); + await editor.canvas.locator('div').filter({ hasText: /^Display an existing chart$/ }).click(); + await editor.canvas.locator('.visualizer-settings__charts-controls').first().click(); - expect( page.getByLabel('Block: Visualizer Chart').getByText('Visualizer') ).not.toBeNull(); + await expect( editor.canvas.getByLabel('Block: Visualizer Chart') ).toBeVisible(); - await expect(page.getByRole('button', { name: 'Edit Chart' })).toBeVisible(); - await page.getByRole('button', { name: 'Edit Chart' }).click(); + // The Edit Chart button lives in the block toolbar / inspector, which + // render in the parent document, not the canvas iframe. + await expect( page.getByRole('button', { name: 'Edit Chart' }).first() ).toBeVisible(); + await page.getByRole('button', { name: 'Edit Chart' }).first().click(); //await page.goto('http://localhost:8889/wp-admin/post.php?post=29&action=edit'); await expect(page.getByLabel('Visualizer', { exact: true }).locator('h1')).toContainText('Visualizer'); await page.getByRole('button', { name: 'Close dialog' }).click(); diff --git a/tests/e2e/specs/usage-logger.spec.js b/tests/e2e/specs/usage-logger.spec.js new file mode 100644 index 00000000..d502e15b --- /dev/null +++ b/tests/e2e/specs/usage-logger.spec.js @@ -0,0 +1,69 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Internal dependencies + */ +const { deleteAllCharts } = require( '../utils/common' ); + +/** + * Regression tests for https://github.com/Codeinwp/visualizer/issues/1359 + * + * A published chart whose `visualizer-settings` meta is a string (instead of + * the sanitized settings array) crashed `Visualizer_Module_Setup::getUsage()` + * with a PHP 8 TypeError, aborting the whole SDK usage collection request. + * The logger must tolerate such charts and still report the others. + */ +test.describe( 'Usage logger', () => { + let corruptedId; + let manualId; + + test.beforeAll( async ( { requestUtils } ) => { + // The assertions below count charts, so start from a clean library. + await deleteAllCharts( requestUtils ); + + // A chart whose settings meta is a corrupted string value. + const corrupted = await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/visualizer', + data: { title: 'Corrupted settings chart', status: 'publish' }, + } ); + corruptedId = corrupted.id; + await requestUtils.rest( { + method: 'POST', + path: `/visualizer-e2e/v1/chart-settings/${ corruptedId }`, + data: { settings: 'corrupted string settings' }, + } ); + + // A healthy chart with a manual configuration, which must still be counted. + const manual = await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/visualizer', + data: { title: 'Manual config chart', status: 'publish' }, + } ); + manualId = manual.id; + await requestUtils.rest( { + method: 'POST', + path: `/visualizer-e2e/v1/chart-settings/${ manualId }`, + data: { settings: { manual: '{"colors": ["#000"]}' } }, + } ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + for ( const id of [ corruptedId, manualId ] ) { + if ( id ) { + await requestUtils.rest( { method: 'DELETE', path: `/wp/v2/visualizer/${ id }`, params: { force: true } } ); + } + } + } ); + + test( 'survives a chart whose settings meta is a string', async ( { requestUtils } ) => { + // Before the fix this request died with a TypeError (HTTP 500). + const usage = await requestUtils.rest( { method: 'GET', path: '/visualizer-e2e/v1/usage' } ); + + expect( usage.manual_config ).toBe( 1 ); + expect( Object.values( usage.types ).reduce( ( a, b ) => a + b, 0 ) ).toBe( 2 ); + } ); +} ); diff --git a/tests/test-chart-data-permissions.php b/tests/test-chart-data-permissions.php index 7867f496..715b784f 100644 --- a/tests/test-chart-data-permissions.php +++ b/tests/test-chart-data-permissions.php @@ -12,6 +12,20 @@ */ class Test_Visualizer_Chart_Data_Permissions extends WP_UnitTestCase { + /** + * A newly created REST chart has no settings metadata yet. + */ + public function test_rest_can_create_chart_without_settings_metadata() { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $request = new WP_REST_Request( 'POST', '/wp/v2/visualizer' ); + $request->set_param( 'title', 'Empty chart' ); + $request->set_param( 'status', 'publish' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + $data = $response->get_data(); + $this->assertIsArray( $data['chart_data']['visualizer-settings'] ); + } + /** * Create a chart owned by the given user. * diff --git a/tests/test-frontend-series.php b/tests/test-frontend-series.php new file mode 100644 index 00000000..1b176df5 --- /dev/null +++ b/tests/test-frontend-series.php @@ -0,0 +1,53 @@ +post->create( array( 'post_type' => Visualizer_Plugin::CPT_VISUALIZER ) ); + update_post_meta( $id, Visualizer_Plugin::CF_SETTINGS, array( 'series' => $settings_series ) ); + if ( is_bool( $series ) ) { + // WordPress persists scalar booleans as empty strings, so a stored boolean can only be + // observed by short-circuiting the lookup. WP_UnitTestCase removes the filter on tear down. + add_filter( + 'get_post_metadata', + function ( $value, $object_id, $meta_key ) use ( $id, $series ) { + return (int) $object_id === $id && Visualizer_Plugin::CF_SERIES === $meta_key ? $series : $value; + }, + 10, + 3 + ); + } else { + update_post_meta( $id, Visualizer_Plugin::CF_SERIES, $series ); + } + $frontend = ( new ReflectionClass( 'Visualizer_Module_Frontend' ) )->newInstanceWithoutConstructor(); + $method = new ReflectionMethod( $frontend, 'getChartData' ); + $method->setAccessible( true ); + $data = $method->invoke( $frontend, 'series-test', $id ); + $this->assertSame( $expected, $data['settings']['series'] ); + $this->assertEquals( $data, get_transient( 'series-test_' . $id ) ); + } + + /** + * Settings-side values are stored inside a serialized array, so booleans and empty strings + * survive as-is. Series-side booleans are injected through the metadata filter. + */ + public function series_values() { + return array( + 'boolean settings' => array( false, array( array() ), false ), + 'empty settings' => array( '', array( array() ), '' ), + 'boolean columns' => array( array( array( 'color' => 'red' ) ), false, array( array( 'color' => 'red' ) ) ), + 'empty columns' => array( array( array( 'color' => 'red' ) ), '', array( array( 'color' => 'red' ) ) ), + 'missing both' => array( array(), '', array() ), + 'valid padding' => array( array( array( 'color' => 'red' ) ), array( array(), array() ), array( array( 'color' => 'red' ), array( 'color' => 'red' ) ) ), + 'equal lengths' => array( array( array() ), array( array() ), array( array() ) ), + ); + } +} diff --git a/tests/test-usage-logger.php b/tests/test-usage-logger.php new file mode 100644 index 00000000..2abf25a9 --- /dev/null +++ b/tests/test-usage-logger.php @@ -0,0 +1,107 @@ +post->create( + array( + 'post_type' => Visualizer_Plugin::CPT_VISUALIZER, + 'post_status' => 'publish', + 'post_content' => wp_slash( serialize( array( array( 'Label' ), array( 'Value' ) ) ) ), + ) + ); + update_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_TYPE, 'line' ); + update_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, $settings ); + return $chart_id; + } + + /** + * A chart whose settings meta is a string must not abort usage collection. + */ + public function test_string_settings_meta_does_not_crash_logger() { + $this->create_chart( 'corrupted string settings' ); + + $usage = apply_filters( 'visualizer_logger_data', array() ); + + $this->assertIsArray( $usage ); + $this->assertSame( 0, $usage['manual_config'] ); + } + + /** + * A chart with no settings meta at all must not abort usage collection. + */ + public function test_missing_settings_meta_does_not_crash_logger() { + $chart_id = $this->create_chart( array() ); + delete_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS ); + + $usage = apply_filters( 'visualizer_logger_data', array() ); + + $this->assertIsArray( $usage ); + $this->assertSame( 0, $usage['manual_config'] ); + } + + /** + * On pro, permission meta that is not shaped like a map of arrays must not + * abort usage collection — a string entry fatals on count(), and a nested + * object fatals on the array offset read. + */ + public function test_malformed_permissions_meta_does_not_crash_logger() { + // The stub stays defined for the rest of the PHPUnit process. That only + // affects code gating on class_exists( 'Visualizer_Pro' ) — the legacy + // license fallback in proFeaturesEnabled() — which no test exercises. + if ( ! class_exists( 'Visualizer_Pro' ) ) { + eval( 'class Visualizer_Pro { const CF_PERMISSIONS = "visualizer-permissions"; }' ); + } + + $chart_id = $this->create_chart( array() ); + update_post_meta( + $chart_id, + Visualizer_Pro::CF_PERMISSIONS, + array( 'permissions' => array( 'edit-specific' => 'administrator' ) ) + ); + + $object_chart_id = $this->create_chart( array() ); + update_post_meta( $object_chart_id, Visualizer_Pro::CF_PERMISSIONS, array( 'permissions' => new stdClass() ) ); + + add_filter( 'visualizer_is_pro', '__return_true' ); + $usage = apply_filters( 'visualizer_logger_data', array() ); + remove_filter( 'visualizer_is_pro', '__return_true' ); + + $this->assertIsArray( $usage ); + $this->assertSame( 0, $usage['permissions'] ); + } + + /** + * Valid array settings still count manual configurations. + */ + public function test_manual_config_still_counted_for_array_settings() { + $this->create_chart( array( 'manual' => '{"colors": ["#000"]}' ) ); + $this->create_chart( 'corrupted string settings' ); + + $usage = apply_filters( 'visualizer_logger_data', array() ); + + $this->assertSame( 1, $usage['manual_config'] ); + $this->assertSame( 2, $usage['types']['line'] ); + } +}