From a5595eb975c47c6eff55a0e598ed87eca1694920 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 23 Sep 2026 13:28:59 +0200 Subject: [PATCH 01/17] Let the e2e runner take a scenario subset Seven Playground boots is too slow a loop for iterating on one screen. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/e2e/run.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index dc0acf2a..71e579c9 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -10,6 +10,8 @@ # # Environment: # E2E_PORT Port for the Playground server (default 9400). +# E2E_SCENARIOS Space-separated subset of scenarios to run +# (default: all of them). # PLAYGROUND_CLI Command that runs the Playground CLI # (default: npx --yes @wp-playground/cli@3.1.54). # @@ -25,7 +27,8 @@ E2E_DIR="$(cd "$(dirname "$0")" && pwd)" PORT="${E2E_PORT:-9400}" BASE_URL="http://127.0.0.1:$PORT" PLAYGROUND_CLI="${PLAYGROUND_CLI:-npx --yes @wp-playground/cli@3.1.54}" -SCENARIOS=(open closed secret-hash-deleted enabled-hash-deleted screen provisioning connection) +# shellcheck disable=SC2206 # A space-separated scenario list, split on purpose. +SCENARIOS=(${E2E_SCENARIOS:-open closed secret-hash-deleted enabled-hash-deleted screen provisioning connection}) for command_name in php npx; do if ! command -v "$command_name" >/dev/null 2>&1; then From ca35235903e678eb26cd1781fa5cae67f3540921 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 23 Sep 2026 13:46:37 +0200 Subject: [PATCH 02/17] Give the Reprint screen the sidebar entry The old screen registers as before and is then removed from the menu global, which leaves it rendering at admin.php?page=wpcom-migration with its hook suffix intact. A new e2e scenario asserts that rather than trusting a reading of core. The connection scenario's own menu assertion is updated to match: it checked the Reprint screen's old submenu row under the BlogVault top-level slug, which no longer exists now that the screen owns a top-level row of its own. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- plugin/reprint/class-settings-page.php | 32 ++++---- plugin/wp_admin.php | 10 +++ tests/e2e/blueprint-menu.json | 22 ++++++ tests/e2e/connection-steps.php | 11 ++- tests/e2e/menu-steps.php | 102 +++++++++++++++++++++++++ tests/e2e/request.php | 7 +- tests/e2e/run.sh | 2 +- 8 files changed, 168 insertions(+), 20 deletions(-) create mode 100644 tests/e2e/blueprint-menu.json create mode 100644 tests/e2e/menu-steps.php diff --git a/README.md b/README.md index 0ee2a6a0..c2356ef3 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Source for the [Migrate to WordPress.com](https://wordpress.org/plugins/wpcom-mi - `plugin/connection/` — `Connection` (package setup, connect, disconnect), `Connect_Page` (the connection section of the screen), `bootstrap.php`. - `bin/build.sh` — writes `build/wpcom-migration/` and `build/wpcom-migration.zip`. - `bin/check-autoload-manifest.php` — asserts which classes the ZIP publishes through the Jetpack autoloader. -- `tests/e2e/` — Playground blueprints and request scripts: one scenario per credential state, one that drives the Reprint migration screen, one that provisions through the REST routes, one that drives the WordPress.com connection. +- `tests/e2e/` — Playground blueprints and request scripts: one scenario per credential state, one that drives the Reprint migration screen, one that provisions through the REST routes, one that drives the WordPress.com connection, one that checks the admin menu. ## Build diff --git a/plugin/reprint/class-settings-page.php b/plugin/reprint/class-settings-page.php index 8be30f3c..9798d5e6 100644 --- a/plugin/reprint/class-settings-page.php +++ b/plugin/reprint/class-settings-page.php @@ -26,13 +26,6 @@ class Settings_Page { */ const PAGE_SLUG = 'wpcom-migration-status'; - /** - * The BlogVault top-level menu slug this screen hangs under. - * - * @var string - */ - const PARENT_SLUG = 'wpcom-migration'; - /** * The admin-post action that saves the secret. * @@ -172,16 +165,29 @@ public function set_connection_section( \Automattic\WPCOM_Migration\Connect_Page } /** - * Adds the menu entry under the plugin's menu. + * Adds the plugin's one sidebar entry, unless the old screen's brand + * has asked for no menu at all. */ public function add_admin_menu() { - $this->page_hook = add_submenu_page( - self::PARENT_SLUG, - __( 'Reprint migration', 'wpcom-migration' ), - __( 'Reprint migration', 'wpcom-migration' ), + /** + * Filters whether the plugin shows a sidebar entry. + * + * The old BlogVault screen answers false when the plugin is + * whitelabelled to hide. Delete the filter with that screen. + * + * @param bool $show Whether to register the menu entry. + */ + if ( ! apply_filters( 'wpcom_migration_show_menu', true ) ) { + return; + } + + $this->page_hook = add_menu_page( + __( 'Migrate to WordPress.com', 'wpcom-migration' ), + __( 'Migrate to WordPress.com', 'wpcom-migration' ), 'manage_options', self::PAGE_SLUG, - array( $this, 'render_page' ) + array( $this, 'render_page' ), + 'dashicons-wordpress-alt' ); } diff --git a/plugin/wp_admin.php b/plugin/wp_admin.php index b3f55d86..dffcec4b 100755 --- a/plugin/wp_admin.php +++ b/plugin/wp_admin.php @@ -49,6 +49,16 @@ public function menu() { $icon = $this->bvinfo->getBrandIcon(); add_menu_page($bname, $bname, 'manage_options', $this->bvinfo->plugname, array($this, 'adminPage'), $icon); + + // The Reprint screen owns the sidebar entry now. Removing the page + // from the $menu global leaves its callback hooked and its + // $_registered_pages entry set, so admin.php?page=wpcom-migration + // still renders and keeps the toplevel_page_wpcom-migration hook + // suffix its stylesheet and notice handling key on. + remove_menu_page($this->bvinfo->plugname); + } else { + // Whitelabelled out of the menu: the Reprint screen hides with it. + add_filter('wpcom_migration_show_menu', '__return_false'); } } diff --git a/tests/e2e/blueprint-menu.json b/tests/e2e/blueprint-menu.json new file mode 100644 index 00000000..e89f0e95 --- /dev/null +++ b/tests/e2e/blueprint-menu.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://playground.wordpress.net/blueprint-schema.json", + "preferredVersions": { "php": "8.2", "wp": "latest" }, + "steps": [ + { + "step": "activatePlugin", + "pluginPath": "wpcom-migration/wpcom_migration.php" + }, + { + "step": "runPHP", + "code": "set_connection_section( new Connect_Page( $plugin_file ) ); do_action( 'admin_menu' ); - $entries = isset( $GLOBALS['submenu']['wpcom-migration'] ) ? $GLOBALS['submenu']['wpcom-migration'] : array(); - $slugs = array_map( + $top_level_items = isset( $GLOBALS['menu'] ) ? $GLOBALS['menu'] : array(); + $slugs = array_map( function ( $entry ) { return $entry[2]; }, - $entries + $top_level_items ); - if ( 1 !== count( array_keys( $slugs, Settings_Page::PAGE_SLUG, true ) ) || in_array( 'wpcom-migration-connect', $slugs, true ) ) { + if ( array( Settings_Page::PAGE_SLUG ) !== $slugs || in_array( 'wpcom-migration-connect', $slugs, true ) ) { throw new RuntimeException( "Step '$step': expected one Reprint migration entry and no account entry, got: " . wp_json_encode( $slugs ) ); } break; diff --git a/tests/e2e/menu-steps.php b/tests/e2e/menu-steps.php new file mode 100644 index 00000000..4c307fbd --- /dev/null +++ b/tests/e2e/menu-steps.php @@ -0,0 +1,102 @@ + * * Scenarios: open, closed, secret-hash-deleted, enabled-hash-deleted, screen, - * provisioning, connection. The secret must match the one the matching + * provisioning, connection, menu. The secret must match the one the matching * blueprint stores. * * @package wpcom-migration @@ -41,6 +41,11 @@ wpcom_migration_e2e_assert_open( $wpcom_migration_endpoint, $wpcom_migration_secret ); break; + case 'menu': + // The blueprint's runPHP steps are the whole test; there is nothing + // to ask the site over HTTP. + break; + case 'closed': // Unsigned with the window closed: the request falls through to // WordPress. The plain front page, no JSON, no export headers. diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index 71e579c9..ce17a8c8 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -28,7 +28,7 @@ PORT="${E2E_PORT:-9400}" BASE_URL="http://127.0.0.1:$PORT" PLAYGROUND_CLI="${PLAYGROUND_CLI:-npx --yes @wp-playground/cli@3.1.54}" # shellcheck disable=SC2206 # A space-separated scenario list, split on purpose. -SCENARIOS=(${E2E_SCENARIOS:-open closed secret-hash-deleted enabled-hash-deleted screen provisioning connection}) +SCENARIOS=(${E2E_SCENARIOS:-open closed secret-hash-deleted enabled-hash-deleted screen provisioning connection menu}) for command_name in php npx; do if ! command -v "$command_name" >/dev/null 2>&1; then From 5186098bccd0bd61cf90a6ddcaf1950670397962 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 23 Sep 2026 13:56:48 +0200 Subject: [PATCH 03/17] Send the Settings link and the activation redirect to the Reprint screen mainUrl() stays put: siteInfoTags() posts it to the app as adminurl, where it still means the old screen. Co-Authored-By: Claude Opus 5.5 (1M context) --- plugin/wp_admin.php | 19 ++++++++++++++++-- tests/e2e/blueprint-menu.json | 8 ++++++++ tests/e2e/menu-steps.php | 38 +++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/plugin/wp_admin.php b/plugin/wp_admin.php index dffcec4b..125f673d 100755 --- a/plugin/wp_admin.php +++ b/plugin/wp_admin.php @@ -23,6 +23,21 @@ public function mainUrl($_params = '') { } } + /** + * Where the plugin's own entry points send the user. The Reprint screen + * when it is loaded; the old screen in a source checkout without + * plugin/vendor/, where the Reprint classes are absent. + * + * Not mainUrl(): siteInfoTags() posts that to the app as `adminurl`, + * where it still describes the old screen. + */ + public function migrationScreenUrl() { + if (class_exists('\Automattic\WPCOM_Migration\Reprint\Settings_Page')) { + return \Automattic\WPCOM_Migration\Reprint\Settings_Page::page_url(); + } + return $this->mainUrl(); + } + function removeAdminNotices() { if (WPCOMHelper::getRawParam('REQUEST', 'page') === $this->bvinfo->plugname) { remove_all_actions('admin_notices'); @@ -37,7 +52,7 @@ public function initHandler() { if ($this->bvinfo->isActivateRedirectSet()) { $this->settings->updateOption($this->bvinfo->plug_redirect, 'no'); if (!wp_doing_ajax()) { - wp_redirect($this->mainUrl()); + wp_redirect($this->migrationScreenUrl()); } } } @@ -81,7 +96,7 @@ public function hidePluginDetails($plugin_metas, $slug) { public function settingsLink($links, $file) { if ( $file == plugin_basename( dirname(__FILE__).'/wpcom_migration.php' ) ) { // phpcs:ignore WordPress.WP.I18n.MissingArgDomain - $links[] = ''.__( 'Settings' ).''; + $links[] = ''.__( 'Settings' ).''; } return $links; } diff --git a/tests/e2e/blueprint-menu.json b/tests/e2e/blueprint-menu.json index e89f0e95..0603a776 100644 --- a/tests/e2e/blueprint-menu.json +++ b/tests/e2e/blueprint-menu.json @@ -17,6 +17,14 @@ { "step": "runPHP", "code": "settingsLink( array(), 'wpcom-migration/wpcom_migration.php' ); + + if ( 1 !== count( $links ) ) { + throw new RuntimeException( 'Expected exactly one Settings link, got: ' . wp_json_encode( $links ) ); + } + if ( false === strpos( $links[0], 'page=' . Settings_Page::PAGE_SLUG ) ) { + throw new RuntimeException( 'The Settings link does not point at the Reprint screen: ' . $links[0] ); + } + break; + + case 'activation-redirect': + $captured = null; + add_filter( + 'wp_redirect', + function ( $location ) use ( &$captured ) { + $captured = $location; + + // An empty location makes wp_redirect() return false + // before it sends a header. + return ''; + } + ); + + update_option( 'wpcomredirect', 'yes' ); + + $admin = new WPCOMWPAdmin( new WPCOMWPSettings(), new WPCOMWPSiteInfo() ); + $admin->initHandler(); + + if ( null === $captured ) { + throw new RuntimeException( 'Activating did not redirect at all.' ); + } + if ( false === strpos( $captured, 'page=' . Settings_Page::PAGE_SLUG ) ) { + throw new RuntimeException( 'The activation redirect does not land on the Reprint screen: ' . $captured ); + } + break; + default: throw new RuntimeException( 'Unknown menu step: ' . $step ); } From c3c83d09ec56d3d299be79c46c474e84cf2a3987 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 23 Sep 2026 14:10:57 +0200 Subject: [PATCH 04/17] Move the by-hand controls to a screen of their own The secret form, the exporter toggle, the export URL and the blog ID leave the migration screen for admin.php?page=wpcom-migration-manual, which nothing links to. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 10 +- bin/check-autoload-manifest.php | 1 + plugin/reprint/bootstrap.php | 2 + plugin/reprint/class-manual-page.php | 391 ++++++++++++++++++ plugin/reprint/class-settings-page.php | 308 +------------- .../{settings-page.js => manual-page.js} | 0 tests/e2e/blueprint-menu.json | 4 + tests/e2e/connection-steps.php | 20 +- tests/e2e/screen-steps.php | 53 ++- 9 files changed, 465 insertions(+), 324 deletions(-) create mode 100644 plugin/reprint/class-manual-page.php rename plugin/reprint/{settings-page.js => manual-page.js} (100%) diff --git a/README.md b/README.md index c2356ef3..7364bcae 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Source for the [Migrate to WordPress.com](https://wordpress.org/plugins/wpcom-mi ## Layout - `plugin/` — plugin source. The BlogVault tree plus `reprint/`, the Reprint export glue. -- `plugin/reprint/` — `Exporter` (credentials, write veto, `?reprint-api-wpcom-migration`), `Settings_Page` (the Reprint migration screen), `REST_Controller` (provisioning routes), `bootstrap.php`. +- `plugin/reprint/` — `Exporter` (credentials, write veto, `?reprint-api-wpcom-migration`), `Settings_Page` (the Reprint migration screen), `Manual_Page` (the by-hand screen), `REST_Controller` (provisioning routes), `bootstrap.php`. - `plugin/connection/` — `Connection` (package setup, connect, disconnect), `Connect_Page` (the connection section of the screen), `bootstrap.php`. - `bin/build.sh` — writes `build/wpcom-migration/` and `build/wpcom-migration.zip`. - `bin/check-autoload-manifest.php` — asserts which classes the ZIP publishes through the Jetpack autoloader. @@ -37,7 +37,13 @@ To activate a source checkout directly (without a build), run `composer install | Set up by WordPress.com | Provisioned with an application password | A *Log in with WordPress.com* link | | Connect this site to WordPress.com | Fresh site | *Log in with WordPress.com* | -`Settings_Page::mode()` picks the first that fits, top to bottom. The *Continue* link's target is filtered by `wpcom_migration_continue_url`. Under every mode but the first, a collapsed *Set up by hand* section holds the export secret form, the exporter toggle, the export URL and the WordPress.com blog ID. Deactivating the plugin disconnects and discards the secret and the exporter state. +`Settings_Page::mode()` picks the first that fits, top to bottom. The *Continue* link's target is filtered by `wpcom_migration_continue_url`. + +The old BlogVault screen at `wp-admin/admin.php?page=wpcom-migration` is no longer in the sidebar. It stays reachable at its URL. + +## Setting up by hand + +`wp-admin/admin.php?page=wpcom-migration-manual` (`manage_options`, single-site only). Nothing links to it; support gives out the URL. It holds the export secret form, the exporter toggle, the export URL and the WordPress.com blog ID. Deactivating the plugin disconnects and discards the secret and the exporter state. ## How WordPress.com provisions the exporter diff --git a/bin/check-autoload-manifest.php b/bin/check-autoload-manifest.php index 13f327a2..1776ef0b 100755 --- a/bin/check-autoload-manifest.php +++ b/bin/check-autoload-manifest.php @@ -41,6 +41,7 @@ 'Automattic\\WPCOM_Migration\\Connect_Page', 'Automattic\\WPCOM_Migration\\Connection', 'Automattic\\WPCOM_Migration\\Reprint\\Exporter', + 'Automattic\\WPCOM_Migration\\Reprint\\Manual_Page', 'Automattic\\WPCOM_Migration\\Reprint\\REST_Controller', 'Automattic\\WPCOM_Migration\\Reprint\\Settings_Page', ); diff --git a/plugin/reprint/bootstrap.php b/plugin/reprint/bootstrap.php index ac078d0c..c940240e 100644 --- a/plugin/reprint/bootstrap.php +++ b/plugin/reprint/bootstrap.php @@ -6,6 +6,7 @@ */ use Automattic\WPCOM_Migration\Reprint\Exporter; +use Automattic\WPCOM_Migration\Reprint\Manual_Page; use Automattic\WPCOM_Migration\Reprint\REST_Controller; use Automattic\WPCOM_Migration\Reprint\Settings_Page; @@ -50,6 +51,7 @@ function wpcom_migration_settings_page( ?Settings_Page $page = null ) { if ( is_admin() ) { wpcom_migration_settings_page( new Settings_Page( $wpcom_migration_plugin_file ) ); + new Manual_Page( $wpcom_migration_plugin_file ); } unset( $wpcom_migration_plugin_file, $wpcom_migration_reprint_autoloader ); diff --git a/plugin/reprint/class-manual-page.php b/plugin/reprint/class-manual-page.php new file mode 100644 index 00000000..2473297f --- /dev/null +++ b/plugin/reprint/class-manual-page.php @@ -0,0 +1,391 @@ +. + * + * @var string + */ + const PAGE_SLUG = 'wpcom-migration-manual'; + + /** + * The admin-post action that saves the secret. + * + * @var string + */ + const SAVE_SECRET_ACTION = 'wpcom_migration_reprint_save_secret'; + + /** + * The admin-post action that turns the exporter on or off. + * + * @var string + */ + const SAVE_ENABLED_ACTION = 'wpcom_migration_reprint_save_enabled'; + + /** + * Query argument carrying the result of a form post back to the screen. + * + * @var string + */ + const NOTICE_QUERY_ARG = 'wpcom_migration_reprint_notice'; + + /** + * Name of the secret input. + * + * @var string + */ + const SECRET_FIELD = 'wpcom_migration_reprint_secret'; + + /** + * Name of the enable checkbox. + * + * @var string + */ + const ENABLED_FIELD = 'wpcom_migration_reprint_enabled'; + + /** + * Script handle for manual-page.js. + * + * @var string + */ + const SCRIPT_HANDLE = 'wpcom-migration-reprint-manual'; + + /** + * Absolute path of the plugin's main file. + * + * @var string + */ + private $plugin_file; + + /** + * Hook suffix returned by add_submenu_page(). + * + * @var string|false + */ + private $page_hook = false; + + /** + * Registers the screen, its form handlers and its script. + * + * @param string $plugin_file Absolute path of the plugin's main file. + */ + public function __construct( $plugin_file ) { + $this->plugin_file = $plugin_file; + + add_action( 'admin_menu', array( $this, 'add_admin_menu' ), 21 ); + add_action( 'admin_post_' . self::SAVE_SECRET_ACTION, array( $this, 'handle_save_secret' ) ); + add_action( 'admin_post_' . self::SAVE_ENABLED_ACTION, array( $this, 'handle_save_enabled' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + } + + /** + * The screen's URL. + * + * @return string + */ + public static function page_url() { + return admin_url( 'admin.php?page=' . self::PAGE_SLUG ); + } + + /** + * Registers the screen and then takes it out of the sidebar, along with + * the row WordPress adds for the parent the first time it gains a child. + * + * Priority 21: Settings_Page creates the parent at 20. + */ + public function add_admin_menu() { + $this->page_hook = add_submenu_page( + Settings_Page::PAGE_SLUG, + __( 'Set up by hand', 'wpcom-migration' ), + __( 'Set up by hand', 'wpcom-migration' ), + 'manage_options', + self::PAGE_SLUG, + array( $this, 'render_page' ) + ); + + remove_submenu_page( Settings_Page::PAGE_SLUG, self::PAGE_SLUG ); + remove_submenu_page( Settings_Page::PAGE_SLUG, Settings_Page::PAGE_SLUG ); + } + + /** + * Enqueues the screen's script on the screen only. + * + * @param string $hook_suffix The current admin page. + */ + public function enqueue_assets( $hook_suffix ) { + if ( false === $this->page_hook || $hook_suffix !== $this->page_hook ) { + return; + } + + $plugin_data = get_file_data( $this->plugin_file, array( 'Version' => 'Version' ) ); + + wp_enqueue_script( + self::SCRIPT_HANDLE, + plugins_url( 'reprint/manual-page.js', $this->plugin_file ), + array( 'wp-a11y' ), + '' !== $plugin_data['Version'] ? $plugin_data['Version'] : false, + true + ); + } + + /** + * Saves the secret and redirects back with a result notice. + */ + public function handle_save_secret() { + $this->authorize( self::SAVE_SECRET_ACTION ); + + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked in authorize(). + $secret = sanitize_text_field( wp_unslash( $_POST[ self::SECRET_FIELD ] ?? '' ) ); + + if ( '' === $secret ) { + $this->redirect_with_notice( 'not_configured' ); + } + + $state = Exporter::get_state(); + if ( $state['secret_valid'] && get_option( Exporter::SECRET_OPTION ) === $secret ) { + $this->redirect_with_notice( 'unchanged' ); + } + + if ( ! Exporter::store_secret( $secret ) ) { + $this->redirect_with_notice( 'storage_failure' ); + } + + Exporter::record_event( 'secret_rotated', array( 'user_id' => get_current_user_id() ) ); + $this->redirect_with_notice( 'saved' ); + } + + /** + * Turns the exporter on or off and redirects back with a result notice. + */ + public function handle_save_enabled() { + $this->authorize( self::SAVE_ENABLED_ACTION ); + + $state = Exporter::get_state(); + if ( ! $state['secret_valid'] ) { + $this->redirect_with_notice( 'not_configured' ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked in authorize(). + if ( isset( $_POST[ self::ENABLED_FIELD ] ) ) { + Exporter::open_export_window(); + Exporter::record_event( 'window_opened', array( 'user_id' => get_current_user_id() ) ); + $this->redirect_with_notice( 'enabled' ); + } + + Exporter::close_export_window(); + Exporter::record_event( 'window_closed', array( 'user_id' => get_current_user_id() ) ); + $this->redirect_with_notice( 'disabled' ); + } + + /** + * Stops a form post that is not a nonce-carrying administrator request on + * a single site. + * + * @param string $action The admin-post action being handled. + */ + private function authorize( $action ) { + if ( is_multisite() ) { + wp_die( esc_html__( 'The exporter is not supported on networks.', 'wpcom-migration' ) ); + } + + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You are not allowed to manage the exporter.', 'wpcom-migration' ) ); + } + + check_admin_referer( $action ); + } + + /** + * Redirects back to the screen with a result code and exits. + * + * @param string $result One of saved, unchanged, enabled, disabled, + * not_configured, storage_failure. + */ + private function redirect_with_notice( $result ) { + wp_safe_redirect( add_query_arg( self::NOTICE_QUERY_ARG, $result, self::page_url() ) ); + exit; + } + + /** + * Renders the screen: the secret form; the exporter toggle and export URL + * once the secret is valid; the blog ID when connected. + */ + public function render_page() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + if ( is_multisite() ) { + echo '

