From bf7b0745cf25603f9e7fe4460b0654beea688ef0 Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Sat, 5 Sep 2026 20:06:54 +0100 Subject: [PATCH 01/18] fix: accept an empty database password when setting up the phpunit suite --- scripts/test-setup-phpunit.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/test-setup-phpunit.ts b/scripts/test-setup-phpunit.ts index 4637b3828..94ef6dc0a 100644 --- a/scripts/test-setup-phpunit.ts +++ b/scripts/test-setup-phpunit.ts @@ -47,7 +47,9 @@ const initialiseDatabase = (): DatabaseOptions => { assertSafeIdentifier(db.schema, 'WP_PHPUNIT_DB_NAME') assertSafeIdentifier(db.user, 'WP_PHPUNIT_DB_USER') assertSimpleString(db.host, 'WP_PHPUNIT_DB_HOST') - assertSimpleString(db.password, 'WP_PHPUNIT_DB_PASS') + if ('' !== db.password) { + assertSimpleString(db.password, 'WP_PHPUNIT_DB_PASS') + } const useDbSocket = 'true' === (process.env.WP_PHPUNIT_DB_USE_SOCKET ?? 'false').toLowerCase() From 7075525b84e688864c1e8264949f8f91ee4827c4 Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Sat, 5 Sep 2026 20:07:02 +0100 Subject: [PATCH 02/18] fix: quote the paths used when installing the wordpress test suite --- scripts/install-wp-tests.sh | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/scripts/install-wp-tests.sh b/scripts/install-wp-tests.sh index ad277eb77..974ff25d4 100755 --- a/scripts/install-wp-tests.sh +++ b/scripts/install-wp-tests.sh @@ -13,7 +13,7 @@ WP_VERSION=${5-latest} SKIP_DB_CREATE=${6-false} TMPDIR=${TMPDIR-/tmp} -TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//") +TMPDIR=$(echo "$TMPDIR" | sed -e "s/\/$//") WP_TESTS_DIR=${WP_TESTS_DIR-$TMPDIR/wordpress-tests-lib} WP_CORE_DIR=${WP_CORE_DIR-$TMPDIR/wordpress/} @@ -42,33 +42,33 @@ set -ex install_wp() { - if [ -d $WP_CORE_DIR ]; then + if [ -d "$WP_CORE_DIR" ]; then return; fi - mkdir -p $WP_CORE_DIR + mkdir -p "$WP_CORE_DIR" if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then - mkdir -p $TMPDIR/wordpress-trunk - rm -rf $TMPDIR/wordpress-trunk/* - svn export --quiet https://core.svn.wordpress.org/trunk $TMPDIR/wordpress-trunk/wordpress - mv $TMPDIR/wordpress-trunk/wordpress/* $WP_CORE_DIR + mkdir -p "$TMPDIR/wordpress-trunk" + rm -rf "$TMPDIR"/wordpress-trunk/* + svn export --quiet https://core.svn.wordpress.org/trunk "$TMPDIR/wordpress-trunk/wordpress" + mv "$TMPDIR"/wordpress-trunk/wordpress/* "$WP_CORE_DIR" else if [ $WP_VERSION == 'latest' ]; then local ARCHIVE_NAME='latest' elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+ ]]; then # https serves multiple offers, whereas http serves single. - download https://wordpress.org/wordpress-$WP_VERSION.tar.gz $TMPDIR/wordpress.tar.gz + download "https://wordpress.org/wordpress-$WP_VERSION.tar.gz" "$TMPDIR/wordpress.tar.gz" ARCHIVE_NAME="wordpress-$WP_VERSION" fi - if [ ! -f $TMPDIR/wordpress.tar.gz ]; then - download https://wordpress.org/${ARCHIVE_NAME}.tar.gz $TMPDIR/wordpress.tar.gz + if [ ! -f "$TMPDIR/wordpress.tar.gz" ]; then + download "https://wordpress.org/${ARCHIVE_NAME}.tar.gz" "$TMPDIR/wordpress.tar.gz" fi - tar --strip-components=1 -zxmf $TMPDIR/wordpress.tar.gz -C $WP_CORE_DIR + tar --strip-components=1 -zxmf "$TMPDIR/wordpress.tar.gz" -C "$WP_CORE_DIR" fi - download https://raw.githubusercontent.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php + download https://raw.githubusercontent.com/markoheijnen/wp-mysqli/master/db.php "$WP_CORE_DIR/wp-content/db.php" } install_test_suite() { @@ -80,11 +80,11 @@ install_test_suite() { fi # set up testing suite if it doesn't yet exist - if [ ! -d $WP_TESTS_DIR ]; then + if [ ! -d "$WP_TESTS_DIR" ]; then local WP_TESTS_VERSION="${WP_TESTS_TAG#tags/}" case "$WP_TESTS_VERSION" in *.*.*) ;; *.*) WP_TESTS_VERSION="$WP_TESTS_VERSION.0" ;; esac - mkdir -p $WP_TESTS_DIR - rm -rf $WP_TESTS_DIR/{includes,data} + mkdir -p "$WP_TESTS_DIR" + rm -rf "$WP_TESTS_DIR"/{includes,data} download "https://github.com/WordPress/wordpress-develop/archive/refs/tags/${WP_TESTS_VERSION}.tar.gz" "$TMPDIR/wp-develop.tar.gz" rm -rf "$TMPDIR/wp-develop" mkdir -p "$TMPDIR/wp-develop" @@ -94,9 +94,9 @@ install_test_suite() { fi if [ ! -f "$WP_TESTS_DIR/wp-tests-config.php" ]; then - download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php + download "https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php" "$WP_TESTS_DIR"/wp-tests-config.php # remove all forward slashes in the end - WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::") + WP_CORE_DIR=$(echo "$WP_CORE_DIR" | sed "s:/\+$::") # Support both older (/src/) and current (/wordpress/) sample config templates. sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php sed $ioption "s:dirname( __FILE__ ) . '/wordpress/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php From fd996e36e8214375cadb22401be14d5adeb6d9c5 Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Sat, 5 Sep 2026 20:07:32 +0100 Subject: [PATCH 03/18] feat: add a setting for the feedback reporter --- src/php/Settings/Settings_Fields.php | 8 +++ src/php/Settings/Settings_Layout.php | 1 + tests/unit/Settings/Feedback_Setting_Test.php | 59 +++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 tests/unit/Settings/Feedback_Setting_Test.php diff --git a/src/php/Settings/Settings_Fields.php b/src/php/Settings/Settings_Fields.php index a875651f4..7fcedc7cf 100644 --- a/src/php/Settings/Settings_Fields.php +++ b/src/php/Settings/Settings_Fields.php @@ -95,6 +95,7 @@ private function init_defaults() { 'enable_flat_files' => false, 'enable_admin_bar' => true, 'admin_bar_snippet_limit' => 20, + 'enable_feedback_reporter' => false, ], 'editor' => [ 'indent_with_tabs' => true, @@ -235,6 +236,13 @@ private function init_fields() { ], ]; + $this->fields['general']['enable_feedback_reporter'] = [ + 'name' => __( 'Enable Feedback Reporter', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Show a button on Code Snippets pages for reporting bugs, requesting features and sending feedback.', 'code-snippets' ), + 'desc' => __( 'Reports include your site address, contact details and a list of active plugins, so that the team can reproduce the problem.', 'code-snippets' ), + ]; + $this->fields['editor'] = [ 'indent_with_tabs' => [ 'name' => __( 'Indent With Tabs', 'code-snippets' ), diff --git a/src/php/Settings/Settings_Layout.php b/src/php/Settings/Settings_Layout.php index ee1fc79ae..8aafd4557 100644 --- a/src/php/Settings/Settings_Layout.php +++ b/src/php/Settings/Settings_Layout.php @@ -98,6 +98,7 @@ public static function get_tab_contents(): array { [ 'debug', 'reset_caches' ], [ 'debug', 'database_update' ], [ 'general', 'complete_uninstall' ], + [ 'general', 'enable_feedback_reporter' ], ], ]; diff --git a/tests/unit/Settings/Feedback_Setting_Test.php b/tests/unit/Settings/Feedback_Setting_Test.php new file mode 100644 index 000000000..a731fe4da --- /dev/null +++ b/tests/unit/Settings/Feedback_Setting_Test.php @@ -0,0 +1,59 @@ +assertArrayHasKey( 'enable_feedback_reporter', $defaults['general'] ); + $this->assertFalse( $defaults['general']['enable_feedback_reporter'] ); + } + + /** + * The field is drawn under Advanced while its value stays in the general section. + * + * @return void + */ + public function test_feedback_reporter_setting_appears_on_the_advanced_tab(): void { + $contents = Settings_Layout::get_tab_contents(); + + $this->assertContains( [ 'general', 'enable_feedback_reporter' ], $contents['advanced'] ); + } + + /** + * The checkbox is the consent gate, so it carries a description of what gets sent. + * + * @return void + */ + public function test_feedback_reporter_field_is_a_checkbox_with_a_disclosure(): void { + $fields = Settings_Fields::get_field_definitions(); + + $this->assertArrayHasKey( 'enable_feedback_reporter', $fields['general'] ); + + $field = $fields['general']['enable_feedback_reporter']; + + $this->assertSame( 'checkbox', $field['type'] ); + $this->assertNotEmpty( $field['name'] ); + $this->assertNotEmpty( $field['label'] ); + $this->assertNotEmpty( $field['desc'] ); + } +} From d7e9d4e0c9659f506efac2edfea374aff21d6171 Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Sat, 5 Sep 2026 20:08:20 +0100 Subject: [PATCH 04/18] feat: add system information capture for feedback reports --- src/php/Utils/System_Info.php | 117 ++++++++++++++++++++++++++ tests/unit/Utils/System_Info_Test.php | 108 ++++++++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 src/php/Utils/System_Info.php create mode 100644 tests/unit/Utils/System_Info_Test.php diff --git a/src/php/Utils/System_Info.php b/src/php/Utils/System_Info.php new file mode 100644 index 000000000..a0b7f9c43 --- /dev/null +++ b/src/php/Utils/System_Info.php @@ -0,0 +1,117 @@ + + */ + public static function get_system_info(): array { + global $wp_version, $wpdb; + + $theme = wp_get_theme(); + $plugins = self::get_active_plugins(); + + $info = [ + 'plugin_version' => PLUGIN_VERSION, + 'edition' => self::get_edition(), + 'wordpress_version' => $wp_version, + 'php_version' => PHP_VERSION, + 'database' => $wpdb->db_server_info(), + 'active_theme' => trim( sprintf( '%s %s', $theme->get( 'Name' ), $theme->get( 'Version' ) ) ), + 'active_plugins' => $plugins, + 'plugin_count' => count( $plugins ), + 'multisite' => is_multisite(), + 'locale' => get_locale(), + 'wp_debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, + 'wp_memory_limit' => defined( 'WP_MEMORY_LIMIT' ) ? WP_MEMORY_LIMIT : '', + 'php_memory_limit' => ini_get( 'memory_limit' ), + 'max_execution_time' => ini_get( 'max_execution_time' ), + 'server_software' => isset( $_SERVER['SERVER_SOFTWARE'] ) + ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) + : '', + 'site_url' => site_url(), + ]; + + return apply_filters( 'code_snippets_feedback_system_info', $info ); + } + + /** + * Reduce the collected details to the short list shown in the panel. + * + * @param array $info Collected system information. + * + * @return array Label and value pairs. + */ + public static function get_summary( array $info ): array { + $version = sprintf( + '%s (%s)', + $info['plugin_version'], + 'pro' === $info['edition'] + ? __( 'Pro', 'code-snippets' ) + : __( 'Free', 'code-snippets' ) + ); + + $wordpress = $info['multisite'] + ? sprintf( '%s (%s)', $info['wordpress_version'], __( 'multisite', 'code-snippets' ) ) + : $info['wordpress_version']; + + return [ + __( 'Code Snippets', 'code-snippets' ) => $version, + __( 'WordPress', 'code-snippets' ) => $wordpress, + __( 'PHP', 'code-snippets' ) => $info['php_version'], + __( 'Database', 'code-snippets' ) => $info['database'], + __( 'Theme', 'code-snippets' ) => $info['active_theme'], + // translators: %d: number of active plugins. + __( 'Plugins', 'code-snippets' ) => sprintf( _n( '%d active', '%d active', $info['plugin_count'], 'code-snippets' ), $info['plugin_count'] ), + ]; + } + + /** + * Which edition of the plugin is running. + * + * @return string Either 'free' or 'pro'. + */ + public static function get_edition(): string { + return defined( 'CODE_SNIPPETS_PRO' ) && CODE_SNIPPETS_PRO ? 'pro' : 'free'; + } + + /** + * List the name and version of every active plugin, sorted for a stable comparison + * between one report and the next. + * + * @return string[] + */ + private static function get_active_plugins(): array { + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + $plugins = []; + + foreach ( get_plugins() as $file => $data ) { + if ( is_plugin_active( $file ) || is_plugin_active_for_network( $file ) ) { + $plugins[] = trim( sprintf( '%s %s', $data['Name'], $data['Version'] ) ); + } + } + + sort( $plugins ); + + return $plugins; + } +} diff --git a/tests/unit/Utils/System_Info_Test.php b/tests/unit/Utils/System_Info_Test.php new file mode 100644 index 000000000..8ee3b9531 --- /dev/null +++ b/tests/unit/Utils/System_Info_Test.php @@ -0,0 +1,108 @@ +assertArrayHasKey( $key, $info ); + } + + $this->assertSame( PHP_VERSION, $info['php_version'] ); + $this->assertSame( get_locale(), $info['locale'] ); + $this->assertSame( site_url(), $info['site_url'] ); + $this->assertIsArray( $info['active_plugins'] ); + $this->assertCount( $info['plugin_count'], $info['active_plugins'] ); + } + + /** + * The edition names the plugin the cloud already knows about. + * + * @return void + */ + public function test_edition_is_reported_as_free_or_pro(): void { + $this->assertContains( System_Info::get_system_info()['edition'], [ 'free', 'pro' ] ); + } + + /** + * The reporter is shown a short summary, and it withholds nothing that the summary omits. + * + * @return void + */ + public function test_summary_lists_the_disclosed_values(): void { + $summary = System_Info::get_summary( System_Info::get_system_info() ); + + $this->assertCount( 6, $summary ); + $this->assertNotEmpty( $summary[ __( 'Code Snippets', 'code-snippets' ) ] ); + $this->assertNotEmpty( $summary[ __( 'WordPress', 'code-snippets' ) ] ); + $this->assertSame( PHP_VERSION, $summary[ __( 'PHP', 'code-snippets' ) ] ); + } + + /** + * Sites can amend what is collected before it is sent. + * + * @return void + */ + public function test_system_info_is_filterable(): void { + add_filter( + 'code_snippets_feedback_system_info', + static function ( array $info ): array { + $info['locale'] = 'xx_XX'; + return $info; + } + ); + + $this->assertSame( 'xx_XX', System_Info::get_system_info()['locale'] ); + } +} From 69f5c5323fbca481fe1f47f713aeadf8cf6194e9 Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Sat, 5 Sep 2026 20:09:57 +0100 Subject: [PATCH 05/18] feat: add the feedback cloud connection --- src/php/Model/Feedback_Connection.php | 183 +++++++++++++++ tests/unit/Model/Feedback_Connection_Test.php | 216 ++++++++++++++++++ 2 files changed, 399 insertions(+) create mode 100644 src/php/Model/Feedback_Connection.php create mode 100644 tests/unit/Model/Feedback_Connection_Test.php diff --git a/src/php/Model/Feedback_Connection.php b/src/php/Model/Feedback_Connection.php new file mode 100644 index 000000000..509b65367 --- /dev/null +++ b/src/php/Model/Feedback_Connection.php @@ -0,0 +1,183 @@ +get_api_url(), self::REPORTS_PATH ); + + if ( $path ) { + $url .= '/' . ltrim( $path, '/' ); + } + + return apply_filters( 'code_snippets_feedback_endpoint_url', $url, $path ); + } + + /** + * Retrieve the credential issued to this site. + * + * @return array Empty when this site has not enrolled. + */ + public function get_credentials(): array { + $stored = get_option( self::CREDENTIALS_OPTION, [] ); + + return is_array( $stored ) ? $stored : []; + } + + /** + * Store the credential issued to this site. + * + * @param array $credentials Credential to store. + * + * @return void + */ + public function save_credentials( array $credentials ): void { + update_option( self::CREDENTIALS_OPTION, $credentials, false ); + } + + /** + * Discard the credential issued to this site. + * + * @return void + */ + public function delete_credentials(): void { + delete_option( self::CREDENTIALS_OPTION ); + } + + /** + * Determine whether a credential has the shape the cloud issues. + * + * @param array $credentials Credential to check. + * + * @return bool + */ + public function is_valid_credentials( array $credentials ): bool { + return ! empty( $credentials['public_id'] ) && ! empty( $credentials['secret'] ) + && preg_match( self::PUBLIC_ID_PATTERN, (string) $credentials['public_id'] ) + && preg_match( self::SECRET_PATTERN, (string) $credentials['secret'] ); + } + + /** + * Create the headers common to every reporting request. + * + * @return array + */ + public function get_request_headers(): array { + $headers = [ + 'Content-Type' => 'application/json; charset=utf-8', + 'Accept' => 'application/json', + 'X-CS-Site' => site_url(), + 'X-CS-Edition' => System_Info::get_edition(), + ]; + + $key = $this->get_key(); + + if ( $key ) { + $headers['Authorization'] = 'Bearer ' . $key; + } + + return $headers; + } + + /** + * Create the headers proving a request came from this site. + * + * @param array $credentials Credential issued to this site. + * @param string $method HTTP method. + * @param string $uri Request path, including any query string. + * @param string $body Raw request body, empty for a GET. + * + * @return array + */ + public function get_signature_headers( array $credentials, string $method, string $uri, string $body ): array { + $offset = isset( $credentials['offset'] ) ? (int) $credentials['offset'] : 0; + $timestamp = (string) ( time() + $offset ); + $payload = $timestamp . '.' . strtoupper( $method ) . '.' . $uri . '.' . hash( 'sha256', $body ); + + return [ + 'X-CS-Site-Id' => (string) $credentials['public_id'], + 'X-CS-Timestamp' => $timestamp, + 'X-CS-Signature' => hash_hmac( 'sha256', $payload, (string) $credentials['secret'] ), + ]; + } + + /** + * Reduce a URL to the part a signature covers. + * + * @param string $url Absolute URL. + * + * @return string Path, followed by the query string when there is one. + */ + public static function get_request_uri( string $url ): string { + $path = (string) wp_parse_url( $url, PHP_URL_PATH ); + $query = wp_parse_url( $url, PHP_URL_QUERY ); + + return $query ? $path . '?' . $query : $path; + } +} diff --git a/tests/unit/Model/Feedback_Connection_Test.php b/tests/unit/Model/Feedback_Connection_Test.php new file mode 100644 index 000000000..30b8e7246 --- /dev/null +++ b/tests/unit/Model/Feedback_Connection_Test.php @@ -0,0 +1,216 @@ + + */ + private array $credentials; + + /** + * Set up before each test. + * + * @return void + */ + public function set_up() { + parent::set_up(); + + $this->connection = new Feedback_Connection(); + $this->credentials = [ + 'public_id' => str_repeat( 'a', 20 ), + 'secret' => str_repeat( 'b', 40 ), + 'offset' => 0, + ]; + } + + /** + * Remove what a test stored or registered. + * + * @return void + */ + public function tear_down() { + delete_option( Feedback_Connection::CREDENTIALS_OPTION ); + remove_all_filters( 'code_snippets_feedback_endpoint_url' ); + remove_all_filters( 'code_snippets_feedback_key' ); + + parent::tear_down(); + } + + /** + * The reporting endpoint hangs off the cloud API URL the rest of the plugin uses. + * + * @return void + */ + public function test_endpoint_url_is_built_from_the_cloud_api_url(): void { + $this->assertSame( + $this->connection->get_api_url() . '/beta-reports', + $this->connection->get_endpoint_url() + ); + + $this->assertSame( + $this->connection->get_api_url() . '/beta-reports/register', + $this->connection->get_endpoint_url( 'register' ) + ); + } + + /** + * Sites pointed at another cloud host can redirect reports with the endpoint filter. + * + * @return void + */ + public function test_endpoint_url_is_filterable(): void { + add_filter( 'code_snippets_feedback_endpoint_url', static fn() => 'https://example.com/reports' ); + + $this->assertSame( 'https://example.com/reports', $this->connection->get_endpoint_url() ); + } + + /** + * The same request always signs the same way, and any change to it does not. + * + * @return void + */ + public function test_signature_covers_the_method_uri_and_body(): void { + $first = $this->connection->get_signature_headers( $this->credentials, 'post', '/api/v1/beta-reports', '{}' ); + $second = $this->connection->get_signature_headers( $this->credentials, 'POST', '/api/v1/beta-reports', '{}' ); + $other_body = $this->connection->get_signature_headers( $this->credentials, 'POST', '/api/v1/beta-reports', '{"a":1}' ); + $other_uri = $this->connection->get_signature_headers( $this->credentials, 'POST', '/api/v1/beta-reports/search', '{}' ); + $other_method = $this->connection->get_signature_headers( $this->credentials, 'GET', '/api/v1/beta-reports', '{}' ); + + $this->assertSame( $first['X-CS-Signature'], $second['X-CS-Signature'] ); + $this->assertNotSame( $first['X-CS-Signature'], $other_body['X-CS-Signature'] ); + $this->assertNotSame( $first['X-CS-Signature'], $other_uri['X-CS-Signature'] ); + $this->assertNotSame( $first['X-CS-Signature'], $other_method['X-CS-Signature'] ); + $this->assertSame( $this->credentials['public_id'], $first['X-CS-Site-Id'] ); + } + + /** + * A site whose clock disagrees with the cloud signs with the corrected time. + * + * @return void + */ + public function test_signature_timestamp_includes_the_stored_offset(): void { + $this->credentials['offset'] = 500; + + $timestamp = (int) $this->connection->get_signature_headers( $this->credentials, 'GET', '/x', '' )['X-CS-Timestamp']; + + $this->assertGreaterThanOrEqual( time() + 495, $timestamp ); + $this->assertLessThanOrEqual( time() + 505, $timestamp ); + } + + /** + * Anything that is not a credential pair of the issued shape is refused. + * + * @return void + */ + public function test_malformed_credentials_are_rejected(): void { + $short_id = [ + 'public_id' => 'short', + 'secret' => str_repeat( 'b', 40 ), + ]; + + $bad_secret = [ + 'public_id' => str_repeat( 'a', 20 ), + 'secret' => 'not-alphanumeric!', + ]; + + $this->assertTrue( $this->connection->is_valid_credentials( $this->credentials ) ); + $this->assertFalse( $this->connection->is_valid_credentials( [] ) ); + $this->assertFalse( $this->connection->is_valid_credentials( $short_id ) ); + $this->assertFalse( $this->connection->is_valid_credentials( $bad_secret ) ); + $this->assertFalse( $this->connection->is_valid_credentials( [ 'public_id' => str_repeat( 'a', 20 ) ] ) ); + } + + /** + * Credentials survive a round trip, and are gone once deleted. + * + * @return void + */ + public function test_credentials_round_trip(): void { + $this->credentials['offset'] = 3; + + $this->connection->save_credentials( $this->credentials ); + $this->assertSame( $this->credentials, $this->connection->get_credentials() ); + + $this->connection->delete_credentials(); + $this->assertSame( [], $this->connection->get_credentials() ); + } + + /** + * The secret is not worth loading on every request, so it is not autoloaded. + * + * @return void + */ + public function test_credentials_are_not_autoloaded(): void { + $this->connection->save_credentials( $this->credentials ); + + wp_cache_delete( 'alloptions', 'options' ); + + $this->assertArrayNotHasKey( Feedback_Connection::CREDENTIALS_OPTION, wp_load_alloptions() ); + } + + /** + * A signature covers the query string as well as the path. + * + * @return void + */ + public function test_request_uri_keeps_the_query_string(): void { + $this->assertSame( + '/api/v1/beta-reports/search?q=hello', + Feedback_Connection::get_request_uri( 'https://example.com/api/v1/beta-reports/search?q=hello' ) + ); + + $this->assertSame( + '/api/v1/beta-reports', + Feedback_Connection::get_request_uri( 'https://example.com/api/v1/beta-reports' ) + ); + } + + /** + * Requests identify the programme, the site and the edition running. + * + * @return void + */ + public function test_request_headers_carry_the_programme_key_and_edition(): void { + $headers = $this->connection->get_request_headers(); + + $this->assertSame( 'Bearer ' . $this->connection->get_key(), $headers['Authorization'] ); + $this->assertContains( $headers['X-CS-Edition'], [ 'free', 'pro' ] ); + $this->assertSame( site_url(), $headers['X-CS-Site'] ); + $this->assertSame( 'application/json', $headers['Accept'] ); + } + + /** + * The programme key can be replaced without editing the plugin. + * + * @return void + */ + public function test_programme_key_is_filterable(): void { + add_filter( 'code_snippets_feedback_key', static fn() => 'csb_replacement' ); + + $this->assertSame( 'csb_replacement', $this->connection->get_key() ); + } +} From 7bcfb68a50c160cd027d2649ad85f8cb8996507f Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Sat, 5 Sep 2026 20:11:35 +0100 Subject: [PATCH 06/18] feat: add the feedback cloud client --- src/php/Client/Feedback_Client.php | 286 ++++++++++++++++ tests/unit/Client/Feedback_Client_Test.php | 372 +++++++++++++++++++++ 2 files changed, 658 insertions(+) create mode 100644 src/php/Client/Feedback_Client.php create mode 100644 tests/unit/Client/Feedback_Client_Test.php diff --git a/src/php/Client/Feedback_Client.php b/src/php/Client/Feedback_Client.php new file mode 100644 index 000000000..807eb5806 --- /dev/null +++ b/src/php/Client/Feedback_Client.php @@ -0,0 +1,286 @@ +connection = $connection; + } + + /** + * Enrol this site and store the credential it is issued. + * + * @param int $carry_offset Clock offset to preserve across a re-enrolment. + * + * @return array Credential issued, or empty when enrolment failed. + */ + public function register_site( int $carry_offset = 0 ): array { + $response = wp_remote_post( + $this->connection->get_endpoint_url( 'register' ), + [ + 'timeout' => self::REGISTRATION_REQUEST_TIMEOUT, + 'headers' => $this->connection->get_request_headers(), + 'body' => wp_json_encode( + [ + 'site_url' => site_url(), + 'edition' => System_Info::get_edition(), + 'plugin_version' => PLUGIN_VERSION, + ] + ), + ] + ); + + if ( is_wp_error( $response ) || 201 !== wp_remote_retrieve_response_code( $response ) ) { + return $this->fail_registration(); + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $body = is_array( $body ) ? $body : []; + + if ( ! $this->connection->is_valid_credentials( $body ) ) { + return $this->fail_registration(); + } + + delete_transient( self::REGISTRATION_FAILURE_TRANSIENT ); + + $credentials = [ + 'public_id' => (string) $body['public_id'], + 'secret' => (string) $body['secret'], + 'offset' => $carry_offset, + ]; + + $this->connection->save_credentials( $credentials ); + + return $credentials; + } + + /** + * Retrieve this site's credential, enrolling first when there is not one. + * + * @return array Credential, or empty when enrolment is unavailable. + */ + public function ensure_credentials(): array { + $credentials = $this->connection->get_credentials(); + + if ( $this->connection->is_valid_credentials( $credentials ) ) { + return $credentials; + } + + if ( get_transient( self::REGISTRATION_FAILURE_TRANSIENT ) ) { + return []; + } + + return $this->register_site(); + } + + /** + * Forward a report to the cloud. + * + * @param array $payload Assembled report. + * @param string $idempotency_key Key identifying this submission. + * + * @return array{status: int, body: array}|WP_Error + */ + public function send_report( array $payload, string $idempotency_key ) { + $headers = $this->connection->get_request_headers(); + $headers['Idempotency-Key'] = $idempotency_key; + + $response = $this->send_signed( + $this->connection->get_endpoint_url(), + 'POST', + $headers, + (string) wp_json_encode( $payload ) + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + return [ + 'status' => wp_remote_retrieve_response_code( $response ), + 'body' => is_array( $body ) ? $body : [], + ]; + } + + /** + * Look for existing reports resembling a title being typed. + * + * @param string $query Title text entered so far. + * + * @return array> Matching reports, empty when none or unavailable. + */ + public function search_reports( string $query ): array { + $url = add_query_arg( [ 'q' => rawurlencode( $query ) ], $this->connection->get_endpoint_url( 'search' ) ); + $response = $this->send_signed( $url, 'GET', $this->connection->get_request_headers(), '' ); + + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + return []; + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + return isset( $body['results'] ) && is_array( $body['results'] ) + ? array_slice( $body['results'], 0, self::MAX_SEARCH_RESULTS ) + : []; + } + + /** + * Record that enrolment failed, and report it as unavailable. + * + * @return array Always empty. + */ + private function fail_registration(): array { + set_transient( self::REGISTRATION_FAILURE_TRANSIENT, 1, self::REGISTRATION_FAILURE_TIMEOUT ); + + return []; + } + + /** + * Send a request signed with this site's credential, recovering once from a rejected + * signature. + * + * @param string $url Absolute endpoint URL. + * @param string $method HTTP method. + * @param array $headers Request headers. + * @param string $body Raw request body, empty for a GET. + * @param bool $retrying Whether this is the second attempt. + * + * @return array|WP_Error + */ + private function send_signed( string $url, string $method, array $headers, string $body, bool $retrying = false ) { + $credentials = $this->ensure_credentials(); + $uri = Feedback_Connection::get_request_uri( $url ); + + $headers = array_diff_key( + $headers, + array_flip( [ 'X-CS-Site-Id', 'X-CS-Timestamp', 'X-CS-Signature' ] ) + ); + + if ( $this->connection->is_valid_credentials( $credentials ) ) { + $headers = array_merge( + $headers, + $this->connection->get_signature_headers( $credentials, $method, $uri, $body ) + ); + } + + $args = [ + 'timeout' => 'GET' === $method ? self::SEARCH_REQUEST_TIMEOUT : self::REPORT_REQUEST_TIMEOUT, + 'redirection' => 0, + 'headers' => $headers, + ]; + + if ( 'GET' === $method ) { + $response = wp_remote_get( $url, $args ); + } else { + $args['body'] = $body; + $response = wp_remote_post( $url, $args ); + } + + if ( is_wp_error( $response ) || $retrying ) { + return $response; + } + + return $this->recover_from_rejected_signature( $response, $url, $method, $headers, $body ); + } + + /** + * Correct whatever the cloud objected to about a signature and try once more. + * + * @param array $response Response received. + * @param string $url Absolute endpoint URL. + * @param string $method HTTP method. + * @param array $headers Request headers. + * @param string $body Raw request body. + * + * @return array|WP_Error + */ + private function recover_from_rejected_signature( array $response, string $url, string $method, array $headers, string $body ) { + if ( 401 !== wp_remote_retrieve_response_code( $response ) ) { + return $response; + } + + $parsed = json_decode( wp_remote_retrieve_body( $response ), true ); + $error = isset( $parsed['code'] ) ? $parsed['code'] : ''; + + if ( 'signature_expired' === $error && isset( $parsed['server_time'] ) ) { + $credentials = $this->connection->get_credentials(); + $credentials['offset'] = (int) $parsed['server_time'] - time(); + $this->connection->save_credentials( $credentials ); + + return $this->send_signed( $url, $method, $headers, $body, true ); + } + + if ( 'invalid_signature' === $error ) { + $credentials = $this->connection->get_credentials(); + $offset = isset( $credentials['offset'] ) ? (int) $credentials['offset'] : 0; + + $this->connection->delete_credentials(); + $this->register_site( $offset ); + + return $this->send_signed( $url, $method, $headers, $body, true ); + } + + return $response; + } +} diff --git a/tests/unit/Client/Feedback_Client_Test.php b/tests/unit/Client/Feedback_Client_Test.php new file mode 100644 index 000000000..85c349407 --- /dev/null +++ b/tests/unit/Client/Feedback_Client_Test.php @@ -0,0 +1,372 @@ + + */ + private array $responses = []; + + /** + * Requests the client made, in order. + * + * @var array> + */ + private array $requests = []; + + /** + * A credential pair of the shape the cloud issues. + * + * @var array + */ + private array $credentials; + + /** + * Set up before each test. + * + * @return void + */ + public function set_up() { + parent::set_up(); + + $this->connection = new Feedback_Connection(); + $this->client = new Feedback_Client( $this->connection ); + $this->responses = []; + $this->requests = []; + $this->credentials = [ + 'public_id' => str_repeat( 'a', 20 ), + 'secret' => str_repeat( 'b', 40 ), + 'offset' => 0, + ]; + + add_filter( 'pre_http_request', [ $this, 'mock_request' ], 10, 3 ); + } + + /** + * Remove what a test stored or registered. + * + * @return void + */ + public function tear_down() { + remove_filter( 'pre_http_request', [ $this, 'mock_request' ] ); + delete_option( Feedback_Connection::CREDENTIALS_OPTION ); + delete_transient( Feedback_Client::REGISTRATION_FAILURE_TRANSIENT ); + + parent::tear_down(); + } + + /** + * Record each request and answer it with the next queued response. + * + * @param mixed $preempt Short-circuit value. + * @param array $args Request arguments. + * @param string $url Request URL. + * + * @return array|WP_Error + */ + public function mock_request( $preempt, $args, $url ) { + $this->requests[] = [ + 'url' => $url, + 'method' => $args['method'] ?? 'GET', + 'headers' => $args['headers'] ?? [], + 'body' => $args['body'] ?? '', + ]; + + return array_shift( $this->responses ) ?? $this->response( 200, [] ); + } + + /** + * Build a response of the shape the HTTP API returns. + * + * @param int $status HTTP status code. + * @param array $body Response body. + * + * @return array + */ + private function response( int $status, array $body ): array { + return [ + 'headers' => [], + 'body' => (string) wp_json_encode( $body ), + 'response' => [ + 'code' => $status, + 'message' => '', + ], + 'cookies' => [], + 'filename' => null, + ]; + } + + /** + * A response enrolling this site successfully. + * + * @return array + */ + private function registration_response(): array { + return $this->response( + 201, + [ + 'public_id' => $this->credentials['public_id'], + 'secret' => $this->credentials['secret'], + ] + ); + } + + /** + * Enrolment stores the credential the cloud issues. + * + * @return void + */ + public function test_registration_stores_the_issued_credential(): void { + $this->responses = [ $this->registration_response() ]; + + $credentials = $this->client->register_site(); + + $this->assertSame( $this->credentials['public_id'], $credentials['public_id'] ); + $this->assertSame( $this->credentials, $this->connection->get_credentials() ); + $this->assertStringEndsWith( '/beta-reports/register', $this->requests[0]['url'] ); + } + + /** + * A credential that is not of the issued shape is discarded rather than stored. + * + * @return void + */ + public function test_registration_rejects_a_malformed_credential(): void { + $this->responses = [ + $this->response( + 201, + [ + 'public_id' => 'too-short', + 'secret' => $this->credentials['secret'], + ] + ), + ]; + + $this->assertSame( [], $this->client->register_site() ); + $this->assertSame( [], $this->connection->get_credentials() ); + $this->assertNotFalse( get_transient( Feedback_Client::REGISTRATION_FAILURE_TRANSIENT ) ); + } + + /** + * An endpoint that just refused enrolment is left alone for a while. + * + * @return void + */ + public function test_failed_enrolment_is_not_retried_immediately(): void { + $this->responses = [ $this->response( 500, [] ) ]; + + $this->assertSame( [], $this->client->register_site() ); + $this->assertSame( [], $this->client->ensure_credentials() ); + $this->assertCount( 1, $this->requests ); + } + + /** + * A site with a valid credential does not enrol again. + * + * @return void + */ + public function test_an_existing_credential_is_reused(): void { + $this->connection->save_credentials( $this->credentials ); + + $this->assertSame( $this->credentials, $this->client->ensure_credentials() ); + $this->assertCount( 0, $this->requests ); + } + + /** + * A report is signed, and carries the key identifying the submission. + * + * @return void + */ + public function test_reports_are_signed_and_carry_the_idempotency_key(): void { + $this->connection->save_credentials( $this->credentials ); + $this->responses = [ $this->response( 200, [ 'reference' => 'CS-1' ] ) ]; + + $result = $this->client->send_report( [ 'report' => [ 'title' => 'A title' ] ], 'key-1' ); + + $this->assertSame( 200, $result['status'] ); + $this->assertSame( 'CS-1', $result['body']['reference'] ); + + $headers = $this->requests[0]['headers']; + + $this->assertSame( 'key-1', $headers['Idempotency-Key'] ); + $this->assertSame( $this->credentials['public_id'], $headers['X-CS-Site-Id'] ); + $this->assertNotEmpty( $headers['X-CS-Signature'] ); + } + + /** + * A clock out of step with the cloud is corrected, and the report is sent again. + * + * @return void + */ + public function test_an_expired_signature_stores_the_offset_and_retries_once(): void { + $this->connection->save_credentials( $this->credentials ); + + $server_time = time() + 4000; + + $this->responses = [ + $this->response( + 401, + [ + 'code' => 'signature_expired', + 'server_time' => $server_time, + ] + ), + $this->response( 200, [ 'reference' => 'CS-2' ] ), + ]; + + $result = $this->client->send_report( [], 'key-2' ); + + $this->assertSame( 200, $result['status'] ); + $this->assertCount( 2, $this->requests ); + $this->assertGreaterThan( 3900, $this->connection->get_credentials()['offset'] ); + } + + /** + * A credential the cloud no longer recognises is replaced, keeping the clock correction. + * + * @return void + */ + public function test_an_invalid_signature_reenrols_carrying_the_offset(): void { + $this->credentials['offset'] = 120; + $this->connection->save_credentials( $this->credentials ); + + $this->responses = [ + $this->response( 401, [ 'code' => 'invalid_signature' ] ), + $this->registration_response(), + $this->response( 200, [ 'reference' => 'CS-3' ] ), + ]; + + $result = $this->client->send_report( [], 'key-3' ); + + $this->assertSame( 200, $result['status'] ); + $this->assertCount( 3, $this->requests ); + $this->assertStringEndsWith( '/beta-reports/register', $this->requests[1]['url'] ); + $this->assertSame( 120, $this->connection->get_credentials()['offset'] ); + } + + /** + * Recovery is attempted once, so a cloud that keeps refusing does not loop. + * + * @return void + */ + public function test_a_rejected_signature_is_only_recovered_from_once(): void { + $this->connection->save_credentials( $this->credentials ); + + $this->responses = [ + $this->response( + 401, + [ + 'code' => 'signature_expired', + 'server_time' => time() + 900, + ] + ), + $this->response( + 401, + [ + 'code' => 'signature_expired', + 'server_time' => time() + 900, + ] + ), + ]; + + $result = $this->client->send_report( [], 'key-4' ); + + $this->assertSame( 401, $result['status'] ); + $this->assertCount( 2, $this->requests ); + } + + /** + * A response the cloud rejects for its own reasons is handed back unchanged. + * + * @return void + */ + public function test_a_client_error_is_returned_with_its_status(): void { + $this->connection->save_credentials( $this->credentials ); + $this->responses = [ $this->response( 422, [ 'message' => 'Title too short.' ] ) ]; + + $result = $this->client->send_report( [], 'key-5' ); + + $this->assertSame( 422, $result['status'] ); + $this->assertSame( 'Title too short.', $result['body']['message'] ); + } + + /** + * A transport failure is passed on rather than swallowed. + * + * @return void + */ + public function test_a_transport_failure_is_returned_as_an_error(): void { + $this->connection->save_credentials( $this->credentials ); + $this->responses = [ new WP_Error( 'http_request_failed', 'Could not resolve host.' ) ]; + + $this->assertWPError( $this->client->send_report( [], 'key-6' ) ); + } + + /** + * The panel is offered a handful of similar reports, not the whole list. + * + * @return void + */ + public function test_search_returns_at_most_five_results(): void { + $this->connection->save_credentials( $this->credentials ); + + $results = []; + + for ( $i = 0; $i < 9; $i++ ) { + $results[] = [ + 'title' => 'Report ' . $i, + 'url' => 'https://example.com/' . $i, + ]; + } + + $this->responses = [ $this->response( 200, [ 'results' => $results ] ) ]; + + $this->assertCount( 5, $this->client->search_reports( 'highlighting' ) ); + $this->assertStringContainsString( 'q=highlighting', $this->requests[0]['url'] ); + } + + /** + * A search the cloud cannot answer leaves the panel with nothing to show. + * + * @return void + */ + public function test_search_returns_an_empty_list_when_the_cloud_fails(): void { + $this->connection->save_credentials( $this->credentials ); + $this->responses = [ $this->response( 503, [] ) ]; + + $this->assertSame( [], $this->client->search_reports( 'highlighting' ) ); + } +} From 292b2a48e5e8983e35e16324c4c8cd0321ee442a Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Sat, 5 Sep 2026 20:14:04 +0100 Subject: [PATCH 07/18] feat: add the feedback reporter admin panel --- config/webpack/webpack-js.ts | 1 + src/js/entries/feedback-capture.ts | 20 +++ src/js/types/Window.ts | 1 + src/php/Admin/Feedback_Error_Capture.php | 60 +++++++ src/php/Admin/Feedback_Panel.php | 197 +++++++++++++++++++++++ src/php/Plugin.php | 3 + tests/unit/Admin/Feedback_Panel_Test.php | 194 ++++++++++++++++++++++ 7 files changed, 476 insertions(+) create mode 100644 src/js/entries/feedback-capture.ts create mode 100644 src/php/Admin/Feedback_Error_Capture.php create mode 100644 src/php/Admin/Feedback_Panel.php create mode 100644 tests/unit/Admin/Feedback_Panel_Test.php diff --git a/config/webpack/webpack-js.ts b/config/webpack/webpack-js.ts index 1fd7741ae..605206585 100644 --- a/config/webpack/webpack-js.ts +++ b/config/webpack/webpack-js.ts @@ -27,6 +27,7 @@ export const jsWebpackConfig: Configuration = { 'admin-bar': `${SOURCE_DIR}/admin-bar.ts`, 'edit': { import: `${SOURCE_DIR}/edit.ts`, dependOn: 'editor' }, 'editor': `${SOURCE_DIR}/editor.ts`, + 'feedback-capture': `${SOURCE_DIR}/feedback-capture.ts`, 'import': `${SOURCE_DIR}/import.ts`, 'insights': `${SOURCE_DIR}/insights.ts`, 'manage': `${SOURCE_DIR}/manage.ts`, diff --git a/src/js/entries/feedback-capture.ts b/src/js/entries/feedback-capture.ts new file mode 100644 index 000000000..60d056cc4 --- /dev/null +++ b/src/js/entries/feedback-capture.ts @@ -0,0 +1,20 @@ +const MAX_ERRORS = 24 + +window.codeSnippetsErrors = window.codeSnippetsErrors ?? [] + +const record = (entry: string): void => { + if (window.codeSnippetsErrors && window.codeSnippetsErrors.length < MAX_ERRORS) { + window.codeSnippetsErrors.push(entry) + } +} + +window.addEventListener('error', event => { + record(`${event.message || 'Error'} — ${event.filename || 'unknown'}:${event.lineno}`) +}, true) + +window.addEventListener('unhandledrejection', event => { + const reason: unknown = event.reason + record(`Unhandled rejection — ${reason instanceof Error ? reason.message : String(reason)}`) +}) + +export {} diff --git a/src/js/types/Window.ts b/src/js/types/Window.ts index 2009244f7..5a96704ba 100644 --- a/src/js/types/Window.ts +++ b/src/js/types/Window.ts @@ -21,6 +21,7 @@ declare global { ) => void } } + codeSnippetsErrors?: string[] readonly pagenow?: string readonly ajaxurl: string readonly tinymce?: tinymce.EditorManager diff --git a/src/php/Admin/Feedback_Error_Capture.php b/src/php/Admin/Feedback_Error_Capture.php new file mode 100644 index 000000000..ff4612d39 --- /dev/null +++ b/src/php/Admin/Feedback_Error_Capture.php @@ -0,0 +1,60 @@ +panel = $panel; + + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ], 0 ); + } + + /** + * Enqueue the error listeners in the document head. + * + * @return void + */ + public function enqueue_assets(): void { + if ( ! $this->panel->should_render() ) { + return; + } + + wp_enqueue_script( + self::SCRIPT_HANDLE, + plugins_url( 'dist/feedback-capture.js', PLUGIN_FILE ), + [], + PLUGIN_VERSION, + false + ); + } +} diff --git a/src/php/Admin/Feedback_Panel.php b/src/php/Admin/Feedback_Panel.php new file mode 100644 index 000000000..54c451f1b --- /dev/null +++ b/src/php/Admin/Feedback_Panel.php @@ -0,0 +1,197 @@ +current_user_can() && $this->is_snippets_screen(); + } + + /** + * Enqueue the panel assets. + * + * @return void + */ + public function enqueue_assets(): void { + if ( ! $this->should_render() ) { + return; + } + + wp_enqueue_style( + self::STYLE_HANDLE, + plugins_url( 'dist/feedback.css', PLUGIN_FILE ), + [ 'wp-components' ], + PLUGIN_VERSION + ); + + wp_enqueue_script( + self::SCRIPT_HANDLE, + plugins_url( 'dist/feedback.js', PLUGIN_FILE ), + [ 'react', 'react-dom', 'wp-components', 'wp-element', 'wp-i18n' ], + PLUGIN_VERSION, + true + ); + + wp_set_script_translations( self::SCRIPT_HANDLE, 'code-snippets' ); + + $user = wp_get_current_user(); + $info = System_Info::get_system_info(); + + wp_localize_script( + self::SCRIPT_HANDLE, + 'CODE_SNIPPETS_FEEDBACK', + [ + 'restUrl' => esc_url_raw( rest_url( Feedback_REST_Controller::get_base_route() ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'user' => [ + 'name' => $user->display_name, + 'email' => $user->user_email, + ], + 'summary' => System_Info::get_summary( $info ), + 'badge' => self::get_badge_label(), + 'version' => $info['plugin_version'], + 'edition' => $info['edition'], + ] + ); + } + + /** + * Print the element the panel mounts into. + * + * @return void + */ + public function render_container(): void { + if ( ! $this->should_render() ) { + return; + } + + printf( '
', esc_attr( self::CONTAINER_ID ) ); + } + + /** + * Describe the build a report was sent from, when it is not a released one. + * + * A released build carries no badge: labelling every install as a test build would + * misrepresent it. Pre-release builds are named so that a report can be read against + * the build it came from. + * + * @param string|null $version Version to describe. Defaults to the running version. + * + * @return string Badge text, empty when there is nothing to say. + */ + public static function get_badge_label( ?string $version = null ): string { + $version = null === $version ? PLUGIN_VERSION : $version; + $label = ''; + + if ( preg_match( '/-(alpha|beta|rc)/i', $version, $matches ) ) { + $names = [ + 'alpha' => _x( 'Alpha', 'pre-release build', 'code-snippets' ), + 'beta' => _x( 'Beta', 'pre-release build', 'code-snippets' ), + 'rc' => _x( 'RC', 'pre-release build', 'code-snippets' ), + ]; + + $label = sprintf( '%s %s', $names[ strtolower( $matches[1] ) ], $version ); + } + + return apply_filters( 'code_snippets_feedback_badge_label', $label, $version ); + } + + /** + * Determine whether the current screen belongs to this plugin. + * + * Matching this plugin's own menu slugs, rather than looking for 'snippet' anywhere in + * the screen identifier, keeps the reporter off screens belonging to other plugins. + * + * @return bool + */ + private function is_snippets_screen(): bool { + if ( ! is_admin() ) { + return false; + } + + $slugs = []; + + foreach ( [ '', 'add', 'edit', 'import', 'settings', 'insights', 'welcome' ] as $menu ) { + $slugs[] = code_snippets()->get_menu_slug( $menu ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; + + if ( $page && in_array( $page, $slugs, true ) ) { + return true; + } + + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + + if ( ! $screen ) { + return false; + } + + foreach ( $slugs as $slug ) { + if ( $slug && substr( $screen->id, -strlen( '_page_' . $slug ) ) === '_page_' . $slug ) { + return true; + } + } + + return false; + } +} diff --git a/src/php/Plugin.php b/src/php/Plugin.php index b2a24544e..bef3c43e0 100644 --- a/src/php/Plugin.php +++ b/src/php/Plugin.php @@ -3,6 +3,8 @@ namespace Code_Snippets; use Code_Snippets\Admin\Bootstrap_Admin; +use Code_Snippets\Admin\Feedback_Error_Capture; +use Code_Snippets\Admin\Feedback_Panel; use Code_Snippets\Admin\Menus\Manage\Manage_Menu; use Code_Snippets\Controller\Cloud_Search_Controller; use Code_Snippets\Core\DB; @@ -129,6 +131,7 @@ public function load_plugin() { if ( is_admin() ) { $this->admin = new Bootstrap_Admin(); new Promotion_Manager(); + new Feedback_Error_Capture( new Feedback_Panel() ); } new Shortcodes(); diff --git a/tests/unit/Admin/Feedback_Panel_Test.php b/tests/unit/Admin/Feedback_Panel_Test.php new file mode 100644 index 000000000..41695b57a --- /dev/null +++ b/tests/unit/Admin/Feedback_Panel_Test.php @@ -0,0 +1,194 @@ +panel = new Feedback_Panel(); + } + + /** + * Remove what a test set. + * + * @return void + */ + public function tear_down() { + update_setting( 'general', Feedback_Panel::SETTING_FIELD, false ); + remove_all_filters( 'code_snippets_feedback_badge_label' ); + unset( $_GET['page'] ); + wp_set_current_user( 0 ); + + parent::tear_down(); + } + + /** + * Place the request on a Code Snippets screen. + * + * @return void + */ + private function visit_snippets_screen(): void { + $_GET['page'] = 'snippets-settings'; + set_current_screen( 'snippets_page_snippets-settings' ); + } + + /** + * Sign in as somebody allowed to manage snippets. + * + * @return void + */ + private function log_in_as_administrator(): void { + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + } + + /** + * Nothing is switched on by an upgrade. + * + * @return void + */ + public function test_is_disabled_by_default(): void { + $this->assertFalse( Feedback_Panel::is_enabled() ); + } + + /** + * The reporter is absent while the setting is off. + * + * @return void + */ + public function test_does_not_render_while_the_setting_is_off(): void { + $this->log_in_as_administrator(); + $this->visit_snippets_screen(); + + $this->assertFalse( $this->panel->should_render() ); + } + + /** + * Somebody who cannot manage snippets is not offered the reporter. + * + * @return void + */ + public function test_does_not_render_without_the_capability(): void { + update_setting( 'general', Feedback_Panel::SETTING_FIELD, true ); + wp_set_current_user( self::factory()->user->create( [ 'role' => 'subscriber' ] ) ); + $this->visit_snippets_screen(); + + $this->assertFalse( $this->panel->should_render() ); + } + + /** + * The reporter belongs to this plugin's screens, not to the whole admin. + * + * @return void + */ + public function test_does_not_render_outside_code_snippets_screens(): void { + update_setting( 'general', Feedback_Panel::SETTING_FIELD, true ); + $this->log_in_as_administrator(); + set_current_screen( 'edit-post' ); + + $this->assertFalse( $this->panel->should_render() ); + } + + /** + * With the setting on, an administrator sees the reporter on a snippets screen. + * + * @return void + */ + public function test_renders_on_a_code_snippets_screen_when_enabled(): void { + update_setting( 'general', Feedback_Panel::SETTING_FIELD, true ); + $this->log_in_as_administrator(); + $this->visit_snippets_screen(); + + $this->assertTrue( $this->panel->should_render() ); + } + + /** + * The container the panel mounts into carries the identifier the script looks for. + * + * @return void + */ + public function test_container_markup_carries_the_expected_id(): void { + update_setting( 'general', Feedback_Panel::SETTING_FIELD, true ); + $this->log_in_as_administrator(); + $this->visit_snippets_screen(); + + ob_start(); + $this->panel->render_container(); + $markup = ob_get_clean(); + + $this->assertStringContainsString( 'id="' . Feedback_Panel::CONTAINER_ID . '"', $markup ); + } + + /** + * Nothing is printed on a screen the reporter does not belong on. + * + * @return void + */ + public function test_container_is_not_printed_when_it_should_not_render(): void { + $this->log_in_as_administrator(); + set_current_screen( 'edit-post' ); + + ob_start(); + $this->panel->render_container(); + + $this->assertSame( '', ob_get_clean() ); + } + + /** + * A released build is not labelled as a test build. + * + * @return void + */ + public function test_badge_label_is_empty_on_a_stable_version(): void { + $this->assertSame( '', Feedback_Panel::get_badge_label( '4.1.0' ) ); + } + + /** + * A pre-release build says so, so a report can be read against the right build. + * + * @return void + */ + public function test_badge_label_names_a_prerelease_version(): void { + $this->assertSame( 'Beta 4.0.0-beta.1', Feedback_Panel::get_badge_label( '4.0.0-beta.1' ) ); + $this->assertSame( 'Alpha 4.0.0-alpha.2', Feedback_Panel::get_badge_label( '4.0.0-alpha.2' ) ); + $this->assertSame( 'RC 4.0.0-rc.1', Feedback_Panel::get_badge_label( '4.0.0-rc.1' ) ); + } + + /** + * The badge wording can be replaced without editing the plugin. + * + * @return void + */ + public function test_badge_label_is_filterable(): void { + add_filter( 'code_snippets_feedback_badge_label', static fn() => 'Preview' ); + + $this->assertSame( 'Preview', Feedback_Panel::get_badge_label( '4.1.0' ) ); + } +} From e622773dd30ffdba5e5386f25e066b438423df3a Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Sat, 5 Sep 2026 20:16:18 +0100 Subject: [PATCH 08/18] feat: add the feedback reporter rest endpoints --- src/php/Plugin.php | 5 + .../Feedback/Feedback_REST_Controller.php | 328 +++++++++++++ .../Feedback_REST_Controller_Test.php | 433 ++++++++++++++++++ 3 files changed, 766 insertions(+) create mode 100644 src/php/REST_API/Feedback/Feedback_REST_Controller.php create mode 100644 tests/unit/REST_API/Feedback_REST_Controller_Test.php diff --git a/src/php/Plugin.php b/src/php/Plugin.php index bef3c43e0..d88cbcbf2 100644 --- a/src/php/Plugin.php +++ b/src/php/Plugin.php @@ -6,6 +6,7 @@ use Code_Snippets\Admin\Feedback_Error_Capture; use Code_Snippets\Admin\Feedback_Panel; use Code_Snippets\Admin\Menus\Manage\Manage_Menu; +use Code_Snippets\Client\Feedback_Client; use Code_Snippets\Controller\Cloud_Search_Controller; use Code_Snippets\Core\DB; use Code_Snippets\Core\Licensing; @@ -17,7 +18,9 @@ use Code_Snippets\Integration\Promotions\Promotion_Manager; use Code_Snippets\Integration\Shortcodes; use Code_Snippets\Model\Basic_Cloud_Connection; +use Code_Snippets\Model\Feedback_Connection; use Code_Snippets\REST_API\Cloud\Cloud_Snippets_REST_Controller; +use Code_Snippets\REST_API\Feedback\Feedback_REST_Controller; use Code_Snippets\REST_API\Import\File_Import_REST_Controller; use Code_Snippets\REST_API\Import\Plugins_Import_REST_Controller; use Code_Snippets\REST_API\Preferences\Demos_Seen_REST_Controller; @@ -167,6 +170,8 @@ public function init_rest_api() { new File_Import_REST_Controller(); new Cloud_Snippets_REST_Controller( $cloud_search ); + + new Feedback_REST_Controller( new Feedback_Client( new Feedback_Connection() ) ); } /** diff --git a/src/php/REST_API/Feedback/Feedback_REST_Controller.php b/src/php/REST_API/Feedback/Feedback_REST_Controller.php new file mode 100644 index 000000000..b5f7b10d9 --- /dev/null +++ b/src/php/REST_API/Feedback/Feedback_REST_Controller.php @@ -0,0 +1,328 @@ +client = $client; + + parent::__construct(); + } + + /** + * Register the reporting routes. + * + * @return void + */ + public function register_routes() { + register_rest_route( + $this->namespace, + self::BASE_ROUTE, + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'send_report' ], + 'permission_callback' => [ $this, 'permission_callback' ], + ] + ); + + register_rest_route( + $this->namespace, + self::BASE_ROUTE . '/search', + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'search_reports' ], + 'permission_callback' => [ $this, 'permission_callback' ], + 'args' => [ + 'q' => [ + 'description' => __( 'Report title to look for.', 'code-snippets' ), + 'type' => 'string', + 'required' => true, + ], + ], + ] + ); + } + + /** + * Determine whether the request may report feedback. + * + * The setting is checked alongside the capability so that switching the reporter off + * closes these routes rather than leaving a route to the cloud open behind a hidden panel. + * + * @param WP_REST_Request $request Incoming HTTP request. + * + * @return bool + */ + public function permission_callback( WP_REST_Request $request ): bool { + return Feedback_Panel::is_enabled() && code_snippets()->current_user_can(); + } + + /** + * Offer reports resembling the title being typed. + * + * @param WP_REST_Request $request Incoming HTTP request. + * + * @return WP_REST_Response + */ + public function search_reports( WP_REST_Request $request ): WP_REST_Response { + $query = trim( sanitize_text_field( (string) $request->get_param( 'q' ) ) ); + + $results = strlen( $query ) < self::MIN_SEARCH_LENGTH + ? [] + : $this->client->search_reports( $query ); + + return new WP_REST_Response( [ 'results' => $results ], 200 ); + } + + /** + * Validate a report and forward it to the cloud. + * + * @param WP_REST_Request $request Incoming HTTP request. + * + * @return WP_REST_Response|WP_Error + */ + public function send_report( WP_REST_Request $request ) { + $invalid = $this->validate_report( $request ); + + if ( $invalid ) { + return $invalid; + } + + $user = wp_get_current_user(); + $throttle_key = 'code_snippets_feedback_' . $user->ID; + + if ( get_transient( $throttle_key ) ) { + return new WP_Error( + 'code_snippets_feedback_throttled', + __( 'That report was just sent. Wait a moment before sending another.', 'code-snippets' ), + [ 'status' => 429 ] + ); + } + + $response = $this->client->send_report( + $this->build_payload( $request ), + $this->get_idempotency_key( $request ) + ); + + if ( is_wp_error( $response ) ) { + return new WP_Error( + 'code_snippets_feedback_transport', + __( 'Could not reach the reporting service. Check the connection and try again.', 'code-snippets' ), + [ 'status' => 502 ] + ); + } + + if ( $response['status'] < 200 || $response['status'] >= 300 ) { + return $this->translate_cloud_error( $response ); + } + + set_transient( $throttle_key, 1, self::THROTTLE_TIMEOUT ); + + return new WP_REST_Response( + [ + 'sent' => true, + 'reference' => isset( $response['body']['reference'] ) ? sanitize_text_field( $response['body']['reference'] ) : '', + 'url' => isset( $response['body']['url'] ) ? esc_url_raw( $response['body']['url'] ) : '', + ], + 200 + ); + } + + /** + * Check a report says enough to be acted on. + * + * @param WP_REST_Request $request Incoming HTTP request. + * + * @return WP_Error|null Error describing the first problem found, or null when there is none. + */ + private function validate_report( WP_REST_Request $request ): ?WP_Error { + $type = sanitize_key( (string) $request->get_param( 'type' ) ); + + if ( ! in_array( $type, self::REPORT_TYPES, true ) ) { + return new WP_Error( + 'code_snippets_feedback_type', + __( 'Choose what kind of feedback this is.', 'code-snippets' ), + [ 'status' => 400 ] + ); + } + + if ( strlen( trim( sanitize_text_field( (string) $request->get_param( 'title' ) ) ) ) < 8 ) { + return new WP_Error( + 'code_snippets_feedback_title', + __( 'Give the report a title of at least 8 characters.', 'code-snippets' ), + [ 'status' => 400 ] + ); + } + + if ( strlen( trim( sanitize_textarea_field( (string) $request->get_param( 'description' ) ) ) ) < 20 ) { + return new WP_Error( + 'code_snippets_feedback_description', + __( 'Describe the problem in a bit more detail.', 'code-snippets' ), + [ 'status' => 400 ] + ); + } + + if ( 'bug' === $type && strlen( trim( sanitize_textarea_field( (string) $request->get_param( 'steps' ) ) ) ) < 20 ) { + return new WP_Error( + 'code_snippets_feedback_steps', + __( 'List the steps that reproduce the bug.', 'code-snippets' ), + [ 'status' => 400 ] + ); + } + + return null; + } + + /** + * Assemble the report sent to the cloud. + * + * @param WP_REST_Request $request Incoming HTTP request. + * + * @return array + */ + private function build_payload( WP_REST_Request $request ): array { + $user = wp_get_current_user(); + $isolation = (array) $request->get_param( 'isolation' ); + $browser = (array) $request->get_param( 'browser' ); + $js_errors = array_slice( (array) $request->get_param( 'js_errors' ), 0, self::MAX_JS_ERRORS ); + + $name = trim( sanitize_text_field( (string) $request->get_param( 'name' ) ) ); + $email = sanitize_email( (string) $request->get_param( 'email' ) ); + + $payload = [ + 'report' => [ + 'type' => sanitize_key( (string) $request->get_param( 'type' ) ), + 'title' => trim( sanitize_text_field( (string) $request->get_param( 'title' ) ) ), + 'description' => trim( sanitize_textarea_field( (string) $request->get_param( 'description' ) ) ), + 'steps' => trim( sanitize_textarea_field( (string) $request->get_param( 'steps' ) ) ), + 'comments' => trim( sanitize_textarea_field( (string) $request->get_param( 'comments' ) ) ), + 'isolation' => [ + 'plugin_only' => ! empty( $isolation['plugin_only'] ), + 'blank_theme' => ! empty( $isolation['blank_theme'] ), + 'reproducible' => ! empty( $isolation['reproducible'] ), + ], + 'page_url' => esc_url_raw( (string) $request->get_param( 'page_url' ) ), + ], + 'reporter' => [ + 'name' => $name ? $name : $user->display_name, + 'email' => $email ? $email : $user->user_email, + 'role' => implode( ', ', $user->roles ), + ], + 'environment' => System_Info::get_system_info(), + 'browser' => [ + 'user_agent' => isset( $browser['userAgent'] ) ? sanitize_text_field( $browser['userAgent'] ) : '', + 'viewport' => isset( $browser['viewport'] ) ? sanitize_text_field( $browser['viewport'] ) : '', + 'screen' => isset( $browser['screen'] ) ? sanitize_text_field( $browser['screen'] ) : '', + 'language' => isset( $browser['language'] ) ? sanitize_text_field( $browser['language'] ) : '', + ], + 'js_errors' => array_map( 'sanitize_textarea_field', $js_errors ), + 'submitted_at' => gmdate( 'c' ), + ]; + + return apply_filters( 'code_snippets_feedback_payload', $payload ); + } + + /** + * Reduce the key naming a submission to the characters the cloud accepts. + * + * @param WP_REST_Request $request Incoming HTTP request. + * + * @return string + */ + private function get_idempotency_key( WP_REST_Request $request ): string { + $key = sanitize_text_field( (string) $request->get_param( 'idempotency_key' ) ); + $key = substr( preg_replace( '/[^A-Za-z0-9\-]/', '', $key ), 0, 64 ); + + return $key ? $key : wp_generate_uuid4(); + } + + /** + * Describe a report the cloud refused. + * + * A refusal the reporter can act on is passed through with its own status and wording; + * anything else is reported as a problem reaching the service. + * + * @param array{status: int, body: array} $response Response from the cloud. + * + * @return WP_Error + */ + private function translate_cloud_error( array $response ): WP_Error { + $is_client_error = $response['status'] >= 400 && $response['status'] < 500; + + $message = $is_client_error && isset( $response['body']['message'] ) + ? sanitize_text_field( $response['body']['message'] ) + : __( 'The reporting service could not accept this report. Try again shortly.', 'code-snippets' ); + + $code = isset( $response['body']['code'] ) + ? 'cloud_' . sanitize_key( $response['body']['code'] ) + : 'code_snippets_feedback_rejected'; + + return new WP_Error( + $code, + $message, + [ 'status' => $is_client_error ? $response['status'] : 502 ] + ); + } +} diff --git a/tests/unit/REST_API/Feedback_REST_Controller_Test.php b/tests/unit/REST_API/Feedback_REST_Controller_Test.php new file mode 100644 index 000000000..3d254c6be --- /dev/null +++ b/tests/unit/REST_API/Feedback_REST_Controller_Test.php @@ -0,0 +1,433 @@ + + */ + private array $responses = []; + + /** + * Bodies of the requests the client sent. + * + * @var array + */ + private array $sent_bodies = []; + + /** + * Set up before each test. + * + * @return void + */ + public function set_up() { + parent::set_up(); + + global $wp_rest_server; + + $this->route = '/' . Feedback_REST_Controller::get_base_route(); + $this->responses = []; + $this->sent_bodies = []; + + update_setting( 'general', Feedback_Panel::SETTING_FIELD, true ); + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + + $connection = new Feedback_Connection(); + $connection->save_credentials( + [ + 'public_id' => str_repeat( 'a', 20 ), + 'secret' => str_repeat( 'b', 40 ), + 'offset' => 0, + ] + ); + + add_filter( 'pre_http_request', [ $this, 'mock_request' ], 10, 3 ); + + $wp_rest_server = new WP_REST_Server(); + new Feedback_REST_Controller( new Feedback_Client( $connection ) ); + do_action( 'rest_api_init', $wp_rest_server ); + } + + /** + * Remove what a test stored or registered. + * + * @return void + */ + public function tear_down() { + global $wp_rest_server; + + remove_filter( 'pre_http_request', [ $this, 'mock_request' ] ); + delete_option( Feedback_Connection::CREDENTIALS_OPTION ); + delete_transient( 'code_snippets_feedback_' . get_current_user_id() ); + update_setting( 'general', Feedback_Panel::SETTING_FIELD, false ); + wp_set_current_user( 0 ); + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Record each request body and answer with the next queued response. + * + * @param mixed $preempt Short-circuit value. + * @param array $args Request arguments. + * @param string $url Request URL. + * + * @return array|WP_Error + */ + public function mock_request( $preempt, $args, $url ) { + $this->sent_bodies[] = (string) ( $args['body'] ?? '' ); + + return array_shift( $this->responses ) ?? $this->cloud_response( 200, [ 'reference' => 'CS-1' ] ); + } + + /** + * Build a response of the shape the HTTP API returns. + * + * @param int $status HTTP status code. + * @param array $body Response body. + * + * @return array + */ + private function cloud_response( int $status, array $body ): array { + return [ + 'headers' => [], + 'body' => (string) wp_json_encode( $body ), + 'response' => [ + 'code' => $status, + 'message' => '', + ], + 'cookies' => [], + 'filename' => null, + ]; + } + + /** + * Build a report that passes validation. + * + * @param array $overrides Values to replace. + * + * @return array + */ + private function valid_report( array $overrides = [] ): array { + return array_merge( + [ + 'type' => 'bug', + 'title' => 'Highlighting stops after switching tabs', + 'description' => 'The editor stops highlighting PHP once the Conditions tab is opened.', + 'steps' => '1. Open a snippet 2. Click Conditions 3. Switch back to Code', + ], + $overrides + ); + } + + /** + * Send a report to the endpoint. + * + * @param array $body Report to send. + * + * @return \WP_REST_Response + */ + private function post_report( array $body ) { + $request = new WP_REST_Request( 'POST', $this->route ); + $request->set_body_params( $body ); + + return rest_get_server()->dispatch( $request ); + } + + /** + * Both routes are available once the reporter is switched on. + * + * @return void + */ + public function test_routes_are_registered(): void { + $routes = rest_get_server()->get_routes(); + + $this->assertArrayHasKey( $this->route, $routes ); + $this->assertArrayHasKey( $this->route . '/search', $routes ); + } + + /** + * Somebody who cannot manage snippets cannot report on the site's behalf. + * + * @return void + */ + public function test_request_is_denied_without_the_snippets_capability(): void { + wp_set_current_user( self::factory()->user->create( [ 'role' => 'subscriber' ] ) ); + + $this->assertSame( 403, $this->post_report( $this->valid_report() )->get_status() ); + } + + /** + * Switching the reporter off closes the route rather than only hiding the panel. + * + * @return void + */ + public function test_request_is_denied_while_the_setting_is_off(): void { + update_setting( 'general', Feedback_Panel::SETTING_FIELD, false ); + + $this->assertSame( 403, $this->post_report( $this->valid_report() )->get_status() ); + } + + /** + * A report has to say what kind of feedback it is. + * + * @return void + */ + public function test_an_unknown_type_is_rejected(): void { + $response = $this->post_report( $this->valid_report( [ 'type' => 'complaint' ] ) ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'code_snippets_feedback_type', $response->get_data()['code'] ); + } + + /** + * A one-word title does not describe anything. + * + * @return void + */ + public function test_a_short_title_is_rejected(): void { + $response = $this->post_report( $this->valid_report( [ 'title' => 'Broken' ] ) ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'code_snippets_feedback_title', $response->get_data()['code'] ); + } + + /** + * Neither does a one-word description. + * + * @return void + */ + public function test_a_short_description_is_rejected(): void { + $response = $this->post_report( $this->valid_report( [ 'description' => 'It broke.' ] ) ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'code_snippets_feedback_description', $response->get_data()['code'] ); + } + + /** + * A bug is only actionable with the steps that produce it. + * + * @return void + */ + public function test_a_bug_without_steps_is_rejected(): void { + $response = $this->post_report( $this->valid_report( [ 'steps' => '' ] ) ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'code_snippets_feedback_steps', $response->get_data()['code'] ); + } + + /** + * A feature request needs no steps. + * + * @return void + */ + public function test_a_feature_request_does_not_need_steps(): void { + $response = $this->post_report( + $this->valid_report( + [ + 'type' => 'feature', + 'steps' => '', + ] + ) + ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * A report the cloud accepts comes back with its reference, and carries the environment + * collected on the server. + * + * @return void + */ + public function test_a_valid_report_is_forwarded_with_the_environment_attached(): void { + $this->responses = [ $this->cloud_response( 200, [ 'reference' => 'CS-42' ] ) ]; + + $response = $this->post_report( $this->valid_report() ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $response->get_data()['sent'] ); + $this->assertSame( 'CS-42', $response->get_data()['reference'] ); + + $sent = json_decode( end( $this->sent_bodies ), true ); + + $this->assertSame( 'bug', $sent['report']['type'] ); + $this->assertSame( PHP_VERSION, $sent['environment']['php_version'] ); + $this->assertNotEmpty( $sent['reporter']['email'] ); + } + + /** + * The reporter's own details are used when they leave the fields alone. + * + * @return void + */ + public function test_the_reporter_defaults_to_the_current_user(): void { + $this->post_report( $this->valid_report() ); + + $user = wp_get_current_user(); + $sent = json_decode( end( $this->sent_bodies ), true ); + + $this->assertSame( $user->display_name, $sent['reporter']['name'] ); + $this->assertSame( $user->user_email, $sent['reporter']['email'] ); + } + + /** + * Only the first few captured errors are worth attaching. + * + * @return void + */ + public function test_js_errors_are_capped(): void { + $errors = []; + + for ( $i = 0; $i < 24; $i++ ) { + $errors[] = 'Error ' . $i; + } + + $this->post_report( $this->valid_report( [ 'js_errors' => $errors ] ) ); + + $sent = json_decode( end( $this->sent_bodies ), true ); + + $this->assertCount( 10, $sent['js_errors'] ); + } + + /** + * A second report straight after the first is held back. + * + * @return void + */ + public function test_a_second_report_within_the_throttle_window_is_rejected(): void { + $this->assertSame( 200, $this->post_report( $this->valid_report() )->get_status() ); + + $response = $this->post_report( $this->valid_report() ); + + $this->assertSame( 429, $response->get_status() ); + $this->assertSame( 'code_snippets_feedback_throttled', $response->get_data()['code'] ); + } + + /** + * A refusal the reporter can act on reaches them in the cloud's own words. + * + * @return void + */ + public function test_a_cloud_client_error_passes_through_with_its_status(): void { + $this->responses = [ + $this->cloud_response( + 422, + [ + 'code' => 'title_taken', + 'message' => 'A report with this title already exists.', + ] + ), + ]; + + $response = $this->post_report( $this->valid_report() ); + + $this->assertSame( 422, $response->get_status() ); + $this->assertSame( 'cloud_title_taken', $response->get_data()['code'] ); + $this->assertSame( 'A report with this title already exists.', $response->get_data()['message'] ); + } + + /** + * A cloud fault is reported as a problem with the service, not with the report. + * + * @return void + */ + public function test_a_cloud_server_error_becomes_a_bad_gateway(): void { + $this->responses = [ $this->cloud_response( 500, [ 'message' => 'Database is down.' ] ) ]; + + $response = $this->post_report( $this->valid_report() ); + + $this->assertSame( 502, $response->get_status() ); + $this->assertStringNotContainsString( 'Database is down.', $response->get_data()['message'] ); + } + + /** + * A cloud that cannot be reached at all is reported as such. + * + * @return void + */ + public function test_a_transport_failure_becomes_a_bad_gateway(): void { + $this->responses = [ new WP_Error( 'http_request_failed', 'Could not resolve host.' ) ]; + + $response = $this->post_report( $this->valid_report() ); + + $this->assertSame( 502, $response->get_status() ); + $this->assertSame( 'code_snippets_feedback_transport', $response->get_data()['code'] ); + } + + /** + * A title barely started is not worth searching for. + * + * @return void + */ + public function test_search_returns_an_empty_list_for_short_queries(): void { + $request = new WP_REST_Request( 'GET', $this->route . '/search' ); + $request->set_param( 'q', 'ab' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( [], $response->get_data()['results'] ); + $this->assertCount( 0, $this->sent_bodies ); + } + + /** + * Similar reports are offered so the same problem is not filed twice. + * + * @return void + */ + public function test_search_offers_similar_reports(): void { + $this->responses = [ + $this->cloud_response( + 200, + [ + 'results' => [ + [ + 'title' => 'Highlighting stops', + 'url' => 'https://example.com/1', + ], + ], + ] + ), + ]; + + $request = new WP_REST_Request( 'GET', $this->route . '/search' ); + $request->set_param( 'q', 'highlighting' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertCount( 1, $response->get_data()['results'] ); + } +} From 0ffd15a25c8c329be0a7f49c0a51faf4099a9552 Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Sat, 5 Sep 2026 20:17:02 +0100 Subject: [PATCH 09/18] feat: add feedback reporter styles --- src/css/feedback.scss | 277 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 src/css/feedback.scss diff --git a/src/css/feedback.scss b/src/css/feedback.scss new file mode 100644 index 000000000..4a69a4b63 --- /dev/null +++ b/src/css/feedback.scss @@ -0,0 +1,277 @@ +@use 'common/theme'; + +$feedback-panel-inline-size: 460px; +$feedback-danger: #d63638; +$feedback-success: #00a32a; + +.code-snippets-feedback-launcher { + position: fixed; + inset-block-end: 24px; + inset-inline-end: 24px; + z-index: 100000; + display: inline-flex; + align-items: center; + gap: 8px; + min-block-size: var(--cs-control-height); + padding-block: 2px; + padding-inline: 16px; + border: none; + border-radius: 24px; + background: var(--cs-color-accent); + color: var(--cs-color-surface); + font-size: var(--cs-font-size-body); + font-weight: 700; + line-height: 2.5715; + cursor: pointer; + box-shadow: 0 4px 14px rgb(0 0 0 / 18%); + transition: transform 0.15s ease, background 0.15s ease; + + &:hover { + background: var(--cs-color-accent-hover); + transform: translateY(-1px); + } + + &:focus-visible { + outline: 2px solid var(--cs-color-accent); + outline-offset: 2px; + } +} + +.code-snippets-feedback-launcher__dot { + inline-size: 7px; + block-size: 7px; + border-radius: 50%; + background: var(--cs-color-primary); + box-shadow: 0 0 0 3px rgb(3 199 210 / 25%); +} + +.code-snippets-feedback-badge { + display: inline-flex; + align-items: center; + gap: 6px; + vertical-align: middle; + margin-inline-start: 12px; + padding-block: 3px; + padding-inline: 10px; + border: 1px solid #f0c0c1; + border-radius: 999px; + background: #fcf0f1; + color: #8a2424; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + line-height: 1.6; + white-space: nowrap; +} + +.code-snippets-feedback-badge__dot { + inline-size: 6px; + block-size: 6px; + border-radius: 50%; + background: $feedback-danger; +} + +.code-snippets-feedback-modal { + position: fixed; + inset-block: 0; + inset-inline-end: 0; + inline-size: $feedback-panel-inline-size; + max-inline-size: 100vw; + block-size: 100%; + max-block-size: 100%; + margin: 0; + border-radius: 0; + border-inline-start: 1px solid var(--cs-color-border-subtle); + box-shadow: -8px 0 32px rgb(0 0 0 / 12%); + transform: none; + + .components-modal__header { + padding-block: 16px; + padding-inline: 24px; + border-block-end: 1px solid var(--cs-color-border-subtle); + } + + .components-modal__content { + display: flex; + flex-direction: column; + padding: 0; + margin-block-start: 0; + } +} + +.code-snippets-feedback-panel__subtitle { + margin-block: 4px 0; + color: var(--cs-color-text-muted); + font-size: 12px; +} + +.code-snippets-feedback-panel__body { + flex: 1; + overflow-y: auto; + padding-block: 20px; + padding-inline: 24px; +} + +.code-snippets-feedback-panel__footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding-block: 16px; + padding-inline: 24px; + border-block-start: 1px solid var(--cs-color-border-subtle); + background: var(--cs-color-surface-subtle); +} + +.code-snippets-feedback-field { + margin-block-end: 18px; + + .components-base-control__help { + color: var(--cs-color-text-muted); + } +} + +.code-snippets-feedback-fieldset { + padding: 0; + margin-block: 0 18px; + margin-inline: 0; + border: 0; + + legend { + padding: 0; + margin-block-end: 8px; + font-size: 13px; + font-weight: 600; + } +} + +.code-snippets-feedback-duplicates { + padding: 12px; + margin-block: -6px 18px; + border: 1px solid #f0c33c; + border-radius: var(--cs-control-radius); + background: #fcf9e8; + + p { + margin-block: 0 8px; + font-size: 12px; + font-weight: 600; + } + + ul { + padding: 0; + margin: 0; + list-style: none; + } + + li { + margin-block-end: 6px; + font-size: 12px; + line-height: 1.4; + + &:last-child { + margin-block-end: 0; + } + } +} + +.code-snippets-feedback-disclosure { + padding-block-start: 14px; + border-block-start: 1px solid var(--cs-color-border-subtle); + + summary { + color: var(--cs-color-accent); + font-weight: 600; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--cs-color-accent); + outline-offset: 2px; + } + } +} + +.code-snippets-feedback-disclosure__list { + display: grid; + grid-template-columns: 108px 1fr; + gap: 4px 12px; + margin-block: 12px 0; + font-size: 12px; + + dt { + color: var(--cs-color-text-muted); + } + + dd { + margin: 0; + word-break: break-word; + } +} + +.code-snippets-feedback-disclosure__note { + margin-block: 12px 0; + color: var(--cs-color-text-muted); + font-size: 12px; +} + +.code-snippets-feedback-message { + padding-block: 10px; + padding-inline: 12px; + margin-block: 0 18px; + border-radius: var(--cs-control-radius); + border-inline-start: 4px solid $feedback-danger; + background: #fcf0f1; + scroll-margin-block-start: 8px; + + &:focus { + outline: 2px solid $feedback-danger; + outline-offset: 2px; + } +} + +.code-snippets-feedback-success { + padding-block: 48px; + padding-inline: 12px; + text-align: center; + + h3 { + margin-block: 0 8px; + font-size: 15px; + } + + p { + margin: 0; + color: var(--cs-color-text-muted); + } + + &::before { + content: ''; + display: block; + inline-size: 8px; + block-size: 8px; + margin-block-end: 12px; + margin-inline: auto; + border-radius: 50%; + background: $feedback-success; + } +} + +@media (width <= 480px) { + .code-snippets-feedback-modal { + inline-size: 100vw; + } + + .code-snippets-feedback-launcher { + inset-block-end: 16px; + inset-inline-end: 16px; + } +} + +@media (prefers-reduced-motion: reduce) { + .code-snippets-feedback-launcher { + transition: none; + + &:hover { + transform: none; + } + } +} From 9894dff81b3981f54c5cd5625395153f85c78d8a Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Sat, 5 Sep 2026 20:22:14 +0100 Subject: [PATCH 10/18] feat: add the feedback reporter panel --- config/webpack/webpack-js.ts | 1 + .../FeedbackReporter/BugDetailFields.tsx | 44 +++++ .../FeedbackReporter/DuplicateReports.tsx | 20 +++ .../EnvironmentDisclosure.tsx | 33 ++++ .../FeedbackReporter/FeedbackForm.tsx | 66 ++++++++ .../FeedbackReporter/FeedbackPanel.tsx | 53 ++++++ .../FeedbackReporter/FeedbackReporter.tsx | 33 ++++ .../FeedbackReporter/FeedbackSuccess.tsx | 26 +++ .../FeedbackReporter/HeadingBadge.tsx | 32 ++++ .../FeedbackReporter/ReportSummaryFields.tsx | 59 +++++++ .../FeedbackReporter/ReporterFields.tsx | 38 +++++ src/js/entries/feedback.ts | 4 + src/js/hooks/useDuplicateReports.ts | 38 +++++ src/js/hooks/useFeedbackReport.ts | 155 ++++++++++++++++++ src/js/types/Feedback.ts | 66 ++++++++ src/js/types/Window.ts | 2 + src/js/utils/restAPI.ts | 5 +- 17 files changed, 674 insertions(+), 1 deletion(-) create mode 100644 src/js/components/FeedbackReporter/BugDetailFields.tsx create mode 100644 src/js/components/FeedbackReporter/DuplicateReports.tsx create mode 100644 src/js/components/FeedbackReporter/EnvironmentDisclosure.tsx create mode 100644 src/js/components/FeedbackReporter/FeedbackForm.tsx create mode 100644 src/js/components/FeedbackReporter/FeedbackPanel.tsx create mode 100644 src/js/components/FeedbackReporter/FeedbackReporter.tsx create mode 100644 src/js/components/FeedbackReporter/FeedbackSuccess.tsx create mode 100644 src/js/components/FeedbackReporter/HeadingBadge.tsx create mode 100644 src/js/components/FeedbackReporter/ReportSummaryFields.tsx create mode 100644 src/js/components/FeedbackReporter/ReporterFields.tsx create mode 100644 src/js/entries/feedback.ts create mode 100644 src/js/hooks/useDuplicateReports.ts create mode 100644 src/js/hooks/useFeedbackReport.ts create mode 100644 src/js/types/Feedback.ts diff --git a/config/webpack/webpack-js.ts b/config/webpack/webpack-js.ts index 605206585..62981acea 100644 --- a/config/webpack/webpack-js.ts +++ b/config/webpack/webpack-js.ts @@ -27,6 +27,7 @@ export const jsWebpackConfig: Configuration = { 'admin-bar': `${SOURCE_DIR}/admin-bar.ts`, 'edit': { import: `${SOURCE_DIR}/edit.ts`, dependOn: 'editor' }, 'editor': `${SOURCE_DIR}/editor.ts`, + 'feedback': `${SOURCE_DIR}/feedback.ts`, 'feedback-capture': `${SOURCE_DIR}/feedback-capture.ts`, 'import': `${SOURCE_DIR}/import.ts`, 'insights': `${SOURCE_DIR}/insights.ts`, diff --git a/src/js/components/FeedbackReporter/BugDetailFields.tsx b/src/js/components/FeedbackReporter/BugDetailFields.tsx new file mode 100644 index 000000000..ab419e755 --- /dev/null +++ b/src/js/components/FeedbackReporter/BugDetailFields.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { CheckboxControl, TextareaControl } from '@wordpress/components' +import { __ } from '@wordpress/i18n' +import type { FeedbackDraft } from '../../types/Feedback' + +export interface BugDetailFieldsProps { + draft: FeedbackDraft + updateDraft: (changes: Partial) => void +} + +export const BugDetailFields: React.FC = ({ draft, updateDraft }) => + <> +
+ updateDraft({ steps })} + /> +
+ +
+ {__('Isolating the problem', 'code-snippets')} + + updateDraft({ isolation: { ...draft.isolation, plugin_only: pluginOnly } })} + /> + + updateDraft({ isolation: { ...draft.isolation, blank_theme: blankTheme } })} + /> + + updateDraft({ isolation: { ...draft.isolation, reproducible } })} + /> +
+ diff --git a/src/js/components/FeedbackReporter/DuplicateReports.tsx b/src/js/components/FeedbackReporter/DuplicateReports.tsx new file mode 100644 index 000000000..6aebf4d31 --- /dev/null +++ b/src/js/components/FeedbackReporter/DuplicateReports.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { __ } from '@wordpress/i18n' +import type { DuplicateReport } from '../../types/Feedback' + +export interface DuplicateReportsProps { + reports: DuplicateReport[] +} + +export const DuplicateReports: React.FC = ({ reports }) => + 0 < reports.length && +
+

