diff --git a/README.md b/README.md index 0ee2a6a0..51bc5130 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ 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. -- `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, and one that checks the menu on a network. ## Build @@ -26,18 +26,26 @@ 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` (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 | |---|---|---| -| 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 | -| 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* | +| The exporter is on until *time* | A migration is running | *Continue on WordPress.com* and *Disconnect*, when connected | +| 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* | -`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. + +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 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. ## 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/connection/class-connect-page.php b/plugin/connection/class-connect-page.php index 7d7fbd9d..42350b44 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,22 +232,25 @@ public function render_disconnect_link() {

- - + + + +

. + * + * @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'; + + /** + * 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. + * + * @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'; + + /** + * 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. + * + * @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_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 ); + } + + /** + * The screen's URL. + * + * @return string + */ + public static function page_url() { + return admin_url( 'admin.php?page=' . self::PAGE_SLUG ); + } + + /** + * Registers the screen as a submenu of Settings_Page. + * + * 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 Reprint manually', 'wpcom-migration' ), + __( 'Set up Reprint manually', 'wpcom-migration' ), + 'manage_options', + 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 ); + } + + /** + * Enqueues the screen's script and 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_script( + self::SCRIPT_HANDLE, + plugins_url( 'reprint/manual-page.js', $this->plugin_file ), + array( 'wp-a11y' ), + $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; }' ); + } + + /** + * 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' ); + } + + /** + * 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. + * + * @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, + * 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() ) ); + 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 Reprint manually', 'wpcom-migration' ) . '

'; + echo '

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

'; + return; + } + + $state = Exporter::get_state(); + + echo '
'; + $this->render_result_notice(); + echo '

' . esc_html__( 'Set up Reprint manually', '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 = 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, '' ) : ''; + ?> +

+

+
+ + + + + +

+
+ render_discard_form(); + } + } + + /** + * Renders the form that removes the secret. + */ + private function render_discard_form() { + ?> +
+ + + +

+
+ +
+

+
+ + + +

+

+
+ +
+

+

+ + + 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' ) ), + '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' ) ), + ); + + 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 8be30f3c..3859d3c9 100644 --- a/plugin/reprint/class-settings-page.php +++ b/plugin/reprint/class-settings-page.php @@ -27,53 +27,11 @@ class Settings_Page { const PAGE_SLUG = 'wpcom-migration-status'; /** - * The BlogVault top-level menu slug this screen hangs under. + * Style handle for settings-page.css. * * @var string */ - const PARENT_SLUG = 'wpcom-migration'; - - /** - * 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'; + const STYLE_HANDLE = 'wpcom-migration-reprint-screen'; /** * The screen is not available: the site is a network. @@ -125,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 */ @@ -140,7 +98,7 @@ class Settings_Page { private $connection_section = null; /** - * Registers the screen, its form handlers and its script. + * Registers the screen, its styles and its notice suppression. * * @param string $plugin_file Absolute path of the plugin's main file. */ @@ -148,9 +106,8 @@ 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' ) ); + add_action( 'admin_head', array( $this, 'remove_admin_notices' ), 3 ); } /** @@ -172,120 +129,98 @@ public function set_connection_section( \Automattic\WPCOM_Migration\Connect_Page } /** - * Adds the menu entry under the plugin's menu. + * 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_submenu_page( - self::PARENT_SLUG, - __( 'Reprint migration', 'wpcom-migration' ), - __( 'Reprint migration', 'wpcom-migration' ), + $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' ); - } - /** - * 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 ) { + /** + * 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 the screen gets a sidebar row. + */ + if ( apply_filters( 'wpcom_migration_show_menu', true ) && ! is_multisite() ) { 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 - ); + remove_menu_page( self::PAGE_SLUG ); + add_action( 'load-' . $this->page_hook, array( $this, 'set_title' ) ); } /** - * Saves the secret and redirects back with a result notice. + * Sets the screen's , which core otherwise looks up in the + * sidebar rows. */ - 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' ); - } + public function set_title() { + global $title; - $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' ); + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- admin-header.php prints this global. + $title = __( 'Migrate to WordPress.com', 'wpcom-migration' ); } /** - * Turns the exporter on or off and redirects back with a result notice. + * Enqueues the screen's styles on the screen only. + * + * @param string $hook_suffix The current admin page. */ - 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' ); + public function enqueue_assets( $hook_suffix ) { + if ( false === $this->page_hook || $hook_suffix !== $this->page_hook ) { + return; } - // 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' ); - } + $plugin_data = get_file_data( $this->plugin_file, array( 'Version' => 'Version' ) ); + $version = '' !== $plugin_data['Version'] ? $plugin_data['Version'] : false; - Exporter::close_export_window(); - Exporter::record_event( 'window_closed', array( 'user_id' => get_current_user_id() ) ); - $this->redirect_with_notice( 'disabled' ); + 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 + ); } /** - * 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. + * Drops other plugins' notices on this screen, as the old main screen + * does: the layout has nowhere to put them. */ - 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' ) ); + 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; } - 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; + remove_all_actions( 'admin_notices' ); + remove_all_actions( 'all_admin_notices' ); } /** - * Renders the screen: the mode, then the by-hand section. + * 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' ) ) { @@ -295,22 +230,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 ); - - echo '<div class="wrap">'; - echo '<h1>' . esc_html__( 'Reprint migration', 'wpcom-migration' ) . '</h1>'; - - $this->render_result_notice(); 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>'; } - - $this->render_mode( $mode, $state, $user_connected ); - - if ( self::MODE_BLOCKED !== $mode ) { - $this->render_manual( $state, $user_connected ); - } - - echo '</div>'; + ?> + <header class="wpcom-migration-header"> + <div class="wpcom-migration-header__wpcom-logo"> + <span class="dashicons dashicons-wordpress-alt" aria-hidden="true"></span> + </div> + </header> + + <div class="wpcom-migration-container"> + <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 $this->render_mode( $mode, $state, $user_connected ); ?> + </main> + </div> + <?php } /** @@ -346,9 +286,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(). @@ -359,13 +299,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(); @@ -375,14 +313,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(); @@ -390,19 +327,18 @@ 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 ) { - $this->render_heading( + $email = null !== $section ? \Automattic\WPCOM_Migration\Connect_Page::connected_email() : null; + if ( null !== $email ) { + $this->render_sentence_html( sprintf( - /* translators: %s: WordPress.com user login. */ - __( 'Connected as %s', 'wpcom-migration' ), - $login + /* 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 { - $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(); @@ -410,8 +346,10 @@ 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_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 ); } @@ -419,7 +357,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 ); @@ -429,21 +366,21 @@ private function render_mode( $mode, array $state, $user_connected ) { } /** - * Renders the mode heading. + * Renders the mode's one sentence. * * @param string $text Plain text. */ - private function render_heading( $text ) { - echo '<h2 class="wpcom-migration-mode">' . esc_html( $text ) . '</h2>'; + private function render_sentence( $text ) { + $this->render_sentence_html( esc_html( $text ) ); } /** - * Renders the mode's one sentence. + * Renders the mode's one sentence from markup the caller has escaped. * - * @param string $text Plain text. + * @param string $html Escaped HTML. */ - private function render_sentence( $text ) { - echo '<p class="wpcom-migration-guidance">' . esc_html( $text ) . '</p>'; + private function render_sentence_html( $html ) { + echo '<p class="wpcom-migration-guidance">' . $html . '</p>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped by the caller. } /** @@ -456,158 +393,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 '<details class="wpcom-migration-manual">'; - echo '<summary><strong>' . esc_html__( 'Set up by hand', 'wpcom-migration' ) . '</strong></summary>'; - echo '<p class="description">' . esc_html__( 'Support may ask you to set this up by hand.', 'wpcom-migration' ) . '</p>'; - $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 '<hr />'; - echo '<p>' . sprintf( - /* translators: %d: WordPress.com blog ID. */ - esc_html__( 'WordPress.com blog ID: %d', 'wpcom-migration' ), - (int) $blog_id - ) . '</p>'; - } - - echo '</details>'; - } - - /** - * 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, '' ) : ''; - ?> - <h2><?php esc_html_e( 'Export secret', 'wpcom-migration' ); ?></h2> - <p><?php esc_html_e( 'Paste the secret supplied by WordPress.com.', 'wpcom-migration' ); ?></p> - <form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>"> - <input type="hidden" name="action" value="<?php echo esc_attr( self::SAVE_SECRET_ACTION ); ?>" /> - <?php wp_nonce_field( self::SAVE_SECRET_ACTION ); ?> - <label class="screen-reader-text" for="wpcom-migration-reprint-secret"><?php esc_html_e( 'Export secret', 'wpcom-migration' ); ?></label> - <input type="password" - class="regular-text code" - id="wpcom-migration-reprint-secret" - name="<?php echo esc_attr( self::SECRET_FIELD ); ?>" - value="<?php echo esc_attr( $stored_secret ); ?>" - autocomplete="off" /> - <button type="button" - class="button wpcom-migration-reprint-toggle-secret" - aria-controls="wpcom-migration-reprint-secret" - aria-pressed="false" - aria-label="<?php esc_attr_e( 'Show secret', 'wpcom-migration' ); ?>" - data-show-label="<?php esc_attr_e( 'Show secret', 'wpcom-migration' ); ?>" - 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' ); ?> - </form> - <?php - } - - /** - * Renders the enable form. - * - * @param array $state Exporter::get_state(). - */ - private function render_enable_form( array $state ) { - ?> - <hr /> - <h2><?php esc_html_e( 'Exporter', 'wpcom-migration' ); ?></h2> - <form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>"> - <input type="hidden" name="action" value="<?php echo esc_attr( self::SAVE_ENABLED_ACTION ); ?>" /> - <?php wp_nonce_field( self::SAVE_ENABLED_ACTION ); ?> - <label> - <input type="checkbox" - name="<?php echo esc_attr( self::ENABLED_FIELD ); ?>" - value="1"<?php checked( $state['window_open'] ); ?> /> - <?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' ); ?> - </form> - <?php - } - - /** - * Renders the read-only endpoint URL with a copy button. - */ - private function render_api_url() { - ?> - <hr /> - <h2><?php esc_html_e( 'Export URL', 'wpcom-migration' ); ?></h2> - <p><?php esc_html_e( 'Use this URL when WordPress.com asks for the export URL.', 'wpcom-migration' ); ?></p> - <input type="text" - class="regular-text code" - id="wpcom-migration-reprint-api-url" - value="<?php echo esc_attr( home_url( '?' . Exporter::QUERY_VAR ) ); ?>" - readonly /> - <button type="button" - class="button wpcom-migration-reprint-copy-url" - data-copied-message="<?php esc_attr_e( 'Export URL copied.', 'wpcom-migration' ); ?>"> - <?php esc_html_e( 'Copy', 'wpcom-migration' ); ?> - </button> - <?php - } - - /** - * Renders the notice for the result of the last form post, if any. - */ - private function render_result_notice() { - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The fixed query value selects a read-only notice. - $result = sanitize_key( wp_unslash( $_GET[ self::NOTICE_QUERY_ARG ] ?? '' ) ); - - $notices = array( - 'saved' => 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 '<div class="' . esc_attr( $classes ) . '"><p>' . wp_kses_post( $message ) . '</p></div>'; - } } 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/plugin/reprint/settings-page.css b/plugin/reprint/settings-page.css new file mode 100644 index 00000000..0f7df3be --- /dev/null +++ b/plugin/reprint/settings-page.css @@ -0,0 +1,124 @@ +/* + * The Reprint migration screen: the old main screen's design, one column. + * + * variables.css supplies the palette, fonts.css the Recoleta face. The old + * screen's style.css is deliberately not a dependency — it is that screen's + * furniture and goes when the screen does. + */ + +body { + background: #fff; +} + +/* The margins wp-admin gives notices inside .wrap. */ +.wpcom-migration-notices { + margin: 12px 0; +} + +.wpcom-migration-notices:empty { + display: none; +} + +.wpcom-migration-header { + display: flex; + padding: 20px; +} + +.wpcom-migration-header__wpcom-logo span { + width: 24px; + height: 24px; + font-size: 24px; +} + +.wpcom-migration-container { + box-sizing: border-box; + max-width: 476px; + margin: 0 auto; + padding: 40px 20px; + font-size: 14px; + color: var( --wpcom-migration-gray-70 ); + text-underline-offset: 4px; +} + +.wpcom-migration-container h1 { + margin: 0; + color: var( --wpcom-migration-gray-100 ); + font-family: var( --wpcom-migration-heading-font-family ); + font-size: 34px; + font-weight: normal; + line-height: 1.2; +} + +.wpcom-migration-container p { + margin: 8px 0; + font-size: inherit; +} + +.wpcom-migration-container a:not( .wpcom-migration-button ) { + color: var( --wpcom-migration-primary-bg ); +} + +.wpcom-migration-container a:hover { + color: var( --wpcom-migration-link-hover-color ); + text-decoration: none; +} + +.wpcom-migration-guidance { + margin-top: 24px; +} + +.wpcom-migration-button { + display: inline-block; + margin: 4px 0; + padding: 12px 24px; + border: 0; + border-radius: 4px; + color: var( --wpcom-migration-white ); + background: var( --wpcom-migration-primary-bg ); + font-size: 14px; + line-height: 1.4; + text-decoration: none; + cursor: pointer; +} + +.wpcom-migration-container a.wpcom-migration-button:hover, +.wpcom-migration-button:hover, +.wpcom-migration-button:focus { + color: var( --wpcom-migration-white ); + background: var( --wpcom-migration-button-hover-color ); +} + +.wpcom-migration-link { + padding: 0; + border: 0; + background: none; + color: var( --wpcom-migration-primary-bg ); + font-size: inherit; + text-decoration: underline; + cursor: pointer; +} + +.wpcom-migration-link--delete { + color: #b32d2e; +} + +.wpcom-migration-description { + color: var( --wpcom-migration-gray-50 ); + font-size: 12px; + line-height: 20px; +} + +/* Fix main content having extra padding on mobile. */ +.toplevel_page_wpcom-migration-status.auto-fold #wpcontent { + padding-left: 0; +} + +@media ( min-width: 961px ) { + .wpcom-migration-container { + padding-top: 100px; + } + + .wpcom-migration-container h1 { + font-size: 44px; + } +} diff --git a/plugin/wp_admin.php b/plugin/wp_admin.php index b3f55d86..cb2a8189 100755 --- a/plugin/wp_admin.php +++ b/plugin/wp_admin.php @@ -23,6 +23,22 @@ public function mainUrl($_params = '') { } } + /** + * Where the plugin's own entry points send the user. The Reprint screen + * 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 simply mainUrl(): siteInfoTags() posts that to the app as + * `adminurl`, where it still describes the old screen. + */ + public function migrationScreenUrl() { + if (!is_multisite() && 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 +53,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()); } } } @@ -47,11 +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); + + // 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 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; @@ -71,7 +111,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[] = '<a href="'.$this->mainUrl().'">'.__( 'Settings' ).'</a>'; + $links[] = '<a href="'.$this->migrationScreenUrl().'">'.__( 'Settings' ).'</a>'; } return $links; } diff --git a/tests/e2e/blueprint-menu.json b/tests/e2e/blueprint-menu.json new file mode 100644 index 00000000..a0397338 --- /dev/null +++ b/tests/e2e/blueprint-menu.json @@ -0,0 +1,46 @@ +{ + "$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": "<?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( 'sidebar' );" + }, + { + "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( 'manual-screen-registered' );" + }, + { + "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( 'screens-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-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' );" + }, + { + "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( 'settings-link' );" + }, + { + "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/blueprint-screen.json b/tests/e2e/blueprint-screen.json index e339f0ff..9a81c57b 100644 --- a/tests/e2e/blueprint-screen.json +++ b/tests/e2e/blueprint-screen.json @@ -57,6 +57,26 @@ { "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/connection-steps.php b/tests/e2e/connection-steps.php index f236dd86..24007769 100644 --- a/tests/e2e/connection-steps.php +++ b/tests/e2e/connection-steps.php @@ -15,6 +15,7 @@ use Automattic\WPCOM_Migration\Connect_Page; use Automattic\WPCOM_Migration\Connection; use Automattic\WPCOM_Migration\Reprint\Exporter; +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 @@ -59,19 +60,22 @@ function wpcom_migration_e2e_connection_step( $step ) { break; case 'assert-single-menu-entry': + // The Reprint screen owns the plugin's one sidebar entry (a + // top-level row, not a submenu of the old BlogVault screen); see + // menu-steps.php for the full menu assertions. require_once ABSPATH . 'wp-admin/includes/plugin.php'; $plugin_file = WP_PLUGIN_DIR . '/wpcom-migration/wpcom_migration.php'; $page = new Settings_Page( $plugin_file ); $page->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; @@ -237,12 +241,12 @@ function ( $entry ) { case 'render-connected-and-enabled': wpcom_migration_e2e_expect_mode( Settings_Page::MODE_READY, $step ); $html = wpcom_migration_e2e_render_connect_page(); - wpcom_migration_e2e_expect_contains( $html, 'Exporter on until', $step ); + wpcom_migration_e2e_expect_contains( $html, 'The exporter is on until', $step ); wpcom_migration_e2e_expect_contains( $html, 'Continue on WordPress.com', $step ); wpcom_migration_e2e_expect_contains( $html, 'value="' . Connect_Page::DISCONNECT_ACTION . '"', $step ); wpcom_migration_e2e_expect_primary_count( $html, 0, $step ); wpcom_migration_e2e_expect_not_contains( $html, 'Log in with WordPress.com', $step ); - wpcom_migration_e2e_expect_not_contains( $html, 'Nothing to do here', $step ); + wpcom_migration_e2e_expect_not_contains( $html, 'This site is set up and ready', $step ); break; case 'render-broken-connected': @@ -252,7 +256,8 @@ function ( $entry ) { delete_option( Exporter::SECRET_HASH_OPTION ); wpcom_migration_e2e_expect_mode( Settings_Page::MODE_BROKEN, $step ); $html = wpcom_migration_e2e_render_connect_page(); - wpcom_migration_e2e_expect_contains( $html, 'no longer matches', $step ); + wpcom_migration_e2e_expect_contains( $html, 'no longer matches this site', $step ); + wpcom_migration_e2e_expect_contains( $html, 'its security salts changed', $step ); wpcom_migration_e2e_expect_contains( $html, 'Continue on WordPress.com', $step ); wpcom_migration_e2e_expect_primary_count( $html, 1, $step ); wpcom_migration_e2e_expect_not_contains( $html, 'Log in with WordPress.com', $step ); @@ -286,8 +291,9 @@ function ( $entry ) { case 'render-not-connected': wpcom_migration_e2e_expect_mode( Settings_Page::MODE_NEEDS_CONNECTING, $step ); $html = wpcom_migration_e2e_render_connect_page(); - wpcom_migration_e2e_expect_contains( $html, 'Reprint migration', $step ); - wpcom_migration_e2e_expect_contains( $html, 'Connect this site to WordPress.com', $step ); + wpcom_migration_e2e_expect_contains( $html, 'Migrate your site to WordPress.com', $step ); + wpcom_migration_e2e_expect_contains( $html, 'Log in with your WordPress.com account', $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_contains( $html, 'value="' . Connect_Page::CONNECT_ACTION . '"', $step ); wpcom_migration_e2e_expect_primary_count( $html, 1, $step ); @@ -334,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' ); @@ -371,18 +380,22 @@ 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 <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 ); wpcom_migration_e2e_expect_contains( $html, 'https://wordpress.com/setup/site-migration?from=' . rawurlencode( home_url() ), $step ); wpcom_migration_e2e_expect_primary_count( $html, 1, $step ); wpcom_migration_e2e_expect_contains( $html, 'value="' . Connect_Page::DISCONNECT_ACTION . '"', $step ); - wpcom_migration_e2e_expect_contains( $html, 'button-link-delete', $step ); - wpcom_migration_e2e_expect_contains( $html, (string) WPCOM_MIGRATION_E2E_BLOG_ID, $step ); + wpcom_migration_e2e_expect_contains( $html, 'wpcom-migration-link--delete', $step ); + wpcom_migration_e2e_expect_not_contains( $html, (string) WPCOM_MIGRATION_E2E_BLOG_ID, $step ); wpcom_migration_e2e_expect_not_contains( $html, 'Log in with WordPress.com', $step ); + + // The blog ID lives on the by-hand screen now. + $manual_html = wpcom_migration_e2e_render_manual_page(); + wpcom_migration_e2e_expect_contains( $manual_html, (string) WPCOM_MIGRATION_E2E_BLOG_ID, $step ); break; case 'render-connected-without-user-data': @@ -504,6 +517,19 @@ function wpcom_migration_e2e_render_connect_page() { return ob_get_clean(); } +/** + * Renders the by-hand screen and returns the markup. + * + * @return string + */ +function wpcom_migration_e2e_render_manual_page() { + $page = new Manual_Page( WP_PLUGIN_DIR . '/wpcom-migration/wpcom_migration.php' ); + + ob_start(); + $page->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' ) ) { @@ -577,15 +603,16 @@ function wpcom_migration_e2e_expect_mode( $expected, $step ) { if ( ! function_exists( 'wpcom_migration_e2e_expect_primary_count' ) ) { /** - * 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." ); } diff --git a/tests/e2e/menu-steps.php b/tests/e2e/menu-steps.php new file mode 100644 index 00000000..2c8cb5ae --- /dev/null +++ b/tests/e2e/menu-steps.php @@ -0,0 +1,315 @@ +<?php +/** + * Drives the admin menu inside Playground, one step per runPHP call. + * + * The blueprint defines WP_ADMIN before wp-load so the plugin registers its + * admin hooks; the menu globals are then built by hand and inspected. Each + * step throws on a failed expectation. + * + * @package wpcom-migration + */ + +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. + * + * @param string $step Step name. + * @throws RuntimeException When an expectation fails. + */ +function wpcom_migration_e2e_menu_step( $step ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + // Firing admin_enqueue_scripts here, outside of a real admin page load, + // needs get_current_screen() defined for core's own hooked callbacks. + require_once ABSPATH . 'wp-admin/includes/screen.php'; + + wp_set_current_user( 1 ); + if ( ! current_user_can( 'manage_options' ) ) { + throw new RuntimeException( 'User 1 cannot manage_options.' ); + } + + if ( 'suppressed' === $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(); + + // 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(); + $GLOBALS['_registered_pages'] = array(); + $GLOBALS['_parent_pages'] = array(); + // phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound + + do_action( wpcom_migration_e2e_menu_hook( $step ), '' ); + + $slugs = wpcom_migration_e2e_menu_slugs(); + } + + switch ( $step ) { + case 'sidebar': + // Mirrors what admin-header.php does before the sidebar and the + // WP 6.9+ command palette read $submenu: the by-hand screen only + // clears its flyout rows on admin_enqueue_scripts. + do_action( 'admin_enqueue_scripts', '' ); + + if ( array( Settings_Page::PAGE_SLUG ) !== $slugs ) { + throw new RuntimeException( 'Expected one sidebar row for the Reprint screen, got: ' . wp_json_encode( $slugs ) ); + } + + if ( ! empty( $GLOBALS['submenu'][ Settings_Page::PAGE_SLUG ] ) ) { + throw new RuntimeException( 'The Reprint screen should draw no flyout: ' . wp_json_encode( $GLOBALS['submenu'][ Settings_Page::PAGE_SLUG ] ) ); + } + break; + + case 'screens-reachable': + wpcom_migration_e2e_assert_screens_reachable( array( Settings_Page::PAGE_SLUG, Manual_Page::PAGE_SLUG, 'wpcom-migration' ) ); + break; + + case 'old-screen-still-reachable': + $hook = get_plugin_page_hookname( 'wpcom-migration', '' ); + if ( 'toplevel_page_wpcom-migration' !== $hook ) { + throw new RuntimeException( "The old screen's hook suffix changed: $hook" ); + } + if ( ! isset( $GLOBALS['_registered_pages'][ $hook ] ) ) { + throw new RuntimeException( 'The old screen is no longer a registered page; its URL would be refused.' ); + } + if ( ! has_action( $hook ) ) { + throw new RuntimeException( 'The old screen has no render callback; its URL would render nothing.' ); + } + break; + + case 'manual-screen-registered': + $hook = get_plugin_page_hookname( Manual_Page::PAGE_SLUG, Settings_Page::PAGE_SLUG ); + if ( ! isset( $GLOBALS['_registered_pages'][ $hook ] ) ) { + throw new RuntimeException( "The by-hand screen is not a registered page: $hook" ); + } + if ( ! has_action( $hook ) ) { + throw new RuntimeException( "The by-hand screen has no render callback: $hook" ); + } + break; + + case 'suppressed': + 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 Reprint manually', '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': + $admin = new WPCOMWPAdmin( new WPCOMWPSettings(), new WPCOMWPSiteInfo() ); + $links = $admin->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; + + 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 ); + } +} + +/** + * The slugs of every top-level row currently in the menu. + * + * @return array + */ +function wpcom_migration_e2e_menu_slugs() { + $slugs = array(); + foreach ( (array) $GLOBALS['menu'] as $item ) { + $slugs[] = $item[2]; + } + sort( $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/request.php b/tests/e2e/request.php index f2a2f5eb..b4f18d9f 100644 --- a/tests/e2e/request.php +++ b/tests/e2e/request.php @@ -6,7 +6,7 @@ * Usage: php tests/e2e/request.php <base-url> <built-plugin-dir> <scenario> * * 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 dc0acf2a..07d43f5f 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -5,11 +5,16 @@ # 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 # # 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 +30,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 menu multisite}) for command_name in php npx; do if ! command -v "$command_name" >/dev/null 2>&1; then @@ -88,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" \ diff --git a/tests/e2e/screen-steps.php b/tests/e2e/screen-steps.php index 36280c94..83633d58 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,36 +28,47 @@ 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 ) { case 'render-unconfigured': wpcom_migration_e2e_expect_mode( Settings_Page::MODE_NEEDS_CONNECTING, $step ); $html = wpcom_migration_e2e_render( $page ); - wpcom_migration_e2e_expect_contains( $html, 'Connect this site to WordPress.com', $step ); + wpcom_migration_e2e_expect_contains( $html, 'Log in with your WordPress.com account', $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_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 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 ); wpcom_migration_e2e_expect_not_contains( $html, 'WordPress.com connection</h2>', $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, '<div class="wrap">', $step ); + wpcom_migration_e2e_expect_not_contains( $html, 'Reprint migration</h1>', $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': // No connection bootstrap: the mode renders without its controls. $bare = new Settings_Page( WP_PLUGIN_DIR . '/wpcom-migration/wpcom_migration.php' ); $html = wpcom_migration_e2e_render( $bare ); - wpcom_migration_e2e_expect_contains( $html, 'Connect this site to WordPress.com', $step ); + wpcom_migration_e2e_expect_contains( $html, 'Log in with your WordPress.com account', $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 +79,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': @@ -78,20 +90,29 @@ 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, 'Set up by WordPress.com', $step ); - wpcom_migration_e2e_expect_contains( $html, 'Nothing to do here', $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. 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, '<h2>Export secret</h2>', $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, '<h2>Export secret</h2>', $step ); - wpcom_migration_e2e_expect_contains( $html, 'Turn the exporter on', $step ); - wpcom_migration_e2e_expect_contains( $html, '<h2>Export URL</h2>', $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, '<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 ); + 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': @@ -102,15 +123,16 @@ function wpcom_migration_e2e_screen_step( $step ) { } wpcom_migration_e2e_expect_mode( Settings_Page::MODE_BROKEN, $step ); $html = wpcom_migration_e2e_render( $page ); - wpcom_migration_e2e_expect_contains( $html, 'no longer matches', $step ); + wpcom_migration_e2e_expect_contains( $html, 'no longer matches this site', $step ); + wpcom_migration_e2e_expect_contains( $html, 'its security salts changed', $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, 'Invalid: the site', $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': @@ -120,16 +142,17 @@ function wpcom_migration_e2e_screen_step( $step ) { } wpcom_migration_e2e_expect_mode( Settings_Page::MODE_READY, $step ); $html = wpcom_migration_e2e_render( $page ); - wpcom_migration_e2e_expect_contains( $html, 'Exporter on until', $step ); + wpcom_migration_e2e_expect_contains( $html, 'The exporter is on until', $step ); + wpcom_migration_e2e_expect_contains( $html, 'each export keeps it on for another hour', $step ); wpcom_migration_e2e_expect_primary_count( $html, 0, $step ); wpcom_migration_e2e_expect_not_contains( $html, 'Log in with WordPress.com', $step ); wpcom_migration_e2e_expect_not_contains( $html, 'Continue on WordPress.com', $step ); - wpcom_migration_e2e_expect_not_contains( $html, 'Nothing to do here', $step ); + wpcom_migration_e2e_expect_not_contains( $html, 'This site is set up and ready', $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': @@ -139,8 +162,34 @@ 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, 'Set up by WordPress.com', $step ); - wpcom_migration_e2e_expect_not_contains( $html, 'Exporter on until', $step ); + wpcom_migration_e2e_expect_contains( $html, 'This site is set up and ready', $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 ); + $_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; + + case 'assert-no-mode-headings': + $html = wpcom_migration_e2e_render( $page ); + wpcom_migration_e2e_expect_not_contains( $html, 'wpcom-migration-mode', $step ); break; default: @@ -160,6 +209,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. * @@ -226,15 +287,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." ); }