' . esc_html__( 'Set up by hand', 'wpcom-migration' ) . '

'; + echo '

' . esc_html__( 'The exporter runs on single sites only.', 'wpcom-migration' ) . '

'; + return; + } + + $state = Exporter::get_state(); + + echo '
'; + echo '

' . esc_html__( 'Set up by hand', 'wpcom-migration' ) . '

'; + echo '

' . esc_html__( 'Support may ask you to set this up by hand.', 'wpcom-migration' ) . '

'; + + $this->render_result_notice(); + $this->render_secret_form( $state ); + + if ( $state['secret_valid'] ) { + $this->render_enable_form( $state ); + $this->render_api_url(); + } + + $blog_id = class_exists( '\Automattic\WPCOM_Migration\Connection' ) && \Automattic\WPCOM_Migration\Connection::is_user_connected() + ? \Automattic\WPCOM_Migration\Connection::blog_id() + : null; + if ( null !== $blog_id ) { + echo '
'; + echo '

' . sprintf( + /* translators: %d: WordPress.com blog ID. */ + esc_html__( 'WordPress.com blog ID: %d', 'wpcom-migration' ), + (int) $blog_id + ) . '

'; + } + + echo '
'; + } + + /** + * Renders the secret form. + * + * @param array $state Exporter::get_state(). + */ + private function render_secret_form( array $state ) { + $stored_secret = $state['secret_valid'] ? (string) get_option( Exporter::SECRET_OPTION, '' ) : ''; + ?> +

+

+
+ + + + + + +
+ +
+

+
+ + + +

+ +
+ +
+

+