{__('Existing reports that look similar', 'code-snippets')}

+ +
diff --git a/src/js/components/FeedbackReporter/EnvironmentDisclosure.tsx b/src/js/components/FeedbackReporter/EnvironmentDisclosure.tsx new file mode 100644 index 000000000..37a5fe686 --- /dev/null +++ b/src/js/components/FeedbackReporter/EnvironmentDisclosure.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import { __, _n, sprintf } from '@wordpress/i18n' + +export interface EnvironmentDisclosureProps { + summary: Record + errorCount: number +} + +export const EnvironmentDisclosure: React.FC = ({ summary, errorCount }) => +
+ {__('What gets sent with this report', 'code-snippets')} + +
+ {Object.entries(summary).map(([label, value]) => + +
{label}
+
{value}
+
+ )} + +
{__('JavaScript errors', 'code-snippets')}
+
+ {0 === errorCount + ? __('none on this page', 'code-snippets') + // translators: %d: number of JavaScript errors captured on the current page. + : sprintf(_n('%d error captured', '%d errors captured', errorCount, 'code-snippets'), errorCount)} +
+
+ +

+ {__('Your site address, email address and full plugin list stay with the Code Snippets team. Only the report itself and the version numbers appear on the public issue tracker.', 'code-snippets')} +

+
diff --git a/src/js/components/FeedbackReporter/FeedbackForm.tsx b/src/js/components/FeedbackReporter/FeedbackForm.tsx new file mode 100644 index 000000000..3dd3c7e76 --- /dev/null +++ b/src/js/components/FeedbackReporter/FeedbackForm.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import { SelectControl, TextareaControl } from '@wordpress/components' +import { __ } from '@wordpress/i18n' +import { BugDetailFields } from './BugDetailFields' +import { ReportSummaryFields } from './ReportSummaryFields' +import { ReporterFields } from './ReporterFields' +import type { FeedbackReport } from '../../hooks/useFeedbackReport' +import type { FeedbackConfig, FeedbackDraft } from '../../types/Feedback' + +const TYPE_OPTIONS = [ + { label: __('Choose one…', 'code-snippets'), value: '' }, + { label: __('Bug — something is broken', 'code-snippets'), value: 'bug' }, + { label: __('Feature request — something is missing', 'code-snippets'), value: 'feature' }, + { label: __('General feedback', 'code-snippets'), value: 'feedback' } +] + +export interface FeedbackFormProps { + config: FeedbackConfig + report: FeedbackReport +} + +export const FeedbackForm: React.FC = ({ config, report }) => { + const { draft, duplicates, errorMessage, updateDraft } = report + const type = draft.type + + return <> + {errorMessage && +

{errorMessage}

} + +
+ updateDraft({ type: value as FeedbackDraft['type'] })} + /> +
+ + {type && <> + + + {'bug' === type && } + +
+ updateDraft({ comments })} + /> +
+ + + } + +} diff --git a/src/js/components/FeedbackReporter/FeedbackPanel.tsx b/src/js/components/FeedbackReporter/FeedbackPanel.tsx new file mode 100644 index 000000000..d64e06876 --- /dev/null +++ b/src/js/components/FeedbackReporter/FeedbackPanel.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import { Button, Modal } from '@wordpress/components' +import { __, sprintf } from '@wordpress/i18n' +import { useFeedbackReport } from '../../hooks/useFeedbackReport' +import { FeedbackForm } from './FeedbackForm' +import { FeedbackSuccess } from './FeedbackSuccess' +import type { FeedbackConfig } from '../../types/Feedback' + +export interface FeedbackPanelProps { + config: FeedbackConfig + onClose: VoidFunction +} + +export const FeedbackPanel: React.FC = ({ config, onClose }) => { + const report = useFeedbackReport(config) + + return +

+ {sprintf( + // translators: 1: plugin version, 2: plugin edition, either Free or Pro. + __('Code Snippets %1$s %2$s', 'code-snippets'), + config.version, + 'pro' === config.edition ? __('Pro', 'code-snippets') : __('Free', 'code-snippets') + )} +

+ +
+ {report.result + ? + : } +
+ +
+ + + {!report.result && + } +
+
+} diff --git a/src/js/components/FeedbackReporter/FeedbackReporter.tsx b/src/js/components/FeedbackReporter/FeedbackReporter.tsx new file mode 100644 index 000000000..ec1808373 --- /dev/null +++ b/src/js/components/FeedbackReporter/FeedbackReporter.tsx @@ -0,0 +1,33 @@ +import React, { useState } from 'react' +import { __ } from '@wordpress/i18n' +import { WithRestAPIContext } from '../../hooks/useRestAPI' +import { FeedbackPanel } from './FeedbackPanel' +import { HeadingBadge } from './HeadingBadge' + +export const FeedbackReporter: React.FC = () => { + const [isOpen, setIsOpen] = useState(false) + const config = window.CODE_SNIPPETS_FEEDBACK + + if (!config) { + return null + } + + return <> + + + + + {isOpen && + + setIsOpen(false)} /> + } + +} diff --git a/src/js/components/FeedbackReporter/FeedbackSuccess.tsx b/src/js/components/FeedbackReporter/FeedbackSuccess.tsx new file mode 100644 index 000000000..62df5667b --- /dev/null +++ b/src/js/components/FeedbackReporter/FeedbackSuccess.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { __, sprintf } from '@wordpress/i18n' +import type { FeedbackReportResponse } from '../../types/Feedback' + +export interface FeedbackSuccessProps { + result: FeedbackReportResponse +} + +/** Only a tracker link the plugin published is worth offering as one. */ +const isTrackerUrl = (url: string): boolean => url.startsWith('https://github.com/') + +export const FeedbackSuccess: React.FC = ({ result }) => +
+

{__('Report sent', 'code-snippets')}

+

+ {result.reference + // translators: %s: reference number identifying the report. + ? sprintf(__('Reference %s.', 'code-snippets'), result.reference) + : __('The team will pick it up from here.', 'code-snippets')} + {' '} + {result.url && isTrackerUrl(result.url) && + + {__('Track it on GitHub', 'code-snippets')} + } +

+
diff --git a/src/js/components/FeedbackReporter/HeadingBadge.tsx b/src/js/components/FeedbackReporter/HeadingBadge.tsx new file mode 100644 index 000000000..a44e6f25b --- /dev/null +++ b/src/js/components/FeedbackReporter/HeadingBadge.tsx @@ -0,0 +1,32 @@ +import React, { useMemo } from 'react' +import { createPortal } from 'react-dom' +import { __ } from '@wordpress/i18n' + +export interface HeadingBadgeProps { + label: string +} + +/** The page heading, wherever this screen happens to render one. */ +const findHeading = (): Element | null => + document.querySelector('.wrap h1.wp-heading-inline') ?? + document.querySelector('#wpbody-content .wrap h1') ?? + document.querySelector('#wpbody-content h1') + +export const HeadingBadge: React.FC = ({ label }) => { + const heading = useMemo(findHeading, []) + + if (!label || !heading) { + return null + } + + return createPortal( + + + {label} + , + heading + ) +} diff --git a/src/js/components/FeedbackReporter/ReportSummaryFields.tsx b/src/js/components/FeedbackReporter/ReportSummaryFields.tsx new file mode 100644 index 000000000..f0fb5efec --- /dev/null +++ b/src/js/components/FeedbackReporter/ReportSummaryFields.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import { TextControl, TextareaControl } from '@wordpress/components' +import { __ } from '@wordpress/i18n' +import { DuplicateReports } from './DuplicateReports' +import type { DuplicateReport, FeedbackDraft, FeedbackType } from '../../types/Feedback' + +/** What the description asks for, and an example answer, for each kind of report. */ +const DESCRIPTION_COPY: Record = { + bug: { + label: __('What went wrong?', 'code-snippets'), + placeholder: __('The editor stopped highlighting PHP and the Save button did nothing.', 'code-snippets') + }, + feature: { + label: __('What would you like Code Snippets to do?', 'code-snippets'), + placeholder: __('Let me tag snippets so I can filter large libraries by project.', 'code-snippets') + }, + feedback: { + label: __('What is on your mind?', 'code-snippets'), + placeholder: __('The new Conditions tab is much clearer, but the icons are hard to tell apart.', 'code-snippets') + } +} + +export interface ReportSummaryFieldsProps { + type: FeedbackType + draft: FeedbackDraft + duplicates: DuplicateReport[] + updateDraft: (changes: Partial) => void +} + +export const ReportSummaryFields: React.FC = ({ + type, + draft, + duplicates, + updateDraft +}) => + <> +
+ updateDraft({ title })} + /> +
+ + + +
+ updateDraft({ description })} + /> +
+ diff --git a/src/js/components/FeedbackReporter/ReporterFields.tsx b/src/js/components/FeedbackReporter/ReporterFields.tsx new file mode 100644 index 000000000..c97460415 --- /dev/null +++ b/src/js/components/FeedbackReporter/ReporterFields.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import { TextControl } from '@wordpress/components' +import { __ } from '@wordpress/i18n' +import { EnvironmentDisclosure } from './EnvironmentDisclosure' +import type { FeedbackConfig, FeedbackDraft } from '../../types/Feedback' + +export interface ReporterFieldsProps { + config: FeedbackConfig + draft: FeedbackDraft + updateDraft: (changes: Partial) => void +} + +export const ReporterFields: React.FC = ({ config, draft, updateDraft }) => + <> +
+ updateDraft({ name })} + /> +
+ +
+ updateDraft({ email })} + /> +
+ + + diff --git a/src/js/entries/feedback.ts b/src/js/entries/feedback.ts new file mode 100644 index 000000000..0f5e30548 --- /dev/null +++ b/src/js/entries/feedback.ts @@ -0,0 +1,4 @@ +import { FeedbackReporter } from '../components/FeedbackReporter/FeedbackReporter' +import { loadComponent } from '../utils/bootstrap' + +loadComponent('code-snippets-feedback-container', FeedbackReporter) diff --git a/src/js/hooks/useDuplicateReports.ts b/src/js/hooks/useDuplicateReports.ts new file mode 100644 index 000000000..e08d82732 --- /dev/null +++ b/src/js/hooks/useDuplicateReports.ts @@ -0,0 +1,38 @@ +import { useEffect, useState } from 'react' +import { useRestAPI } from './useRestAPI' +import type { DuplicateReport, DuplicateSearchResponse } from '../types/Feedback' + +/** Shortest title worth looking for existing reports of. */ +const MIN_SEARCH_LENGTH = 6 + +/** How long to wait after the last keystroke before searching. */ +const SEARCH_DEBOUNCE_MS = 600 + +/** + * Offer reports already filed about whatever is being described, so the same problem is + * not reported twice. A cloud that cannot answer leaves the list empty rather than + * interrupting the report being written. + */ +export const useDuplicateReports = (restUrl: string, title: string): DuplicateReport[] => { + const { api } = useRestAPI() + const [duplicates, setDuplicates] = useState([]) + + useEffect(() => { + const query = title.trim() + + if (MIN_SEARCH_LENGTH > query.length) { + setDuplicates([]) + return + } + + const timer = setTimeout(() => { + api.get(`${restUrl}/search?q=${encodeURIComponent(query)}`) + .then(data => setDuplicates(data.results)) + .catch(() => setDuplicates([])) + }, SEARCH_DEBOUNCE_MS) + + return () => clearTimeout(timer) + }, [api, restUrl, title]) + + return duplicates +} diff --git a/src/js/hooks/useFeedbackReport.ts b/src/js/hooks/useFeedbackReport.ts new file mode 100644 index 000000000..3aafef654 --- /dev/null +++ b/src/js/hooks/useFeedbackReport.ts @@ -0,0 +1,155 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import { __ } from '@wordpress/i18n' +import { useDuplicateReports } from './useDuplicateReports' +import { useRestAPI } from './useRestAPI' +import type { + DuplicateReport, + FeedbackConfig, + FeedbackDraft, + FeedbackReportRequest, + FeedbackReportResponse +} from '../types/Feedback' + +/** Most captured errors to attach to a report. */ +const MAX_JS_ERRORS = 10 + +/** Shortest title that summarises anything. */ +const MIN_TITLE_LENGTH = 8 + +/** Shortest free-text answer that describes anything. */ +const MIN_TEXT_LENGTH = 20 + +interface AxiosLikeError { + response?: { + data?: { + message?: string + } + } +} + +export interface FeedbackValidity { + title: boolean + description: boolean + steps: boolean +} + +export interface FeedbackReport { + draft: FeedbackDraft + duplicates: DuplicateReport[] + errorMessage: string + invalidFields: FeedbackValidity + isSending: boolean + result: FeedbackReportResponse | undefined + updateDraft: (changes: Partial) => void + submit: VoidFunction +} + +const emptyDraft = (config: FeedbackConfig): FeedbackDraft => ({ + type: '', + title: '', + description: '', + steps: '', + comments: '', + name: config.user.name, + email: config.user.email, + isolation: { + plugin_only: false, + blank_theme: false, + reproducible: false + } +}) + +const describeBrowser = (): FeedbackReportRequest['browser'] => ({ + userAgent: navigator.userAgent, + viewport: `${window.innerWidth}×${window.innerHeight}`, + screen: `${window.screen.width}×${window.screen.height}`, + language: navigator.language +}) + +/** + * The message an error carries, whether it came from WordPress, from the cloud, or from + * the request never arriving. + */ +const describeError = (error: unknown): string => + (error).response?.data?.message ?? + __('The report could not be sent. Check the connection and try again.', 'code-snippets') + +/** The first message describing what is still missing from a report. */ +const firstValidationMessage = (invalid: FeedbackValidity): string => { + if (invalid.title) { + return __('Give the report a one-line title.', 'code-snippets') + } + + if (invalid.description) { + return __('Add a little more detail to the description.', 'code-snippets') + } + + if (invalid.steps) { + return __('List the steps that reproduce the bug.', 'code-snippets') + } + + return '' +} + +export const useFeedbackReport = (config: FeedbackConfig): FeedbackReport => { + const { api } = useRestAPI() + + const [draft, setDraft] = useState(() => emptyDraft(config)) + const [errorMessage, setErrorMessage] = useState('') + const [isSending, setIsSending] = useState(false) + const [result, setResult] = useState() + + const idempotencyKey = useRef(window.crypto.randomUUID()) + + const invalidFields = useMemo(() => ({ + title: MIN_TITLE_LENGTH > draft.title.trim().length, + description: MIN_TEXT_LENGTH > draft.description.trim().length, + steps: 'bug' === draft.type && MIN_TEXT_LENGTH > draft.steps.trim().length + }), [draft.type, draft.title, draft.description, draft.steps]) + + const updateDraft = useCallback((changes: Partial) => { + setDraft(current => ({ ...current, ...changes })) + }, []) + + const duplicates = useDuplicateReports(config.restUrl, draft.title) + + const submit = useCallback(() => { + const type = draft.type + + if (!type) { + return + } + + const message = firstValidationMessage(invalidFields) + + if (message) { + setErrorMessage(message) + return + } + + setErrorMessage('') + setIsSending(true) + + const request: FeedbackReportRequest = { + type, + idempotency_key: idempotencyKey.current, + title: draft.title.trim(), + description: draft.description.trim(), + steps: 'bug' === type ? draft.steps.trim() : '', + comments: draft.comments.trim(), + isolation: draft.isolation, + name: draft.name.trim(), + email: draft.email.trim(), + page_url: window.location.href, + js_errors: (window.codeSnippetsErrors ?? []).slice(0, MAX_JS_ERRORS), + browser: describeBrowser() + } + + api.post(config.restUrl, request) + .then(setResult) + .catch((error: unknown) => setErrorMessage(describeError(error))) + .finally(() => setIsSending(false)) + }, [api, config.restUrl, draft, invalidFields]) + + return { draft, duplicates, errorMessage, invalidFields, isSending, result, updateDraft, submit } +} diff --git a/src/js/types/Feedback.ts b/src/js/types/Feedback.ts new file mode 100644 index 000000000..c1be3cd21 --- /dev/null +++ b/src/js/types/Feedback.ts @@ -0,0 +1,66 @@ +export type FeedbackType = 'bug' | 'feature' | 'feedback' + +export interface FeedbackConfig { + restUrl: string + nonce: string + user: { + name: string + email: string + } + summary: Record + badge: string + version: string + edition: 'free' | 'pro' +} + +export interface FeedbackIsolation { + plugin_only: boolean + blank_theme: boolean + reproducible: boolean +} + +export interface FeedbackDraft { + type: FeedbackType | '' + title: string + description: string + steps: string + comments: string + name: string + email: string + isolation: FeedbackIsolation +} + +export interface FeedbackReportRequest { + type: FeedbackType + idempotency_key: string + title: string + description: string + steps: string + comments: string + isolation: FeedbackIsolation + name: string + email: string + page_url: string + js_errors: string[] + browser: { + userAgent: string + viewport: string + screen: string + language: string + } +} + +export interface FeedbackReportResponse { + sent: boolean + reference: string + url: string +} + +export interface DuplicateReport { + title: string + url: string +} + +export interface DuplicateSearchResponse { + results: DuplicateReport[] +} diff --git a/src/js/types/Window.ts b/src/js/types/Window.ts index 5a96704ba..8bbc8105f 100644 --- a/src/js/types/Window.ts +++ b/src/js/types/Window.ts @@ -2,6 +2,7 @@ import type { SnippetSchema } from './schema/SnippetSchema' import type { ChangelogSchema, ImageLinkSchema } from './schema/WelcomeSchema' import type Prism from 'prismjs' import type tinymce from 'tinymce' +import type { FeedbackConfig } from './Feedback' import type { InsightsChartViews, InsightsSummary } from './Insights' import type { Snippet } from './Snippet' import type { SnippetView } from './SnippetView' @@ -123,5 +124,6 @@ declare global { partners: ImageLinkSchema[] } readonly CODE_SNIPPETS_INSIGHTS?: InsightsSummary + readonly CODE_SNIPPETS_FEEDBACK?: FeedbackConfig } } diff --git a/src/js/utils/restAPI.ts b/src/js/utils/restAPI.ts index d361dce22..881e31a83 100644 --- a/src/js/utils/restAPI.ts +++ b/src/js/utils/restAPI.ts @@ -55,8 +55,11 @@ export const applyMethodOverride = (config: InternalAxiosRequestConfig): Interna * session, and the snippet editor is a screen people leave open for a long * time. Once it lapsed, every save failed with a 403 and the only cure was * reloading the page, which loses whatever was being written. + * + * The feedback reporter mounts on screens that do not enqueue the main + * `CODE_SNIPPETS` object, so it carries a nonce of its own to fall back on. */ -let restNonce = window.CODE_SNIPPETS?.restAPI.nonce +let restNonce = window.CODE_SNIPPETS?.restAPI.nonce ?? window.CODE_SNIPPETS_FEEDBACK?.nonce let runOnceNonce = window.CODE_SNIPPETS_MANAGE?.runOnceNonce /** The Run Once nonce as last refreshed by the Heartbeat, or the one rendered with the page. */ From 68000b41c6f4219997c3c038f1e021876be66b4e Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Sat, 5 Sep 2026 20:24:06 +0100 Subject: [PATCH 11/18] feat: add end-to-end coverage for the feedback reporter --- tests/e2e/feedback-reporter.spec.ts | 90 +++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 tests/e2e/feedback-reporter.spec.ts diff --git a/tests/e2e/feedback-reporter.spec.ts b/tests/e2e/feedback-reporter.spec.ts new file mode 100644 index 000000000..4b1b8398c --- /dev/null +++ b/tests/e2e/feedback-reporter.spec.ts @@ -0,0 +1,90 @@ +import { expect, test } from '@playwright/test' +import { wpCli } from './helpers/wpCli' +import type { Page } from '@playwright/test' + +const SNIPPETS_URL = '/wp-admin/admin.php?page=snippets' +const SETTINGS_URL = '/wp-admin/admin.php?page=snippets-settings§ion=advanced' +const LAUNCHER = '.code-snippets-feedback-launcher' +const PANEL = '.code-snippets-feedback-modal' + +/** + * Switch the reporter on or off through the plugin's own settings API, so the stored value + * and its cache stay in step however the option is shaped on this install. + */ +const setReporterEnabled = async (enabled: boolean): Promise => { + await wpCli([ + 'eval', + `Code_Snippets\\Settings\\update_setting('general', 'enable_feedback_reporter', ${enabled ? 'true' : 'false'});` + ]) +} + +const openPanel = async (page: Page): Promise => { + await page.goto(SNIPPETS_URL) + await page.locator(LAUNCHER).click() + await expect(page.locator(PANEL)).toBeVisible() +} + +test.describe('Feedback reporter', () => { + test.afterAll(async () => { + await setReporterEnabled(false) + }) + + test('stays hidden until the setting is switched on', async ({ page }) => { + await setReporterEnabled(false) + await page.goto(SNIPPETS_URL) + + await expect(page.locator(LAUNCHER)).toHaveCount(0) + }) + + test('is offered on the Advanced settings tab', async ({ page }) => { + await page.goto(SETTINGS_URL) + + await expect(page.locator('input[name*="enable_feedback_reporter"]')).toHaveCount(1) + }) + + test('opens and closes the panel once enabled', async ({ page }) => { + await setReporterEnabled(true) + await openPanel(page) + + await expect(page.getByRole('dialog')).toContainText('Send feedback') + + await page.keyboard.press('Escape') + await expect(page.locator(PANEL)).toHaveCount(0) + }) + + test('asks for a longer title before sending anything', async ({ page }) => { + await setReporterEnabled(true) + await openPanel(page) + + let requested = false + await page.route('**/code-snippets/v1/feedback', route => { + requested = true + return route.abort() + }) + + await page.getByLabel('What kind of feedback is this?').selectOption('feedback') + await page.getByRole('button', { name: 'Send report' }).click() + + await expect(page.locator('.code-snippets-feedback-message')).toBeVisible() + expect(requested).toBe(false) + }) + + test('confirms a report the service accepted', async ({ page }) => { + await setReporterEnabled(true) + await openPanel(page) + + await page.route('**/code-snippets/v1/feedback', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ sent: true, reference: 'CS-2026', url: '' }) + })) + + await page.getByLabel('What kind of feedback is this?').selectOption('feedback') + await page.getByLabel('Title').fill('The Conditions tab icons are hard to tell apart') + await page.getByLabel('What is on your mind?').fill('The new Conditions tab reads much more clearly than the old one.') + await page.getByRole('button', { name: 'Send report' }).click() + + await expect(page.locator('.code-snippets-feedback-success')).toContainText('Report sent') + await expect(page.locator('.code-snippets-feedback-success')).toContainText('CS-2026') + }) +}) From 892b830f7b40d376aa4c41f03aad173d61425e77 Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Sat, 5 Sep 2026 20:25:35 +0100 Subject: [PATCH 12/18] fix: collect the feedback environment summary once rather than on every page load --- src/php/Admin/Feedback_Panel.php | 40 +++++++++++++++++++++--- tests/unit/Admin/Feedback_Panel_Test.php | 31 ++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/php/Admin/Feedback_Panel.php b/src/php/Admin/Feedback_Panel.php index 54c451f1b..39ada4618 100644 --- a/src/php/Admin/Feedback_Panel.php +++ b/src/php/Admin/Feedback_Panel.php @@ -29,6 +29,16 @@ class Feedback_Panel { */ public const CONTAINER_ID = 'code-snippets-feedback-container'; + /** + * Transient holding the environment summary shown in the panel. + */ + public const SUMMARY_TRANSIENT = 'code_snippets_feedback_summary'; + + /** + * How long, in seconds, the environment summary is reused for. + */ + private const SUMMARY_TIMEOUT = 15 * MINUTE_IN_SECONDS; + /** * Script handle. */ @@ -93,7 +103,6 @@ public function enqueue_assets(): void { wp_set_script_translations( self::SCRIPT_HANDLE, 'code-snippets' ); $user = wp_get_current_user(); - $info = System_Info::get_system_info(); wp_localize_script( self::SCRIPT_HANDLE, @@ -105,14 +114,37 @@ public function enqueue_assets(): void { 'name' => $user->display_name, 'email' => $user->user_email, ], - 'summary' => System_Info::get_summary( $info ), + 'summary' => $this->get_cached_summary(), 'badge' => self::get_badge_label(), - 'version' => $info['plugin_version'], - 'edition' => $info['edition'], + 'version' => PLUGIN_VERSION, + 'edition' => System_Info::get_edition(), ] ); } + /** + * Retrieve the environment summary shown in the panel. + * + * Collecting it means reading the header of every installed plugin, which is too much + * to repeat on every admin page load for a panel that is rarely opened. The report + * itself is assembled from freshly collected details when one is sent. + * + * @return array + */ + private function get_cached_summary(): array { + $summary = get_transient( self::SUMMARY_TRANSIENT ); + + if ( is_array( $summary ) ) { + return $summary; + } + + $summary = System_Info::get_summary( System_Info::get_system_info() ); + + set_transient( self::SUMMARY_TRANSIENT, $summary, self::SUMMARY_TIMEOUT ); + + return $summary; + } + /** * Print the element the panel mounts into. * diff --git a/tests/unit/Admin/Feedback_Panel_Test.php b/tests/unit/Admin/Feedback_Panel_Test.php index 41695b57a..7caa6cc2b 100644 --- a/tests/unit/Admin/Feedback_Panel_Test.php +++ b/tests/unit/Admin/Feedback_Panel_Test.php @@ -44,6 +44,7 @@ public function set_up() { public function tear_down() { update_setting( 'general', Feedback_Panel::SETTING_FIELD, false ); remove_all_filters( 'code_snippets_feedback_badge_label' ); + delete_transient( Feedback_Panel::SUMMARY_TRANSIENT ); unset( $_GET['page'] ); wp_set_current_user( 0 ); @@ -161,6 +162,36 @@ public function test_container_is_not_printed_when_it_should_not_render(): void $this->assertSame( '', ob_get_clean() ); } + /** + * Reading every plugin header is too much work to repeat on each page load, so the + * summary the panel shows is collected once and reused. + * + * @return void + */ + public function test_the_environment_summary_is_only_collected_once(): void { + update_setting( 'general', Feedback_Panel::SETTING_FIELD, true ); + $this->log_in_as_administrator(); + $this->visit_snippets_screen(); + + $collected = 0; + + add_filter( + 'code_snippets_feedback_system_info', + static function ( array $info ) use ( &$collected ): array { + ++$collected; + return $info; + } + ); + + $this->panel->enqueue_assets(); + $this->panel->enqueue_assets(); + + remove_all_filters( 'code_snippets_feedback_system_info' ); + + $this->assertSame( 1, $collected ); + $this->assertIsArray( get_transient( Feedback_Panel::SUMMARY_TRANSIENT ) ); + } + /** * A released build is not labelled as a test build. * From 1e3e3727442a083463a3434bc10c9f07627f4c80 Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Sat, 5 Sep 2026 21:07:22 +0100 Subject: [PATCH 13/18] fix: pin the feedback drawer header and buttons and slide it in from the edge --- src/css/feedback.scss | 69 +++++++++++++++++++++++++---- tests/e2e/feedback-reporter.spec.ts | 24 ++++++++++ 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/src/css/feedback.scss b/src/css/feedback.scss index 4a69a4b63..b05117160 100644 --- a/src/css/feedback.scss +++ b/src/css/feedback.scss @@ -71,7 +71,31 @@ $feedback-success: #00a32a; background: $feedback-danger; } -.code-snippets-feedback-modal { +// The drawer slides in from the inline end, so it reads as arriving from the edge of the +// screen rather than appearing over the page. Modal animates its frame by name, so the +// scale-and-fade it ships with has to be replaced rather than overridden a property at a +// time. WordPress supplies the duration, and leaves it unset under reduced motion. +@keyframes code-snippets-feedback-slide-in { + from { + transform: translateX(calc(100% * var(--cs-direction-multiplier))); + } + + to { + transform: translateX(0); + } +} + +@keyframes code-snippets-feedback-slide-out { + from { + transform: translateX(0); + } + + to { + transform: translateX(calc(100% * var(--cs-direction-multiplier))); + } +} + +.components-modal__frame.code-snippets-feedback-modal { position: fixed; inset-block: 0; inset-inline-end: 0; @@ -83,37 +107,58 @@ $feedback-success: #00a32a; border-radius: 0; border-inline-start: 1px solid var(--cs-color-border-subtle); box-shadow: -8px 0 32px rgb(0 0 0 / 12%); - transform: none; + animation-name: code-snippets-feedback-slide-in; .components-modal__header { - padding-block: 16px; padding-inline: 24px; border-block-end: 1px solid var(--cs-color-border-subtle); } + // The header is positioned over the content, which keeps the offset WordPress gives it + // to clear it. Only the body scrolls, so the buttons stay reachable however long the + // form gets. .components-modal__content { display: flex; - flex-direction: column; + flex-flow: column; + min-block-size: 0; padding: 0; - margin-block-start: 0; + overflow: hidden; } + + // Newer Modal versions wrap children in an unstyled focus container. + .components-modal__header + div { + display: flex; + flex-flow: column; + flex: 1; + min-block-size: 0; + overflow: hidden; + } +} + +.components-modal__screen-overlay.is-animating-out .components-modal__frame.code-snippets-feedback-modal { + animation-name: code-snippets-feedback-slide-out; } .code-snippets-feedback-panel__subtitle { - margin-block: 4px 0; + flex-shrink: 0; + padding-block: 12px 0; + padding-inline: 24px; + margin: 0; color: var(--cs-color-text-muted); font-size: 12px; } .code-snippets-feedback-panel__body { flex: 1; + min-block-size: 0; overflow-y: auto; - padding-block: 20px; + padding-block: 16px 20px; padding-inline: 24px; } .code-snippets-feedback-panel__footer { display: flex; + flex-shrink: 0; justify-content: flex-end; gap: 10px; padding-block: 16px; @@ -142,6 +187,14 @@ $feedback-success: #00a32a; font-size: 13px; font-weight: 600; } + + .components-base-control { + margin-block-end: 10px; + + &:last-child { + margin-block-end: 0; + } + } } .code-snippets-feedback-duplicates { @@ -256,7 +309,7 @@ $feedback-success: #00a32a; } @media (width <= 480px) { - .code-snippets-feedback-modal { + .components-modal__frame.code-snippets-feedback-modal { inline-size: 100vw; } diff --git a/tests/e2e/feedback-reporter.spec.ts b/tests/e2e/feedback-reporter.spec.ts index 4b1b8398c..a757d6e3c 100644 --- a/tests/e2e/feedback-reporter.spec.ts +++ b/tests/e2e/feedback-reporter.spec.ts @@ -52,6 +52,30 @@ test.describe('Feedback reporter', () => { await expect(page.locator(PANEL)).toHaveCount(0) }) + test('keeps the whole drawer usable when the form is long', async ({ page }) => { + await setReporterEnabled(true) + await openPanel(page) + + // A bug report is the longest form, and the one most likely to overflow. + await page.getByLabel('What kind of feedback is this?').selectOption('bug') + + const header = page.locator('.components-modal__header') + const subtitle = page.locator('.code-snippets-feedback-panel__subtitle') + const footer = page.locator('.code-snippets-feedback-panel__footer') + + const headerBox = await header.boundingBox() ?? { y: 0, height: 0 } + const subtitleBox = await subtitle.boundingBox() ?? { y: 0 } + const footerBox = await footer.boundingBox() ?? { y: 0, height: 0 } + const viewport = page.viewportSize() ?? { height: 0 } + + // The header floats above the content, so the content has to start below it. + expect(subtitleBox.y).toBeGreaterThanOrEqual(headerBox.y + headerBox.height) + + // The buttons stay on screen however long the form gets. + expect(footerBox.y + footerBox.height).toBeLessThanOrEqual(viewport.height) + await expect(page.getByRole('button', { name: 'Send report' })).toBeInViewport() + }) + test('asks for a longer title before sending anything', async ({ page }) => { await setReporterEnabled(true) await openPanel(page) From cdd853b72f2e23b04fbff03f8d5e56f14e6a1df8 Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Sat, 5 Sep 2026 21:07:30 +0100 Subject: [PATCH 14/18] chore: allow the playwright base url to be overridden --- config/playwright/playwright.config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/config/playwright/playwright.config.ts b/config/playwright/playwright.config.ts index 995db3edd..68a00b275 100644 --- a/config/playwright/playwright.config.ts +++ b/config/playwright/playwright.config.ts @@ -40,7 +40,9 @@ export default defineConfig({ ['junit', { outputFile: join(process.cwd(), 'test-results', 'results.xml') }] ], use: { - baseURL: 'http://localhost:8888', + // Overridable so the suite can be pointed at a second environment, which wp-env + // gives a different port when one is already running. + baseURL: process.env.WP_E2E_BASE_URL ?? 'http://localhost:8888', trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure' From 5f3ad621d087ca16f23256cc293a416991ba467c Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Sat, 5 Sep 2026 22:17:52 +0100 Subject: [PATCH 15/18] fix: report why the feedback service could not be reached --- .../REST_API/Feedback/Feedback_REST_Controller.php | 8 +++++++- .../REST_API/Feedback_REST_Controller_Test.php | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/php/REST_API/Feedback/Feedback_REST_Controller.php b/src/php/REST_API/Feedback/Feedback_REST_Controller.php index b5f7b10d9..b770dad9a 100644 --- a/src/php/REST_API/Feedback/Feedback_REST_Controller.php +++ b/src/php/REST_API/Feedback/Feedback_REST_Controller.php @@ -167,9 +167,15 @@ public function send_report( WP_REST_Request $request ) { ); if ( is_wp_error( $response ) ) { + // The transport's own message names the cause — a blocked outbound request, a + // certificate problem, a timeout — which is the only way anyone can act on this. return new WP_Error( 'code_snippets_feedback_transport', - __( 'Could not reach the reporting service. Check the connection and try again.', 'code-snippets' ), + sprintf( + /* translators: %s: error message describing why the request failed. */ + __( 'Could not reach the reporting service: %s', 'code-snippets' ), + $response->get_error_message() + ), [ 'status' => 502 ] ); } diff --git a/tests/unit/REST_API/Feedback_REST_Controller_Test.php b/tests/unit/REST_API/Feedback_REST_Controller_Test.php index 3d254c6be..593254ecc 100644 --- a/tests/unit/REST_API/Feedback_REST_Controller_Test.php +++ b/tests/unit/REST_API/Feedback_REST_Controller_Test.php @@ -387,6 +387,20 @@ public function test_a_transport_failure_becomes_a_bad_gateway(): void { $this->assertSame( 'code_snippets_feedback_transport', $response->get_data()['code'] ); } + /** + * A blocked or failed request names its cause, since that is the only part anyone can + * act on. + * + * @return void + */ + public function test_a_transport_failure_names_its_cause(): void { + $this->responses = [ new WP_Error( 'http_request_failed', 'cURL error 7: Failed to connect to codesnippets.cloud port 443' ) ]; + + $response = $this->post_report( $this->valid_report() ); + + $this->assertStringContainsString( 'cURL error 7', $response->get_data()['message'] ); + } + /** * A title barely started is not worth searching for. * From 5f2e90195ab19b6fdf68ec34d3129e6e57515118 Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Sat, 5 Sep 2026 22:17:56 +0100 Subject: [PATCH 16/18] fix: build the feedback search url so it works with plain permalinks --- src/js/hooks/useDuplicateReports.ts | 7 ++++--- src/js/hooks/useFeedbackReport.ts | 2 +- src/js/types/Feedback.ts | 1 + src/js/utils/restAPI.ts | 13 +++++++++++++ src/php/Admin/Feedback_Panel.php | 17 ++++++++++------- tests/e2e/feedback-reporter.spec.ts | 15 +++++++++++++++ 6 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/js/hooks/useDuplicateReports.ts b/src/js/hooks/useDuplicateReports.ts index e08d82732..c265da648 100644 --- a/src/js/hooks/useDuplicateReports.ts +++ b/src/js/hooks/useDuplicateReports.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { addQueryArg } from '../utils/restAPI' import { useRestAPI } from './useRestAPI' import type { DuplicateReport, DuplicateSearchResponse } from '../types/Feedback' @@ -13,7 +14,7 @@ const SEARCH_DEBOUNCE_MS = 600 * not reported twice. A cloud that cannot answer leaves the list empty rather than * interrupting the report being written. */ -export const useDuplicateReports = (restUrl: string, title: string): DuplicateReport[] => { +export const useDuplicateReports = (searchUrl: string, title: string): DuplicateReport[] => { const { api } = useRestAPI() const [duplicates, setDuplicates] = useState([]) @@ -26,13 +27,13 @@ export const useDuplicateReports = (restUrl: string, title: string): DuplicateRe } const timer = setTimeout(() => { - api.get(`${restUrl}/search?q=${encodeURIComponent(query)}`) + api.get(addQueryArg(searchUrl, 'q', query)) .then(data => setDuplicates(data.results)) .catch(() => setDuplicates([])) }, SEARCH_DEBOUNCE_MS) return () => clearTimeout(timer) - }, [api, restUrl, title]) + }, [api, searchUrl, title]) return duplicates } diff --git a/src/js/hooks/useFeedbackReport.ts b/src/js/hooks/useFeedbackReport.ts index 3aafef654..22bb11a8a 100644 --- a/src/js/hooks/useFeedbackReport.ts +++ b/src/js/hooks/useFeedbackReport.ts @@ -111,7 +111,7 @@ export const useFeedbackReport = (config: FeedbackConfig): FeedbackReport => { setDraft(current => ({ ...current, ...changes })) }, []) - const duplicates = useDuplicateReports(config.restUrl, draft.title) + const duplicates = useDuplicateReports(config.searchUrl, draft.title) const submit = useCallback(() => { const type = draft.type diff --git a/src/js/types/Feedback.ts b/src/js/types/Feedback.ts index c1be3cd21..b1847e1b4 100644 --- a/src/js/types/Feedback.ts +++ b/src/js/types/Feedback.ts @@ -2,6 +2,7 @@ export type FeedbackType = 'bug' | 'feature' | 'feedback' export interface FeedbackConfig { restUrl: string + searchUrl: string nonce: string user: { name: string diff --git a/src/js/utils/restAPI.ts b/src/js/utils/restAPI.ts index 881e31a83..699137cd4 100644 --- a/src/js/utils/restAPI.ts +++ b/src/js/utils/restAPI.ts @@ -109,3 +109,16 @@ export const REST_API_AXIOS_CONFIG: AxiosRequestConfig = { 'Access-Control': window.CODE_SNIPPETS?.restAPI.cloud.token } } + +/** + * Add a query parameter to a REST URL. + * + * Concatenation is not enough: with plain permalinks a REST URL already carries the route + * in a query string, so a second `?` would bury the parameter inside the route instead of + * adding one. + */ +export const addQueryArg = (url: string, name: string, value: string): string => { + const parsed = new URL(url, window.location.origin) + parsed.searchParams.set(name, value) + return parsed.toString() +} diff --git a/src/php/Admin/Feedback_Panel.php b/src/php/Admin/Feedback_Panel.php index 39ada4618..9045ee8ba 100644 --- a/src/php/Admin/Feedback_Panel.php +++ b/src/php/Admin/Feedback_Panel.php @@ -108,16 +108,19 @@ public function enqueue_assets(): void { self::SCRIPT_HANDLE, 'CODE_SNIPPETS_FEEDBACK', [ - 'restUrl' => esc_url_raw( rest_url( Feedback_REST_Controller::get_base_route() ) ), - 'nonce' => wp_create_nonce( 'wp_rest' ), - 'user' => [ + 'restUrl' => esc_url_raw( rest_url( Feedback_REST_Controller::get_base_route() ) ), + // Built here rather than appended in the browser: with plain permalinks the + // route travels in a query parameter, where a path cannot simply be added. + 'searchUrl' => esc_url_raw( rest_url( Feedback_REST_Controller::get_base_route() . '/search' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'user' => [ 'name' => $user->display_name, 'email' => $user->user_email, ], - 'summary' => $this->get_cached_summary(), - 'badge' => self::get_badge_label(), - 'version' => PLUGIN_VERSION, - 'edition' => System_Info::get_edition(), + 'summary' => $this->get_cached_summary(), + 'badge' => self::get_badge_label(), + 'version' => PLUGIN_VERSION, + 'edition' => System_Info::get_edition(), ] ); } diff --git a/tests/e2e/feedback-reporter.spec.ts b/tests/e2e/feedback-reporter.spec.ts index a757d6e3c..59947f4cc 100644 --- a/tests/e2e/feedback-reporter.spec.ts +++ b/tests/e2e/feedback-reporter.spec.ts @@ -76,6 +76,21 @@ test.describe('Feedback reporter', () => { await expect(page.getByRole('button', { name: 'Send report' })).toBeInViewport() }) + test('reaches the duplicate search route while a title is typed', async ({ page }) => { + await setReporterEnabled(true) + await openPanel(page) + + const search = page.waitForResponse(response => + response.url().includes('feedback/search') || response.url().includes('feedback%2Fsearch')) + + await page.getByLabel('What kind of feedback is this?').selectOption('feedback') + await page.getByLabel('Title').fill('Highlighting stops after switching tabs') + + // A route the request never reaches would answer 404, leaving the panel unable to + // offer reports that already exist. + expect((await search).status()).toBe(200) + }) + test('asks for a longer title before sending anything', async ({ page }) => { await setReporterEnabled(true) await openPanel(page) From afc6a16cdfb17d83ae9d759cb2044e72a7239dae Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Sat, 5 Sep 2026 22:38:25 +0100 Subject: [PATCH 17/18] fix: send feedback reports to the reporting service regardless of the cloud url --- src/php/Model/Feedback_Connection.php | 34 +++++++++++++-- tests/unit/Model/Feedback_Connection_Test.php | 42 +++++++++++++++++-- 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/php/Model/Feedback_Connection.php b/src/php/Model/Feedback_Connection.php index 509b65367..4fefbae9e 100644 --- a/src/php/Model/Feedback_Connection.php +++ b/src/php/Model/Feedback_Connection.php @@ -7,6 +7,11 @@ /** * Connection to the Code Snippets Cloud reporting API. * + * The reporting endpoint is resolved on its own rather than from the cloud API URL the rest + * of the plugin uses. Those are separate services: a site pointed at a local cloud build for + * development would otherwise send its reports there too, where nobody would read them, and + * the reports would fail outright whenever that build was not running. + * * Trust model: the programme key below is public. It ships in the plugin source and identifies * the reporting programme, not the site, in the same way as the public token on the parent * class. Per-site authenticity comes from the registration handshake instead: a site enrols @@ -27,9 +32,14 @@ class Feedback_Connection extends Basic_Cloud_Connection { private const PROGRAMME_KEY = 'csb_nhv937hQa0mbBNyB0n9FTQvXZR6i3d9UA2OAZU2E04lu9loS'; /** - * Path of the reporting endpoint, relative to the cloud API URL. + * Host serving the reporting API. + */ + private const REPORTS_HOST = 'https://codesnippets.cloud'; + + /** + * Path of the reporting endpoint, relative to the reporting host. */ - private const REPORTS_PATH = 'beta-reports'; + private const REPORTS_PATH = 'api/v1/beta-reports'; /** * Name of the option holding this site's credential. @@ -62,6 +72,24 @@ public function get_key(): string { return apply_filters( 'code_snippets_feedback_key', trim( $key ) ); } + /** + * Retrieve the host serving the reporting API. + * + * `CS_BETA_FEEDBACK_HOST` names a reporting service on its own, so it wins over the + * cloud URL a site may be pointing elsewhere for unrelated development. + * + * @return string + */ + public function get_host(): string { + $host = self::REPORTS_HOST; + + if ( defined( 'CS_BETA_FEEDBACK_HOST' ) && CS_BETA_FEEDBACK_HOST ) { + $host = CS_BETA_FEEDBACK_HOST; + } + + return untrailingslashit( apply_filters( 'code_snippets_feedback_host', $host ) ); + } + /** * Retrieve the URL of a reporting endpoint. * @@ -70,7 +98,7 @@ public function get_key(): string { * @return string */ public function get_endpoint_url( string $path = '' ): string { - $url = sprintf( '%s/%s', $this->get_api_url(), self::REPORTS_PATH ); + $url = sprintf( '%s/%s', $this->get_host(), self::REPORTS_PATH ); if ( $path ) { $url .= '/' . ltrim( $path, '/' ); diff --git a/tests/unit/Model/Feedback_Connection_Test.php b/tests/unit/Model/Feedback_Connection_Test.php index 30b8e7246..9ad70b571 100644 --- a/tests/unit/Model/Feedback_Connection_Test.php +++ b/tests/unit/Model/Feedback_Connection_Test.php @@ -55,26 +55,60 @@ public function set_up() { public function tear_down() { delete_option( Feedback_Connection::CREDENTIALS_OPTION ); remove_all_filters( 'code_snippets_feedback_endpoint_url' ); + remove_all_filters( 'code_snippets_feedback_host' ); remove_all_filters( 'code_snippets_feedback_key' ); parent::tear_down(); } /** - * The reporting endpoint hangs off the cloud API URL the rest of the plugin uses. + * Reports go to the reporting service, at the path it serves them on. * * @return void */ - public function test_endpoint_url_is_built_from_the_cloud_api_url(): void { + public function test_endpoint_url_addresses_the_reporting_service(): void { $this->assertSame( - $this->connection->get_api_url() . '/beta-reports', + 'https://codesnippets.cloud/api/v1/beta-reports', $this->connection->get_endpoint_url() ); $this->assertSame( - $this->connection->get_api_url() . '/beta-reports/register', + 'https://codesnippets.cloud/api/v1/beta-reports/register', $this->connection->get_endpoint_url( 'register' ) ); + + $this->assertSame( + 'https://codesnippets.cloud/api/v1/beta-reports/search', + $this->connection->get_endpoint_url( 'search' ) + ); + } + + /** + * Reporting and the snippet library are separate services. A site pointed at a cloud + * build of its own for development still reports to the service that reads reports. + * + * @return void + */ + public function test_endpoint_url_ignores_the_cloud_api_url(): void { + $this->assertStringStartsWith( 'https://codesnippets.cloud/', $this->connection->get_endpoint_url() ); + + add_filter( 'code_snippets_feedback_host', static fn() => 'http://localhost:8080' ); + + $this->assertSame( + 'http://localhost:8080/api/v1/beta-reports', + $this->connection->get_endpoint_url() + ); + } + + /** + * A trailing slash on the host does not double up in the endpoint. + * + * @return void + */ + public function test_endpoint_url_tolerates_a_trailing_slash_on_the_host(): void { + add_filter( 'code_snippets_feedback_host', static fn() => 'https://example.com/' ); + + $this->assertSame( 'https://example.com/api/v1/beta-reports', $this->connection->get_endpoint_url() ); } /** From 29d3d36f721888e0dd2741f4841a883e560fa820 Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Sat, 5 Sep 2026 23:36:39 +0100 Subject: [PATCH 18/18] fix: address review findings on the feedback reporter --- config/playwright/playwright.config.ts | 11 ++++-- scripts/install-wp-tests.sh | 7 +++- .../FeedbackReporter/FeedbackReporter.tsx | 11 +++--- src/js/entries/feedback-capture.ts | 6 ++- src/js/hooks/useDuplicateReports.ts | 13 +++++-- src/js/utils/restAPI.ts | 8 +++- src/php/Client/Feedback_Client.php | 28 ++++++++----- src/php/Core/Uninstaller.php | 8 ++++ src/php/Model/Feedback_Connection.php | 9 +---- .../Feedback/Feedback_REST_Controller.php | 33 ++++++++++++++-- src/php/Utils/System_Info.php | 23 +++++++++-- tests/e2e/feedback-reporter.spec.ts | 19 +++++++-- tests/unit/Admin/Feedback_Panel_Test.php | 22 +++++++++++ tests/unit/Client/Feedback_Client_Test.php | 32 +++++++++++++++ .../Feedback_REST_Controller_Test.php | 37 ++++++++++++++++++ tests/unit/Utils/System_Info_Test.php | 39 ++++++++++++++++++- 16 files changed, 259 insertions(+), 47 deletions(-) diff --git a/config/playwright/playwright.config.ts b/config/playwright/playwright.config.ts index 68a00b275..c0fac8c8e 100644 --- a/config/playwright/playwright.config.ts +++ b/config/playwright/playwright.config.ts @@ -12,6 +12,13 @@ const ASSERT_TIMEOUT_SECONDS = 30 const MILLISECONDS_IN_SECOND = 1000 +// Overridable so the suite can be pointed at a second environment, which wp-env gives a +// different port when one is already running. An empty value is treated as unset: Playwright +// resolves relative paths against baseURL, and an empty base cannot be resolved against. +const DEFAULT_BASE_URL = 'http://localhost:8888' +const configuredBaseUrl = process.env.WP_E2E_BASE_URL?.trim() ?? '' +const baseURL = '' === configuredBaseUrl ? DEFAULT_BASE_URL : configuredBaseUrl + const baseTestsDir = join(__dirname, '..', '..', 'tests') const storageState = join(baseTestsDir, 'e2e/.auth/user.json') const rtlSpecs = /rtl-layout\.spec\.ts/ @@ -40,9 +47,7 @@ export default defineConfig({ ['junit', { outputFile: join(process.cwd(), 'test-results', 'results.xml') }] ], use: { - // Overridable so the suite can be pointed at a second environment, which wp-env - // gives a different port when one is already running. - baseURL: process.env.WP_E2E_BASE_URL ?? 'http://localhost:8888', + baseURL, trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure' diff --git a/scripts/install-wp-tests.sh b/scripts/install-wp-tests.sh index 974ff25d4..17dcf0f59 100755 --- a/scripts/install-wp-tests.sh +++ b/scripts/install-wp-tests.sh @@ -97,9 +97,12 @@ install_test_suite() { download "https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php" "$WP_TESTS_DIR"/wp-tests-config.php # remove all forward slashes in the end WP_CORE_DIR=$(echo "$WP_CORE_DIR" | sed "s:/\+$::") + # sed reads '&' and '\' in the replacement as syntax, so a path containing either + # would be written to the config mangled. + WP_CORE_DIR_ESCAPED=$(printf '%s' "$WP_CORE_DIR" | sed -e 's:[\\&]:\\&:g') # Support both older (/src/) and current (/wordpress/) sample config templates. - sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php - sed $ioption "s:dirname( __FILE__ ) . '/wordpress/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR_ESCAPED/':" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s:dirname( __FILE__ ) . '/wordpress/':'$WP_CORE_DIR_ESCAPED/':" "$WP_TESTS_DIR"/wp-tests-config.php sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php diff --git a/src/js/components/FeedbackReporter/FeedbackReporter.tsx b/src/js/components/FeedbackReporter/FeedbackReporter.tsx index ec1808373..5aa4f2f14 100644 --- a/src/js/components/FeedbackReporter/FeedbackReporter.tsx +++ b/src/js/components/FeedbackReporter/FeedbackReporter.tsx @@ -12,7 +12,9 @@ export const FeedbackReporter: React.FC = () => { return null } - return <> + // The provider registers a heartbeat listener without cleanup, so it is mounted once + // for the page rather than on each open. + return - {isOpen && - - setIsOpen(false)} /> - } - + {isOpen && setIsOpen(false)} />} + } diff --git a/src/js/entries/feedback-capture.ts b/src/js/entries/feedback-capture.ts index 60d056cc4..59e7ef3c4 100644 --- a/src/js/entries/feedback-capture.ts +++ b/src/js/entries/feedback-capture.ts @@ -8,8 +8,12 @@ const record = (entry: string): void => { } } +// Capture phase also delivers failed resource loads, as bare Events carrying none of the +// detail below. Recording those would fill the buffer with entries naming nothing. window.addEventListener('error', event => { - record(`${event.message || 'Error'} — ${event.filename || 'unknown'}:${event.lineno}`) + if (event instanceof ErrorEvent) { + record(`${event.message || 'Error'} — ${event.filename || 'unknown'}:${event.lineno}`) + } }, true) window.addEventListener('unhandledrejection', event => { diff --git a/src/js/hooks/useDuplicateReports.ts b/src/js/hooks/useDuplicateReports.ts index c265da648..4a13fe4de 100644 --- a/src/js/hooks/useDuplicateReports.ts +++ b/src/js/hooks/useDuplicateReports.ts @@ -26,13 +26,18 @@ export const useDuplicateReports = (searchUrl: string, title: string): Duplicate return } + let active = true + const timer = setTimeout(() => { - api.get(addQueryArg(searchUrl, 'q', query)) - .then(data => setDuplicates(data.results)) - .catch(() => setDuplicates([])) + api.get(addQueryArg({ url: searchUrl, name: 'q', value: query })) + .then(data => active && setDuplicates(data.results)) + .catch(() => active && setDuplicates([])) }, SEARCH_DEBOUNCE_MS) - return () => clearTimeout(timer) + return () => { + active = false + clearTimeout(timer) + } }, [api, searchUrl, title]) return duplicates diff --git a/src/js/utils/restAPI.ts b/src/js/utils/restAPI.ts index 699137cd4..5bd5a0b87 100644 --- a/src/js/utils/restAPI.ts +++ b/src/js/utils/restAPI.ts @@ -110,6 +110,12 @@ export const REST_API_AXIOS_CONFIG: AxiosRequestConfig = { } } +export interface QueryArg { + url: string + name: string + value: string +} + /** * Add a query parameter to a REST URL. * @@ -117,7 +123,7 @@ export const REST_API_AXIOS_CONFIG: AxiosRequestConfig = { * in a query string, so a second `?` would bury the parameter inside the route instead of * adding one. */ -export const addQueryArg = (url: string, name: string, value: string): string => { +export const addQueryArg = ({ url, name, value }: QueryArg): string => { const parsed = new URL(url, window.location.origin) parsed.searchParams.set(name, value) return parsed.toString() diff --git a/src/php/Client/Feedback_Client.php b/src/php/Client/Feedback_Client.php index 807eb5806..5afe3cd7b 100644 --- a/src/php/Client/Feedback_Client.php +++ b/src/php/Client/Feedback_Client.php @@ -171,6 +171,8 @@ public function send_report( array $payload, string $idempotency_key ) { * @return array> Matching reports, empty when none or unavailable. */ public function search_reports( string $query ): array { + // `add_query_arg()` builds the query through `build_query()`, which does not encode + // values, so the term is encoded here. $url = add_query_arg( [ 'q' => rawurlencode( $query ) ], $this->connection->get_endpoint_url( 'search' ) ); $response = $this->send_signed( $url, 'GET', $this->connection->get_request_headers(), '' ); @@ -210,20 +212,26 @@ private function fail_registration(): array { */ private function send_signed( string $url, string $method, array $headers, string $body, bool $retrying = false ) { $credentials = $this->ensure_credentials(); - $uri = Feedback_Connection::get_request_uri( $url ); - - $headers = array_diff_key( - $headers, - array_flip( [ 'X-CS-Site-Id', 'X-CS-Timestamp', 'X-CS-Signature' ] ) - ); - if ( $this->connection->is_valid_credentials( $credentials ) ) { - $headers = array_merge( - $headers, - $this->connection->get_signature_headers( $credentials, $method, $uri, $body ) + // Without a credential the cloud cannot tell who is reporting, so the request would + // be refused anyway. Stopping here keeps the report off the wire. + if ( ! $this->connection->is_valid_credentials( $credentials ) ) { + return new WP_Error( + 'code_snippets_feedback_unregistered', + __( 'This site is not enrolled with the reporting service.', 'code-snippets' ) ); } + $uri = Feedback_Connection::get_request_uri( $url ); + + $headers = array_merge( + array_diff_key( + $headers, + array_flip( [ 'X-CS-Site-Id', 'X-CS-Timestamp', 'X-CS-Signature' ] ) + ), + $this->connection->get_signature_headers( $credentials, $method, $uri, $body ) + ); + $args = [ 'timeout' => 'GET' === $method ? self::SEARCH_REQUEST_TIMEOUT : self::REPORT_REQUEST_TIMEOUT, 'redirection' => 0, diff --git a/src/php/Core/Uninstaller.php b/src/php/Core/Uninstaller.php index aee6e30ae..7dcb25075 100644 --- a/src/php/Core/Uninstaller.php +++ b/src/php/Core/Uninstaller.php @@ -9,6 +9,10 @@ namespace Code_Snippets\Core; +use Code_Snippets\Admin\Feedback_Panel; +use Code_Snippets\Client\Feedback_Client; +use Code_Snippets\Model\Feedback_Connection; + /** * Uninstaller class. * @@ -82,6 +86,10 @@ private function uninstall_current_site() { delete_transient( 'code_snippets_cloud_links' ); delete_transient( 'cs_codevault_snippets' ); delete_transient( 'cs_local_to_cloud_map' ); + + delete_option( Feedback_Connection::CREDENTIALS_OPTION ); + delete_transient( Feedback_Panel::SUMMARY_TRANSIENT ); + delete_transient( Feedback_Client::REGISTRATION_FAILURE_TRANSIENT ); } /** diff --git a/src/php/Model/Feedback_Connection.php b/src/php/Model/Feedback_Connection.php index 4fefbae9e..c12810323 100644 --- a/src/php/Model/Feedback_Connection.php +++ b/src/php/Model/Feedback_Connection.php @@ -62,14 +62,7 @@ class Feedback_Connection extends Basic_Cloud_Connection { * @return string */ public function get_key(): string { - $key = self::PROGRAMME_KEY; - - if ( '' === trim( $key ) ) { - $env = getenv( 'BETA_PROGRAMME_KEY' ); - $key = false === $env ? '' : (string) $env; - } - - return apply_filters( 'code_snippets_feedback_key', trim( $key ) ); + return apply_filters( 'code_snippets_feedback_key', self::PROGRAMME_KEY ); } /** diff --git a/src/php/REST_API/Feedback/Feedback_REST_Controller.php b/src/php/REST_API/Feedback/Feedback_REST_Controller.php index b770dad9a..57d3424ce 100644 --- a/src/php/REST_API/Feedback/Feedback_REST_Controller.php +++ b/src/php/REST_API/Feedback/Feedback_REST_Controller.php @@ -53,6 +53,16 @@ class Feedback_REST_Controller extends REST_Controller { */ private const MAX_JS_ERRORS = 10; + /** + * Shortest title that summarises anything. + */ + private const MIN_TITLE_LENGTH = 8; + + /** + * Shortest free-text answer that describes anything. + */ + private const MIN_TEXT_LENGTH = 20; + /** * Client used to reach the cloud. * @@ -129,7 +139,7 @@ public function permission_callback( WP_REST_Request $request ): bool { public function search_reports( WP_REST_Request $request ): WP_REST_Response { $query = trim( sanitize_text_field( (string) $request->get_param( 'q' ) ) ); - $results = strlen( $query ) < self::MIN_SEARCH_LENGTH + $results = self::text_length( $query ) < self::MIN_SEARCH_LENGTH ? [] : $this->client->search_reports( $query ); @@ -196,6 +206,21 @@ public function send_report( WP_REST_Request $request ) { ); } + /** + * Count the characters in a value, as the panel counts them. + * + * The panel measures with JavaScript's string length, so counting bytes here would let + * a report through that the panel refused, and would measure non-Latin scripts against + * a limit several times longer than intended. + * + * @param string $value Value to measure. + * + * @return int + */ + private static function text_length( string $value ): int { + return (int) preg_match_all( '/./us', $value ); + } + /** * Check a report says enough to be acted on. * @@ -214,7 +239,7 @@ private function validate_report( WP_REST_Request $request ): ?WP_Error { ); } - if ( strlen( trim( sanitize_text_field( (string) $request->get_param( 'title' ) ) ) ) < 8 ) { + if ( self::text_length( trim( sanitize_text_field( (string) $request->get_param( 'title' ) ) ) ) < self::MIN_TITLE_LENGTH ) { return new WP_Error( 'code_snippets_feedback_title', __( 'Give the report a title of at least 8 characters.', 'code-snippets' ), @@ -222,7 +247,7 @@ private function validate_report( WP_REST_Request $request ): ?WP_Error { ); } - if ( strlen( trim( sanitize_textarea_field( (string) $request->get_param( 'description' ) ) ) ) < 20 ) { + if ( self::text_length( trim( sanitize_textarea_field( (string) $request->get_param( 'description' ) ) ) ) < self::MIN_TEXT_LENGTH ) { return new WP_Error( 'code_snippets_feedback_description', __( 'Describe the problem in a bit more detail.', 'code-snippets' ), @@ -230,7 +255,7 @@ private function validate_report( WP_REST_Request $request ): ?WP_Error { ); } - if ( 'bug' === $type && strlen( trim( sanitize_textarea_field( (string) $request->get_param( 'steps' ) ) ) ) < 20 ) { + if ( 'bug' === $type && self::text_length( trim( sanitize_textarea_field( (string) $request->get_param( 'steps' ) ) ) ) < self::MIN_TEXT_LENGTH ) { return new WP_Error( 'code_snippets_feedback_steps', __( 'List the steps that reproduce the bug.', 'code-snippets' ), diff --git a/src/php/Utils/System_Info.php b/src/php/Utils/System_Info.php index a0b7f9c43..574dff4e1 100644 --- a/src/php/Utils/System_Info.php +++ b/src/php/Utils/System_Info.php @@ -52,7 +52,10 @@ public static function get_system_info(): array { } /** - * Reduce the collected details to the short list shown in the panel. + * Describe the collected details in the form shown in the panel. + * + * Every value the report carries appears here. The panel is where the reporter is told + * what they are about to send, so anything left out would go without disclosure. * * @param array $info Collected system information. * @@ -71,14 +74,28 @@ public static function get_summary( array $info ): array { ? sprintf( '%s (%s)', $info['wordpress_version'], __( 'multisite', 'code-snippets' ) ) : $info['wordpress_version']; + $limits = sprintf( + /* translators: 1: WordPress memory limit, 2: PHP memory limit, 3: maximum execution time, in seconds. */ + __( 'WordPress %1$s, PHP %2$s, %3$ss execution', 'code-snippets' ), + $info['wp_memory_limit'], + $info['php_memory_limit'], + $info['max_execution_time'] + ); + return [ __( 'Code Snippets', 'code-snippets' ) => $version, __( 'WordPress', 'code-snippets' ) => $wordpress, __( 'PHP', 'code-snippets' ) => $info['php_version'], __( 'Database', 'code-snippets' ) => $info['database'], __( 'Theme', 'code-snippets' ) => $info['active_theme'], - // translators: %d: number of active plugins. - __( 'Plugins', 'code-snippets' ) => sprintf( _n( '%d active', '%d active', $info['plugin_count'], 'code-snippets' ), $info['plugin_count'] ), + __( 'Server', 'code-snippets' ) => $info['server_software'], + __( 'Language', 'code-snippets' ) => $info['locale'], + __( 'Debug mode', 'code-snippets' ) => $info['wp_debug'] ? __( 'on', 'code-snippets' ) : __( 'off', 'code-snippets' ), + __( 'Limits', 'code-snippets' ) => $limits, + __( 'Site address', 'code-snippets' ) => $info['site_url'], + __( 'Plugins', 'code-snippets' ) => $info['active_plugins'] + ? implode( ', ', $info['active_plugins'] ) + : __( 'none active', 'code-snippets' ), ]; } diff --git a/tests/e2e/feedback-reporter.spec.ts b/tests/e2e/feedback-reporter.spec.ts index 59947f4cc..35a09472a 100644 --- a/tests/e2e/feedback-reporter.spec.ts +++ b/tests/e2e/feedback-reporter.spec.ts @@ -63,10 +63,21 @@ test.describe('Feedback reporter', () => { const subtitle = page.locator('.code-snippets-feedback-panel__subtitle') const footer = page.locator('.code-snippets-feedback-panel__footer') - const headerBox = await header.boundingBox() ?? { y: 0, height: 0 } - const subtitleBox = await subtitle.boundingBox() ?? { y: 0 } - const footerBox = await footer.boundingBox() ?? { y: 0, height: 0 } - const viewport = page.viewportSize() ?? { height: 0 } + const headerBox = await header.boundingBox() + const subtitleBox = await subtitle.boundingBox() + const footerBox = await footer.boundingBox() + const viewport = page.viewportSize() + + // A missing box would otherwise satisfy the comparisons below without measuring + // anything, so each one has to be present before it is read. + expect(headerBox).not.toBeNull() + expect(subtitleBox).not.toBeNull() + expect(footerBox).not.toBeNull() + expect(viewport).not.toBeNull() + + if (!headerBox || !subtitleBox || !footerBox || !viewport) { + return + } // The header floats above the content, so the content has to start below it. expect(subtitleBox.y).toBeGreaterThanOrEqual(headerBox.y + headerBox.height) diff --git a/tests/unit/Admin/Feedback_Panel_Test.php b/tests/unit/Admin/Feedback_Panel_Test.php index 7caa6cc2b..861a28b06 100644 --- a/tests/unit/Admin/Feedback_Panel_Test.php +++ b/tests/unit/Admin/Feedback_Panel_Test.php @@ -7,6 +7,7 @@ namespace Code_Snippets\Admin; +use Code_Snippets\REST_API\Feedback\Feedback_REST_Controller; use Code_Snippets\UnitTestCase; use function Code_Snippets\Settings\update_setting; @@ -192,6 +193,27 @@ static function ( array $info ) use ( &$collected ): array { $this->assertIsArray( get_transient( Feedback_Panel::SUMMARY_TRANSIENT ) ); } + /** + * The panel is handed a search URL built by WordPress. It cannot append the route + * itself, because plain permalinks carry the route in a query parameter. + * + * @return void + */ + public function test_the_search_url_is_localised(): void { + update_setting( 'general', Feedback_Panel::SETTING_FIELD, true ); + $this->log_in_as_administrator(); + $this->visit_snippets_screen(); + + $this->panel->enqueue_assets(); + + $data = wp_scripts()->get_data( 'code-snippets-feedback', 'data' ); + + $this->assertStringContainsString( + sprintf( '"searchUrl":"%s"', rest_url( Feedback_REST_Controller::get_base_route() . '/search' ) ), + (string) $data + ); + } + /** * A released build is not labelled as a test build. * diff --git a/tests/unit/Client/Feedback_Client_Test.php b/tests/unit/Client/Feedback_Client_Test.php index 85c349407..fb9e32cb9 100644 --- a/tests/unit/Client/Feedback_Client_Test.php +++ b/tests/unit/Client/Feedback_Client_Test.php @@ -335,6 +335,22 @@ public function test_a_transport_failure_is_returned_as_an_error(): void { $this->assertWPError( $this->client->send_report( [], 'key-6' ) ); } + /** + * An unenrolled site does not put a report on the wire. The cloud could not tell who + * sent it, so the contents would travel for nothing. + * + * @return void + */ + public function test_a_report_is_not_sent_without_a_credential(): void { + set_transient( Feedback_Client::REGISTRATION_FAILURE_TRANSIENT, 1, 90 ); + + $result = $this->client->send_report( [ 'report' => [ 'title' => 'A title' ] ], 'key-7' ); + + $this->assertWPError( $result ); + $this->assertSame( 'code_snippets_feedback_unregistered', $result->get_error_code() ); + $this->assertCount( 0, $this->requests ); + } + /** * The panel is offered a handful of similar reports, not the whole list. * @@ -358,6 +374,22 @@ public function test_search_returns_at_most_five_results(): void { $this->assertStringContainsString( 'q=highlighting', $this->requests[0]['url'] ); } + /** + * The search term is encoded once. Encoding it twice would send the escapes themselves + * as the search text. + * + * @return void + */ + public function test_the_search_term_is_encoded_once(): void { + $this->connection->save_credentials( $this->credentials ); + $this->responses = [ $this->response( 200, [ 'results' => [] ] ) ]; + + $this->client->search_reports( 'syntax highlighting' ); + + $this->assertStringContainsString( 'q=syntax%20highlighting', $this->requests[0]['url'] ); + $this->assertStringNotContainsString( '%2520', $this->requests[0]['url'] ); + } + /** * A search the cloud cannot answer leaves the panel with nothing to show. * diff --git a/tests/unit/REST_API/Feedback_REST_Controller_Test.php b/tests/unit/REST_API/Feedback_REST_Controller_Test.php index 593254ecc..0fab3fbc2 100644 --- a/tests/unit/REST_API/Feedback_REST_Controller_Test.php +++ b/tests/unit/REST_API/Feedback_REST_Controller_Test.php @@ -288,6 +288,43 @@ public function test_a_valid_report_is_forwarded_with_the_environment_attached() $this->assertNotEmpty( $sent['reporter']['email'] ); } + /** + * The environment is collected on the server, so a request cannot dictate what the + * report says about the site it came from. + * + * @return void + */ + public function test_a_forged_environment_is_ignored(): void { + $this->post_report( + $this->valid_report( + [ + 'environment' => [ + 'php_version' => '0.0.0', + 'site_url' => 'https://example.invalid', + ], + ] + ) + ); + + $sent = json_decode( end( $this->sent_bodies ), true ); + + $this->assertSame( PHP_VERSION, $sent['environment']['php_version'] ); + $this->assertSame( site_url(), $sent['environment']['site_url'] ); + } + + /** + * A title written in a non-Latin script is measured in characters, as the panel + * measures it, rather than in bytes. + * + * @return void + */ + public function test_a_short_multibyte_title_is_rejected(): void { + $response = $this->post_report( $this->valid_report( [ 'title' => '短い題名' ] ) ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'code_snippets_feedback_title', $response->get_data()['code'] ); + } + /** * The reporter's own details are used when they leave the fields alone. * diff --git a/tests/unit/Utils/System_Info_Test.php b/tests/unit/Utils/System_Info_Test.php index 8ee3b9531..dd16dcfbd 100644 --- a/tests/unit/Utils/System_Info_Test.php +++ b/tests/unit/Utils/System_Info_Test.php @@ -83,12 +83,49 @@ public function test_edition_is_reported_as_free_or_pro(): void { public function test_summary_lists_the_disclosed_values(): void { $summary = System_Info::get_summary( System_Info::get_system_info() ); - $this->assertCount( 6, $summary ); $this->assertNotEmpty( $summary[ __( 'Code Snippets', 'code-snippets' ) ] ); $this->assertNotEmpty( $summary[ __( 'WordPress', 'code-snippets' ) ] ); $this->assertSame( PHP_VERSION, $summary[ __( 'PHP', 'code-snippets' ) ] ); } + /** + * The panel is where the reporter is told what they are about to send, so every value + * the report carries has to appear in the summary. + * + * @return void + */ + public function test_summary_withholds_nothing_the_report_sends(): void { + $info = System_Info::get_system_info(); + $disclosed = implode( ' | ', System_Info::get_summary( $info ) ); + + $undisclosed = []; + + // The plugin list, the count and the booleans are disclosed in a readable form + // rather than verbatim, and the edition is shown by its name. + $rephrased = [ 'active_plugins', 'plugin_count', 'edition' ]; + + foreach ( $info as $key => $value ) { + if ( in_array( $key, $rephrased, true ) || is_bool( $value ) ) { + continue; + } + + if ( '' !== (string) $value && false === strpos( $disclosed, (string) $value ) ) { + $undisclosed[] = $key; + } + } + + $this->assertSame( [], $undisclosed, 'Values sent with a report but not shown to the reporter.' ); + + $this->assertStringContainsString( + 'pro' === $info['edition'] ? __( 'Pro', 'code-snippets' ) : __( 'Free', 'code-snippets' ), + $disclosed + ); + + foreach ( $info['active_plugins'] as $plugin ) { + $this->assertStringContainsString( $plugin, $disclosed ); + } + } + /** * Sites can amend what is collected before it is sent. *