+ + + array( 'success', __( 'Secret saved.', 'wpcom-migration' ) ), + 'unchanged' => array( 'success', __( 'The secret was already up to date.', 'wpcom-migration' ) ), + 'enabled' => array( 'success', __( 'Exporter on for the next hour.', 'wpcom-migration' ) ), + 'disabled' => array( 'success', __( 'Exporter off.', 'wpcom-migration' ) ), + 'not_configured' => array( 'error', __( 'Enter an export secret first.', 'wpcom-migration' ) ), + 'storage_failure' => array( 'error', __( 'The secret could not be saved.', 'wpcom-migration' ) ), + ); + + if ( ! isset( $notices[ $result ] ) ) { + return; + } + + $this->render_notice( $notices[ $result ][0], esc_html( $notices[ $result ][1] ), true ); + } + + /** + * Renders one native inline notice. + * + * @param string $type error, success, warning or info. + * @param string $message Escaped HTML. + * @param bool $dismissible Whether to add the dismiss control. + */ + private function render_notice( $type, $message, $dismissible = false ) { + if ( ! in_array( $type, array( 'error', 'success', 'warning', 'info' ), true ) ) { + $type = 'error'; + } + + $classes = 'notice notice-' . $type . ' inline'; + if ( $dismissible ) { + $classes .= ' is-dismissible'; + } + + echo '

' . wp_kses_post( $message ) . '

'; + } +} diff --git a/plugin/reprint/class-settings-page.php b/plugin/reprint/class-settings-page.php index 9798d5e6..e1fde5c3 100644 --- a/plugin/reprint/class-settings-page.php +++ b/plugin/reprint/class-settings-page.php @@ -26,48 +26,6 @@ class Settings_Page { */ const PAGE_SLUG = 'wpcom-migration-status'; - /** - * The admin-post action that saves the secret. - * - * @var string - */ - const SAVE_SECRET_ACTION = 'wpcom_migration_reprint_save_secret'; - - /** - * The admin-post action that turns the exporter on or off. - * - * @var string - */ - const SAVE_ENABLED_ACTION = 'wpcom_migration_reprint_save_enabled'; - - /** - * Query argument carrying the result of a form post back to the screen. - * - * @var string - */ - const NOTICE_QUERY_ARG = 'wpcom_migration_reprint_notice'; - - /** - * Name of the secret input. - * - * @var string - */ - const SECRET_FIELD = 'wpcom_migration_reprint_secret'; - - /** - * Name of the enable checkbox. - * - * @var string - */ - const ENABLED_FIELD = 'wpcom_migration_reprint_enabled'; - - /** - * Script handle for settings-page.js. - * - * @var string - */ - const SCRIPT_HANDLE = 'wpcom-migration-reprint-settings'; - /** * The screen is not available: the site is a network. * @@ -133,7 +91,7 @@ class Settings_Page { private $connection_section = null; /** - * Registers the screen, its form handlers and its script. + * Registers the screen. * * @param string $plugin_file Absolute path of the plugin's main file. */ @@ -141,9 +99,6 @@ public function __construct( $plugin_file ) { $this->plugin_file = $plugin_file; add_action( 'admin_menu', array( $this, 'add_admin_menu' ), 20 ); - add_action( 'admin_post_' . self::SAVE_SECRET_ACTION, array( $this, 'handle_save_secret' ) ); - add_action( 'admin_post_' . self::SAVE_ENABLED_ACTION, array( $this, 'handle_save_enabled' ) ); - add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); } /** @@ -192,106 +147,8 @@ public function add_admin_menu() { } /** - * Enqueues the screen's script on the screen only. - * - * @param string $hook_suffix The current admin page. - */ - public function enqueue_assets( $hook_suffix ) { - if ( false === $this->page_hook || $hook_suffix !== $this->page_hook ) { - return; - } - - $plugin_data = get_file_data( $this->plugin_file, array( 'Version' => 'Version' ) ); - - wp_enqueue_script( - self::SCRIPT_HANDLE, - plugins_url( 'reprint/settings-page.js', $this->plugin_file ), - array( 'wp-a11y' ), - '' !== $plugin_data['Version'] ? $plugin_data['Version'] : false, - true - ); - } - - /** - * Saves the secret and redirects back with a result notice. - */ - public function handle_save_secret() { - $this->authorize( self::SAVE_SECRET_ACTION ); - - // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked in authorize(). - $secret = sanitize_text_field( wp_unslash( $_POST[ self::SECRET_FIELD ] ?? '' ) ); - - if ( '' === $secret ) { - $this->redirect_with_notice( 'not_configured' ); - } - - $state = Exporter::get_state(); - if ( $state['secret_valid'] && get_option( Exporter::SECRET_OPTION ) === $secret ) { - $this->redirect_with_notice( 'unchanged' ); - } - - if ( ! Exporter::store_secret( $secret ) ) { - $this->redirect_with_notice( 'storage_failure' ); - } - - Exporter::record_event( 'secret_rotated', array( 'user_id' => get_current_user_id() ) ); - $this->redirect_with_notice( 'saved' ); - } - - /** - * Turns the exporter on or off and redirects back with a result notice. - */ - public function handle_save_enabled() { - $this->authorize( self::SAVE_ENABLED_ACTION ); - - $state = Exporter::get_state(); - if ( ! $state['secret_valid'] ) { - $this->redirect_with_notice( 'not_configured' ); - } - - // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked in authorize(). - if ( isset( $_POST[ self::ENABLED_FIELD ] ) ) { - Exporter::open_export_window(); - Exporter::record_event( 'window_opened', array( 'user_id' => get_current_user_id() ) ); - $this->redirect_with_notice( 'enabled' ); - } - - Exporter::close_export_window(); - Exporter::record_event( 'window_closed', array( 'user_id' => get_current_user_id() ) ); - $this->redirect_with_notice( 'disabled' ); - } - - /** - * Stops a form post that is not a nonce-carrying administrator request on - * a single site. - * - * @param string $action The admin-post action being handled. - */ - private function authorize( $action ) { - if ( is_multisite() ) { - wp_die( esc_html__( 'The exporter is not supported on networks.', 'wpcom-migration' ) ); - } - - if ( ! current_user_can( 'manage_options' ) ) { - wp_die( esc_html__( 'You are not allowed to manage the exporter.', 'wpcom-migration' ) ); - } - - check_admin_referer( $action ); - } - - /** - * Redirects back to the screen with a result code and exits. - * - * @param string $result One of saved, unchanged, enabled, disabled, - * not_configured, storage_failure. - */ - private function redirect_with_notice( $result ) { - wp_safe_redirect( add_query_arg( self::NOTICE_QUERY_ARG, $result, self::page_url() ) ); - exit; - } - - /** - * Renders the screen: the mode, then the by-hand section. + * Renders the screen: the mode and nothing else. The by-hand controls + * live on Manual_Page. */ public function render_page() { if ( ! current_user_can( 'manage_options' ) ) { @@ -305,17 +162,12 @@ public function render_page() { echo '
'; echo '

' . esc_html__( 'Reprint migration', 'wpcom-migration' ) . '

'; - $this->render_result_notice(); if ( null !== $this->connection_section ) { $this->connection_section->render_result_notice(); } $this->render_mode( $mode, $state, $user_connected ); - if ( self::MODE_BLOCKED !== $mode ) { - $this->render_manual( $state, $user_connected ); - } - echo '
'; } @@ -462,158 +314,4 @@ private static function window_closes_at( array $state ) { $expires = $state['window_expires_at'] + (int) ( get_option( 'gmt_offset' ) * HOUR_IN_SECONDS ); return date_i18n( get_option( 'time_format' ), $expires ); } - - /** - * Renders the collapsed by-hand section: the secret form; the exporter - * toggle and export URL once the secret is valid; the blog ID when - * connected. - * - * @param array $state Exporter::get_state(). - * @param bool $user_connected Whether the current user is connected to WordPress.com. - */ - private function render_manual( array $state, $user_connected ) { - echo '
'; - echo '' . esc_html__( 'Set up by hand', 'wpcom-migration' ) . ''; - echo '

' . esc_html__( 'Support may ask you to set this up by hand.', 'wpcom-migration' ) . '

'; - $this->render_secret_form( $state ); - - if ( $state['secret_valid'] ) { - $this->render_enable_form( $state ); - $this->render_api_url(); - } - - $blog_id = $user_connected ? \Automattic\WPCOM_Migration\Connection::blog_id() : null; - if ( null !== $blog_id ) { - echo '
'; - echo '

' . sprintf( - /* translators: %d: WordPress.com blog ID. */ - esc_html__( 'WordPress.com blog ID: %d', 'wpcom-migration' ), - (int) $blog_id - ) . '

'; - } - - echo '
'; - } - - /** - * Renders the secret form. - * - * @param array $state Exporter::get_state(). - */ - private function render_secret_form( array $state ) { - $stored_secret = $state['secret_valid'] ? (string) get_option( Exporter::SECRET_OPTION, '' ) : ''; - ?> -

-

-
- - - - - - -
- -
-

-
- - - -

- -
- -
-

-

- - - array( 'success', __( 'Secret saved.', 'wpcom-migration' ) ), - 'unchanged' => array( 'success', __( 'The secret was already up to date.', 'wpcom-migration' ) ), - 'enabled' => array( 'success', __( 'Exporter on for the next hour.', 'wpcom-migration' ) ), - 'disabled' => array( 'success', __( 'Exporter off.', 'wpcom-migration' ) ), - 'not_configured' => array( 'error', __( 'Enter an export secret first.', 'wpcom-migration' ) ), - 'storage_failure' => array( 'error', __( 'The secret could not be saved.', 'wpcom-migration' ) ), - ); - - if ( ! isset( $notices[ $result ] ) ) { - return; - } - - $this->render_notice( $notices[ $result ][0], esc_html( $notices[ $result ][1] ), true ); - } - - /** - * Renders one native inline notice. - * - * @param string $type error, success, warning or info. - * @param string $message Escaped HTML. - * @param bool $dismissible Whether to add the dismiss control. - */ - private function render_notice( $type, $message, $dismissible = false ) { - if ( ! in_array( $type, array( 'error', 'success', 'warning', 'info' ), true ) ) { - $type = 'error'; - } - - $classes = 'notice notice-' . $type . ' inline'; - if ( $dismissible ) { - $classes .= ' is-dismissible'; - } - - echo '

' . wp_kses_post( $message ) . '

'; - } } diff --git a/plugin/reprint/settings-page.js b/plugin/reprint/manual-page.js similarity index 100% rename from plugin/reprint/settings-page.js rename to plugin/reprint/manual-page.js diff --git a/tests/e2e/blueprint-menu.json b/tests/e2e/blueprint-menu.json index 0603a776..7e982ca0 100644 --- a/tests/e2e/blueprint-menu.json +++ b/tests/e2e/blueprint-menu.json @@ -10,6 +10,10 @@ "step": "runPHP", "code": "render_page(); + return ob_get_clean(); +} + // screen-steps.php defines the helpers below too. One blueprint loads one // file, so they never meet; the guards cover a future combined load. if ( ! function_exists( 'wpcom_migration_e2e_post' ) ) { diff --git a/tests/e2e/screen-steps.php b/tests/e2e/screen-steps.php index 36280c94..5d3ad79c 100644 --- a/tests/e2e/screen-steps.php +++ b/tests/e2e/screen-steps.php @@ -9,6 +9,7 @@ */ use Automattic\WPCOM_Migration\Reprint\Exporter; +use Automattic\WPCOM_Migration\Reprint\Manual_Page; use Automattic\WPCOM_Migration\Reprint\Settings_Page; /** @@ -27,6 +28,7 @@ function wpcom_migration_e2e_screen_step( $step ) { $page = new Settings_Page( WP_PLUGIN_DIR . '/wpcom-migration/wpcom_migration.php' ); $page->set_connection_section( new \Automattic\WPCOM_Migration\Connect_Page( WP_PLUGIN_DIR . '/wpcom-migration/wpcom_migration.php' ) ); + $manual = new Manual_Page( WP_PLUGIN_DIR . '/wpcom-migration/wpcom_migration.php' ); $secret = 'smoke-secret-0123456789abcdef0123456789abcdef'; switch ( $step ) { @@ -36,8 +38,8 @@ function wpcom_migration_e2e_screen_step( $step ) { wpcom_migration_e2e_expect_contains( $html, 'Connect this site to WordPress.com', $step ); wpcom_migration_e2e_expect_contains( $html, 'Log in with WordPress.com', $step ); wpcom_migration_e2e_expect_primary_count( $html, 1, $step ); - wpcom_migration_e2e_expect_contains( $html, 'Set up by hand', $step ); - wpcom_migration_e2e_expect_contains( $html, 'id="wpcom-migration-reprint-secret"', $step ); + wpcom_migration_e2e_expect_not_contains( $html, 'Set up by hand', $step ); + wpcom_migration_e2e_expect_not_contains( $html, 'id="wpcom-migration-reprint-secret"', $step ); wpcom_migration_e2e_expect_not_contains( $html, 'wpcom-migration-reprint-api-url', $step ); wpcom_migration_e2e_expect_not_contains( $html, 'wpcom-migration-status', $step ); wpcom_migration_e2e_expect_not_contains( $html, 'WordPress.com connection', $step ); @@ -51,12 +53,12 @@ function wpcom_migration_e2e_screen_step( $step ) { wpcom_migration_e2e_expect_contains( $html, 'Connect this site to WordPress.com', $step ); wpcom_migration_e2e_expect_not_contains( $html, 'Log in with WordPress.com', $step ); wpcom_migration_e2e_expect_primary_count( $html, 0, $step ); - wpcom_migration_e2e_expect_contains( $html, 'id="wpcom-migration-reprint-secret"', $step ); + wpcom_migration_e2e_expect_not_contains( $html, 'id="wpcom-migration-reprint-secret"', $step ); break; case 'save-secret-empty': - wpcom_migration_e2e_post( Settings_Page::SAVE_SECRET_ACTION, array( Settings_Page::SECRET_FIELD => '' ) ); - $page->handle_save_secret(); // Redirects and exits. + wpcom_migration_e2e_post( Manual_Page::SAVE_SECRET_ACTION, array( Manual_Page::SECRET_FIELD => '' ) ); + $manual->handle_save_secret(); // Redirects and exits. break; case 'assert-unconfigured': @@ -67,8 +69,8 @@ function wpcom_migration_e2e_screen_step( $step ) { break; case 'save-secret': - wpcom_migration_e2e_post( Settings_Page::SAVE_SECRET_ACTION, array( Settings_Page::SECRET_FIELD => $secret ) ); - $page->handle_save_secret(); + wpcom_migration_e2e_post( Manual_Page::SAVE_SECRET_ACTION, array( Manual_Page::SECRET_FIELD => $secret ) ); + $manual->handle_save_secret(); break; case 'assert-secret-saved': @@ -83,15 +85,22 @@ function wpcom_migration_e2e_screen_step( $step ) { // The login stays, demoted to a link. wpcom_migration_e2e_expect_contains( $html, 'Log in with WordPress.com', $step ); wpcom_migration_e2e_expect_primary_count( $html, 0, $step ); - wpcom_migration_e2e_expect_contains( $html, 'id="wpcom-migration-reprint-api-url"', $step ); - wpcom_migration_e2e_expect_contains( $html, esc_attr( home_url( '?' . Exporter::QUERY_VAR ) ), $step ); + wpcom_migration_e2e_expect_not_contains( $html, 'id="wpcom-migration-reprint-api-url"', $step ); + wpcom_migration_e2e_expect_not_contains( $html, '

Export secret

', $step ); + wpcom_migration_e2e_expect_not_contains( $html, 'Turn the exporter on', $step ); wpcom_migration_e2e_expect_not_contains( $html, 'Exporter disabled', $step ); - wpcom_migration_e2e_expect_contains( $html, '

Export secret

', $step ); - wpcom_migration_e2e_expect_contains( $html, 'Turn the exporter on', $step ); - wpcom_migration_e2e_expect_contains( $html, '

Export URL

', $step ); wpcom_migration_e2e_expect_not_contains( $html, 'Shared secret', $step ); wpcom_migration_e2e_expect_not_contains( $html, 'Export window', $step ); wpcom_migration_e2e_expect_not_contains( $html, 'Remote API URL', $step ); + + $manual_html = wpcom_migration_e2e_render_manual( $manual ); + wpcom_migration_e2e_expect_contains( $manual_html, '

Set up by hand

', $step ); + wpcom_migration_e2e_expect_contains( $manual_html, 'id="wpcom-migration-reprint-secret"', $step ); + wpcom_migration_e2e_expect_contains( $manual_html, 'id="wpcom-migration-reprint-api-url"', $step ); + wpcom_migration_e2e_expect_contains( $manual_html, esc_attr( home_url( '?' . Exporter::QUERY_VAR ) ), $step ); + wpcom_migration_e2e_expect_contains( $manual_html, '

Export secret

', $step ); + wpcom_migration_e2e_expect_contains( $manual_html, 'Turn the exporter on', $step ); + wpcom_migration_e2e_expect_contains( $manual_html, '

Export URL

', $step ); break; case 'invalidate-secret': @@ -109,8 +118,8 @@ function wpcom_migration_e2e_screen_step( $step ) { break; case 'enable': - wpcom_migration_e2e_post( Settings_Page::SAVE_ENABLED_ACTION, array( Settings_Page::ENABLED_FIELD => '1' ) ); - $page->handle_save_enabled(); + wpcom_migration_e2e_post( Manual_Page::SAVE_ENABLED_ACTION, array( Manual_Page::ENABLED_FIELD => '1' ) ); + $manual->handle_save_enabled(); break; case 'assert-enabled': @@ -128,8 +137,8 @@ function wpcom_migration_e2e_screen_step( $step ) { break; case 'disable': - wpcom_migration_e2e_post( Settings_Page::SAVE_ENABLED_ACTION, array() ); - $page->handle_save_enabled(); + wpcom_migration_e2e_post( Manual_Page::SAVE_ENABLED_ACTION, array() ); + $manual->handle_save_enabled(); break; case 'assert-disabled': @@ -160,6 +169,18 @@ function wpcom_migration_e2e_render( Settings_Page $page ) { return ob_get_clean(); } +/** + * Renders the by-hand screen and returns the markup. + * + * @param Manual_Page $page The screen. + * @return string + */ +function wpcom_migration_e2e_render_manual( Manual_Page $page ) { + ob_start(); + $page->render_page(); + return ob_get_clean(); +} + /** * Fills in a signed POST request for an admin-post action. * From e5750f1f66426f038bdd26078578d886a6d9140b Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 23 Sep 2026 14:23:09 +0200 Subject: [PATCH 05/17] Let each mode's sentence carry its state The branded layout has one fixed heading, so the per-mode headings go and their sentences have to say where the site stands on their own. tests/e2e/connection-steps.php gets the matching copy updates so the whole suite stays green. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 10 +++---- plugin/reprint/class-settings-page.php | 37 ++++++++------------------ tests/e2e/blueprint-screen.json | 4 +++ tests/e2e/connection-steps.php | 10 ++++--- tests/e2e/screen-steps.php | 27 ++++++++++++------- 5 files changed, 44 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 7364bcae..61fee89b 100644 --- a/README.md +++ b/README.md @@ -26,16 +26,16 @@ To activate a source checkout directly (without a build), run `composer install ## The Reprint migration screen -`wp-admin/admin.php?page=wpcom-migration-status` (under the plugin's menu; `manage_options` to view and to set up by hand; the `administrator` role to connect, disconnect, or provision through the routes; single-site only). The screen shows one mode at a time — a heading, one sentence, at most one button, and the links that fit: +`wp-admin/admin.php?page=wpcom-migration-status` (under the plugin's menu; `manage_options` to view and to set up by hand; the `administrator` role to connect, disconnect, or provision through the routes; single-site only). The screen shows one mode at a time — one sentence saying where the site stands, at most one button, and the links that fit: | Mode | When | Shows | |---|---|---| -| Not available on networks | Multisite | Nothing | +| The exporter runs on single sites only | Multisite | Nothing | | The export secret no longer matches this site | The site's salts changed | *Log in with WordPress.com*, or *Continue on WordPress.com* when connected | -| Exporter on until *time* | A migration is running | *Continue on WordPress.com* and *Disconnect*, when connected | +| The exporter is on until *time* | A migration is running | *Continue on WordPress.com* and *Disconnect*, when connected | | Connected as *login* | Logged in; the migration has not started | *Continue on WordPress.com*, a *Disconnect* link | -| Set up by WordPress.com | Provisioned with an application password | A *Log in with WordPress.com* link | -| Connect this site to WordPress.com | Fresh site | *Log in with WordPress.com* | +| This site is set up and ready | Provisioned with an application password | A *Log in with WordPress.com* link | +| Log in with your WordPress.com account | Fresh site | *Log in with WordPress.com* | `Settings_Page::mode()` picks the first that fits, top to bottom. The *Continue* link's target is filtered by `wpcom_migration_continue_url`. diff --git a/plugin/reprint/class-settings-page.php b/plugin/reprint/class-settings-page.php index e1fde5c3..89e5b1bc 100644 --- a/plugin/reprint/class-settings-page.php +++ b/plugin/reprint/class-settings-page.php @@ -204,9 +204,9 @@ public static function mode( array $state, $user_connected ) { } /** - * Renders the mode: a heading, one sentence, at most one primary button, - * and the links that fit. Connection controls render only when a - * section is attached. + * Renders the mode: one sentence stating where the site stands, and the + * controls that fit. Connection controls render only when a section is + * attached. * * @param string $mode One of the MODE_* constants. * @param array $state Exporter::get_state(). @@ -217,13 +217,11 @@ private function render_mode( $mode, array $state, $user_connected ) { switch ( $mode ) { case self::MODE_BLOCKED: - $this->render_heading( __( 'Not available on networks', 'wpcom-migration' ) ); - $this->render_sentence( __( 'The exporter runs on single sites only.', 'wpcom-migration' ) ); + $this->render_sentence( __( 'The exporter runs on single sites only, so this site can\'t be migrated from here.', 'wpcom-migration' ) ); return; case self::MODE_BROKEN: - $this->render_heading( __( 'The export secret no longer matches this site', 'wpcom-migration' ) ); - $this->render_sentence( __( 'The site\'s salts changed. Start the migration again on WordPress.com, or save a new secret by hand below.', 'wpcom-migration' ) ); + $this->render_sentence( __( 'The export secret no longer matches this site — its security salts changed. Start the migration again on WordPress.com.', 'wpcom-migration' ) ); if ( null !== $section && $user_connected ) { $section->render_continue_button( true ); $section->render_disconnect_link(); @@ -233,14 +231,13 @@ private function render_mode( $mode, array $state, $user_connected ) { return; case self::MODE_READY: - $this->render_heading( + $this->render_sentence( sprintf( /* translators: %s: time of day. */ - __( 'Exporter on until %s', 'wpcom-migration' ), + __( 'The exporter is on until %s. The migration runs from WordPress.com; each export keeps it on for another hour.', 'wpcom-migration' ), self::window_closes_at( $state ) ) ); - $this->render_sentence( __( 'The migration runs from WordPress.com. Each export keeps the exporter on for another hour.', 'wpcom-migration' ) ); if ( null !== $section && $user_connected ) { $section->render_continue_button( false ); $section->render_disconnect_link(); @@ -250,17 +247,16 @@ private function render_mode( $mode, array $state, $user_connected ) { case self::MODE_CONNECTED_WAITING: $login = null !== $section ? \Automattic\WPCOM_Migration\Connect_Page::connected_login() : null; if ( null !== $login ) { - $this->render_heading( + $this->render_sentence( sprintf( /* translators: %s: WordPress.com user login. */ - __( 'Connected as %s', 'wpcom-migration' ), + __( 'Connected as %s. WordPress.com sets up the exporter when the migration starts.', 'wpcom-migration' ), $login ) ); } else { - $this->render_heading( __( 'Connected to WordPress.com', 'wpcom-migration' ) ); + $this->render_sentence( __( 'Connected to WordPress.com. WordPress.com sets up the exporter when the migration starts.', 'wpcom-migration' ) ); } - $this->render_sentence( __( 'WordPress.com sets up the exporter when the migration starts.', 'wpcom-migration' ) ); if ( null !== $section ) { $section->render_continue_button( true ); $section->render_disconnect_link(); @@ -268,8 +264,7 @@ private function render_mode( $mode, array $state, $user_connected ) { return; case self::MODE_PROVISIONED_WAITING: - $this->render_heading( __( 'Set up by WordPress.com', 'wpcom-migration' ) ); - $this->render_sentence( __( 'The exporter is off until the migration starts. Nothing to do here.', 'wpcom-migration' ) ); + $this->render_sentence( __( 'This site is set up and ready. The exporter stays off until the migration starts on WordPress.com.', 'wpcom-migration' ) ); if ( null !== $section ) { $section->render_connect_button( false ); } @@ -277,7 +272,6 @@ private function render_mode( $mode, array $state, $user_connected ) { case self::MODE_NEEDS_CONNECTING: default: - $this->render_heading( __( 'Connect this site to WordPress.com', 'wpcom-migration' ) ); $this->render_sentence( __( 'Log in with your WordPress.com account so WordPress.com can read this site and migrate it. Nothing is copied until you start the migration there.', 'wpcom-migration' ) ); if ( null !== $section ) { $section->render_connect_button( true ); @@ -286,15 +280,6 @@ private function render_mode( $mode, array $state, $user_connected ) { } } - /** - * Renders the mode heading. - * - * @param string $text Plain text. - */ - private function render_heading( $text ) { - echo '

' . esc_html( $text ) . '

'; - } - /** * Renders the mode's one sentence. * diff --git a/tests/e2e/blueprint-screen.json b/tests/e2e/blueprint-screen.json index e339f0ff..cd10f8ab 100644 --- a/tests/e2e/blueprint-screen.json +++ b/tests/e2e/blueprint-screen.json @@ -57,6 +57,10 @@ { "step": "runPHP", "code": " Date: Wed, 23 Sep 2026 14:38:36 +0200 Subject: [PATCH 06/17] Give the Reprint screen the old main screen's design One centred column, the WordPress mark alone in the header, the serif heading, and the connection controls where the email form was. The screen owns its stylesheet rather than borrowing the old screen's, which goes when that screen does. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- plugin/connection/class-connect-page.php | 10 +- plugin/reprint/class-settings-page.php | 92 +++++++++++++++--- plugin/reprint/settings-page.css | 114 +++++++++++++++++++++++ tests/e2e/connection-steps.php | 11 ++- tests/e2e/screen-steps.php | 16 +++- 6 files changed, 218 insertions(+), 27 deletions(-) create mode 100644 plugin/reprint/settings-page.css diff --git a/README.md b/README.md index 61fee89b..bd15c6f4 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ To activate a source checkout directly (without a build), run `composer install ## The Reprint migration screen -`wp-admin/admin.php?page=wpcom-migration-status` (under the plugin's menu; `manage_options` to view and to set up by hand; the `administrator` role to connect, disconnect, or provision through the routes; single-site only). The screen shows one mode at a time — one sentence saying where the site stands, at most one button, and the links that fit: +`wp-admin/admin.php?page=wpcom-migration-status` (the plugin's one menu entry; `manage_options` to view; the `administrator` role to connect, disconnect, or provision through the routes; single-site only). It carries the old main screen's design: the WordPress mark, a serif heading and one centred column. The screen shows one mode at a time — one sentence saying where the site stands, at most one button, and the links that fit: | Mode | When | Shows | |---|---|---| diff --git a/plugin/connection/class-connect-page.php b/plugin/connection/class-connect-page.php index 7d7fbd9d..bc494b7f 100644 --- a/plugin/connection/class-connect-page.php +++ b/plugin/connection/class-connect-page.php @@ -190,11 +190,11 @@ public function render_connect_button( $primary ) {

-

-

+

- target="_top" href=""> + target="_top" href="">

@@ -232,8 +232,8 @@ public function render_disconnect_link() {

- - + +

plugin_file = $plugin_file; add_action( 'admin_menu', array( $this, 'add_admin_menu' ), 20 ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + add_action( 'admin_head', array( $this, 'remove_admin_notices' ), 3 ); } /** @@ -147,8 +156,55 @@ public function add_admin_menu() { } /** - * Renders the screen: the mode and nothing else. The by-hand controls - * live on Manual_Page. + * Enqueues the screen's styles on the screen only. + * + * @param string $hook_suffix The current admin page. + */ + public function enqueue_assets( $hook_suffix ) { + if ( false === $this->page_hook || $hook_suffix !== $this->page_hook ) { + return; + } + + $plugin_data = get_file_data( $this->plugin_file, array( 'Version' => 'Version' ) ); + $version = '' !== $plugin_data['Version'] ? $plugin_data['Version'] : false; + + wp_enqueue_style( + 'wpcom-migration-variables', + plugins_url( 'assets/css/variables.css', $this->plugin_file ), + array(), + $version + ); + wp_enqueue_style( + 'wpcom-migration-fonts', + plugins_url( 'assets/css/fonts.css', $this->plugin_file ), + array(), + $version + ); + wp_enqueue_style( + self::STYLE_HANDLE, + plugins_url( 'reprint/settings-page.css', $this->plugin_file ), + array( 'wpcom-migration-variables', 'wpcom-migration-fonts', 'dashicons' ), + $version + ); + } + + /** + * Drops other plugins' notices on this screen, as the old main screen + * does: the layout has nowhere to put them. + */ + public function remove_admin_notices() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Comparing the page slug selects a display behaviour. + if ( self::PAGE_SLUG !== sanitize_key( wp_unslash( $_GET['page'] ?? '' ) ) ) { + return; + } + + remove_all_actions( 'admin_notices' ); + remove_all_actions( 'all_admin_notices' ); + } + + /** + * Renders the screen: the old main screen's design, with the mode where + * that screen had its email form. */ public function render_page() { if ( ! current_user_can( 'manage_options' ) ) { @@ -158,17 +214,27 @@ public function render_page() { $state = Exporter::get_state(); $user_connected = null !== $this->connection_section && \Automattic\WPCOM_Migration\Connection::is_user_connected(); $mode = self::mode( $state, $user_connected ); + ?> +
+ +
+ +
+
+

+

+ connection_section ) { + $this->connection_section->render_result_notice(); + } - echo '
'; - echo '

' . esc_html__( 'Reprint migration', 'wpcom-migration' ) . '

'; - - if ( null !== $this->connection_section ) { - $this->connection_section->render_result_notice(); - } - - $this->render_mode( $mode, $state, $user_connected ); - - echo '
'; + $this->render_mode( $mode, $state, $user_connected ); + ?> +
+
+ ', $step ); wpcom_migration_e2e_expect_not_contains( $html, 'Not configured yet', $step ); + wpcom_migration_e2e_expect_contains( $html, 'class="wpcom-migration-header"', $step ); + wpcom_migration_e2e_expect_contains( $html, 'class="wpcom-migration-container"', $step ); + wpcom_migration_e2e_expect_contains( $html, 'Migrate your site to WordPress.com', $step ); + wpcom_migration_e2e_expect_contains( $html, 'WordPress.com copies your posts, pages, media and settings', $step ); + wpcom_migration_e2e_expect_not_contains( $html, '
', $step ); + wpcom_migration_e2e_expect_not_contains( $html, 'Reprint migration', $step ); + wpcom_migration_e2e_expect_not_contains( $html, 'button-primary', $step ); + wpcom_migration_e2e_expect_not_contains( $html, 'Powered by', $step ); + wpcom_migration_e2e_expect_not_contains( $html, 'wpcom-migration-sidebar', $step ); break; case 'render-unconfigured-without-section': @@ -256,15 +265,16 @@ function wpcom_migration_e2e_expect_mode( $expected, $step ) { } /** - * Fails unless the markup holds exactly the expected number of primary buttons. + * Fails unless the markup holds exactly the expected number of primary + * buttons. The screen's primary is the branded button, not wp-admin's. * * @param string $html Rendered markup. - * @param int $expected Expected count of 'button-primary'. + * @param int $expected Expected count of 'wpcom-migration-button'. * @param string $step Name of the step, for the error message. * @throws RuntimeException When the count differs. */ function wpcom_migration_e2e_expect_primary_count( $html, $expected, $step ) { - $count = substr_count( $html, 'button-primary' ); + $count = substr_count( $html, 'wpcom-migration-button' ); if ( $expected !== $count ) { throw new RuntimeException( "Step '$step': expected $expected primary button(s), found $count." ); } From ebaf13a4dd0d83e8cf2a8dc991aef38d32a9e634 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 23 Sep 2026 15:01:24 +0200 Subject: [PATCH 07/17] Fix the by-hand screen's 403 by hiding its submenu row later Manual_Page::add_admin_menu() removed its own submenu row inside admin_menu, before admin.php can scan $submenu to find the page's parent. That left the by-hand screen unreachable by URL, refused with a 403. Move the removal to a new hide_submenu(), hooked on admin_enqueue_scripts at priority 1, after admin.php has authorized the request and resolved its render hook, but before the WP 6.9+ command palette reads $submenu at priority 10. Co-Authored-By: Claude Opus 5.5 (1M context) --- plugin/reprint/class-manual-page.php | 16 ++++++- tests/e2e/blueprint-menu.json | 4 ++ tests/e2e/menu-steps.php | 66 ++++++++++++++++++++++++---- 3 files changed, 76 insertions(+), 10 deletions(-) diff --git a/plugin/reprint/class-manual-page.php b/plugin/reprint/class-manual-page.php index 2473297f..6ab9160d 100644 --- a/plugin/reprint/class-manual-page.php +++ b/plugin/reprint/class-manual-page.php @@ -93,6 +93,8 @@ public function __construct( $plugin_file ) { add_action( 'admin_post_' . self::SAVE_SECRET_ACTION, array( $this, 'handle_save_secret' ) ); add_action( 'admin_post_' . self::SAVE_ENABLED_ACTION, array( $this, 'handle_save_enabled' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + // Priority 1: before the Command Palette (priority 10) lists $submenu. + add_action( 'admin_enqueue_scripts', array( $this, 'hide_submenu' ), 1 ); } /** @@ -105,8 +107,7 @@ public static function page_url() { } /** - * Registers the screen and then takes it out of the sidebar, along with - * the row WordPress adds for the parent the first time it gains a child. + * Registers the screen as a submenu of Settings_Page. * * Priority 21: Settings_Page creates the parent at 20. */ @@ -119,7 +120,18 @@ public function add_admin_menu() { self::PAGE_SLUG, array( $this, 'render_page' ) ); + } + /** + * Takes the screen out of the sidebar, along with the row WordPress adds + * for the parent the first time it gains a child. + * + * Can't run in add_admin_menu(): admin.php finds a submenu page's parent + * by scanning $submenu, and it does that before it lets the request in, + * so the rows have to survive until then. admin_enqueue_scripts fires + * once that check has passed. + */ + public function hide_submenu() { remove_submenu_page( Settings_Page::PAGE_SLUG, self::PAGE_SLUG ); remove_submenu_page( Settings_Page::PAGE_SLUG, Settings_Page::PAGE_SLUG ); } diff --git a/tests/e2e/blueprint-menu.json b/tests/e2e/blueprint-menu.json index 7e982ca0..246e00e4 100644 --- a/tests/e2e/blueprint-menu.json +++ b/tests/e2e/blueprint-menu.json @@ -14,6 +14,10 @@ "step": "runPHP", "code": " Date: Wed, 23 Sep 2026 15:32:46 +0200 Subject: [PATCH 08/17] Keep hidden screens reachable and give networks the old screen back Every hidden case now registers the screen and then takes its row out of $menu, so a whitelabelled brand no longer 403s the Reprint screen and the URLs Connect_Page, the Settings link and the activation redirect use. On a network the old screen keeps its network-admin row and is where the Settings link and the activation redirect lead; subsites get no row. Screens taken out of $menu set their own . The menu e2e drives the real whitelabel path, checks titles and prints step failures; a new multisite scenario runs through run-blueprint. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- README.md | 4 +- plugin/reprint/class-settings-page.php | 44 ++++-- plugin/wp_admin.php | 39 +++-- tests/e2e/blueprint-menu.json | 8 + tests/e2e/blueprint-multisite.json | 21 +++ tests/e2e/menu-steps.php | 199 ++++++++++++++++++++----- tests/e2e/run.sh | 23 ++- 7 files changed, 273 insertions(+), 65 deletions(-) create mode 100644 tests/e2e/blueprint-multisite.json diff --git a/README.md b/README.md index bd15c6f4..ced8aff6 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Source for the [Migrate to WordPress.com](https://wordpress.org/plugins/wpcom-mi - `plugin/connection/` — `Connection` (package setup, connect, disconnect), `Connect_Page` (the connection section of the screen), `bootstrap.php`. - `bin/build.sh` — writes `build/wpcom-migration/` and `build/wpcom-migration.zip`. - `bin/check-autoload-manifest.php` — asserts which classes the ZIP publishes through the Jetpack autoloader. -- `tests/e2e/` — Playground blueprints and request scripts: one scenario per credential state, one that drives the Reprint migration screen, one that provisions through the REST routes, one that drives the WordPress.com connection, one that checks the admin menu. +- `tests/e2e/` — Playground blueprints and request scripts: one scenario per credential state, one that drives the Reprint migration screen, one that provisions through the REST routes, one that drives the WordPress.com connection, one that checks the admin menu, and one that checks the menu on a network. ## Build @@ -41,6 +41,8 @@ To activate a source checkout directly (without a build), run `composer install The old BlogVault screen at `wp-admin/admin.php?page=wpcom-migration` is no longer in the sidebar. It stays reachable at its URL. +On a network the old screen keeps its row in the network admin, and the *Settings* link and the activation redirect lead there; subsites get no row. A brand whitelabelled with `hide` or `hide_from_menu` takes away the Reprint screen's row too; the screen stays reachable at its URL. + ## Setting up by hand `wp-admin/admin.php?page=wpcom-migration-manual` (`manage_options`, single-site only). Nothing links to it; support gives out the URL. It holds the export secret form, the exporter toggle, the export URL and the WordPress.com blog ID. Deactivating the plugin disconnects and discards the secret and the exporter state. diff --git a/plugin/reprint/class-settings-page.php b/plugin/reprint/class-settings-page.php index c1f8f1ac..655b0ac1 100644 --- a/plugin/reprint/class-settings-page.php +++ b/plugin/reprint/class-settings-page.php @@ -83,7 +83,7 @@ class Settings_Page { private $plugin_file; /** - * Hook suffix returned by add_submenu_page(). + * Hook suffix returned by add_menu_page(). * * @var string|false */ @@ -129,30 +129,46 @@ public function set_connection_section( \Automattic\WPCOM_Migration\Connect_Page } /** - * Adds the plugin's one sidebar entry, unless the old screen's brand - * has asked for no menu at all. + * Registers the screen as the plugin's one sidebar entry, then takes the + * row back out on a network or when the old screen's brand has asked for + * no menu. The screen stays reachable at its URL either way. */ public function add_admin_menu() { + $this->page_hook = add_menu_page( + __( 'Migrate to WordPress.com', 'wpcom-migration' ), + __( 'Migrate to WordPress.com', 'wpcom-migration' ), + 'manage_options', + self::PAGE_SLUG, + array( $this, 'render_page' ), + 'dashicons-wordpress-alt' + ); + /** - * Filters whether the plugin shows a sidebar entry. + * Filters whether the screen gets a sidebar row. The screen stays + * reachable at its URL either way. * * The old BlogVault screen answers false when the plugin is * whitelabelled to hide. Delete the filter with that screen. * - * @param bool $show Whether to register the menu entry. + * @param bool $show Whether the screen gets a sidebar row. */ - if ( ! apply_filters( 'wpcom_migration_show_menu', true ) ) { + if ( apply_filters( 'wpcom_migration_show_menu', true ) && ! is_multisite() ) { return; } - $this->page_hook = add_menu_page( - __( 'Migrate to WordPress.com', 'wpcom-migration' ), - __( 'Migrate to WordPress.com', 'wpcom-migration' ), - 'manage_options', - self::PAGE_SLUG, - array( $this, 'render_page' ), - 'dashicons-wordpress-alt' - ); + remove_menu_page( self::PAGE_SLUG ); + add_action( 'load-' . $this->page_hook, array( $this, 'set_title' ) ); + } + + /** + * Sets the screen's <title>, which core otherwise looks up in the + * sidebar rows. + */ + public function set_title() { + global $title; + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- admin-header.php prints this global. + $title = __( 'Migrate to WordPress.com', 'wpcom-migration' ); } /** diff --git a/plugin/wp_admin.php b/plugin/wp_admin.php index 125f673d..cb2a8189 100755 --- a/plugin/wp_admin.php +++ b/plugin/wp_admin.php @@ -25,14 +25,15 @@ public function mainUrl($_params = '') { /** * Where the plugin's own entry points send the user. The Reprint screen - * when it is loaded; the old screen in a source checkout without + * on a single site when it is loaded; the old screen on a network, where + * the Reprint screen can't migrate, and in a source checkout without * plugin/vendor/, where the Reprint classes are absent. * - * Not mainUrl(): siteInfoTags() posts that to the app as `adminurl`, - * where it still describes the old screen. + * Not simply mainUrl(): siteInfoTags() posts that to the app as + * `adminurl`, where it still describes the old screen. */ public function migrationScreenUrl() { - if (class_exists('\Automattic\WPCOM_Migration\Reprint\Settings_Page')) { + if (!is_multisite() && class_exists('\Automattic\WPCOM_Migration\Reprint\Settings_Page')) { return \Automattic\WPCOM_Migration\Reprint\Settings_Page::page_url(); } return $this->mainUrl(); @@ -62,21 +63,35 @@ public function menu() { if (!is_array($brand) || (!array_key_exists('hide', $brand) && !array_key_exists('hide_from_menu', $brand))) { $bname = $this->bvinfo->getBrandName(); $icon = $this->bvinfo->getBrandIcon(); - add_menu_page($bname, $bname, 'manage_options', $this->bvinfo->plugname, + $hook = add_menu_page($bname, $bname, 'manage_options', $this->bvinfo->plugname, array($this, 'adminPage'), $icon); - // The Reprint screen owns the sidebar entry now. Removing the page - // from the $menu global leaves its callback hooked and its - // $_registered_pages entry set, so admin.php?page=wpcom-migration - // still renders and keeps the toplevel_page_wpcom-migration hook - // suffix its stylesheet and notice handling key on. - remove_menu_page($this->bvinfo->plugname); + // On a single site the Reprint screen owns the sidebar entry. + // Removing the page from the $menu global leaves its callback + // hooked and its $_registered_pages entry set, so + // admin.php?page=wpcom-migration still renders and keeps the + // toplevel_page_wpcom-migration hook suffix its stylesheet and + // notice handling key on. The network admin, where the Reprint + // screen doesn't run, keeps this row. + if (!is_multisite()) { + remove_menu_page($this->bvinfo->plugname); + add_action('load-'.$hook, array($this, 'setScreenTitle')); + } } else { - // Whitelabelled out of the menu: the Reprint screen hides with it. + // Whitelabelled out of the menu: the Reprint screen drops its row too. add_filter('wpcom_migration_show_menu', '__return_false'); } } + /** + * Sets the old screen's <title>, which core otherwise looks up in the + * sidebar rows. + */ + public function setScreenTitle() { + global $title; + $title = $this->bvinfo->getBrandName(); + } + public function hidePluginDetails($plugin_metas, $slug) { $brand = $this->bvinfo->getBrandInfo(); $bvslug = $this->bvinfo->slug; diff --git a/tests/e2e/blueprint-menu.json b/tests/e2e/blueprint-menu.json index 246e00e4..a0397338 100644 --- a/tests/e2e/blueprint-menu.json +++ b/tests/e2e/blueprint-menu.json @@ -22,6 +22,10 @@ "step": "runPHP", "code": "<?php define( 'WP_ADMIN', true ); require '/wordpress/wp-load.php'; require '/wordpress/wp-content/wpcom-migration-e2e/menu-steps.php'; wpcom_migration_e2e_menu_step( 'old-screen-still-reachable' );" }, + { + "step": "runPHP", + "code": "<?php define( 'WP_ADMIN', true ); require '/wordpress/wp-load.php'; require '/wordpress/wp-content/wpcom-migration-e2e/menu-steps.php'; wpcom_migration_e2e_menu_step( 'old-screen-title' );" + }, { "step": "runPHP", "code": "<?php define( 'WP_ADMIN', true ); require '/wordpress/wp-load.php'; require '/wordpress/wp-content/wpcom-migration-e2e/menu-steps.php'; wpcom_migration_e2e_menu_step( 'suppressed' );" @@ -33,6 +37,10 @@ { "step": "runPHP", "code": "<?php define( 'WP_ADMIN', true ); require '/wordpress/wp-load.php'; require '/wordpress/wp-content/wpcom-migration-e2e/menu-steps.php'; wpcom_migration_e2e_menu_step( 'activation-redirect' );" + }, + { + "step": "runPHP", + "code": "<?php define( 'WP_ADMIN', true ); require '/wordpress/wp-load.php'; require '/wordpress/wp-content/wpcom-migration-e2e/menu-steps.php'; wpcom_migration_e2e_menu_step( 'whitelabelled' );" } ] } diff --git a/tests/e2e/blueprint-multisite.json b/tests/e2e/blueprint-multisite.json new file mode 100644 index 00000000..c3bdd08e --- /dev/null +++ b/tests/e2e/blueprint-multisite.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://playground.wordpress.net/blueprint-schema.json", + "preferredVersions": { "php": "8.2", "wp": "latest" }, + "steps": [ + { + "step": "enableMultisite" + }, + { + "step": "runPHP", + "code": "<?php require '/wordpress/wp-load.php'; require_once ABSPATH . 'wp-admin/includes/plugin.php'; $result = activate_plugin( 'wpcom-migration/wpcom_migration.php', '', true ); if ( is_wp_error( $result ) ) { file_put_contents( 'php://stderr', $result->get_error_message() . \"\\n\" ); exit( 1 ); }" + }, + { + "step": "runPHP", + "code": "<?php define( 'WP_ADMIN', true ); require '/wordpress/wp-load.php'; require '/wordpress/wp-content/wpcom-migration-e2e/menu-steps.php'; wpcom_migration_e2e_menu_step( 'multisite-subsite' );" + }, + { + "step": "runPHP", + "code": "<?php define( 'WP_ADMIN', true ); define( 'WP_NETWORK_ADMIN', true ); require '/wordpress/wp-load.php'; require '/wordpress/wp-content/wpcom-migration-e2e/menu-steps.php'; wpcom_migration_e2e_menu_step( 'multisite-network' );" + } + ] +} diff --git a/tests/e2e/menu-steps.php b/tests/e2e/menu-steps.php index ed3f4437..9ed9f4a1 100644 --- a/tests/e2e/menu-steps.php +++ b/tests/e2e/menu-steps.php @@ -12,6 +12,15 @@ use Automattic\WPCOM_Migration\Reprint\Manual_Page; use Automattic\WPCOM_Migration\Reprint\Settings_Page; +// WordPress's fatal handler would swallow the message into a generic error +// page; print it where run.sh shows the log instead. +set_exception_handler( + function ( $exception ) { + file_put_contents( 'php://stderr', get_class( $exception ) . ': ' . $exception->getMessage() . "\n" ); + exit( 1 ); + } +); + /** * Runs one menu step. * @@ -33,12 +42,18 @@ function wpcom_migration_e2e_menu_step( $step ) { add_filter( 'wpcom_migration_show_menu', '__return_false', 99 ); } + if ( 'whitelabelled' === $step ) { + // The brand option WPCOMInfo::getBrandInfo() reads; menu() then + // registers no old screen and asks the Reprint screen to hide. + update_option( 'wpcombrand', array( 'hide_from_menu' => true ) ); + } + $slugs = array(); - // 'screens-reachable' drives admin_menu itself, once per slug, with - // $pagenow and $plugin_page set the way admin.php sets them; the shared - // fire above would just be redone with the wrong globals. - if ( 'screens-reachable' !== $step ) { + // These steps drive admin_menu themselves, with $pagenow and + // $plugin_page set the way admin.php sets them; the shared fire below + // would just be redone with the wrong globals. + if ( ! in_array( $step, array( 'screens-reachable', 'old-screen-title' ), true ) ) { // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Core's own menu globals; this test builds them by hand. $GLOBALS['menu'] = array(); $GLOBALS['submenu'] = array(); @@ -46,7 +61,7 @@ function wpcom_migration_e2e_menu_step( $step ) { $GLOBALS['_parent_pages'] = array(); // phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound - do_action( 'admin_menu', '' ); + do_action( wpcom_migration_e2e_menu_hook( $step ), '' ); $slugs = wpcom_migration_e2e_menu_slugs(); } @@ -68,38 +83,7 @@ function wpcom_migration_e2e_menu_step( $step ) { break; case 'screens-reachable': - // What admin.php enforces before it renders a page: refuse with a - // 403 unless the page is both authorized and hooked. This is the - // path the 'manual-screen-registered' step below doesn't cover, - // because it looks the parent up by passing it in directly instead - // of letting core discover it from $submenu the way admin.php does. - foreach ( array( Settings_Page::PAGE_SLUG, Manual_Page::PAGE_SLUG, 'wpcom-migration' ) as $slug ) { - // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Core's own globals; this test sets them the way admin.php does. - $GLOBALS['pagenow'] = 'admin.php'; - $GLOBALS['plugin_page'] = $slug; - $GLOBALS['parent_file'] = null; - $GLOBALS['menu'] = array(); - $GLOBALS['submenu'] = array(); - $GLOBALS['_registered_pages'] = array(); - $GLOBALS['_parent_pages'] = array(); - $GLOBALS['_wp_menu_nopriv'] = array(); - $GLOBALS['_wp_submenu_nopriv'] = array(); - // phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound - - do_action( 'admin_menu', '' ); - - // admin.php checks both of these before it fires - // admin_enqueue_scripts, while still requiring - // wp-admin/includes/menu.php. wp-admin/includes/menu.php - // refuses with a 403 when this is false. - if ( ! user_can_access_admin_page() ) { - throw new RuntimeException( "admin.php?page=$slug would be refused with a 403." ); - } - // admin.php renders the page through this hook. - if ( ! get_plugin_page_hook( $slug, 'admin.php' ) ) { - throw new RuntimeException( "admin.php?page=$slug has no render hook." ); - } - } + wpcom_migration_e2e_assert_screens_reachable( array( Settings_Page::PAGE_SLUG, Manual_Page::PAGE_SLUG, 'wpcom-migration' ) ); break; case 'old-screen-still-reachable': @@ -129,6 +113,29 @@ function wpcom_migration_e2e_menu_step( $step ) { if ( array() !== $slugs ) { throw new RuntimeException( 'wpcom_migration_show_menu=false should leave no sidebar row, got: ' . wp_json_encode( $slugs ) ); } + wpcom_migration_e2e_assert_title( Settings_Page::PAGE_SLUG, __( 'Migrate to WordPress.com', 'wpcom-migration' ) ); + wpcom_migration_e2e_assert_title( Manual_Page::PAGE_SLUG, __( 'Set up by hand', 'wpcom-migration' ) ); + wpcom_migration_e2e_assert_screens_reachable( array( Settings_Page::PAGE_SLUG, Manual_Page::PAGE_SLUG, 'wpcom-migration' ) ); + break; + + case 'whitelabelled': + try { + if ( array() !== $slugs ) { + throw new RuntimeException( 'A brand with hide_from_menu should leave no sidebar row, got: ' . wp_json_encode( $slugs ) ); + } + if ( false !== apply_filters( 'wpcom_migration_show_menu', true ) ) { + throw new RuntimeException( 'A brand with hide_from_menu should turn wpcom_migration_show_menu off.' ); + } + // The brand drops the old screen entirely, as it always has. + wpcom_migration_e2e_assert_screens_reachable( array( Settings_Page::PAGE_SLUG, Manual_Page::PAGE_SLUG ) ); + } finally { + delete_option( 'wpcombrand' ); + } + break; + + case 'old-screen-title': + $admin = new WPCOMWPAdmin( new WPCOMWPSettings(), new WPCOMWPSiteInfo() ); + wpcom_migration_e2e_assert_title( 'wpcom-migration', $admin->bvinfo->getBrandName() ); break; case 'settings-link': @@ -169,6 +176,36 @@ function ( $location ) use ( &$captured ) { } break; + case 'multisite-subsite': + // The plugin's entry points on a network lead to the old screen + // in the network admin; a subsite has no row, since the Reprint + // screen can't migrate a network. + do_action( 'admin_enqueue_scripts', '' ); + + if ( array() !== $slugs ) { + throw new RuntimeException( 'A subsite of a network should have no plugin row, got: ' . wp_json_encode( $slugs ) ); + } + + $admin = new WPCOMWPAdmin( new WPCOMWPSettings(), new WPCOMWPSiteInfo() ); + $expected = network_admin_url( 'admin.php?page=wpcom-migration' ); + if ( $expected !== $admin->migrationScreenUrl() ) { + throw new RuntimeException( 'On a network the plugin should send users to ' . $expected . ', got: ' . $admin->migrationScreenUrl() ); + } + + wpcom_migration_e2e_assert_title( Settings_Page::PAGE_SLUG, __( 'Migrate to WordPress.com', 'wpcom-migration' ) ); + wpcom_migration_e2e_assert_screens_reachable( array( Settings_Page::PAGE_SLUG, Manual_Page::PAGE_SLUG ) ); + break; + + case 'multisite-network': + if ( array( 'wpcom-migration' ) !== $slugs ) { + throw new RuntimeException( 'The network admin should keep the old screen\'s row, got: ' . wp_json_encode( $slugs ) ); + } + + $admin = new WPCOMWPAdmin( new WPCOMWPSettings(), new WPCOMWPSiteInfo() ); + wpcom_migration_e2e_assert_title( 'wpcom-migration', $admin->bvinfo->getBrandName(), 'network_admin_menu' ); + wpcom_migration_e2e_assert_screens_reachable( array( 'wpcom-migration' ), 'network_admin_menu' ); + break; + default: throw new RuntimeException( 'Unknown menu step: ' . $step ); } @@ -188,3 +225,91 @@ function wpcom_migration_e2e_menu_slugs() { return $slugs; } + +/** + * Asserts each admin.php?page=<slug> would render: authorized and hooked, + * as admin.php checks before it lets the request in. + * + * Drives admin_menu itself, once per slug, with $pagenow and $plugin_page + * set the way admin.php sets them. This is the path the + * 'manual-screen-registered' step doesn't cover, because that looks the + * parent up by passing it in directly instead of letting core discover it + * from $submenu the way admin.php does. + * + * @param array $page_slugs Page slugs. + * @param string $menu_hook admin_menu, or network_admin_menu for the network admin. + * @throws RuntimeException When a page would be refused or render nothing. + */ +function wpcom_migration_e2e_assert_screens_reachable( array $page_slugs, $menu_hook = 'admin_menu' ) { + foreach ( $page_slugs as $slug ) { + wpcom_migration_e2e_fire_admin_menu_for( $slug, $menu_hook ); + + // wp-admin/includes/menu.php refuses with a 403 when this is false. + if ( ! user_can_access_admin_page() ) { + throw new RuntimeException( "admin.php?page=$slug would be refused with a 403." ); + } + // admin.php renders the page through this hook. + if ( ! get_plugin_page_hook( $slug, 'admin.php' ) ) { + throw new RuntimeException( "admin.php?page=$slug has no render hook." ); + } + } +} + +/** + * Asserts the <title> admin-header.php would print for admin.php?page=<slug>: + * admin.php fires load-<hook> and then admin-header.php asks + * get_admin_page_title(). + * + * @param string $slug Page slug. + * @param string $expected Expected title. + * @param string $menu_hook admin_menu, or network_admin_menu for the network admin. + * @throws RuntimeException When the title differs. + */ +function wpcom_migration_e2e_assert_title( $slug, $expected, $menu_hook = 'admin_menu' ) { + wpcom_migration_e2e_fire_admin_menu_for( $slug, $menu_hook ); + + $hook = get_plugin_page_hook( $slug, 'admin.php' ); + if ( ! $hook ) { + throw new RuntimeException( "admin.php?page=$slug has no render hook." ); + } + do_action( "load-{$hook}" ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores -- Core's own dynamic hook. + + $title = get_admin_page_title(); + if ( $expected !== $title ) { + throw new RuntimeException( "admin.php?page=$slug would have the title " . wp_json_encode( $title ) . ', expected ' . wp_json_encode( $expected ) . '.' ); + } +} + +/** + * Resets core's menu globals and fires the menu hook as admin.php does for + * admin.php?page=<slug>. + * + * @param string $slug Page slug. + * @param string $menu_hook admin_menu, or network_admin_menu for the network admin. + */ +function wpcom_migration_e2e_fire_admin_menu_for( $slug, $menu_hook = 'admin_menu' ) { + // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Core's own globals; this test sets them the way admin.php does. + $GLOBALS['pagenow'] = 'admin.php'; + $GLOBALS['plugin_page'] = $slug; + $GLOBALS['parent_file'] = null; + $GLOBALS['title'] = null; + $GLOBALS['menu'] = array(); + $GLOBALS['submenu'] = array(); + $GLOBALS['_registered_pages'] = array(); + $GLOBALS['_parent_pages'] = array(); + $GLOBALS['_wp_menu_nopriv'] = array(); + $GLOBALS['_wp_submenu_nopriv'] = array(); + // phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound + + do_action( $menu_hook, '' ); +} + +/** + * The menu hook a step's admin screen fires. + * + * @param string $step Step name. + * @return string + */ +function wpcom_migration_e2e_menu_hook( $step ) { + return 'multisite-network' === $step ? 'network_admin_menu' : 'admin_menu'; +} diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index ce17a8c8..07d43f5f 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -5,6 +5,9 @@ # settings screen through its form handlers, and once more to provision # through the REST routes with application passwords. # +# The multisite scenario runs its blueprint without a server: Playground +# refuses to enable multisite on a custom port, and its steps need no HTTP. +# # Usage: tests/e2e/run.sh <built-plugin-dir> # e.g. tests/e2e/run.sh build/wpcom-migration # @@ -28,7 +31,7 @@ PORT="${E2E_PORT:-9400}" BASE_URL="http://127.0.0.1:$PORT" PLAYGROUND_CLI="${PLAYGROUND_CLI:-npx --yes @wp-playground/cli@3.1.54}" # shellcheck disable=SC2206 # A space-separated scenario list, split on purpose. -SCENARIOS=(${E2E_SCENARIOS:-open closed secret-hash-deleted enabled-hash-deleted screen provisioning connection menu}) +SCENARIOS=(${E2E_SCENARIOS:-open closed secret-hash-deleted enabled-hash-deleted screen provisioning connection menu multisite}) for command_name in php npx; do if ! command -v "$command_name" >/dev/null 2>&1; then @@ -91,6 +94,24 @@ for scenario in "${SCENARIOS[@]}"; do echo "== $scenario" server_log="$(mktemp)" + if [ "$scenario" = "multisite" ]; then + # shellcheck disable=SC2086 # PLAYGROUND_CLI is a command line, split on purpose. + if ! $PLAYGROUND_CLI run-blueprint \ + --site-url=http://localhost \ + --blueprint="$E2E_DIR/blueprint-$scenario.json" \ + --mount="$PLUGIN_DIR:/wordpress/wp-content/plugins/wpcom-migration" \ + --mount="$E2E_DIR:/wordpress/wp-content/wpcom-migration-e2e" \ + >"$server_log" 2>&1; then + echo "Playground log for '$scenario':" >&2 + cat "$server_log" >&2 + exit 1 + fi + rm -f "$server_log" + server_log="" + echo "Scenario '$scenario' passed." + continue + fi + # shellcheck disable=SC2086 # PLAYGROUND_CLI is a command line, split on purpose. $PLAYGROUND_CLI server \ --port="$PORT" \ From b88d927c1f8c4b160ab40a0d201c0a98bfbf007e Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund <fredrik.rombach.ekelund@automattic.com> Date: Wed, 23 Sep 2026 15:48:01 +0200 Subject: [PATCH 09/17] Say which WordPress.com email the site is connected as The account's email is what people recognise; the login often isn't. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- README.md | 2 +- plugin/connection/class-connect-page.php | 9 +++++---- plugin/reprint/class-settings-page.php | 8 ++++---- tests/e2e/connection-steps.php | 6 +++--- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ced8aff6..1c885b6b 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ To activate a source checkout directly (without a build), run `composer install | The exporter runs on single sites only | Multisite | Nothing | | The export secret no longer matches this site | The site's salts changed | *Log in with WordPress.com*, or *Continue on WordPress.com* when connected | | The exporter is on until *time* | A migration is running | *Continue on WordPress.com* and *Disconnect*, when connected | -| Connected as *login* | Logged in; the migration has not started | *Continue on WordPress.com*, a *Disconnect* link | +| Connected as *email* | Logged in; the migration has not started | *Continue on WordPress.com*, a *Disconnect* link | | This site is set up and ready | Provisioned with an application password | A *Log in with WordPress.com* link | | Log in with your WordPress.com account | Fresh site | *Log in with WordPress.com* | diff --git a/plugin/connection/class-connect-page.php b/plugin/connection/class-connect-page.php index bc494b7f..a80dd7d4 100644 --- a/plugin/connection/class-connect-page.php +++ b/plugin/connection/class-connect-page.php @@ -240,14 +240,15 @@ public function render_disconnect_link() { } /** - * The WordPress.com login of the connected user, when the package has it. + * The WordPress.com email address of the connected user, when the package + * has it. * * @return string|null */ - public static function connected_login() { + public static function connected_email() { $user_data = Connection::connected_wpcom_user(); - if ( is_array( $user_data ) && ! empty( $user_data['login'] ) ) { - return (string) $user_data['login']; + if ( is_array( $user_data ) && ! empty( $user_data['email'] ) ) { + return (string) $user_data['email']; } return null; diff --git a/plugin/reprint/class-settings-page.php b/plugin/reprint/class-settings-page.php index 655b0ac1..a14ece61 100644 --- a/plugin/reprint/class-settings-page.php +++ b/plugin/reprint/class-settings-page.php @@ -327,13 +327,13 @@ private function render_mode( $mode, array $state, $user_connected ) { return; case self::MODE_CONNECTED_WAITING: - $login = null !== $section ? \Automattic\WPCOM_Migration\Connect_Page::connected_login() : null; - if ( null !== $login ) { + $email = null !== $section ? \Automattic\WPCOM_Migration\Connect_Page::connected_email() : null; + if ( null !== $email ) { $this->render_sentence( sprintf( - /* translators: %s: WordPress.com user login. */ + /* translators: %s: WordPress.com account email address. */ __( 'Connected as %s. WordPress.com sets up the exporter when the migration starts.', 'wpcom-migration' ), - $login + $email ) ); } else { diff --git a/tests/e2e/connection-steps.php b/tests/e2e/connection-steps.php index 5d56b5dd..8a5dfff4 100644 --- a/tests/e2e/connection-steps.php +++ b/tests/e2e/connection-steps.php @@ -377,9 +377,9 @@ function ( $location ) { case 'render-connected': wpcom_migration_e2e_expect_mode( Settings_Page::MODE_CONNECTED_WAITING, $step ); $html = wpcom_migration_e2e_render_connect_page(); - wpcom_migration_e2e_expect_contains( $html, 'Connected as e2e-tester', $step ); - if ( 1 !== substr_count( $html, 'Connected as e2e-tester' ) ) { - throw new RuntimeException( "Step '$step': expected 'Connected as e2e-tester' exactly once: " . substr( $html, 0, 400 ) ); + wpcom_migration_e2e_expect_contains( $html, 'Connected as e2e@example.com', $step ); + if ( 1 !== substr_count( $html, 'Connected as e2e@example.com' ) ) { + throw new RuntimeException( "Step '$step': expected 'Connected as e2e@example.com' exactly once: " . substr( $html, 0, 400 ) ); } wpcom_migration_e2e_expect_contains( $html, 'sets up the exporter when the migration starts', $step ); wpcom_migration_e2e_expect_contains( $html, 'Continue on WordPress.com', $step ); From 59b90eab63d840a9b56c3c4a0cd999ff1e649447 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund <fredrik.rombach.ekelund@automattic.com> Date: Wed, 23 Sep 2026 15:49:22 +0200 Subject: [PATCH 10/17] Bold the connected email Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- plugin/reprint/class-settings-page.php | 19 ++++++++++++++----- tests/e2e/connection-steps.php | 6 +++--- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/plugin/reprint/class-settings-page.php b/plugin/reprint/class-settings-page.php index a14ece61..3b73d49a 100644 --- a/plugin/reprint/class-settings-page.php +++ b/plugin/reprint/class-settings-page.php @@ -329,11 +329,11 @@ private function render_mode( $mode, array $state, $user_connected ) { case self::MODE_CONNECTED_WAITING: $email = null !== $section ? \Automattic\WPCOM_Migration\Connect_Page::connected_email() : null; if ( null !== $email ) { - $this->render_sentence( + $this->render_sentence_html( sprintf( - /* translators: %s: WordPress.com account email address. */ - __( 'Connected as %s. WordPress.com sets up the exporter when the migration starts.', 'wpcom-migration' ), - $email + /* translators: %s: WordPress.com account email address, in bold. */ + esc_html__( 'Connected as %s. WordPress.com sets up the exporter when the migration starts.', 'wpcom-migration' ), + '<strong>' . esc_html( $email ) . '</strong>' ) ); } else { @@ -368,7 +368,16 @@ private function render_mode( $mode, array $state, $user_connected ) { * @param string $text Plain text. */ private function render_sentence( $text ) { - echo '<p class="wpcom-migration-guidance">' . esc_html( $text ) . '</p>'; + $this->render_sentence_html( esc_html( $text ) ); + } + + /** + * Renders the mode's one sentence from markup the caller has escaped. + * + * @param string $html Escaped HTML. + */ + private function render_sentence_html( $html ) { + echo '<p class="wpcom-migration-guidance">' . $html . '</p>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped by the caller. } /** diff --git a/tests/e2e/connection-steps.php b/tests/e2e/connection-steps.php index 8a5dfff4..3331e0bd 100644 --- a/tests/e2e/connection-steps.php +++ b/tests/e2e/connection-steps.php @@ -377,9 +377,9 @@ function ( $location ) { case 'render-connected': wpcom_migration_e2e_expect_mode( Settings_Page::MODE_CONNECTED_WAITING, $step ); $html = wpcom_migration_e2e_render_connect_page(); - wpcom_migration_e2e_expect_contains( $html, 'Connected as e2e@example.com', $step ); - if ( 1 !== substr_count( $html, 'Connected as e2e@example.com' ) ) { - throw new RuntimeException( "Step '$step': expected 'Connected as e2e@example.com' exactly once: " . substr( $html, 0, 400 ) ); + wpcom_migration_e2e_expect_contains( $html, 'Connected as <strong>e2e@example.com</strong>', $step ); + if ( 1 !== substr_count( $html, 'Connected as <strong>e2e@example.com</strong>' ) ) { + throw new RuntimeException( "Step '$step': expected 'Connected as <strong>e2e@example.com</strong>' exactly once: " . substr( $html, 0, 400 ) ); } wpcom_migration_e2e_expect_contains( $html, 'sets up the exporter when the migration starts', $step ); wpcom_migration_e2e_expect_contains( $html, 'Continue on WordPress.com', $step ); From 703d5a61d69a8efd1e5e3b3f7f8e4f6c68c1be7f Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund <fredrik.rombach.ekelund@automattic.com> Date: Wed, 23 Sep 2026 16:05:01 +0200 Subject: [PATCH 11/17] Put the screen's notices at the top and bold the ready state Notices render above the page, where wp-admin puts them, rather than between the intro and the mode's sentence. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- plugin/reprint/class-settings-page.php | 19 +++++++++++-------- plugin/reprint/settings-page.css | 9 +++++++++ tests/e2e/connection-steps.php | 3 +++ tests/e2e/screen-steps.php | 2 +- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/plugin/reprint/class-settings-page.php b/plugin/reprint/class-settings-page.php index 3b73d49a..3859d3c9 100644 --- a/plugin/reprint/class-settings-page.php +++ b/plugin/reprint/class-settings-page.php @@ -230,6 +230,12 @@ public function render_page() { $state = Exporter::get_state(); $user_connected = null !== $this->connection_section && \Automattic\WPCOM_Migration\Connection::is_user_connected(); $mode = self::mode( $state, $user_connected ); + if ( null !== $this->connection_section ) { + // Where wp-admin puts notices: above the page, not inside the column. + echo '<div class="wpcom-migration-notices">'; + $this->connection_section->render_result_notice(); + echo '</div>'; + } ?> <header class="wpcom-migration-header"> <div class="wpcom-migration-header__wpcom-logo"> @@ -241,13 +247,7 @@ public function render_page() { <main class="wpcom-migration-content"> <h1><?php esc_html_e( 'Migrate your site to WordPress.com', 'wpcom-migration' ); ?></h1> <p><?php esc_html_e( 'Get ready for better speed, security, and support. WordPress.com copies your posts, pages, media and settings across for you.', 'wpcom-migration' ); ?></p> - <?php - if ( null !== $this->connection_section ) { - $this->connection_section->render_result_notice(); - } - - $this->render_mode( $mode, $state, $user_connected ); - ?> + <?php $this->render_mode( $mode, $state, $user_connected ); ?> </main> </div> <?php @@ -346,7 +346,10 @@ private function render_mode( $mode, array $state, $user_connected ) { return; case self::MODE_PROVISIONED_WAITING: - $this->render_sentence( __( 'This site is set up and ready. The exporter stays off until the migration starts on WordPress.com.', 'wpcom-migration' ) ); + $this->render_sentence_html( + '<strong>' . esc_html__( 'This site is set up and ready.', 'wpcom-migration' ) . '</strong> ' + . esc_html__( 'The exporter stays off until the migration starts on WordPress.com.', 'wpcom-migration' ) + ); if ( null !== $section ) { $section->render_connect_button( false ); } diff --git a/plugin/reprint/settings-page.css b/plugin/reprint/settings-page.css index b0755dd5..e5216014 100644 --- a/plugin/reprint/settings-page.css +++ b/plugin/reprint/settings-page.css @@ -10,6 +10,15 @@ body { background: #fff; } +/* The margins wp-admin gives notices inside .wrap. */ +.wpcom-migration-notices { + margin: 10px 20px 0 2px; +} + +.wpcom-migration-notices:empty { + display: none; +} + .wpcom-migration-header { display: flex; padding: 20px; diff --git a/tests/e2e/connection-steps.php b/tests/e2e/connection-steps.php index 3331e0bd..24007769 100644 --- a/tests/e2e/connection-steps.php +++ b/tests/e2e/connection-steps.php @@ -340,6 +340,9 @@ function ( $location ) { $_GET[ Connect_Page::CODE_QUERY_ARG ] = 'e2e_blocked'; $html = wpcom_migration_e2e_render_connect_page(); wpcom_migration_e2e_expect_contains( $html, 'could not register', $step ); + if ( strpos( $html, 'could not register' ) > strpos( $html, 'class="wpcom-migration-header"' ) ) { + throw new RuntimeException( "Step '$step': the notice should sit above the screen's header, where wp-admin puts notices." ); + } wpcom_migration_e2e_expect_contains( $html, 'e2e_blocked', $step ); wpcom_migration_e2e_expect_not_contains( $html, 'Blocked by the e2e scenario', $step ); delete_option( 'wpcom_migration_e2e_last_redirect' ); diff --git a/tests/e2e/screen-steps.php b/tests/e2e/screen-steps.php index 78274cd0..11d1542a 100644 --- a/tests/e2e/screen-steps.php +++ b/tests/e2e/screen-steps.php @@ -90,7 +90,7 @@ function wpcom_migration_e2e_screen_step( $step ) { } wpcom_migration_e2e_expect_mode( Settings_Page::MODE_PROVISIONED_WAITING, $step ); $html = wpcom_migration_e2e_render( $page ); - wpcom_migration_e2e_expect_contains( $html, 'This site is set up and ready', $step ); + wpcom_migration_e2e_expect_contains( $html, '<strong>This site is set up and ready.</strong>', $step ); wpcom_migration_e2e_expect_not_contains( $html, 'Set up by WordPress.com', $step ); wpcom_migration_e2e_expect_not_contains( $html, 'Nothing to do here', $step ); // The login stays, demoted to a link. From b57df74a737a39bed9beb2701e6bc85b9a0d278e Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund <fredrik.rombach.ekelund@automattic.com> Date: Wed, 23 Sep 2026 16:14:12 +0200 Subject: [PATCH 12/17] Let the by-hand screen remove the export secret A Remove secret button discards the secret and closes the export window, the same cleanup deactivation and disconnect run. An empty secret field still means "nothing entered", not "delete". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- README.md | 2 +- plugin/reprint/class-exporter.php | 3 ++- plugin/reprint/class-manual-page.php | 39 +++++++++++++++++++++++++++- tests/e2e/blueprint-screen.json | 16 ++++++++++++ tests/e2e/screen-steps.php | 17 ++++++++++++ 5 files changed, 74 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1c885b6b..28879f05 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ On a network the old screen keeps its row in the network admin, and the *Setting ## Setting up by hand -`wp-admin/admin.php?page=wpcom-migration-manual` (`manage_options`, single-site only). Nothing links to it; support gives out the URL. It holds the export secret form, the exporter toggle, the export URL and the WordPress.com blog ID. Deactivating the plugin disconnects and discards the secret and the exporter state. +`wp-admin/admin.php?page=wpcom-migration-manual` (`manage_options`, single-site only). Nothing links to it; support gives out the URL. It holds the export secret form, a *Remove secret* button (which also turns the exporter off), the exporter toggle, the export URL and the WordPress.com blog ID. Deactivating the plugin disconnects and discards the secret and the exporter state. ## How WordPress.com provisions the exporter diff --git a/plugin/reprint/class-exporter.php b/plugin/reprint/class-exporter.php index 46e5429b..536d19cf 100644 --- a/plugin/reprint/class-exporter.php +++ b/plugin/reprint/class-exporter.php @@ -198,7 +198,8 @@ public static function record_event( $event, array $context = array() ) { * Discards any stored export credentials. * * Runs on plugin activation and deactivation, clearing whatever was - * written while protect_options() was not in place. + * written while protect_options() was not in place; on disconnect; and + * from the by-hand screen's Remove secret button. */ public static function discard_credentials() { $had_any = false; diff --git a/plugin/reprint/class-manual-page.php b/plugin/reprint/class-manual-page.php index 6ab9160d..e15e7f19 100644 --- a/plugin/reprint/class-manual-page.php +++ b/plugin/reprint/class-manual-page.php @@ -39,6 +39,13 @@ class Manual_Page { */ const SAVE_ENABLED_ACTION = 'wpcom_migration_reprint_save_enabled'; + /** + * The admin-post action that removes the secret and turns the exporter off. + * + * @var string + */ + const DISCARD_SECRET_ACTION = 'wpcom_migration_reprint_discard_secret'; + /** * Query argument carrying the result of a form post back to the screen. * @@ -92,6 +99,7 @@ public function __construct( $plugin_file ) { add_action( 'admin_menu', array( $this, 'add_admin_menu' ), 21 ); add_action( 'admin_post_' . self::SAVE_SECRET_ACTION, array( $this, 'handle_save_secret' ) ); add_action( 'admin_post_' . self::SAVE_ENABLED_ACTION, array( $this, 'handle_save_enabled' ) ); + add_action( 'admin_post_' . self::DISCARD_SECRET_ACTION, array( $this, 'handle_discard_secret' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); // Priority 1: before the Command Palette (priority 10) lists $submenu. add_action( 'admin_enqueue_scripts', array( $this, 'hide_submenu' ), 1 ); @@ -206,6 +214,17 @@ public function handle_save_enabled() { $this->redirect_with_notice( 'disabled' ); } + /** + * Removes the secret, turns the exporter off, and redirects back with a + * result notice. + */ + public function handle_discard_secret() { + $this->authorize( self::DISCARD_SECRET_ACTION ); + + Exporter::discard_credentials(); + $this->redirect_with_notice( 'discarded' ); + } + /** * Stops a form post that is not a nonce-carrying administrator request on * a single site. @@ -228,7 +247,7 @@ private function authorize( $action ) { * Redirects back to the screen with a result code and exits. * * @param string $result One of saved, unchanged, enabled, disabled, - * not_configured, storage_failure. + * discarded, not_configured, storage_failure. */ private function redirect_with_notice( $result ) { wp_safe_redirect( add_query_arg( self::NOTICE_QUERY_ARG, $result, self::page_url() ) ); @@ -311,6 +330,23 @@ class="button wpcom-migration-reprint-toggle-secret" <?php submit_button( __( 'Save secret', 'wpcom-migration' ), 'secondary', 'wpcom_migration_reprint_save_secret_submit' ); ?> </form> <?php + if ( $state['has_secret'] ) { + $this->render_discard_form(); + } + } + + /** + * Renders the form that removes the secret. + */ + private function render_discard_form() { + ?> + <form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>"> + <input type="hidden" name="action" value="<?php echo esc_attr( self::DISCARD_SECRET_ACTION ); ?>" /> + <?php wp_nonce_field( self::DISCARD_SECRET_ACTION ); ?> + <?php submit_button( __( 'Remove secret', 'wpcom-migration' ), 'delete', 'wpcom_migration_reprint_discard_secret_submit', false ); ?> + <p class="description"><?php esc_html_e( 'Also turns the exporter off. WordPress.com can no longer export this site until a new secret is saved.', 'wpcom-migration' ); ?></p> + </form> + <?php } /** @@ -370,6 +406,7 @@ private function render_result_notice() { 'unchanged' => array( 'success', __( 'The secret was already up to date.', 'wpcom-migration' ) ), 'enabled' => array( 'success', __( 'Exporter on for the next hour.', 'wpcom-migration' ) ), 'disabled' => array( 'success', __( 'Exporter off.', 'wpcom-migration' ) ), + 'discarded' => array( 'success', __( 'Secret removed. The exporter is off.', 'wpcom-migration' ) ), 'not_configured' => array( 'error', __( 'Enter an export secret first.', 'wpcom-migration' ) ), 'storage_failure' => array( 'error', __( 'The secret could not be saved.', 'wpcom-migration' ) ), ); diff --git a/tests/e2e/blueprint-screen.json b/tests/e2e/blueprint-screen.json index cd10f8ab..9a81c57b 100644 --- a/tests/e2e/blueprint-screen.json +++ b/tests/e2e/blueprint-screen.json @@ -58,6 +58,22 @@ "step": "runPHP", "code": "<?php require '/wordpress/wp-load.php'; require '/wordpress/wp-content/wpcom-migration-e2e/screen-steps.php'; wpcom_migration_e2e_screen_step( 'enable' );" }, + { + "step": "runPHP", + "code": "<?php require '/wordpress/wp-load.php'; require '/wordpress/wp-content/wpcom-migration-e2e/screen-steps.php'; wpcom_migration_e2e_screen_step( 'discard-secret' );" + }, + { + "step": "runPHP", + "code": "<?php require '/wordpress/wp-load.php'; require '/wordpress/wp-content/wpcom-migration-e2e/screen-steps.php'; wpcom_migration_e2e_screen_step( 'assert-secret-discarded' );" + }, + { + "step": "runPHP", + "code": "<?php require '/wordpress/wp-load.php'; require '/wordpress/wp-content/wpcom-migration-e2e/screen-steps.php'; wpcom_migration_e2e_screen_step( 'save-secret' );" + }, + { + "step": "runPHP", + "code": "<?php require '/wordpress/wp-load.php'; require '/wordpress/wp-content/wpcom-migration-e2e/screen-steps.php'; wpcom_migration_e2e_screen_step( 'enable' );" + }, { "step": "runPHP", "code": "<?php require '/wordpress/wp-load.php'; require '/wordpress/wp-content/wpcom-migration-e2e/screen-steps.php'; wpcom_migration_e2e_screen_step( 'assert-no-mode-headings' );" diff --git a/tests/e2e/screen-steps.php b/tests/e2e/screen-steps.php index 11d1542a..d5bedef3 100644 --- a/tests/e2e/screen-steps.php +++ b/tests/e2e/screen-steps.php @@ -112,6 +112,7 @@ function wpcom_migration_e2e_screen_step( $step ) { wpcom_migration_e2e_expect_contains( $manual_html, '<h2>Export secret</h2>', $step ); wpcom_migration_e2e_expect_contains( $manual_html, 'Turn the exporter on', $step ); wpcom_migration_e2e_expect_contains( $manual_html, '<h2>Export URL</h2>', $step ); + wpcom_migration_e2e_expect_contains( $manual_html, 'Remove secret', $step ); break; case 'invalidate-secret': @@ -165,6 +166,22 @@ function wpcom_migration_e2e_screen_step( $step ) { wpcom_migration_e2e_expect_not_contains( $html, 'The exporter is on until', $step ); break; + case 'discard-secret': + wpcom_migration_e2e_post( Manual_Page::DISCARD_SECRET_ACTION, array() ); + $manual->handle_discard_secret(); + break; + + case 'assert-secret-discarded': + $state = Exporter::get_state(); + if ( $state['has_secret'] || $state['window_open'] ) { + throw new RuntimeException( 'Removing the secret should delete it and close the window: ' . wp_json_encode( $state ) ); + } + wpcom_migration_e2e_expect_mode( Settings_Page::MODE_NEEDS_CONNECTING, $step ); + $manual_html = wpcom_migration_e2e_render_manual( $manual ); + wpcom_migration_e2e_expect_not_contains( $manual_html, 'Remove secret', $step ); + wpcom_migration_e2e_expect_not_contains( $manual_html, 'Turn the exporter on', $step ); + break; + case 'assert-no-mode-headings': $html = wpcom_migration_e2e_render( $page ); wpcom_migration_e2e_expect_not_contains( $html, 'wpcom-migration-mode', $step ); From b48c3e251a808e5cbba76a2c0a3218d8cbb49fb2 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund <fredrik.rombach.ekelund@automattic.com> Date: Wed, 23 Sep 2026 16:18:46 +0200 Subject: [PATCH 13/17] Make Remove secret a link, and give descriptions paragraph spacing Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- plugin/reprint/class-manual-page.php | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/plugin/reprint/class-manual-page.php b/plugin/reprint/class-manual-page.php index e15e7f19..fc48ecde 100644 --- a/plugin/reprint/class-manual-page.php +++ b/plugin/reprint/class-manual-page.php @@ -74,6 +74,13 @@ class Manual_Page { */ const SCRIPT_HANDLE = 'wpcom-migration-reprint-manual'; + /** + * Style handle for the screen's inline styles. + * + * @var string + */ + const STYLE_HANDLE = 'wpcom-migration-reprint-manual'; + /** * Absolute path of the plugin's main file. * @@ -145,7 +152,7 @@ public function hide_submenu() { } /** - * Enqueues the screen's script on the screen only. + * Enqueues the screen's script and styles on the screen only. * * @param string $hook_suffix The current admin page. */ @@ -155,14 +162,20 @@ public function enqueue_assets( $hook_suffix ) { } $plugin_data = get_file_data( $this->plugin_file, array( 'Version' => 'Version' ) ); + $version = '' !== $plugin_data['Version'] ? $plugin_data['Version'] : false; wp_enqueue_script( self::SCRIPT_HANDLE, plugins_url( 'reprint/manual-page.js', $this->plugin_file ), array( 'wp-a11y' ), - '' !== $plugin_data['Version'] ? $plugin_data['Version'] : false, + $version, true ); + + // Descriptions get a paragraph's margin, not wp-admin's tighter one. + wp_register_style( self::STYLE_HANDLE, false, array(), $version ); + wp_enqueue_style( self::STYLE_HANDLE ); + wp_add_inline_style( self::STYLE_HANDLE, '.wpcom-migration-manual p.description { margin: 1em 0; }' ); } /** @@ -271,7 +284,7 @@ public function render_page() { $state = Exporter::get_state(); - echo '<div class="wrap">'; + echo '<div class="wrap wpcom-migration-manual">'; echo '<h1>' . esc_html__( 'Set up by hand', 'wpcom-migration' ) . '</h1>'; echo '<p class="description">' . esc_html__( 'Support may ask you to set this up by hand.', 'wpcom-migration' ) . '</p>'; @@ -343,7 +356,7 @@ private function render_discard_form() { <form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>"> <input type="hidden" name="action" value="<?php echo esc_attr( self::DISCARD_SECRET_ACTION ); ?>" /> <?php wp_nonce_field( self::DISCARD_SECRET_ACTION ); ?> - <?php submit_button( __( 'Remove secret', 'wpcom-migration' ), 'delete', 'wpcom_migration_reprint_discard_secret_submit', false ); ?> + <button type="submit" name="wpcom_migration_reprint_discard_secret_submit" class="button-link"><?php esc_html_e( 'Remove secret', 'wpcom-migration' ); ?></button> <p class="description"><?php esc_html_e( 'Also turns the exporter off. WordPress.com can no longer export this site until a new secret is saved.', 'wpcom-migration' ); ?></p> </form> <?php From 7cc3eaa0fe720fb3e1c02b6929d74087461d7e46 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund <fredrik.rombach.ekelund@automattic.com> Date: Wed, 23 Sep 2026 16:27:24 +0200 Subject: [PATCH 14/17] Make the debug page's save buttons primary, without p.submit Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- plugin/reprint/class-manual-page.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugin/reprint/class-manual-page.php b/plugin/reprint/class-manual-page.php index fc48ecde..2101846c 100644 --- a/plugin/reprint/class-manual-page.php +++ b/plugin/reprint/class-manual-page.php @@ -340,7 +340,7 @@ class="button wpcom-migration-reprint-toggle-secret" data-hide-label="<?php esc_attr_e( 'Hide secret', 'wpcom-migration' ); ?>"> <span class="dashicons dashicons-visibility" aria-hidden="true"></span> </button> - <?php submit_button( __( 'Save secret', 'wpcom-migration' ), 'secondary', 'wpcom_migration_reprint_save_secret_submit' ); ?> + <p><?php submit_button( __( 'Save secret', 'wpcom-migration' ), 'primary', 'wpcom_migration_reprint_save_secret_submit', false ); ?></p> </form> <?php if ( $state['has_secret'] ) { @@ -381,7 +381,7 @@ private function render_enable_form( array $state ) { <?php esc_html_e( 'Turn the exporter on', 'wpcom-migration' ); ?> </label> <p class="description"><?php esc_html_e( 'While on, anyone with the export secret can download this site\'s database and files. It turns itself off an hour after the last export.', 'wpcom-migration' ); ?></p> - <?php submit_button( __( 'Save', 'wpcom-migration' ), 'secondary', 'wpcom_migration_reprint_save_enabled_submit' ); ?> + <p><?php submit_button( __( 'Save', 'wpcom-migration' ), 'primary', 'wpcom_migration_reprint_save_enabled_submit', false ); ?></p> </form> <?php } From c9f0f1b372e96aa137734a79fd88fcb4010b7936 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund <fredrik.rombach.ekelund@automattic.com> Date: Wed, 23 Sep 2026 16:28:56 +0200 Subject: [PATCH 15/17] Put the debug page's notices above its heading Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- plugin/reprint/class-manual-page.php | 2 +- tests/e2e/screen-steps.php | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/plugin/reprint/class-manual-page.php b/plugin/reprint/class-manual-page.php index 2101846c..570fd45d 100644 --- a/plugin/reprint/class-manual-page.php +++ b/plugin/reprint/class-manual-page.php @@ -285,10 +285,10 @@ public function render_page() { $state = Exporter::get_state(); echo '<div class="wrap wpcom-migration-manual">'; + $this->render_result_notice(); echo '<h1>' . esc_html__( 'Set up by hand', 'wpcom-migration' ) . '</h1>'; echo '<p class="description">' . esc_html__( 'Support may ask you to set this up by hand.', 'wpcom-migration' ) . '</p>'; - $this->render_result_notice(); $this->render_secret_form( $state ); if ( $state['secret_valid'] ) { diff --git a/tests/e2e/screen-steps.php b/tests/e2e/screen-steps.php index d5bedef3..f3bd542d 100644 --- a/tests/e2e/screen-steps.php +++ b/tests/e2e/screen-steps.php @@ -177,7 +177,12 @@ function wpcom_migration_e2e_screen_step( $step ) { throw new RuntimeException( 'Removing the secret should delete it and close the window: ' . wp_json_encode( $state ) ); } wpcom_migration_e2e_expect_mode( Settings_Page::MODE_NEEDS_CONNECTING, $step ); - $manual_html = wpcom_migration_e2e_render_manual( $manual ); + $_GET[ Manual_Page::NOTICE_QUERY_ARG ] = 'discarded'; + $manual_html = wpcom_migration_e2e_render_manual( $manual ); + wpcom_migration_e2e_expect_contains( $manual_html, 'Secret removed.', $step ); + if ( strpos( $manual_html, 'Secret removed.' ) > strpos( $manual_html, '<h1>' ) ) { + throw new RuntimeException( "Step '$step': the notice should sit above the page heading." ); + } wpcom_migration_e2e_expect_not_contains( $manual_html, 'Remove secret', $step ); wpcom_migration_e2e_expect_not_contains( $manual_html, 'Turn the exporter on', $step ); break; From 4e876fde1d81d099c840eed0bab6b57f0294adcb Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund <fredrik.rombach.ekelund@automattic.com> Date: Wed, 23 Sep 2026 16:30:31 +0200 Subject: [PATCH 16/17] Rename the by-hand screen to "Set up Reprint manually" Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- README.md | 2 +- plugin/reprint/class-manual-page.php | 8 ++++---- tests/e2e/menu-steps.php | 2 +- tests/e2e/screen-steps.php | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 28879f05..51bc5130 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The old BlogVault screen at `wp-admin/admin.php?page=wpcom-migration` is no long On a network the old screen keeps its row in the network admin, and the *Settings* link and the activation redirect lead there; subsites get no row. A brand whitelabelled with `hide` or `hide_from_menu` takes away the Reprint screen's row too; the screen stays reachable at its URL. -## Setting up by hand +## Setting up Reprint manually `wp-admin/admin.php?page=wpcom-migration-manual` (`manage_options`, single-site only). Nothing links to it; support gives out the URL. It holds the export secret form, a *Remove secret* button (which also turns the exporter off), the exporter toggle, the export URL and the WordPress.com blog ID. Deactivating the plugin disconnects and discards the secret and the exporter state. diff --git a/plugin/reprint/class-manual-page.php b/plugin/reprint/class-manual-page.php index 570fd45d..a816394f 100644 --- a/plugin/reprint/class-manual-page.php +++ b/plugin/reprint/class-manual-page.php @@ -129,8 +129,8 @@ public static function page_url() { public function add_admin_menu() { $this->page_hook = add_submenu_page( Settings_Page::PAGE_SLUG, - __( 'Set up by hand', 'wpcom-migration' ), - __( 'Set up by hand', 'wpcom-migration' ), + __( 'Set up Reprint manually', 'wpcom-migration' ), + __( 'Set up Reprint manually', 'wpcom-migration' ), 'manage_options', self::PAGE_SLUG, array( $this, 'render_page' ) @@ -277,7 +277,7 @@ public function render_page() { } if ( is_multisite() ) { - echo '<div class="wrap"><h1>' . esc_html__( 'Set up by hand', 'wpcom-migration' ) . '</h1>'; + echo '<div class="wrap"><h1>' . esc_html__( 'Set up Reprint manually', 'wpcom-migration' ) . '</h1>'; echo '<p>' . esc_html__( 'The exporter runs on single sites only.', 'wpcom-migration' ) . '</p></div>'; return; } @@ -286,7 +286,7 @@ public function render_page() { echo '<div class="wrap wpcom-migration-manual">'; $this->render_result_notice(); - echo '<h1>' . esc_html__( 'Set up by hand', 'wpcom-migration' ) . '</h1>'; + echo '<h1>' . esc_html__( 'Set up Reprint manually', 'wpcom-migration' ) . '</h1>'; echo '<p class="description">' . esc_html__( 'Support may ask you to set this up by hand.', 'wpcom-migration' ) . '</p>'; $this->render_secret_form( $state ); diff --git a/tests/e2e/menu-steps.php b/tests/e2e/menu-steps.php index 9ed9f4a1..2c8cb5ae 100644 --- a/tests/e2e/menu-steps.php +++ b/tests/e2e/menu-steps.php @@ -114,7 +114,7 @@ function wpcom_migration_e2e_menu_step( $step ) { throw new RuntimeException( 'wpcom_migration_show_menu=false should leave no sidebar row, got: ' . wp_json_encode( $slugs ) ); } wpcom_migration_e2e_assert_title( Settings_Page::PAGE_SLUG, __( 'Migrate to WordPress.com', 'wpcom-migration' ) ); - wpcom_migration_e2e_assert_title( Manual_Page::PAGE_SLUG, __( 'Set up by hand', 'wpcom-migration' ) ); + wpcom_migration_e2e_assert_title( Manual_Page::PAGE_SLUG, __( 'Set up Reprint manually', 'wpcom-migration' ) ); wpcom_migration_e2e_assert_screens_reachable( array( Settings_Page::PAGE_SLUG, Manual_Page::PAGE_SLUG, 'wpcom-migration' ) ); break; diff --git a/tests/e2e/screen-steps.php b/tests/e2e/screen-steps.php index f3bd542d..83633d58 100644 --- a/tests/e2e/screen-steps.php +++ b/tests/e2e/screen-steps.php @@ -39,7 +39,7 @@ function wpcom_migration_e2e_screen_step( $step ) { wpcom_migration_e2e_expect_not_contains( $html, 'Connect this site to WordPress.com', $step ); wpcom_migration_e2e_expect_contains( $html, 'Log in with WordPress.com', $step ); wpcom_migration_e2e_expect_primary_count( $html, 1, $step ); - wpcom_migration_e2e_expect_not_contains( $html, 'Set up by hand', $step ); + wpcom_migration_e2e_expect_not_contains( $html, 'Set up Reprint manually', $step ); wpcom_migration_e2e_expect_not_contains( $html, 'id="wpcom-migration-reprint-secret"', $step ); wpcom_migration_e2e_expect_not_contains( $html, 'wpcom-migration-reprint-api-url', $step ); wpcom_migration_e2e_expect_not_contains( $html, 'wpcom-migration-status', $step ); @@ -105,7 +105,7 @@ function wpcom_migration_e2e_screen_step( $step ) { wpcom_migration_e2e_expect_not_contains( $html, 'Remote API URL', $step ); $manual_html = wpcom_migration_e2e_render_manual( $manual ); - wpcom_migration_e2e_expect_contains( $manual_html, '<h1>Set up by hand</h1>', $step ); + wpcom_migration_e2e_expect_contains( $manual_html, '<h1>Set up Reprint manually</h1>', $step ); wpcom_migration_e2e_expect_contains( $manual_html, 'id="wpcom-migration-reprint-secret"', $step ); wpcom_migration_e2e_expect_contains( $manual_html, 'id="wpcom-migration-reprint-api-url"', $step ); wpcom_migration_e2e_expect_contains( $manual_html, esc_attr( home_url( '?' . Exporter::QUERY_VAR ) ), $step ); From 2beafa52185f9a0b618d8a18541c25e0d9678be3 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund <fredrik.rombach.ekelund@automattic.com> Date: Wed, 23 Sep 2026 16:30:52 +0200 Subject: [PATCH 17/17] Various manual edits --- plugin/connection/class-connect-page.php | 6 ++++-- plugin/reprint/settings-page.css | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/plugin/connection/class-connect-page.php b/plugin/connection/class-connect-page.php index a80dd7d4..42350b44 100644 --- a/plugin/connection/class-connect-page.php +++ b/plugin/connection/class-connect-page.php @@ -232,8 +232,10 @@ public function render_disconnect_link() { <input type="hidden" name="action" value="<?php echo esc_attr( self::DISCONNECT_ACTION ); ?>" /> <?php wp_nonce_field( self::DISCONNECT_ACTION ); ?> <p> - <button type="submit" name="wpcom_migration_disconnect_submit" class="wpcom-migration-link wpcom-migration-link--delete"><?php esc_html_e( 'Disconnect', 'wpcom-migration' ); ?></button> - <span class="wpcom-migration-description"><?php esc_html_e( 'Removes this site\'s WordPress.com connection and the export secret WordPress.com installed.', 'wpcom-migration' ); ?></span> + <span class="wpcom-migration-description"> + <button type="submit" name="wpcom_migration_disconnect_submit" class="wpcom-migration-link wpcom-migration-link--delete"><?php esc_html_e( 'Disconnect', 'wpcom-migration' ); ?></button> + <?php esc_html_e( 'Removes this site\'s WordPress.com connection and the export secret WordPress.com installed.', 'wpcom-migration' ); ?> + </span> </p> </form> <?php diff --git a/plugin/reprint/settings-page.css b/plugin/reprint/settings-page.css index e5216014..0f7df3be 100644 --- a/plugin/reprint/settings-page.css +++ b/plugin/reprint/settings-page.css @@ -12,7 +12,7 @@ body { /* The margins wp-admin gives notices inside .wrap. */ .wpcom-migration-notices { - margin: 10px 20px 0 2px; + margin: 12px 0; } .wpcom-migration-notices:empty { @@ -54,7 +54,7 @@ body { font-size: inherit; } -.wpcom-migration-container a { +.wpcom-migration-container a:not( .wpcom-migration-button ) { color: var( --wpcom-migration-primary-bg ); } @@ -69,6 +69,7 @@ body { .wpcom-migration-button { display: inline-block; + margin: 4px 0; padding: 12px 24px; border: 0; border-radius: 4px;