From c9904e69d9100a77b4f1d36bf05fb8c11a03416d Mon Sep 17 00:00:00 2001 From: Copons Date: Tue, 23 Jun 2026 15:07:16 +0100 Subject: [PATCH 01/13] AI Launchpad: honor is_visible_callback when building the task list build_tasks() included any AI-selected task that existed in the catalog, ignoring the catalog's own is_visible_callback. Legacy Launchpad_Task_Lists::build() drops non-visible tasks (WooCommerce tasks with no WooCommerce, goal-gated payment tasks, etc.), so the AI path could surface tasks that can't be completed and whose CTA would 404. Filter the read path on is_visible() (made public on Launchpad_Task_Lists, mirroring the already-public load_calypso_path()). Gating the read rather than the write keeps the deterministic fallback usable, since its fixed per-goal lists also contain conditionally-visible tasks. Load wp-admin/includes/plugin.php first so visibility callbacks that call is_plugin_active() don't fatal in the REST context. Verified on Atomic: GET /wpcom/v2/ai-launchpad drops 17 inapplicable tasks (WooCommerce, Sensei, About-page, membership/subscriber) and returns 200. Part of DOTOBRD-480. --- .../fix-ai-launchpad-honor-task-visibility | 4 ++ .../ai-launchpad/class-ai-launchpad-rest.php | 21 +++++++++ .../launchpad/class-launchpad-task-lists.php | 6 ++- .../ai-launchpad/AI_Launchpad_REST_Test.php | 45 +++++++++++++++++++ 4 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/fix-ai-launchpad-honor-task-visibility diff --git a/projects/packages/jetpack-mu-wpcom/changelog/fix-ai-launchpad-honor-task-visibility b/projects/packages/jetpack-mu-wpcom/changelog/fix-ai-launchpad-honor-task-visibility new file mode 100644 index 000000000000..d5396d15d6a0 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/fix-ai-launchpad-honor-task-visibility @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +AI Launchpad: only surface tasks the catalog would make visible on the site (honor is_visible_callback), so plugin- or goal-gated tasks no longer appear where they can't be completed. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php index 5e3498f062ee..04d7bffa2522 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php @@ -349,6 +349,14 @@ private function build_tasks( $tasks ) { $definitions = wpcom_launchpad_get_task_definitions(); $built = array(); + // Some catalog visibility callbacks call is_plugin_active() (e.g. the + // WooCommerce tasks), which lives in wp-admin/includes/plugin.php and is not + // loaded during a REST request. Load it once so the is_visible() gate below + // can't fatal on those callbacks. + if ( ! function_exists( 'is_plugin_active' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + foreach ( $tasks as $task ) { if ( ! is_array( $task ) || ! isset( $task['id'] ) || ! isset( $task['subtitle'] ) ) { continue; @@ -361,6 +369,19 @@ private function build_tasks( $tasks ) { $definition = $definitions[ $task['id'] ]; $definition['id'] = $task['id']; + // Honor the catalog's own visibility gate, mirroring the legacy + // Launchpad_Task_Lists::build(): drop any task the catalog would hide + // on this site (e.g. WooCommerce tasks with no WooCommerce, or + // goal-gated tasks). The AI can select from the full menu, but a task + // it picks that is not applicable here must not render — the CTA would + // 404 and the task could never complete. Filtering on read (rather than + // rejecting on write) keeps the deterministic fallback usable: its fixed + // per-goal lists also contain conditionally-visible tasks, so gating the + // write path could strand a goal with too few surviving tasks. + if ( ! wpcom_launchpad_checklists()->is_visible( $definition ) ) { + continue; + } + $built[] = array( 'id' => $task['id'], 'subtitle' => $task['subtitle'], diff --git a/projects/packages/jetpack-mu-wpcom/src/features/launchpad/class-launchpad-task-lists.php b/projects/packages/jetpack-mu-wpcom/src/features/launchpad/class-launchpad-task-lists.php index 289b5630a59e..66c5af5c6f79 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/launchpad/class-launchpad-task-lists.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/launchpad/class-launchpad-task-lists.php @@ -407,11 +407,15 @@ public function build( $id, $launchpad_context = null ) { * Allows a function to be called to determine if a task should be visible. * For instance: we don't even want to show the verify_email task if it's already done. * + * Public so callers that build their own task lists from the catalog (e.g. the + * AI Launchpad REST controller) can apply the same visibility gate as build(), + * mirroring the public load_calypso_path(). + * * @param Task $task_definition A task definition. * @param string|null $launchpad_context Optional. Screen in which launchpad is loading. * @return boolean True if task is visible, false if not. */ - protected function is_visible( $task_definition, $launchpad_context = null ) { + public function is_visible( $task_definition, $launchpad_context = null ) { if ( empty( $task_definition ) ) { return false; } diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php index 61ca6b1c19d6..4d848f727ff1 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php @@ -197,6 +197,51 @@ public function test_get_returns_composite_shape() { $this->assertNull( $last_task['calypso_path'] ); } + /** + * Test that GET drops tasks the catalog would hide on this site (is_visible_callback), + * while keeping the visible ones. WooCommerce tasks are gated to WoA sites with + * WooCommerce active, so woo_products is not visible in the test environment. + */ + public function test_get_excludes_non_visible_tasks() { + wp_set_current_user( $this->admin_id ); + + $payload = self::valid_payload(); + $payload['tasks'] = array( + array( + 'id' => 'first_post_published', + 'subtitle' => 'Share your first trail story.', + ), + array( + 'id' => 'woo_products', + 'subtitle' => 'Add your first product.', + ), + array( + 'id' => 'site_launched', + 'subtitle' => 'Go live and share your journey.', + ), + ); + + update_option( + 'wpcom_ai_launchpad_ai_output', + array( + 'version' => 1, + 'source' => 'ai', + 'generated_at' => 1717000000, + 'payload' => $payload, + ), + false + ); + + $result = $this->call_api( Requests::GET ); + + $this->assertSame( 200, $result->get_status() ); + + $ids = array_column( $result->get_data()['tasks'], 'id' ); + $this->assertContains( 'first_post_published', $ids ); + $this->assertContains( 'site_launched', $ids ); + $this->assertNotContains( 'woo_products', $ids ); + } + /** * Test that GET requires authentication. */ From de25c472724035359a349274dc3a2bca4bf6879c Mon Sep 17 00:00:00 2001 From: Copons Date: Tue, 23 Jun 2026 16:14:38 +0100 Subject: [PATCH 02/13] AI Launchpad: complete acknowledgment tasks on CTA click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six "acknowledgment" tasks (complete_profile, manage_subscribers, manage_paid_newsletter_plan, earn_money, start_building_your_audience, site_monitoring_page) have no completion signal when the launchpad runs in wp-admin: in Calypso they complete on click / page-visit, which writes the status to Calypso's selected site — never from the wp-admin context. The AI Launchpad runs in wp-admin (on both Simple and Atomic), so it has no way to tick them. Add a small allowlisted POST /ai-launchpad/complete-task endpoint that marks one of these tasks complete, restricted to the COMPLETE_ON_CLICK_TASK_IDS allowlist and to tasks actually on the site's AI-selected list. The tailored list calls it when the user clicks an acknowledgment task's CTA (awaited before the same-tab navigation, best-effort so a failed write never blocks the CTA). Verified in wp-admin on Atomic: clicking "Complete your profile" fires the POST (200) and the task ticks complete; the endpoint rejects non-allowlisted and unknown ids. Part of DOTOBRD-480. --- .../add-ai-launchpad-complete-on-click | 4 + .../ai-launchpad/class-ai-launchpad-rest.php | 79 +++++++++++++++++++ .../js/tailored-list/model.test.mts | 15 ++++ .../ai-launchpad/js/tailored-list/model.ts | 28 +++++++ .../js/tailored-list/tailored-list.tsx | 13 +++ .../ai-launchpad/AI_Launchpad_REST_Test.php | 68 ++++++++++++++++ 6 files changed, 207 insertions(+) create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-complete-on-click diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-complete-on-click b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-complete-on-click new file mode 100644 index 000000000000..58d4fcb68fce --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-complete-on-click @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +AI Launchpad: mark acknowledgment tasks (e.g. Complete your profile) done when their CTA is clicked, since they have no completion signal on Atomic. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php index 04d7bffa2522..e650768ca2e7 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php @@ -19,6 +19,25 @@ class AI_Launchpad_REST extends WP_REST_Controller { const LAUNCH_TASK_IDS = array( 'site_launched', 'blog_launched', 'woo_launch_site', 'link_in_bio_launched', 'videopress_launched' ); + /** + * Acknowledgment tasks that have no completion signal when the launchpad runs + * in wp-admin: in the legacy launchpad they complete on click / page-visit in + * Calypso, which writes the status to Calypso's *selected* site — never from + * the wp-admin context. The AI Launchpad runs in wp-admin (on both Simple and + * Atomic), so it has no way to tick them; it marks them complete locally when + * the user clicks their CTA. Server-side allowlist so the complete-task route + * can only tick these ids, never arbitrary catalog tasks. Mirrored client-side + * in model.ts (COMPLETE_ON_CLICK_TASK_IDS). + */ + const COMPLETE_ON_CLICK_TASK_IDS = array( + 'complete_profile', + 'manage_subscribers', + 'manage_paid_newsletter_plan', + 'earn_money', + 'start_building_your_audience', + 'site_monitoring_page', + ); + /** * Class constructor. */ @@ -88,6 +107,26 @@ public function register_routes() { ) ); + register_rest_route( + $this->namespace, + $this->rest_base . '/complete-task', + array( + array( + 'methods' => 'POST', + 'callback' => array( $this, 'complete_task' ), + 'permission_callback' => array( $this, 'can_write' ), + 'args' => array( + 'task_id' => array( + 'description' => 'The acknowledgment task to mark complete.', + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_key', + ), + ), + ), + ) + ); + register_rest_route( $this->namespace, $this->rest_base . '/tailored', @@ -291,6 +330,46 @@ public function update_tailored( $request ) { return array( 'ai_output' => $ai_output ); } + /** + * Marks an acknowledgment task complete. These tasks have no completion signal + * in wp-admin — in Calypso they complete on click/page-visit, written to + * Calypso's selected site — so the client calls this when the user clicks the + * task's CTA. Restricted to the COMPLETE_ON_CLICK_TASK_IDS allowlist and to + * tasks actually on the site's AI-selected list, so the route can't tick + * arbitrary catalog tasks. + * + * @param WP_REST_Request $request Request object. + * @return array|WP_Error + */ + public function complete_task( $request ) { + $task_id = $request['task_id']; + + if ( ! in_array( $task_id, self::COMPLETE_ON_CLICK_TASK_IDS, true ) ) { + return new WP_Error( + 'ai_launchpad_task_not_completable', + __( 'This task cannot be completed this way.', 'jetpack-mu-wpcom' ), + array( 'status' => 400 ) + ); + } + + // Only tasks the AI actually put on this site's list may be completed, so a + // capable user can't tick a task that isn't on their launchpad. + if ( ! in_array( $task_id, wpcom_ai_launchpad_get_ai_task_ids(), true ) ) { + return new WP_Error( + 'ai_launchpad_task_not_selected', + __( 'This task is not on the tailored list.', 'jetpack-mu-wpcom' ), + array( 'status' => 404 ) + ); + } + + wpcom_mark_launchpad_task_complete( $task_id ); + + return array( + 'completed' => true, + 'task_id' => $task_id, + ); + } + /** * Deletes the AI output and marks the AI Launchpad as dismissed. * diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.test.mts b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.test.mts index e4322e90ce9d..f1c0790c4696 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.test.mts +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.test.mts @@ -7,6 +7,7 @@ import { validateAgainstSchema } from '../lib/schema-validator.ts'; import { ctaKind, firstIncompleteIndex, + isCompleteOnClickTask, isTaskActionable, launchSiteUrl, resolveCtaUrl, @@ -156,6 +157,20 @@ describe( 'isTaskActionable', () => { } ); } ); +describe( 'isCompleteOnClickTask', () => { + it( 'is true for acknowledgment tasks with no Atomic completion signal', () => { + assert.equal( isCompleteOnClickTask( 'complete_profile' ), true ); + assert.equal( isCompleteOnClickTask( 'earn_money' ), true ); + assert.equal( isCompleteOnClickTask( 'site_monitoring_page' ), true ); + } ); + + it( 'is false for tasks that complete via a real signal or listener', () => { + assert.equal( isCompleteOnClickTask( 'first_post_published' ), false ); + assert.equal( isCompleteOnClickTask( 'site_theme_selected' ), false ); + assert.equal( isCompleteOnClickTask( 'woo_products' ), false ); + } ); +} ); + describe( 'resolveCtaUrl', () => { /** * Build CtaHandlers that record the clicked task IDs and return marker URLs. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.ts b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.ts index a5384236fac6..3fea867f48d5 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.ts +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.ts @@ -73,6 +73,34 @@ export function ctaKind( taskId: string ): CtaKind { return 'deeplink'; } +/** + * Acknowledgment tasks with no completion signal in wp-admin: in Calypso they + * complete on click / page-visit, which writes the status to Calypso's selected + * site — never from the wp-admin context where the AI Launchpad runs (on both + * Simple and Atomic). The AI Launchpad marks them complete locally when the user + * clicks their CTA. Mirrors COMPLETE_ON_CLICK_TASK_IDS in + * class-ai-launchpad-rest.php. + */ +const COMPLETE_ON_CLICK_TASK_IDS = [ + 'complete_profile', + 'manage_subscribers', + 'manage_paid_newsletter_plan', + 'earn_money', + 'start_building_your_audience', + 'site_monitoring_page', +]; + +/** + * Whether a task should be marked complete client-side when its CTA is clicked, + * because it has no completion signal on Atomic (an acknowledgment task). + * + * @param taskId - The catalog task ID. + * @return True for acknowledgment tasks. + */ +export function isCompleteOnClickTask( taskId: string ): boolean { + return COMPLETE_ON_CLICK_TASK_IDS.includes( taskId ); +} + /** * Build the wordpress.com launch-flow URL for a launch task. Launch tasks have * no catalog deeplink; the legacy launchpad widget routes them to diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/tailored-list.tsx b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/tailored-list.tsx index 2a1f5f966703..e9808030d208 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/tailored-list.tsx +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/tailored-list.tsx @@ -7,6 +7,7 @@ import { trackTaskClicked } from '../lib/tracks.ts'; import { Layout } from './layout.tsx'; import { firstIncompleteIndex, + isCompleteOnClickTask, isTaskActionable, resolveCtaUrl, tasksFromFixture, @@ -183,6 +184,18 @@ export function TailoredList( { pendingTailor, initialData, site }: Props = {} ) }, siteUrl ); + // Acknowledgment tasks have no completion signal in wp-admin (they + // complete only in Calypso), so clicking the CTA is the completion. + // Persist it before navigating away (same-tab nav unloads the page, + // cancelling an un-awaited request), best-effort so a failed write never + // blocks the navigation. + if ( isCompleteOnClickTask( task.id ) ) { + await apiFetch( { + path: '/wpcom/v2/ai-launchpad/complete-task', + method: 'POST', + data: { task_id: task.id }, + } ).catch( () => {} ); + } if ( url ) { navigate( url ); } diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php index 4d848f727ff1..668953141b26 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php @@ -8,6 +8,8 @@ //phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.NotAbsolutePath require_once \Automattic\Jetpack\Jetpack_Mu_Wpcom::PKG_DIR . 'src/features/launchpad/launchpad.php'; //phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.NotAbsolutePath +require_once \Automattic\Jetpack\Jetpack_Mu_Wpcom::PKG_DIR . 'src/features/ai-launchpad/helpers.php'; +//phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.NotAbsolutePath require_once \Automattic\Jetpack\Jetpack_Mu_Wpcom::PKG_DIR . 'src/features/ai-launchpad/class-ai-launchpad-rest.php'; use PHPUnit\Framework\Attributes\CoversClass; @@ -562,6 +564,9 @@ public function test_subscriber_is_denied() { $result = $this->call_api( 'PUT', '/tailored', self::valid_payload() ); $this->assertSame( 403, $result->get_status() ); + $result = $this->call_api( 'POST', '/complete-task', array( 'task_id' => 'complete_profile' ) ); + $this->assertSame( 403, $result->get_status() ); + $result = $this->call_api( Requests::DELETE ); $this->assertSame( 403, $result->get_status() ); @@ -572,6 +577,69 @@ public function test_subscriber_is_denied() { $this->assertFalse( get_option( 'wpcom_ai_launchpad_ai_output' ) ); } + /** + * Seeds the AI output option with the given task IDs (launch task last) so + * wpcom_ai_launchpad_get_ai_task_ids() reports them as on the site's list. + * + * @param string[] $task_ids The task IDs to seed. + */ + private function seed_ai_output_with_tasks( array $task_ids ) { + $tasks = array(); + foreach ( $task_ids as $id ) { + $tasks[] = array( + 'id' => $id, + 'subtitle' => 'Subtitle for ' . $id . '.', + ); + } + update_option( + 'wpcom_ai_launchpad_ai_output', + array( + 'version' => 1, + 'source' => 'ai', + 'generated_at' => 1717000000, + 'payload' => array( 'tasks' => $tasks ), + ), + false + ); + } + + /** + * Test that POST /complete-task marks an allowlisted acknowledgment task complete. + */ + public function test_complete_task_marks_acknowledgment_task() { + wp_set_current_user( $this->admin_id ); + $this->seed_ai_output_with_tasks( array( 'complete_profile', 'site_launched' ) ); + + $result = $this->call_api( 'POST', '/complete-task', array( 'task_id' => 'complete_profile' ) ); + + $this->assertSame( 200, $result->get_status() ); + $this->assertTrue( $result->get_data()['completed'] ); + $statuses = get_option( 'launchpad_checklist_tasks_statuses' ); + $this->assertTrue( ! empty( $statuses['complete_profile'] ) ); + } + + /** + * Test that POST /complete-task rejects ids that are not completable this way: + * a non-allowlisted task (even if on the list) and an allowlisted task that is + * not on the site's AI-selected list. + */ + public function test_complete_task_rejects_invalid_tasks() { + wp_set_current_user( $this->admin_id ); + $this->seed_ai_output_with_tasks( array( 'first_post_published', 'complete_profile', 'site_launched' ) ); + + // On the list, but not an acknowledgment task. + $not_allowlisted = $this->call_api( 'POST', '/complete-task', array( 'task_id' => 'first_post_published' ) ); + $this->assertSame( 400, $not_allowlisted->get_status() ); + $this->assertSame( 'ai_launchpad_task_not_completable', $not_allowlisted->get_data()['code'] ); + + // Allowlisted, but not on this site's list. + $not_selected = $this->call_api( 'POST', '/complete-task', array( 'task_id' => 'earn_money' ) ); + $this->assertSame( 404, $not_selected->get_status() ); + $this->assertSame( 'ai_launchpad_task_not_selected', $not_selected->get_data()['code'] ); + + $this->assertFalse( get_option( 'launchpad_checklist_tasks_statuses' ) ); + } + /** * Test that DELETE removes the AI output, sets dismissed, and leaves statuses untouched. */ From 84c4a6d3b8328c2c73ba6cd8e7eab9d12c4dc1b2 Mon Sep 17 00:00:00 2001 From: Copons Date: Tue, 23 Jun 2026 17:17:51 +0100 Subject: [PATCH 03/13] AI Launchpad: complete Jetpack Social tasks from wp-admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connect_social_media, drive_traffic, and post_sharing_enabled have no add_listener_callback and complete in Calypso only, so a wp-admin launchpad never ticks them. Jetpack Social runs locally, though, so the real state is readable: a Publicize connection exists (connect_social_media / drive_traffic) or the Publicize module is active (post_sharing_enabled). Add AI_Launchpad_Social_Listener, which reconciles these on the AI Launchpad page load (admin_init, gated to that page and to incomplete AI-selected tasks so the Publicize lookup stays off every other admin page). There is no local "connection created" action on Atomic — connections are created through a proxied wpcom request — so a check-on-load is the available signal. Mirrors AI_Launchpad_Theme_Listener. Also raise the package's phpunit memory limit to 256M in the test bootstrap: the Brain Monkey / Patchwork suite instruments every loaded file and was already at the 128M default ceiling, so adding tests OOM'd mid-run. Test-only; production is unaffected. Verified on Atomic: with Publicize active and zero connections, the launchpad page load completes post_sharing_enabled and leaves the connection tasks incomplete; off-page loads are a no-op. Part of DOTOBRD-480. --- .../add-ai-launchpad-social-listener | 4 + .../features/ai-launchpad/ai-launchpad.php | 1 + .../class-ai-launchpad-social-listener.php | 93 ++++++++++++++++ .../jetpack-mu-wpcom/tests/php/bootstrap.php | 22 ++++ .../AI_Launchpad_Social_Listener_Test.php | 104 ++++++++++++++++++ .../ai-launchpad/fixtures/social-stubs.php | 48 ++++++++ 6 files changed, 272 insertions(+) create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-social-listener create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-social-listener.php create mode 100644 projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Social_Listener_Test.php create mode 100644 projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/fixtures/social-stubs.php diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-social-listener b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-social-listener new file mode 100644 index 000000000000..f72378c45aab --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-social-listener @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +AI Launchpad: complete the Jetpack Social tasks (connect social media, drive traffic, enable post sharing) from wp-admin once a Publicize connection exists or the Publicize module is active. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/ai-launchpad.php b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/ai-launchpad.php index 343c4cd0eacd..ce8a0e1649c3 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/ai-launchpad.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/ai-launchpad.php @@ -19,6 +19,7 @@ require_once __DIR__ . '/class-ai-launchpad-rest.php'; require_once __DIR__ . '/class-ai-launchpad-listeners.php'; require_once __DIR__ . '/class-ai-launchpad-theme-listener.php'; +require_once __DIR__ . '/class-ai-launchpad-social-listener.php'; require_once __DIR__ . '/class-ai-launchpad-dev-enable.php'; /** diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-social-listener.php b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-social-listener.php new file mode 100644 index 000000000000..080260ca4c97 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-social-listener.php @@ -0,0 +1,93 @@ +is_task_id_complete( 'post_sharing_enabled' ) + && class_exists( Publicize_Utils::class ) + && Publicize_Utils::is_publicize_active() + ) { + wpcom_mark_launchpad_task_complete( 'post_sharing_enabled' ); + } + + // connect_social_media / drive_traffic complete once a Publicize connection + // exists. connect_social_media id-maps to drive_traffic, so completing + // either writes the same status; we mark whichever the AI selected. + $connection_tasks = array_filter( + array( 'connect_social_media', 'drive_traffic' ), + static function ( $task_id ) use ( $ai_task_ids, $task_lists ) { + return in_array( $task_id, $ai_task_ids, true ) && ! $task_lists->is_task_id_complete( $task_id ); + } + ); + + if ( empty( $connection_tasks ) || ! class_exists( Connections::class ) ) { + return; + } + + $connections = Connections::get_all(); + if ( ! is_array( $connections ) || empty( $connections ) ) { + return; + } + + foreach ( $connection_tasks as $task_id ) { + wpcom_mark_launchpad_task_complete( $task_id ); + } + } +} + +AI_Launchpad_Social_Listener::register(); diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/bootstrap.php b/projects/packages/jetpack-mu-wpcom/tests/php/bootstrap.php index 8c1ca558a693..9f96370dedc5 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/bootstrap.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/bootstrap.php @@ -5,6 +5,28 @@ * @package automattic/ */ +// Brain Monkey / Patchwork instruments every included file, and this package's +// suite loads a large amount of mu-wpcom code, so it runs up against the default +// 128M limit. Raise it (only when the current limit is lower, and never when it +// is unlimited) so adding tests doesn't OOM mid-run. +$mu_wpcom_mem_limit = trim( ini_get( 'memory_limit' ) ); +if ( '' !== $mu_wpcom_mem_limit && '-1' !== $mu_wpcom_mem_limit ) { + $mu_wpcom_mem_bytes = (int) $mu_wpcom_mem_limit; + switch ( strtolower( $mu_wpcom_mem_limit[ strlen( $mu_wpcom_mem_limit ) - 1 ] ) ) { + case 'g': + $mu_wpcom_mem_bytes *= 1024; + // Fall through. + case 'm': + $mu_wpcom_mem_bytes *= 1024; + // Fall through. + case 'k': + $mu_wpcom_mem_bytes *= 1024; + } + if ( $mu_wpcom_mem_bytes < 256 * 1024 * 1024 ) { + ini_set( 'memory_limit', '256M' ); // phpcs:ignore WordPress.PHP.IniSet.memory_limit_Blacklisted + } +} + /** * Include the composer autoloader. */ diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Social_Listener_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Social_Listener_Test.php new file mode 100644 index 000000000000..a4a24674eb4e --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Social_Listener_Test.php @@ -0,0 +1,104 @@ + $id, + 'subtitle' => 'Subtitle.', + ); + }, + $task_ids + ); + update_option( + 'wpcom_ai_launchpad_ai_output', + array( 'payload' => array( 'tasks' => $tasks ) ), + false + ); + } + + /** + * Test that a social task completes only when it is AI-selected, its local + * signal is true, and the request is the AI Launchpad page — and not otherwise. + */ + public function test_completes_only_when_selected_signalled_and_on_page() { + $task_lists = wpcom_launchpad_checklists(); + + // Selected + on-page, but signals off: nothing completes. + $this->seed_tasks( array( 'post_sharing_enabled', 'connect_social_media', 'drive_traffic' ) ); + AI_Launchpad_Social_Listener::maybe_complete_social_tasks(); + $this->assertFalse( $task_lists->is_task_id_complete( 'post_sharing_enabled' ) ); + $this->assertFalse( $task_lists->is_task_id_complete( 'connect_social_media' ) ); + + // Turn the signals on for the remaining cases. + Publicize_Utils::$active = true; + Connections::$all = array( array( 'connection_id' => '1' ) ); + + // Signalled + selected, but off the launchpad page: still nothing. + $_GET['page'] = 'some-other-page'; + AI_Launchpad_Social_Listener::maybe_complete_social_tasks(); + $this->assertFalse( $task_lists->is_task_id_complete( 'post_sharing_enabled' ) ); + $this->assertFalse( $task_lists->is_task_id_complete( 'connect_social_media' ) ); + + // Signalled + on-page, but the tasks are not AI-selected: still nothing. + $_GET['page'] = 'ai-launchpad-wp-admin'; + $this->seed_tasks( array( 'site_launched' ) ); + AI_Launchpad_Social_Listener::maybe_complete_social_tasks(); + $this->assertFalse( $task_lists->is_task_id_complete( 'post_sharing_enabled' ) ); + $this->assertFalse( $task_lists->is_task_id_complete( 'connect_social_media' ) ); + + // All three conditions met: the selected social tasks complete. + $this->seed_tasks( array( 'post_sharing_enabled', 'connect_social_media', 'drive_traffic' ) ); + AI_Launchpad_Social_Listener::maybe_complete_social_tasks(); + $this->assertTrue( $task_lists->is_task_id_complete( 'post_sharing_enabled' ) ); + $this->assertTrue( $task_lists->is_task_id_complete( 'connect_social_media' ) ); + $this->assertTrue( $task_lists->is_task_id_complete( 'drive_traffic' ) ); + } +} diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/fixtures/social-stubs.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/fixtures/social-stubs.php new file mode 100644 index 000000000000..7f6e2539ec15 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/fixtures/social-stubs.php @@ -0,0 +1,48 @@ + Date: Tue, 23 Jun 2026 17:26:19 +0100 Subject: [PATCH 04/13] AI Launchpad: complete About-page tasks from wp-admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add_about_page and update_about_page rely on the catalog's _wpcom_template_layout_category meta, which the dotcom editor toolkit provides (not registered on Atomic), and on the AI's createPatternPage only creating a draft — so they never tick in a wp-admin launchpad. Tag the AI-created About page with our own registered marker meta (_wpcom_ai_launchpad_about_page, set by createPatternPage) and add AI_Launchpad_About_Page_Listener, which watches that page's status transitions: first publish completes add_about_page, a later edit completes update_about_page. Independent of the layout-category meta. Verified on Atomic: a page created via /wp/v2/pages with the marker meta (201, meta set) completes add_about_page on publish and update_about_page on a subsequent edit. Part of DOTOBRD-480. --- .../add-ai-launchpad-about-page-listener | 4 + .../features/ai-launchpad/ai-launchpad.php | 1 + ...class-ai-launchpad-about-page-listener.php | 94 ++++++++++++++ .../ai-launchpad/js/lib/pattern-page.ts | 4 + .../AI_Launchpad_About_Page_Listener_Test.php | 115 ++++++++++++++++++ 5 files changed, 218 insertions(+) create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-about-page-listener create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-about-page-listener.php create mode 100644 projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_About_Page_Listener_Test.php diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-about-page-listener b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-about-page-listener new file mode 100644 index 000000000000..9912e343964f --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-about-page-listener @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +AI Launchpad: complete the About-page tasks from wp-admin by tagging the AI-created About page and completing add_about_page / update_about_page when it is published or edited. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/ai-launchpad.php b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/ai-launchpad.php index ce8a0e1649c3..56f0fed3b76e 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/ai-launchpad.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/ai-launchpad.php @@ -20,6 +20,7 @@ require_once __DIR__ . '/class-ai-launchpad-listeners.php'; require_once __DIR__ . '/class-ai-launchpad-theme-listener.php'; require_once __DIR__ . '/class-ai-launchpad-social-listener.php'; +require_once __DIR__ . '/class-ai-launchpad-about-page-listener.php'; require_once __DIR__ . '/class-ai-launchpad-dev-enable.php'; /** diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-about-page-listener.php b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-about-page-listener.php new file mode 100644 index 000000000000..e6c8e97aba20 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-about-page-listener.php @@ -0,0 +1,94 @@ + 'boolean', + 'single' => true, + 'show_in_rest' => true, + 'auth_callback' => static function () { + return current_user_can( 'edit_pages' ); + }, + ) + ); + } + + /** + * Completes the About-page tasks on the AI About page's status transitions: + * first publish -> add_about_page, a later edit of the published page -> + * update_about_page. Only fires for the marked page and AI-selected tasks. + * + * @param string $new_status The new post status. + * @param string $old_status The previous post status. + * @param \WP_Post $post The post being transitioned. + * @return void + */ + public static function maybe_complete( $new_status, $old_status, $post ) { + if ( 'publish' !== $new_status || ! ( $post instanceof \WP_Post ) || 'page' !== $post->post_type ) { + return; + } + + $ai_task_ids = wpcom_ai_launchpad_get_ai_task_ids(); + if ( empty( $ai_task_ids ) ) { + return; + } + + if ( ! get_post_meta( $post->ID, self::META_KEY, true ) ) { + return; + } + + if ( 'publish' !== $old_status ) { + // First publish of the AI About page. + if ( in_array( 'add_about_page', $ai_task_ids, true ) ) { + wpcom_mark_launchpad_task_complete( 'add_about_page' ); + } + } elseif ( in_array( 'update_about_page', $ai_task_ids, true ) ) { + // A later edit of the already-published AI About page. + wpcom_mark_launchpad_task_complete( 'update_about_page' ); + } + } +} + +AI_Launchpad_About_Page_Listener::register(); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/lib/pattern-page.ts b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/lib/pattern-page.ts index d69c2e2d09e5..60f0a40ccac9 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/lib/pattern-page.ts +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/lib/pattern-page.ts @@ -143,6 +143,10 @@ export async function createPatternPage( title: pattern?.title ?? inferred.brand_name ?? 'New page', content: pattern?.html ?? '', status: 'draft', + // Tag this as the AI Launchpad About page so the server-side listener + // can complete add_about_page / update_about_page when it is published + // or edited, independent of the catalog's layout-category meta. + meta: { _wpcom_ai_launchpad_about_page: true }, }, } ) ) as CreatedPage; diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_About_Page_Listener_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_About_Page_Listener_Test.php new file mode 100644 index 000000000000..3547aa651fa1 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_About_Page_Listener_Test.php @@ -0,0 +1,115 @@ + $id, + 'subtitle' => 'Subtitle.', + ); + }, + $task_ids + ); + update_option( 'wpcom_ai_launchpad_ai_output', array( 'payload' => array( 'tasks' => $tasks ) ), false ); + } + + /** + * Creates a page, optionally tagged with the AI About-page marker. + * + * @param bool $marked Whether to set the marker meta. + * @return WP_Post + */ + private function make_page( $marked ) { + $page_id = wp_insert_post( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'About', + ) + ); + if ( $marked ) { + update_post_meta( $page_id, AI_Launchpad_About_Page_Listener::META_KEY, true ); + } + return get_post( $page_id ); + } + + /** + * Test that register_meta registers the marker meta for pages. + */ + public function test_registers_marker_meta() { + AI_Launchpad_About_Page_Listener::register_meta(); + + $this->assertTrue( + registered_meta_key_exists( 'post', AI_Launchpad_About_Page_Listener::META_KEY, 'page' ) + ); + } + + /** + * Test that the marked AI About page completes add_about_page on first publish + * and update_about_page on a later edit — and that an unmarked page or an + * unselected task completes nothing. + */ + public function test_completes_about_tasks_on_marked_page_transitions() { + $this->seed_tasks( array( 'add_about_page', 'update_about_page' ) ); + $task_lists = wpcom_launchpad_checklists(); + + // An unmarked page: nothing completes. + $plain = $this->make_page( false ); + AI_Launchpad_About_Page_Listener::maybe_complete( 'publish', 'draft', $plain ); + $this->assertFalse( $task_lists->is_task_id_complete( 'add_about_page' ) ); + + $page = $this->make_page( true ); + + // First publish of the marked page completes add_about_page only. + AI_Launchpad_About_Page_Listener::maybe_complete( 'publish', 'draft', $page ); + $this->assertTrue( $task_lists->is_task_id_complete( 'add_about_page' ) ); + $this->assertFalse( $task_lists->is_task_id_complete( 'update_about_page' ) ); + + // A later edit of the already-published marked page completes update_about_page. + AI_Launchpad_About_Page_Listener::maybe_complete( 'publish', 'publish', $page ); + $this->assertTrue( $task_lists->is_task_id_complete( 'update_about_page' ) ); + } + + /** + * Test that a marked page does not complete tasks the AI did not select. + */ + public function test_no_completion_when_task_not_selected() { + $this->seed_tasks( array( 'site_launched' ) ); + $page = $this->make_page( true ); + + AI_Launchpad_About_Page_Listener::maybe_complete( 'publish', 'draft', $page ); + + $this->assertFalse( wpcom_launchpad_checklists()->is_task_id_complete( 'add_about_page' ) ); + } +} From 897bf4bd1da73743abbd42d391c2796c2374fef2 Mon Sep 17 00:00:00 2001 From: Copons Date: Tue, 23 Jun 2026 18:01:53 +0100 Subject: [PATCH 05/13] AI Launchpad: complete subscriber-count tasks from wp-admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit subscribers_added, import_subscribers, and add_10_email_subscribers complete in Calypso only or via wpcom_launchpad_get_newsletter_subscriber_count, which hard-requires IS_WPCOM and returns 0 on Atomic, so a wp-admin launchpad never ticks them. Add AI_Launchpad_Subscribers_Listener, which reconciles these on the AI Launchpad page load (admin_init, gated to that page and to incomplete AI-selected tasks so the lookup stays off every other admin page): subscribers_added / import_subscribers complete once the site has at least one email subscriber, add_10_email_subscribers once it has ten. The count comes from Jetpack's fetch_subscriber_counts() (the Subscribe block's jetpack.fetchSubscriberCounts path, transient-cached) — the blog-token GET /sites/{id}/subscribers/stats endpoint returns 400 missing_params on Atomic, so the proven counts path is used instead. Mirrors AI_Launchpad_Social_Listener. add_10_email_subscribers also has an IS_WPCOM-only visibility gate (wpcom_launchpad_are_newsletter_subscriber_counts_available), so the read-path visibility filter would have hidden it on Atomic and made the completion invisible. Add AI_Launchpad_REST::FORCE_VISIBLE_TASK_IDS, a documented allowlist that skips the catalog visibility gate for tasks whose data the AI Launchpad retrieves cross-platform. Verified on Atomic: at the real count (0 email subscribers) nothing completes; with a count of 10 all three tick; GET /wpcom/v2/ai-launchpad returns add_10_email_subscribers despite its catalog visibility being false. Part of DOTOBRD-480. --- .../add-ai-launchpad-subscriber-listener | 4 + .../features/ai-launchpad/ai-launchpad.php | 1 + .../ai-launchpad/class-ai-launchpad-rest.php | 18 +- ...lass-ai-launchpad-subscribers-listener.php | 129 +++++++++++++ .../ai-launchpad/AI_Launchpad_REST_Test.php | 40 ++++ ...AI_Launchpad_Subscribers_Listener_Test.php | 176 ++++++++++++++++++ 6 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-subscriber-listener create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-subscribers-listener.php create mode 100644 projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Subscribers_Listener_Test.php diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-subscriber-listener b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-subscriber-listener new file mode 100644 index 000000000000..4706b06f80aa --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-subscriber-listener @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +AI Launchpad: complete the subscriber tasks (import subscribers, get your first 10 subscribers) from wp-admin by reading the site's email subscriber count on Atomic, and show the first-10 task there despite its WordPress.com-only visibility gate. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/ai-launchpad.php b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/ai-launchpad.php index 56f0fed3b76e..f1a5bac22915 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/ai-launchpad.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/ai-launchpad.php @@ -20,6 +20,7 @@ require_once __DIR__ . '/class-ai-launchpad-listeners.php'; require_once __DIR__ . '/class-ai-launchpad-theme-listener.php'; require_once __DIR__ . '/class-ai-launchpad-social-listener.php'; +require_once __DIR__ . '/class-ai-launchpad-subscribers-listener.php'; require_once __DIR__ . '/class-ai-launchpad-about-page-listener.php'; require_once __DIR__ . '/class-ai-launchpad-dev-enable.php'; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php index e650768ca2e7..a9bf17449af9 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php @@ -38,6 +38,19 @@ class AI_Launchpad_REST extends WP_REST_Controller { 'site_monitoring_page', ); + /** + * Tasks whose catalog `is_visible_callback` encodes an `IS_WPCOM`-only platform + * assumption that the AI Launchpad overrides because it retrieves the same data + * cross-platform. `add_10_email_subscribers` is gated by + * `wpcom_launchpad_are_newsletter_subscriber_counts_available` (false off + * WordPress.com), but AI_Launchpad_Subscribers_Listener reads the count on + * Atomic via `/sites/{id}/subscribers/stats`, so the task must still render and + * its visibility gate is skipped here. + */ + const FORCE_VISIBLE_TASK_IDS = array( + 'add_10_email_subscribers', + ); + /** * Class constructor. */ @@ -457,7 +470,10 @@ private function build_tasks( $tasks ) { // rejecting on write) keeps the deterministic fallback usable: its fixed // per-goal lists also contain conditionally-visible tasks, so gating the // write path could strand a goal with too few surviving tasks. - if ( ! wpcom_launchpad_checklists()->is_visible( $definition ) ) { + if ( + ! in_array( $task['id'], self::FORCE_VISIBLE_TASK_IDS, true ) + && ! wpcom_launchpad_checklists()->is_visible( $definition ) + ) { continue; } diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-subscribers-listener.php b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-subscribers-listener.php new file mode 100644 index 000000000000..baad282c3efa --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-subscribers-listener.php @@ -0,0 +1,129 @@ +is_task_id_complete( $task_id ); + } + ); + + $first_ten_pending = in_array( 'add_10_email_subscribers', $ai_task_ids, true ) + && ! $task_lists->is_task_id_complete( 'add_10_email_subscribers' ); + + // Nothing selected-and-incomplete: skip the remote call entirely. + if ( empty( $added_tasks ) && ! $first_ten_pending ) { + return; + } + + $count = static::get_email_subscriber_count(); + if ( null === $count ) { + return; + } + + if ( $count > 0 ) { + foreach ( $added_tasks as $task_id ) { + wpcom_mark_launchpad_task_complete( $task_id ); + } + } + + if ( $first_ten_pending && $count >= self::FIRST_TEN_TARGET ) { + wpcom_mark_launchpad_task_complete( 'add_10_email_subscribers' ); + } + } + + /** + * The site's email subscriber count, retrieved on Atomic via Jetpack's + * `fetch_subscriber_counts()` (the Subscribe block's `jetpack.fetchSubscriberCounts` + * path, transient-cached). The blog-token `subscribers/stats` REST endpoint is + * not reliably reachable here, so this uses the proven counts path instead. + * + * @return int|null The email subscriber count, or null when it cannot be retrieved. + */ + protected static function get_email_subscriber_count() { + if ( ! function_exists( '\Automattic\Jetpack\Extensions\Subscriptions\fetch_subscriber_counts' ) ) { + return null; + } + + $counts = \Automattic\Jetpack\Extensions\Subscriptions\fetch_subscriber_counts(); + + // On Atomic the helper reports a 'failed' status when the wpcom call errored; + // treat that as unknown rather than zero so a transient failure never sticks. + if ( isset( $counts['status'] ) && 'failed' === $counts['status'] ) { + return null; + } + + if ( ! isset( $counts['value']['email_subscribers'] ) ) { + return null; + } + + return (int) $counts['value']['email_subscribers']; + } +} + +AI_Launchpad_Subscribers_Listener::register(); diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php index 668953141b26..62884eb81910 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php @@ -244,6 +244,46 @@ public function test_get_excludes_non_visible_tasks() { $this->assertNotContains( 'woo_products', $ids ); } + /** + * Test that GET keeps add_10_email_subscribers even though its catalog + * visibility callback (wpcom_launchpad_are_newsletter_subscriber_counts_available) + * is false off WordPress.com: the AI Launchpad retrieves the subscriber count + * on Atomic via the subscribers/stats endpoint, so the legacy IS_WPCOM-only + * visibility gate must not hide the task there. + */ + public function test_get_keeps_subscriber_count_task_despite_wpcom_only_visibility() { + wp_set_current_user( $this->admin_id ); + + // Sanity check: the catalog visibility gate is indeed false in this + // (non-WordPress.com) environment, so the assertion below proves the override. + $this->assertFalse( wpcom_launchpad_are_newsletter_subscriber_counts_available() ); + + $payload = self::valid_payload(); + $payload['tasks'] = array( + array( + 'id' => 'add_10_email_subscribers', + 'subtitle' => 'Grow your list to ten subscribers.', + ), + ); + + update_option( + 'wpcom_ai_launchpad_ai_output', + array( + 'version' => 1, + 'source' => 'ai', + 'generated_at' => 1717000000, + 'payload' => $payload, + ), + false + ); + + $result = $this->call_api( Requests::GET ); + + $this->assertSame( 200, $result->get_status() ); + $ids = array_column( $result->get_data()['tasks'], 'id' ); + $this->assertContains( 'add_10_email_subscribers', $ids ); + } + /** * Test that GET requires authentication. */ diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Subscribers_Listener_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Subscribers_Listener_Test.php new file mode 100644 index 000000000000..675ba39c76e9 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Subscribers_Listener_Test.php @@ -0,0 +1,176 @@ + $id, + 'subtitle' => 'Subtitle.', + ); + }, + $task_ids + ); + update_option( + 'wpcom_ai_launchpad_ai_output', + array( 'payload' => array( 'tasks' => $tasks ) ), + false + ); + } + + /** + * The subscriber-count fetch only runs, and tasks only complete, when the + * request is the AI Launchpad page and the tasks are AI-selected. + */ + public function test_completes_only_when_selected_and_on_page() { + $task_lists = wpcom_launchpad_checklists(); + + // A non-zero count is available for all the negative cases below. + AI_Launchpad_Subscribers_Listener_Test_Double::$count = 25; + + // Off the launchpad page: nothing completes. + $_GET['page'] = 'some-other-page'; + $this->seed_tasks( array( 'subscribers_added', 'import_subscribers', 'add_10_email_subscribers' ) ); + AI_Launchpad_Subscribers_Listener_Test_Double::maybe_complete_subscriber_tasks(); + $this->assertFalse( $task_lists->is_task_id_complete( 'subscribers_added' ) ); + $this->assertFalse( $task_lists->is_task_id_complete( 'add_10_email_subscribers' ) ); + + // On-page, but the tasks are not AI-selected: still nothing. + $_GET['page'] = 'ai-launchpad-wp-admin'; + $this->seed_tasks( array( 'site_launched' ) ); + AI_Launchpad_Subscribers_Listener_Test_Double::maybe_complete_subscriber_tasks(); + $this->assertFalse( $task_lists->is_task_id_complete( 'subscribers_added' ) ); + $this->assertFalse( $task_lists->is_task_id_complete( 'add_10_email_subscribers' ) ); + + // On-page + selected: the tasks complete. + $this->seed_tasks( array( 'subscribers_added', 'import_subscribers', 'add_10_email_subscribers' ) ); + AI_Launchpad_Subscribers_Listener_Test_Double::maybe_complete_subscriber_tasks(); + $this->assertTrue( $task_lists->is_task_id_complete( 'subscribers_added' ) ); + $this->assertTrue( $task_lists->is_task_id_complete( 'import_subscribers' ) ); + $this->assertTrue( $task_lists->is_task_id_complete( 'add_10_email_subscribers' ) ); + } + + /** + * When the count is unavailable (fetch failed), nothing completes. + */ + public function test_no_completion_when_count_unavailable() { + $task_lists = wpcom_launchpad_checklists(); + AI_Launchpad_Subscribers_Listener_Test_Double::$count = null; + $this->seed_tasks( array( 'subscribers_added', 'add_10_email_subscribers' ) ); + + AI_Launchpad_Subscribers_Listener_Test_Double::maybe_complete_subscriber_tasks(); + + $this->assertFalse( $task_lists->is_task_id_complete( 'subscribers_added' ) ); + $this->assertFalse( $task_lists->is_task_id_complete( 'add_10_email_subscribers' ) ); + } + + /** + * A zero count completes nothing. + */ + public function test_zero_count_completes_nothing() { + $task_lists = wpcom_launchpad_checklists(); + AI_Launchpad_Subscribers_Listener_Test_Double::$count = 0; + $this->seed_tasks( array( 'subscribers_added', 'import_subscribers', 'add_10_email_subscribers' ) ); + + AI_Launchpad_Subscribers_Listener_Test_Double::maybe_complete_subscriber_tasks(); + + $this->assertFalse( $task_lists->is_task_id_complete( 'subscribers_added' ) ); + $this->assertFalse( $task_lists->is_task_id_complete( 'add_10_email_subscribers' ) ); + } + + /** + * A count of at least one but below ten completes the "added" tasks only; + * add_10_email_subscribers needs ten. + */ + public function test_count_below_ten_completes_added_only() { + $task_lists = wpcom_launchpad_checklists(); + AI_Launchpad_Subscribers_Listener_Test_Double::$count = 9; + $this->seed_tasks( array( 'subscribers_added', 'import_subscribers', 'add_10_email_subscribers' ) ); + + AI_Launchpad_Subscribers_Listener_Test_Double::maybe_complete_subscriber_tasks(); + + $this->assertTrue( $task_lists->is_task_id_complete( 'subscribers_added' ) ); + $this->assertTrue( $task_lists->is_task_id_complete( 'import_subscribers' ) ); + $this->assertFalse( $task_lists->is_task_id_complete( 'add_10_email_subscribers' ) ); + } + + /** + * A count of exactly ten completes add_10_email_subscribers too. + */ + public function test_count_of_ten_completes_first_ten_task() { + $task_lists = wpcom_launchpad_checklists(); + AI_Launchpad_Subscribers_Listener_Test_Double::$count = 10; + $this->seed_tasks( array( 'add_10_email_subscribers' ) ); + + AI_Launchpad_Subscribers_Listener_Test_Double::maybe_complete_subscriber_tasks(); + + $this->assertTrue( $task_lists->is_task_id_complete( 'add_10_email_subscribers' ) ); + } +} From 63630e109f818977dc7dc97a702e53b736741141 Mon Sep 17 00:00:00 2001 From: Copons Date: Wed, 24 Jun 2026 14:59:13 +0100 Subject: [PATCH 06/13] AI Launchpad: complete setup_ssh on CTA click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup_ssh's real signal (an SSH user exists) is unreachable from the launchpad's Atomic context: the wpcom hosting/ssh-users endpoint rejects a blog-token request (401) and a Jetpack-user-token request (403, user_can_manage_hosting gate), and a8c_hosting_ssh_user_created fires wpcom-server-side, not on the WoA site. Calypso's own hosting form completes setup_ssh optimistically (sftp-form.tsx handleCreateUser calls completeTasks(['setup_ssh']) on SFTP-user create; it does not poll the endpoint). Reuse that strategy: add setup_ssh to the COMPLETE_ON_CLICK_TASK_IDS allowlist (server class-ai-launchpad-rest.php and client model.ts), so the launchpad ticks it on CTA click — the CTA opens the same hosting page where the user creates credentials. Relying on Calypso's own write instead is not viable for AI Launchpad sites: useCompleteLaunchpadTasksWithNotice filters requested slugs against the site's generic launchpad checklist and no-ops when a task isn't in it, and AI-only tasks are driven by the AI output option, not that checklist. Tracked as DOTOBRD-487. Verified on Atomic: GET /wpcom/v2/ai-launchpad renders setup_ssh, and POST /ai-launchpad/complete-task (200) ticks it complete. Part of DOTOBRD-480. --- ...d-ai-launchpad-setup-ssh-complete-on-click | 4 +++ .../ai-launchpad/class-ai-launchpad-rest.php | 25 +++++++++++++------ .../js/tailored-list/model.test.mts | 1 + .../ai-launchpad/js/tailored-list/model.ts | 16 ++++++------ .../ai-launchpad/AI_Launchpad_REST_Test.php | 18 +++++++++++++ 5 files changed, 49 insertions(+), 15 deletions(-) create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-setup-ssh-complete-on-click diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-setup-ssh-complete-on-click b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-setup-ssh-complete-on-click new file mode 100644 index 000000000000..f793621277f8 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-setup-ssh-complete-on-click @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +AI Launchpad: complete the Set up SSH task when its CTA is clicked, reusing Calypso's optimistic hosting-form completion since the real SSH-user signal is unreachable from the Atomic context. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php index a9bf17449af9..f3c14c38fa26 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php @@ -20,14 +20,22 @@ class AI_Launchpad_REST extends WP_REST_Controller { const LAUNCH_TASK_IDS = array( 'site_launched', 'blog_launched', 'woo_launch_site', 'link_in_bio_launched', 'videopress_launched' ); /** - * Acknowledgment tasks that have no completion signal when the launchpad runs - * in wp-admin: in the legacy launchpad they complete on click / page-visit in - * Calypso, which writes the status to Calypso's *selected* site — never from - * the wp-admin context. The AI Launchpad runs in wp-admin (on both Simple and - * Atomic), so it has no way to tick them; it marks them complete locally when - * the user clicks their CTA. Server-side allowlist so the complete-task route - * can only tick these ids, never arbitrary catalog tasks. Mirrored client-side - * in model.ts (COMPLETE_ON_CLICK_TASK_IDS). + * Tasks whose completion the AI Launchpad writes when the user clicks their CTA, + * because the real signal is unreachable when the launchpad runs in wp-admin: + * + * - The acknowledgment tasks (complete_profile … site_monitoring_page) complete + * on click / page-visit in Calypso, which writes the status to Calypso's + * *selected* site — never from the wp-admin context. + * - setup_ssh's real signal (an SSH user exists) is only readable through the + * wpcom hosting endpoint, which rejects the launchpad's Atomic context (blog + * token 401, Jetpack-user token 403). Calypso's own hosting form completes it + * optimistically when the user creates SFTP credentials; this reuses that + * strategy, ticking it when the user opens the same hosting page via the CTA. + * + * The AI Launchpad runs in wp-admin (on both Simple and Atomic), so it marks + * these complete locally on CTA click. Server-side allowlist so the complete-task + * route can only tick these ids, never arbitrary catalog tasks. Mirrored + * client-side in model.ts (COMPLETE_ON_CLICK_TASK_IDS). */ const COMPLETE_ON_CLICK_TASK_IDS = array( 'complete_profile', @@ -36,6 +44,7 @@ class AI_Launchpad_REST extends WP_REST_Controller { 'earn_money', 'start_building_your_audience', 'site_monitoring_page', + 'setup_ssh', ); /** diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.test.mts b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.test.mts index f1c0790c4696..12ce4759a7fa 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.test.mts +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.test.mts @@ -162,6 +162,7 @@ describe( 'isCompleteOnClickTask', () => { assert.equal( isCompleteOnClickTask( 'complete_profile' ), true ); assert.equal( isCompleteOnClickTask( 'earn_money' ), true ); assert.equal( isCompleteOnClickTask( 'site_monitoring_page' ), true ); + assert.equal( isCompleteOnClickTask( 'setup_ssh' ), true ); } ); it( 'is false for tasks that complete via a real signal or listener', () => { diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.ts b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.ts index 3fea867f48d5..49dec45e61ad 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.ts +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.ts @@ -74,11 +74,12 @@ export function ctaKind( taskId: string ): CtaKind { } /** - * Acknowledgment tasks with no completion signal in wp-admin: in Calypso they - * complete on click / page-visit, which writes the status to Calypso's selected - * site — never from the wp-admin context where the AI Launchpad runs (on both - * Simple and Atomic). The AI Launchpad marks them complete locally when the user - * clicks their CTA. Mirrors COMPLETE_ON_CLICK_TASK_IDS in + * Tasks marked complete client-side when their CTA is clicked, because the real + * signal is unreachable in wp-admin where the AI Launchpad runs (on both Simple + * and Atomic). The acknowledgment tasks complete on click / page-visit in Calypso + * (a write to Calypso's selected site, never from wp-admin); setup_ssh's SSH-user + * signal is unreachable from the Atomic context, so this reuses Calypso's + * optimistic hosting-form completion. Mirrors COMPLETE_ON_CLICK_TASK_IDS in * class-ai-launchpad-rest.php. */ const COMPLETE_ON_CLICK_TASK_IDS = [ @@ -88,14 +89,15 @@ const COMPLETE_ON_CLICK_TASK_IDS = [ 'earn_money', 'start_building_your_audience', 'site_monitoring_page', + 'setup_ssh', ]; /** * Whether a task should be marked complete client-side when its CTA is clicked, - * because it has no completion signal on Atomic (an acknowledgment task). + * because it has no reachable completion signal on Atomic. * * @param taskId - The catalog task ID. - * @return True for acknowledgment tasks. + * @return True for complete-on-click tasks. */ export function isCompleteOnClickTask( taskId: string ): boolean { return COMPLETE_ON_CLICK_TASK_IDS.includes( taskId ); diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php index 62884eb81910..36fae5909c29 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php @@ -658,6 +658,24 @@ public function test_complete_task_marks_acknowledgment_task() { $this->assertTrue( ! empty( $statuses['complete_profile'] ) ); } + /** + * Test that setup_ssh completes via the complete-on-click route, reusing + * Calypso's optimistic completion strategy (its hosting form marks setup_ssh + * complete when the user creates SFTP credentials; the real SSH-user signal is + * unreachable from the launchpad's Atomic context). + */ + public function test_complete_task_marks_setup_ssh() { + wp_set_current_user( $this->admin_id ); + $this->seed_ai_output_with_tasks( array( 'setup_ssh', 'site_launched' ) ); + + $result = $this->call_api( 'POST', '/complete-task', array( 'task_id' => 'setup_ssh' ) ); + + $this->assertSame( 200, $result->get_status() ); + $this->assertTrue( $result->get_data()['completed'] ); + $statuses = get_option( 'launchpad_checklist_tasks_statuses' ); + $this->assertTrue( ! empty( $statuses['setup_ssh'] ) ); + } + /** * Test that POST /complete-task rejects ids that are not completable this way: * a non-allowlisted task (even if on the list) and an allowlisted task that is From 672112a7d7e666fbfab39cdaa2156411818b2e69 Mon Sep 17 00:00:00 2001 From: Copons Date: Wed, 24 Jun 2026 16:48:36 +0100 Subject: [PATCH 07/13] AI Launchpad: complete memberships tasks from wp-admin stripe_connected, set_up_payments, paid_offer_created, and newsletter_plan_created never complete in the AI Launchpad on Atomic: their catalog completion reads wpcom_launchpad_get_membership_settings(), which returns null under IS_ATOMIC, and stripe_connected / paid_offer_created recompute from it (ignoring any stored option) so an option-writing listener could not surface them either. The signals are readable locally on Atomic, though: Jetpack syncs the connected-account flag down as the jetpack-memberships-has-connected-account site option (wpcom memberships/connected-accounts.php pushes it to the Jetpack site; the launchpad write beside it targets the shadow blog and never reaches Atomic), and mirrors membership plans as the local jp_mem_plan CPT. Add AI_Launchpad_Memberships, which recomputes these four tasks' completion from Jetpack_Memberships' local signals (has_connected_account / has_configured_plans_jetpack_recurring_payments, plus the 'newsletter' variant), and have the REST read path (build_tasks) use it instead of the catalog callback for them. checklist_statuses in the response is overlaid to match. No cross-repo wpcom endpoint needed. Verified on Atomic: with no Stripe/plans all four report incomplete; with a synced connected-account option and jp_mem_plan posts (including a newsletter tier) all four report complete via GET /wpcom/v2/ai-launchpad. Part of DOTOBRD-480. --- .../add-ai-launchpad-memberships-completion | 4 + .../features/ai-launchpad/ai-launchpad.php | 1 + .../class-ai-launchpad-memberships.php | 71 +++++++++++++++ .../ai-launchpad/class-ai-launchpad-rest.php | 21 ++++- .../AI_Launchpad_Memberships_Test.php | 88 +++++++++++++++++++ .../ai-launchpad/AI_Launchpad_REST_Test.php | 42 +++++++++ .../fixtures/memberships-stubs.php | 32 +++++++ 7 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-memberships-completion create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-memberships.php create mode 100644 projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Memberships_Test.php create mode 100644 projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/fixtures/memberships-stubs.php diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-memberships-completion b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-memberships-completion new file mode 100644 index 000000000000..dd7adf93ce5a --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-memberships-completion @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +AI Launchpad: complete the memberships tasks (connect Stripe, set up payments, create an offer, create a paid newsletter) from wp-admin by reading Jetpack's local membership signals on Atomic, where the catalog's membership-settings checks are always false. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/ai-launchpad.php b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/ai-launchpad.php index f1a5bac22915..8a4c6667343b 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/ai-launchpad.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/ai-launchpad.php @@ -16,6 +16,7 @@ // request regardless of which admin page is showing. require_once __DIR__ . '/helpers.php'; require_once __DIR__ . '/eligibility.php'; +require_once __DIR__ . '/class-ai-launchpad-memberships.php'; require_once __DIR__ . '/class-ai-launchpad-rest.php'; require_once __DIR__ . '/class-ai-launchpad-listeners.php'; require_once __DIR__ . '/class-ai-launchpad-theme-listener.php'; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-memberships.php b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-memberships.php new file mode 100644 index 000000000000..0ff3d3369f3a --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-memberships.php @@ -0,0 +1,71 @@ +build_tasks( $ai_output['payload']['tasks'] ); } + // The membership tasks' completion is recomputed in build_tasks() (their + // option is never written on Atomic), so overlay it here to keep + // checklist_statuses consistent with tasks[].completed for them. + $checklist_statuses = (array) get_option( 'launchpad_checklist_tasks_statuses', array() ); + foreach ( $tasks as $task ) { + if ( AI_Launchpad_Memberships::has_override( $task['id'] ) ) { + $checklist_statuses[ $task['id'] ] = $task['completed']; + } + } + return array( 'wizard' => is_array( $wizard ) ? $wizard : null, 'ai_output' => is_array( $ai_output ) ? $ai_output : null, 'tasks' => $tasks, - 'checklist_statuses' => (array) get_option( 'launchpad_checklist_tasks_statuses', array() ), + 'checklist_statuses' => $checklist_statuses, 'dismissed' => (bool) get_option( self::OPTION_DISMISSED, false ), 'is_eligible' => true, // Site context the client needs: the front-end URL drives the launch-task @@ -486,11 +496,18 @@ private function build_tasks( $tasks ) { continue; } + // The membership tasks' catalog callbacks recompute from membership + // settings that are null on Atomic (always false there); recompute them + // from Jetpack_Memberships' local signals instead. + $completed = AI_Launchpad_Memberships::has_override( $task['id'] ) + ? AI_Launchpad_Memberships::is_task_complete( $task['id'] ) + : wpcom_launchpad_checklists()->is_task_complete( $definition ); + $built[] = array( 'id' => $task['id'], 'subtitle' => $task['subtitle'], 'title' => isset( $definition['get_title'] ) ? $definition['get_title']() : '', - 'completed' => wpcom_launchpad_checklists()->is_task_complete( $definition ), + 'completed' => $completed, 'calypso_path' => wpcom_launchpad_checklists()->load_calypso_path( $definition ), ); } diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Memberships_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Memberships_Test.php new file mode 100644 index 000000000000..bd0eda022d3b --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Memberships_Test.php @@ -0,0 +1,88 @@ +assertFalse( AI_Launchpad_Memberships::is_task_complete( 'stripe_connected' ) ); + $this->assertFalse( AI_Launchpad_Memberships::is_task_complete( 'set_up_payments' ) ); + + AI_Launchpad_Stub_Jetpack_Memberships::$connected = true; + + $this->assertTrue( AI_Launchpad_Memberships::is_task_complete( 'stripe_connected' ) ); + $this->assertTrue( AI_Launchpad_Memberships::is_task_complete( 'set_up_payments' ) ); + } + + /** + * The paid_offer_created task follows the any-paid-plan signal. + */ + public function test_paid_offer_follows_configured_plans() { + $this->assertFalse( AI_Launchpad_Memberships::is_task_complete( 'paid_offer_created' ) ); + + AI_Launchpad_Stub_Jetpack_Memberships::$plans = true; + + $this->assertTrue( AI_Launchpad_Memberships::is_task_complete( 'paid_offer_created' ) ); + } + + /** + * The newsletter_plan_created task follows the newsletter-plan signal, + * independent of the generic paid-plan signal. + */ + public function test_newsletter_plan_follows_newsletter_signal() { + // A generic paid plan alone does not complete the newsletter task. + AI_Launchpad_Stub_Jetpack_Memberships::$plans = true; + $this->assertFalse( AI_Launchpad_Memberships::is_task_complete( 'newsletter_plan_created' ) ); + + AI_Launchpad_Stub_Jetpack_Memberships::$newsletter_plans = true; + $this->assertTrue( AI_Launchpad_Memberships::is_task_complete( 'newsletter_plan_created' ) ); + } + + /** + * Only the four membership tasks are overridden. + */ + public function test_only_membership_tasks_are_overridden() { + $this->assertTrue( AI_Launchpad_Memberships::has_override( 'stripe_connected' ) ); + $this->assertTrue( AI_Launchpad_Memberships::has_override( 'set_up_payments' ) ); + $this->assertTrue( AI_Launchpad_Memberships::has_override( 'paid_offer_created' ) ); + $this->assertTrue( AI_Launchpad_Memberships::has_override( 'newsletter_plan_created' ) ); + $this->assertFalse( AI_Launchpad_Memberships::has_override( 'first_post_published' ) ); + $this->assertFalse( AI_Launchpad_Memberships::has_override( 'setup_ssh' ) ); + } + + /** + * A task that isn't overridden is never reported complete by this helper. + */ + public function test_non_membership_task_is_not_complete() { + AI_Launchpad_Stub_Jetpack_Memberships::$connected = true; + $this->assertFalse( AI_Launchpad_Memberships::is_task_complete( 'first_post_published' ) ); + } +} diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php index 36fae5909c29..a9eb0d5f2716 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php @@ -9,6 +9,9 @@ require_once \Automattic\Jetpack\Jetpack_Mu_Wpcom::PKG_DIR . 'src/features/launchpad/launchpad.php'; //phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.NotAbsolutePath require_once \Automattic\Jetpack\Jetpack_Mu_Wpcom::PKG_DIR . 'src/features/ai-launchpad/helpers.php'; +require_once __DIR__ . '/fixtures/memberships-stubs.php'; +//phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.NotAbsolutePath +require_once \Automattic\Jetpack\Jetpack_Mu_Wpcom::PKG_DIR . 'src/features/ai-launchpad/class-ai-launchpad-memberships.php'; //phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.NotAbsolutePath require_once \Automattic\Jetpack\Jetpack_Mu_Wpcom::PKG_DIR . 'src/features/ai-launchpad/class-ai-launchpad-rest.php'; @@ -284,6 +287,45 @@ public function test_get_keeps_subscriber_count_task_despite_wpcom_only_visibili $this->assertContains( 'add_10_email_subscribers', $ids ); } + /** + * Test that GET recomputes the membership tasks' completion from + * Jetpack_Memberships' local signals, since their catalog callbacks are always + * false on Atomic (membership settings are null there). + */ + public function test_get_overrides_membership_task_completion() { + wp_set_current_user( $this->admin_id ); + AI_Launchpad_Stub_Jetpack_Memberships::$connected = false; + AI_Launchpad_Stub_Jetpack_Memberships::$plans = false; + AI_Launchpad_Stub_Jetpack_Memberships::$newsletter_plans = false; + $this->seed_ai_output_with_tasks( array( 'stripe_connected', 'paid_offer_created', 'site_launched' ) ); + + $get = function () { + $data = $this->call_api( Requests::GET )->get_data(); + $map = array(); + foreach ( $data['tasks'] as $task ) { + $map[ $task['id'] ] = $task['completed']; + } + return array( $map, $data['checklist_statuses'] ); + }; + + // No connected account / no plans: both incomplete. + list( $map, $statuses ) = $get(); + $this->assertFalse( $map['stripe_connected'] ); + $this->assertFalse( $map['paid_offer_created'] ); + + // Turn the local signals on: both complete, via the override (the catalog + // callback would still report false on Atomic). + AI_Launchpad_Stub_Jetpack_Memberships::$connected = true; + AI_Launchpad_Stub_Jetpack_Memberships::$plans = true; + list( $map, $statuses ) = $get(); + $this->assertTrue( $map['stripe_connected'] ); + $this->assertTrue( $map['paid_offer_created'] ); + + // checklist_statuses agrees with tasks[].completed for the overridden tasks. + $this->assertTrue( $statuses['stripe_connected'] ); + $this->assertTrue( $statuses['paid_offer_created'] ); + } + /** * Test that GET requires authentication. */ diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/fixtures/memberships-stubs.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/fixtures/memberships-stubs.php new file mode 100644 index 000000000000..a8b47d07d88b --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/fixtures/memberships-stubs.php @@ -0,0 +1,32 @@ + Date: Wed, 24 Jun 2026 17:52:08 +0100 Subject: [PATCH 08/13] AI Launchpad: complete share_site via a "Mark as complete" button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit share_site has no real completion signal (sharing is a transient client action; even Calypso completes it optimistically when the user copies/shares the URL via its share-site modal) and no get_calypso_path, so the AI Launchpad rendered it as a card with no CTA — it could never be completed from wp-admin. Add a "Mark as complete" button shown for any complete-on-click task that has no CTA destination (isCompleteOnClickTask(id) && !canStart; currently just share_site). It POSTs the existing allowlisted complete-task endpoint and flips the card to done in place, only on a successful write. share_site is added to COMPLETE_ON_CLICK_TASK_IDS (server allowlist + model.ts). The acknowledgment tasks and setup_ssh are unaffected — they have destinations, so "Get started" still POSTs then navigates. Verified on Atomic: GET /wpcom/v2/ai-launchpad renders share_site with a null calypso_path (so the button shows), and POST /ai-launchpad/complete-task ticks it. Part of DOTOBRD-480. --- .../add-ai-launchpad-share-site-mark-complete | 4 ++ .../ai-launchpad/class-ai-launchpad-rest.php | 5 +++ .../js/tailored-list/model.test.mts | 1 + .../ai-launchpad/js/tailored-list/model.ts | 1 + .../js/tailored-list/tailored-list.tsx | 27 ++++++++++++ .../js/tailored-list/task-card.tsx | 42 +++++++++++++++---- .../ai-launchpad/AI_Launchpad_REST_Test.php | 17 ++++++++ 7 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-share-site-mark-complete diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-share-site-mark-complete b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-share-site-mark-complete new file mode 100644 index 000000000000..eca02192a705 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-share-site-mark-complete @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +AI Launchpad: add a "Mark as complete" button for tasks with no CTA destination (Share your site), so they can be completed from wp-admin where they otherwise had no action. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php index 8fedac37b02e..01e7b1232c2c 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php @@ -31,6 +31,10 @@ class AI_Launchpad_REST extends WP_REST_Controller { * token 401, Jetpack-user token 403). Calypso's own hosting form completes it * optimistically when the user creates SFTP credentials; this reuses that * strategy, ticking it when the user opens the same hosting page via the CTA. + * - share_site has no real signal at all (sharing is a transient client action; + * even Calypso completes it optimistically when the user copies/shares the URL) + * and no CTA destination, so the tailored list offers a "Mark as complete" + * button that hits this route. * * The AI Launchpad runs in wp-admin (on both Simple and Atomic), so it marks * these complete locally on CTA click. Server-side allowlist so the complete-task @@ -45,6 +49,7 @@ class AI_Launchpad_REST extends WP_REST_Controller { 'start_building_your_audience', 'site_monitoring_page', 'setup_ssh', + 'share_site', ); /** diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.test.mts b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.test.mts index 12ce4759a7fa..1cd36928fa37 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.test.mts +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.test.mts @@ -163,6 +163,7 @@ describe( 'isCompleteOnClickTask', () => { assert.equal( isCompleteOnClickTask( 'earn_money' ), true ); assert.equal( isCompleteOnClickTask( 'site_monitoring_page' ), true ); assert.equal( isCompleteOnClickTask( 'setup_ssh' ), true ); + assert.equal( isCompleteOnClickTask( 'share_site' ), true ); } ); it( 'is false for tasks that complete via a real signal or listener', () => { diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.ts b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.ts index 49dec45e61ad..272fd30c6af0 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.ts +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/model.ts @@ -90,6 +90,7 @@ const COMPLETE_ON_CLICK_TASK_IDS = [ 'start_building_your_audience', 'site_monitoring_page', 'setup_ssh', + 'share_site', ]; /** diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/tailored-list.tsx b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/tailored-list.tsx index e9808030d208..dec1903aff3e 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/tailored-list.tsx +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/tailored-list.tsx @@ -207,6 +207,29 @@ export function TailoredList( { pendingTailor, initialData, site }: Props = {} ) } }; + // Complete-on-click tasks with no CTA destination (e.g. share_site) can't be + // "started", so the card offers "Mark as complete": persist the completion and + // flip the card to done in place (no navigation). Only flips on a successful + // write so a failed POST doesn't show a completion that reverts on reload. + const handleMarkComplete = async ( task: EnrichedTask ) => { + setBusyId( task.id ); + try { + trackTaskClicked( { task_id: task.id } ); + await apiFetch( { + path: '/wpcom/v2/ai-launchpad/complete-task', + method: 'POST', + data: { task_id: task.id }, + } ); + setTasks( prev => + prev ? prev.map( t => ( t.id === task.id ? { ...t, completed: true } : t ) ) : prev + ); + } catch { + // Leave the task incomplete on failure. + } finally { + setBusyId( null ); + } + }; + const handleSkip = ( task: EnrichedTask ) => { setSkippedIds( prev => new Set( prev ).add( task.id ) ); }; @@ -225,8 +248,12 @@ export function TailoredList( { pendingTailor, initialData, site }: Props = {} ) task={ task } isBusy={ busyId === task.id } canStart={ isTaskActionable( task, output, siteUrl ) } + canMarkComplete={ + isCompleteOnClickTask( task.id ) && ! isTaskActionable( task, output, siteUrl ) + } defaultOpen={ index === firstOpenIndex } onGetStarted={ () => handleGetStarted( task ) } + onMarkComplete={ () => handleMarkComplete( task ) } onSkip={ () => handleSkip( task ) } /> ) ) } diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/task-card.tsx b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/task-card.tsx index 5d9f96db771e..86bb59931715 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/task-card.tsx +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/tailored-list/task-card.tsx @@ -44,8 +44,10 @@ interface Props { task: EnrichedTask; isBusy: boolean; canStart: boolean; + canMarkComplete: boolean; defaultOpen: boolean; onGetStarted: () => void; + onMarkComplete: () => void; onSkip: () => void; } @@ -102,17 +104,29 @@ function getCtaLabel( taskId: string ): string { * the always-visible header, which is the toggle trigger); expanding reveals the * AI subtitle and the action-specific CTA / "Skip" actions. * - * @param props - The component props. - * @param props.task - The enriched task to render. - * @param props.isBusy - Whether the primary action is in flight. - * @param props.canStart - Whether the task has an actionable CTA destination. - * @param props.defaultOpen - Whether the card starts expanded (uncontrolled, so - * the user can then collapse it without it reopening). - * @param props.onGetStarted - Called when the primary CTA is clicked. - * @param props.onSkip - Called when "Skip" is clicked. + * @param props - The component props. + * @param props.task - The enriched task to render. + * @param props.isBusy - Whether the primary action is in flight. + * @param props.canStart - Whether the task has an actionable CTA destination. + * @param props.canMarkComplete - Whether the task offers a "Mark as complete" button + * (a complete-on-click task with no CTA destination). + * @param props.defaultOpen - Whether the card starts expanded (uncontrolled, so + * the user can then collapse it without it reopening). + * @param props.onGetStarted - Called when the primary CTA is clicked. + * @param props.onMarkComplete - Called when "Mark as complete" is clicked. + * @param props.onSkip - Called when "Skip" is clicked. * @return The task card element. */ -export function TaskCard( { task, isBusy, canStart, defaultOpen, onGetStarted, onSkip }: Props ) { +export function TaskCard( { + task, + isBusy, + canStart, + canMarkComplete, + defaultOpen, + onGetStarted, + onMarkComplete, + onSkip, +}: Props ) { if ( task.completed ) { return ( @@ -142,6 +156,16 @@ export function TaskCard( { task, isBusy, canStart, defaultOpen, onGetStarted, o { getCtaLabel( task.id ) } ) } + { ! canStart && canMarkComplete && ( + + ) } diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php index a9eb0d5f2716..5ab54d8a4802 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php @@ -718,6 +718,23 @@ public function test_complete_task_marks_setup_ssh() { $this->assertTrue( ! empty( $statuses['setup_ssh'] ) ); } + /** + * Test that share_site completes via the complete-on-click route. It has no CTA + * destination, so the tailored list offers a "Mark as complete" button that + * hits this route; sharing is a transient client action with no real signal. + */ + public function test_complete_task_marks_share_site() { + wp_set_current_user( $this->admin_id ); + $this->seed_ai_output_with_tasks( array( 'share_site', 'site_launched' ) ); + + $result = $this->call_api( 'POST', '/complete-task', array( 'task_id' => 'share_site' ) ); + + $this->assertSame( 200, $result->get_status() ); + $this->assertTrue( $result->get_data()['completed'] ); + $statuses = get_option( 'launchpad_checklist_tasks_statuses' ); + $this->assertTrue( ! empty( $statuses['share_site'] ) ); + } + /** * Test that POST /complete-task rejects ids that are not completable this way: * a non-allowlisted task (even if on the list) and an allowlisted task that is From 3758516a7431c3e374d373dd823a9f5d2ebcd493 Mon Sep 17 00:00:00 2001 From: Copons Date: Fri, 26 Jun 2026 11:12:49 +0100 Subject: [PATCH 09/13] AI Launchpad: point social/design task CTAs at wp-admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog sends connect_social_media to Calypso /marketing/connections even though it completes on the wp-admin Jetpack Social page, and the design "Select a design" tasks (design_completed, design_selected) to a Calypso setup step-flow rather than the theme browser — a poor fit for the wp-admin AI Launchpad. Add AI_Launchpad_REST::CTA_OVERRIDES, a read-side map applied in build_tasks that repoints connect_social_media to admin.php?page=jetpack-social (matching where it completes, and drive_traffic's CTA) and the design tasks to themes.php. Overriding on read leaves the shared catalog (used by the legacy launchpad too) untouched, mirroring FORCE_VISIBLE_TASK_IDS. admin_url() yields an absolute URL the client navigates as-is, so no client change is needed. Verified on Atomic: GET /wpcom/v2/ai-launchpad returns the wp-admin URLs for these tasks and leaves non-overridden tasks' paths unchanged. Part of DOTOBRD-481. --- .../changelog/fix-ai-launchpad-wp-admin-ctas | 4 ++++ .../ai-launchpad/class-ai-launchpad-rest.php | 22 +++++++++++++++++- .../ai-launchpad/AI_Launchpad_REST_Test.php | 23 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/fix-ai-launchpad-wp-admin-ctas diff --git a/projects/packages/jetpack-mu-wpcom/changelog/fix-ai-launchpad-wp-admin-ctas b/projects/packages/jetpack-mu-wpcom/changelog/fix-ai-launchpad-wp-admin-ctas new file mode 100644 index 000000000000..57fe52696959 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/fix-ai-launchpad-wp-admin-ctas @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +AI Launchpad: point the social and design task CTAs at wp-admin (Jetpack Social, the theme browser) instead of Calypso flows, so they match where the tasks complete and keep the user in wp-admin. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php index 01e7b1232c2c..d589894d4a32 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php @@ -65,6 +65,22 @@ class AI_Launchpad_REST extends WP_REST_Controller { 'add_10_email_subscribers', ); + /** + * CTA destinations the AI Launchpad repoints to wp-admin, keyed by task id and + * mapping to an `admin_url()` path. The catalog sends these to Calypso flows + * that are a poor fit for the wp-admin AI Launchpad: connect_social_media goes + * to Calypso `/marketing/connections` even though it completes on the wp-admin + * Jetpack Social page (where this lands it, matching drive_traffic), and the + * design "Select a design" tasks go to a Calypso setup step-flow rather than the + * theme browser. Overridden on read so the shared catalog (used by the legacy + * launchpad too) is left untouched. + */ + const CTA_OVERRIDES = array( + 'connect_social_media' => 'admin.php?page=jetpack-social', + 'design_completed' => 'themes.php', + 'design_selected' => 'themes.php', + ); + /** * Class constructor. */ @@ -508,12 +524,16 @@ private function build_tasks( $tasks ) { ? AI_Launchpad_Memberships::is_task_complete( $task['id'] ) : wpcom_launchpad_checklists()->is_task_complete( $definition ); + $calypso_path = isset( self::CTA_OVERRIDES[ $task['id'] ] ) + ? admin_url( self::CTA_OVERRIDES[ $task['id'] ] ) + : wpcom_launchpad_checklists()->load_calypso_path( $definition ); + $built[] = array( 'id' => $task['id'], 'subtitle' => $task['subtitle'], 'title' => isset( $definition['get_title'] ) ? $definition['get_title']() : '', 'completed' => $completed, - 'calypso_path' => wpcom_launchpad_checklists()->load_calypso_path( $definition ), + 'calypso_path' => $calypso_path, ); } diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php index 5ab54d8a4802..362838059937 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php @@ -326,6 +326,29 @@ public function test_get_overrides_membership_task_completion() { $this->assertTrue( $statuses['paid_offer_created'] ); } + /** + * Test that GET repoints the social/design CTAs to wp-admin targets, since the + * catalog sends them to Calypso flows that are a poor fit for the wp-admin AI + * Launchpad (and connect_social_media completes on the wp-admin Jetpack Social + * page, where its CTA should land). + */ + public function test_get_overrides_calypso_ctas_with_wp_admin_targets() { + wp_set_current_user( $this->admin_id ); + $this->seed_ai_output_with_tasks( array( 'connect_social_media', 'design_selected', 'site_launched' ) ); + + $paths = array(); + foreach ( $this->call_api( Requests::GET )->get_data()['tasks'] as $task ) { + $paths[ $task['id'] ] = $task['calypso_path']; + } + + $this->assertSame( admin_url( 'admin.php?page=jetpack-social' ), $paths['connect_social_media'] ); + $this->assertSame( admin_url( 'themes.php' ), $paths['design_selected'] ); + // A task without an override keeps its catalog path unchanged (null for the + // launch task, which routes to the wordpress.com launch flow client-side). + $this->assertArrayHasKey( 'site_launched', $paths ); + $this->assertNull( $paths['site_launched'] ); + } + /** * Test that GET requires authentication. */ From 4c0ca4e4b4dd23e0fbe6900cab9a6a67e08df9bf Mon Sep 17 00:00:00 2001 From: Copons Date: Fri, 26 Jun 2026 11:41:46 +0100 Subject: [PATCH 10/13] AI Launchpad: add an ?all_tasks=1 testing query param Render the full task catalog instead of the tailored list, so every task can be exercised from a single site without running the wizard. GET /ai-launchpad accepts all_tasks=1, which builds from the whole catalog with the per-site visibility gate bypassed (build_all_catalog_tasks; each task enriched in isolation so one that can't be built in this context is skipped). The client (isAllTasksMode) detects the param on the page URL, skips the wizard, and fetches with it. Read-only and admin-gated (rides on the existing edit_posts + eligibility check); the persisted tailored output is untouched. Verified on Atomic: GET ...?all_tasks=1 returns the full catalog (visibility bypassed, includes normally-hidden tasks), 200, with no enrichment failures; normal mode is unaffected. Part of DOTOBRD-480. --- .../add-ai-launchpad-all-tasks-testing-param | 4 ++ .../ai-launchpad/class-ai-launchpad-rest.php | 68 ++++++++++++++++--- .../src/features/ai-launchpad/js/app.tsx | 10 ++- .../js/lib/orchestration.test.mts | 15 +++- .../ai-launchpad/js/lib/orchestration.ts | 13 ++++ .../ai-launchpad/AI_Launchpad_REST_Test.php | 20 ++++++ 6 files changed, 117 insertions(+), 13 deletions(-) create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-all-tasks-testing-param diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-all-tasks-testing-param b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-all-tasks-testing-param new file mode 100644 index 000000000000..2e3b66c9778c --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-all-tasks-testing-param @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +AI Launchpad: add an ?all_tasks=1 testing query param that skips tailoring and renders the full task catalog, so every task can be exercised from a single site. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php index d589894d4a32..e4811f51afdf 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php @@ -103,6 +103,15 @@ public function register_routes() { 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_data' ), 'permission_callback' => array( $this, 'can_read' ), + 'args' => array( + // Testing aid: render the full task catalog (visibility bypassed, + // no tailoring) so every task can be exercised from one site. + 'all_tasks' => array( + 'description' => 'Return the full task catalog instead of the tailored list (testing aid).', + 'type' => 'boolean', + 'default' => false, + ), + ), ), array( 'methods' => WP_REST_Server::DELETABLE, @@ -241,17 +250,25 @@ private function check_eligibility() { /** * Composite read: wizard payload, AI output, enriched tasks, statuses, and eligibility. * + * @param WP_REST_Request|null $request Request object (for the `all_tasks` testing param). * @return array */ - public function get_data() { + public function get_data( $request = null ) { $wizard = get_option( self::OPTION_WIZARD ); $ai_output = get_option( self::OPTION_AI_OUTPUT ); - // Guard the nested payload: partial/legacy/failed writes may leave the - // option as an array without payload.tasks, which would warn and break. - $tasks = array(); - if ( is_array( $ai_output ) && isset( $ai_output['payload']['tasks'] ) && is_array( $ai_output['payload']['tasks'] ) ) { - $tasks = $this->build_tasks( $ai_output['payload']['tasks'] ); + // Testing aid: ?all_tasks=1 renders the whole catalog (visibility bypassed), + // independent of the persisted tailored output, so every task can be + // exercised from a single site. + if ( $request instanceof WP_REST_Request && $request->get_param( 'all_tasks' ) ) { + $tasks = $this->build_all_catalog_tasks(); + } else { + // Guard the nested payload: partial/legacy/failed writes may leave the + // option as an array without payload.tasks, which would warn and break. + $tasks = array(); + if ( is_array( $ai_output ) && isset( $ai_output['payload']['tasks'] ) && is_array( $ai_output['payload']['tasks'] ) ) { + $tasks = $this->build_tasks( $ai_output['payload']['tasks'] ); + } } // The membership tasks' completion is recomputed in build_tasks() (their @@ -471,13 +488,45 @@ private function sanitize_subtitle( $subtitle ) { return mb_substr( $subtitle, 0, 200 ); } + /** + * Builds the enriched task list for every task in the catalog, bypassing the + * per-site visibility gate. Backs the `?all_tasks=1` testing aid; each task is + * enriched in isolation so one that can't be built in this context is skipped + * rather than breaking the whole view. + * + * @return array + */ + private function build_all_catalog_tasks() { + $built = array(); + foreach ( array_keys( wpcom_launchpad_get_task_definitions() ) as $task_id ) { + try { + $one = $this->build_tasks( + array( + array( + 'id' => $task_id, + 'subtitle' => $task_id, + ), + ), + true + ); + if ( ! empty( $one ) ) { + $built[] = $one[0]; + } + } catch ( \Throwable $e ) { + continue; + } + } + return $built; + } + /** * Enriches the persisted tasks with title, completion state, and CTA path from the catalog. * - * @param array $tasks The persisted `payload.tasks` array. + * @param array $tasks The persisted `payload.tasks` array. + * @param bool $bypass_visibility Skip the catalog visibility gate (for the all-tasks testing view). * @return array */ - private function build_tasks( $tasks ) { + private function build_tasks( $tasks, $bypass_visibility = false ) { $definitions = wpcom_launchpad_get_task_definitions(); $built = array(); @@ -511,7 +560,8 @@ private function build_tasks( $tasks ) { // per-goal lists also contain conditionally-visible tasks, so gating the // write path could strand a goal with too few surviving tasks. if ( - ! in_array( $task['id'], self::FORCE_VISIBLE_TASK_IDS, true ) + ! $bypass_visibility + && ! in_array( $task['id'], self::FORCE_VISIBLE_TASK_IDS, true ) && ! wpcom_launchpad_checklists()->is_visible( $definition ) ) { continue; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/app.tsx b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/app.tsx index ff948b54d218..9f2aa337f88e 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/app.tsx +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/app.tsx @@ -1,6 +1,6 @@ import apiFetch from '@wordpress/api-fetch'; import { useEffect, useState } from '@wordpress/element'; -import { decideInitialView, type View } from './lib/orchestration.ts'; +import { decideInitialView, isAllTasksMode, type View } from './lib/orchestration.ts'; import { TailoredList } from './tailored-list/tailored-list.tsx'; import { Wizard } from './wizard/wizard.tsx'; import type { TailorResult } from './lib/types.ts'; @@ -31,12 +31,16 @@ export function App() { useEffect( () => { let cancelled = false; - apiFetch< LaunchpadData >( { path: '/wpcom/v2/ai-launchpad' } ).then( data => { + // Testing aid: ?all_tasks=1 skips the wizard and renders the full task + // catalog (the endpoint returns every task, visibility bypassed). + const allTasks = isAllTasksMode( window.location.search ); + const path = allTasks ? '/wpcom/v2/ai-launchpad?all_tasks=1' : '/wpcom/v2/ai-launchpad'; + apiFetch< LaunchpadData >( { path } ).then( data => { if ( cancelled ) { return; } setInitialData( data ); - setView( decideInitialView( data ) ); + setView( allTasks ? 'list' : decideInitialView( data ) ); } ); return () => { cancelled = true; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/lib/orchestration.test.mts b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/lib/orchestration.test.mts index 82bf33aceb4b..f756a8592eea 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/lib/orchestration.test.mts +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/lib/orchestration.test.mts @@ -1,10 +1,23 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { decideInitialView, type OrchestrationData } from './orchestration.ts'; +import { decideInitialView, isAllTasksMode, type OrchestrationData } from './orchestration.ts'; import type { TailoredOutput } from './types.ts'; const PAYLOAD = {} as TailoredOutput; +describe( 'isAllTasksMode', () => { + it( 'is true when the all_tasks query param is set', () => { + assert.equal( isAllTasksMode( '?page=ai-launchpad-wp-admin&all_tasks=1' ), true ); + assert.equal( isAllTasksMode( '?all_tasks=1' ), true ); + } ); + + it( 'is false when the param is absent or not enabling', () => { + assert.equal( isAllTasksMode( '?page=ai-launchpad-wp-admin' ), false ); + assert.equal( isAllTasksMode( '' ), false ); + assert.equal( isAllTasksMode( '?all_tasks=0' ), false ); + } ); +} ); + describe( 'decideInitialView', () => { it( 'shows the wizard when the site has no AI output (new user)', () => { const data: OrchestrationData = { ai_output: null }; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/lib/orchestration.ts b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/lib/orchestration.ts index 2a6f3cab01e3..e2593931d98a 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/lib/orchestration.ts +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/js/lib/orchestration.ts @@ -26,3 +26,16 @@ export type View = 'wizard' | 'list'; export function decideInitialView( data: OrchestrationData ): View { return data.ai_output ? 'list' : 'wizard'; } + +/** + * Whether the page was opened in the all-tasks testing mode (`?all_tasks=1` on + * the admin page URL). In this mode the app skips the wizard and renders the + * full task catalog (see the `all_tasks` param on `GET /ai-launchpad`), so every + * task can be exercised from a single site. + * + * @param search - The page's `location.search` string. + * @return True when the all-tasks param is enabled. + */ +export function isAllTasksMode( search: string ): boolean { + return new URLSearchParams( search ).get( 'all_tasks' ) === '1'; +} diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php index 362838059937..63f3f577293e 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php @@ -349,6 +349,26 @@ public function test_get_overrides_calypso_ctas_with_wp_admin_targets() { $this->assertNull( $paths['site_launched'] ); } + /** + * Test that GET with ?all_tasks=1 returns the full catalog (a testing aid), + * bypassing per-site visibility and not depending on any persisted AI output. + */ + public function test_get_all_tasks_param_returns_full_catalog() { + wp_set_current_user( $this->admin_id ); + // No ai_output seeded — all-tasks mode is independent of the tailored output. + + $result = $this->call_api( Requests::GET, '', null, array( 'all_tasks' => '1' ) ); + + $this->assertSame( 200, $result->get_status() ); + $ids = array_column( $result->get_data()['tasks'], 'id' ); + // Far more than a tailored list (~6 tasks): the whole catalog. + $this->assertGreaterThan( 40, count( $ids ) ); + // Includes a task normally hidden by the visibility gate (woo_products needs + // WooCommerce, absent in the test env) — proving the bypass. + $this->assertContains( 'woo_products', $ids ); + $this->assertContains( 'first_post_published', $ids ); + } + /** * Test that GET requires authentication. */ From e3931489da238267d43891291f1a3f4e27fd5136 Mon Sep 17 00:00:00 2001 From: Copons Date: Fri, 26 Jun 2026 12:57:54 +0100 Subject: [PATCH 11/13] AI Launchpad: fix phan, add fetch coverage, consolidate changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Suppress PhanUndeclared* on the Jetpack-plugin classes/functions the listeners reference (Publicize Connections/Publicize_Utils, fetch_subscriber_counts, Jetpack_Memberships) via @phan-file-suppress — they exist at runtime on Atomic and every call is guarded by class_exists/function_exists. - Cover AI_Launchpad_Subscribers_Listener::get_email_subscriber_count()'s real parse of fetch_subscriber_counts() (status/value handling) with a stubbed helper and a probe subclass. - Consolidate the ten AI Launchpad changelog entries into one. Part of DOTOBRD-480. --- .../add-ai-launchpad-about-page-listener | 4 -- .../add-ai-launchpad-all-tasks-testing-param | 4 -- .../add-ai-launchpad-complete-on-click | 4 -- .../add-ai-launchpad-memberships-completion | 4 -- ...d-ai-launchpad-setup-ssh-complete-on-click | 4 -- .../add-ai-launchpad-share-site-mark-complete | 4 -- .../add-ai-launchpad-social-listener | 4 -- .../add-ai-launchpad-subscriber-listener | 4 -- .../add-ai-launchpad-wp-admin-completion | 4 ++ .../fix-ai-launchpad-honor-task-visibility | 4 -- .../changelog/fix-ai-launchpad-wp-admin-ctas | 4 -- .../class-ai-launchpad-memberships.php | 2 + .../class-ai-launchpad-social-listener.php | 2 + ...lass-ai-launchpad-subscribers-listener.php | 2 + .../AI_Launchpad_Social_Listener_Test.php | 2 + ...AI_Launchpad_Subscribers_Listener_Test.php | 45 +++++++++++++++++++ .../fixtures/subscriptions-stubs.php | 22 +++++++++ 17 files changed, 79 insertions(+), 40 deletions(-) delete mode 100644 projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-about-page-listener delete mode 100644 projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-all-tasks-testing-param delete mode 100644 projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-complete-on-click delete mode 100644 projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-memberships-completion delete mode 100644 projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-setup-ssh-complete-on-click delete mode 100644 projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-share-site-mark-complete delete mode 100644 projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-social-listener delete mode 100644 projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-subscriber-listener create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-wp-admin-completion delete mode 100644 projects/packages/jetpack-mu-wpcom/changelog/fix-ai-launchpad-honor-task-visibility delete mode 100644 projects/packages/jetpack-mu-wpcom/changelog/fix-ai-launchpad-wp-admin-ctas create mode 100644 projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/fixtures/subscriptions-stubs.php diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-about-page-listener b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-about-page-listener deleted file mode 100644 index 9912e343964f..000000000000 --- a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-about-page-listener +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: added - -AI Launchpad: complete the About-page tasks from wp-admin by tagging the AI-created About page and completing add_about_page / update_about_page when it is published or edited. diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-all-tasks-testing-param b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-all-tasks-testing-param deleted file mode 100644 index 2e3b66c9778c..000000000000 --- a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-all-tasks-testing-param +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: added - -AI Launchpad: add an ?all_tasks=1 testing query param that skips tailoring and renders the full task catalog, so every task can be exercised from a single site. diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-complete-on-click b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-complete-on-click deleted file mode 100644 index 58d4fcb68fce..000000000000 --- a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-complete-on-click +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: added - -AI Launchpad: mark acknowledgment tasks (e.g. Complete your profile) done when their CTA is clicked, since they have no completion signal on Atomic. diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-memberships-completion b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-memberships-completion deleted file mode 100644 index dd7adf93ce5a..000000000000 --- a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-memberships-completion +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: added - -AI Launchpad: complete the memberships tasks (connect Stripe, set up payments, create an offer, create a paid newsletter) from wp-admin by reading Jetpack's local membership signals on Atomic, where the catalog's membership-settings checks are always false. diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-setup-ssh-complete-on-click b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-setup-ssh-complete-on-click deleted file mode 100644 index f793621277f8..000000000000 --- a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-setup-ssh-complete-on-click +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: added - -AI Launchpad: complete the Set up SSH task when its CTA is clicked, reusing Calypso's optimistic hosting-form completion since the real SSH-user signal is unreachable from the Atomic context. diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-share-site-mark-complete b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-share-site-mark-complete deleted file mode 100644 index eca02192a705..000000000000 --- a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-share-site-mark-complete +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: added - -AI Launchpad: add a "Mark as complete" button for tasks with no CTA destination (Share your site), so they can be completed from wp-admin where they otherwise had no action. diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-social-listener b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-social-listener deleted file mode 100644 index f72378c45aab..000000000000 --- a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-social-listener +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: added - -AI Launchpad: complete the Jetpack Social tasks (connect social media, drive traffic, enable post sharing) from wp-admin once a Publicize connection exists or the Publicize module is active. diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-subscriber-listener b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-subscriber-listener deleted file mode 100644 index 4706b06f80aa..000000000000 --- a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-subscriber-listener +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: added - -AI Launchpad: complete the subscriber tasks (import subscribers, get your first 10 subscribers) from wp-admin by reading the site's email subscriber count on Atomic, and show the first-10 task there despite its WordPress.com-only visibility gate. diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-wp-admin-completion b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-wp-admin-completion new file mode 100644 index 000000000000..b9e04be6bb6b --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-wp-admin-completion @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +AI Launchpad: make tasks completable from wp-admin on Simple and Atomic — honor the catalog visibility gate, add local completion listeners (Jetpack Social, About page), retrieve real signals on Atomic (subscriber counts, memberships), and complete acknowledgment / no-signal tasks on CTA click (including a "Mark as complete" button and an SSH task). Point the social and design CTAs at wp-admin, and add an ?all_tasks=1 testing param that renders the full task catalog. diff --git a/projects/packages/jetpack-mu-wpcom/changelog/fix-ai-launchpad-honor-task-visibility b/projects/packages/jetpack-mu-wpcom/changelog/fix-ai-launchpad-honor-task-visibility deleted file mode 100644 index d5396d15d6a0..000000000000 --- a/projects/packages/jetpack-mu-wpcom/changelog/fix-ai-launchpad-honor-task-visibility +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: fixed - -AI Launchpad: only surface tasks the catalog would make visible on the site (honor is_visible_callback), so plugin- or goal-gated tasks no longer appear where they can't be completed. diff --git a/projects/packages/jetpack-mu-wpcom/changelog/fix-ai-launchpad-wp-admin-ctas b/projects/packages/jetpack-mu-wpcom/changelog/fix-ai-launchpad-wp-admin-ctas deleted file mode 100644 index 57fe52696959..000000000000 --- a/projects/packages/jetpack-mu-wpcom/changelog/fix-ai-launchpad-wp-admin-ctas +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: fixed - -AI Launchpad: point the social and design task CTAs at wp-admin (Jetpack Social, the theme browser) instead of Calypso flows, so they match where the tasks complete and keep the user in wp-admin. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-memberships.php b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-memberships.php index 0ff3d3369f3a..f20bc8ecbbad 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-memberships.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-memberships.php @@ -3,6 +3,8 @@ * AI Launchpad memberships completion override. * * @package automattic/jetpack-mu-wpcom + * + * @phan-file-suppress PhanUndeclaredClassMethod -- Jetpack_Memberships ships in the Jetpack plugin, available at runtime on Atomic; calls are guarded by class_exists. */ /** diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-social-listener.php b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-social-listener.php index 080260ca4c97..210b02b32dbd 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-social-listener.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-social-listener.php @@ -3,6 +3,8 @@ * AI Launchpad Jetpack Social completion listener. * * @package automattic/jetpack-mu-wpcom + * + * @phan-file-suppress PhanUndeclaredClassReference, PhanUndeclaredClassMethod -- The Publicize classes (Connections, Publicize_Utils) ship in the Jetpack plugin, available at runtime on Atomic; calls are guarded by class_exists. */ use Automattic\Jetpack\Jetpack_Mu_Wpcom\AI_Launchpad; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-subscribers-listener.php b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-subscribers-listener.php index baad282c3efa..c866aa1ac0af 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-subscribers-listener.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-subscribers-listener.php @@ -3,6 +3,8 @@ * AI Launchpad subscriber-count completion listener. * * @package automattic/jetpack-mu-wpcom + * + * @phan-file-suppress PhanUndeclaredFunction -- fetch_subscriber_counts() ships in the Jetpack plugin, available at runtime on Atomic; the call is guarded by function_exists. */ use Automattic\Jetpack\Jetpack_Mu_Wpcom\AI_Launchpad; diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Social_Listener_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Social_Listener_Test.php index a4a24674eb4e..868fc9d80539 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Social_Listener_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Social_Listener_Test.php @@ -3,6 +3,8 @@ * Test class for AI_Launchpad_Social_Listener. * * @package automattic/jetpack-mu-wpcom + * + * @phan-file-suppress PhanUndeclaredClassStaticProperty -- The Publicize stubs are aliased onto the real (Jetpack-plugin) class names at runtime; phan can't see the aliased static props. */ use Automattic\Jetpack\Publicize\Connections; diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Subscribers_Listener_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Subscribers_Listener_Test.php index 675ba39c76e9..7dd49d668fd8 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Subscribers_Listener_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Subscribers_Listener_Test.php @@ -10,6 +10,7 @@ // phpcs:disable Generic.Files.OneObjectStructurePerFile.MultipleFound -- the test double and its test case share this file. require_once __DIR__ . '/fixtures/social-stubs.php'; +require_once __DIR__ . '/fixtures/subscriptions-stubs.php'; //phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.NotAbsolutePath require_once \Automattic\Jetpack\Jetpack_Mu_Wpcom::PKG_DIR . 'src/features/ai-launchpad/helpers.php'; //phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.NotAbsolutePath @@ -37,6 +38,19 @@ protected static function get_email_subscriber_count() { } } +/** + * Probe that exposes the real (non-overridden) fetch so its parsing of + * fetch_subscriber_counts() can be tested against the stubbed helper. + */ +class AI_Launchpad_Subscribers_Listener_Real_Probe extends AI_Launchpad_Subscribers_Listener { + /** + * @return int|null + */ + public static function probe() { + return parent::get_email_subscriber_count(); + } +} + /** * Test class for AI_Launchpad_Subscribers_Listener. * @@ -173,4 +187,35 @@ public function test_count_of_ten_completes_first_ten_task() { $this->assertTrue( $task_lists->is_task_id_complete( 'add_10_email_subscribers' ) ); } + + /** + * The real fetch reads email_subscribers from Jetpack's fetch_subscriber_counts(). + */ + public function test_real_fetch_returns_email_subscriber_count() { + $GLOBALS['ai_launchpad_stub_subscriber_counts'] = array( + 'status' => 'success', + 'value' => array( 'email_subscribers' => 7 ), + ); + $this->assertSame( 7, AI_Launchpad_Subscribers_Listener_Real_Probe::probe() ); + } + + /** + * A failed counts lookup is treated as unknown (null), not zero, so a transient + * failure never sticks a task as incomplete-forever or completes it wrongly. + */ + public function test_real_fetch_returns_null_on_failed_status() { + $GLOBALS['ai_launchpad_stub_subscriber_counts'] = array( + 'status' => 'failed', + 'value' => array( 'email_subscribers' => 7 ), + ); + $this->assertNull( AI_Launchpad_Subscribers_Listener_Real_Probe::probe() ); + } + + /** + * A counts payload missing the email_subscribers key is unknown (null). + */ + public function test_real_fetch_returns_null_when_count_absent() { + $GLOBALS['ai_launchpad_stub_subscriber_counts'] = array( 'value' => array() ); + $this->assertNull( AI_Launchpad_Subscribers_Listener_Real_Probe::probe() ); + } } diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/fixtures/subscriptions-stubs.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/fixtures/subscriptions-stubs.php new file mode 100644 index 000000000000..04e3e3486110 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/fixtures/subscriptions-stubs.php @@ -0,0 +1,22 @@ + Date: Fri, 26 Jun 2026 13:08:08 +0100 Subject: [PATCH 12/13] AI Launchpad: drop now-unused phan suppression The subscriptions test fixture declares fetch_subscriber_counts() in its namespace, so phan (which analyzes test fixtures too) no longer sees the call as undeclared and the @phan-file-suppress became an UnusedPluginFileSuppression. Part of DOTOBRD-480. --- .../ai-launchpad/class-ai-launchpad-subscribers-listener.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-subscribers-listener.php b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-subscribers-listener.php index c866aa1ac0af..baad282c3efa 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-subscribers-listener.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-subscribers-listener.php @@ -3,8 +3,6 @@ * AI Launchpad subscriber-count completion listener. * * @package automattic/jetpack-mu-wpcom - * - * @phan-file-suppress PhanUndeclaredFunction -- fetch_subscriber_counts() ships in the Jetpack plugin, available at runtime on Atomic; the call is guarded by function_exists. */ use Automattic\Jetpack\Jetpack_Mu_Wpcom\AI_Launchpad; From 45fb5cd02cefe036bd0ec048314a0675894a427f Mon Sep 17 00:00:00 2001 From: Copons Date: Fri, 26 Jun 2026 14:10:01 +0100 Subject: [PATCH 13/13] AI Launchpad: hide Jetpack Social tasks on private sites connect_social_media, drive_traffic, and post_sharing_enabled point at the Jetpack Social admin page, which wpcom doesn't load on a private site (Publicize is gated the same way, Publicize_Setup::should_load()), so their CTA would 404. Hide them in build_tasks() when the site is private. The launchpad runs only on the wpcom platform, so the private-site flag (blog_public = -1, the core of Status::is_private_site()) is the whole condition; read it directly to avoid a hard Status-package dependency in this read path. Verified on Atomic: with blog_public = 1 the three Social tasks show; with -1 they are hidden and the rest of the list is unchanged. Part of DOTOBRD-481. --- .../add-ai-launchpad-wp-admin-completion | 2 +- .../ai-launchpad/class-ai-launchpad-rest.php | 34 +++++++++++++++++++ .../ai-launchpad/AI_Launchpad_REST_Test.php | 32 +++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-wp-admin-completion b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-wp-admin-completion index b9e04be6bb6b..584f6dcb6a0e 100644 --- a/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-wp-admin-completion +++ b/projects/packages/jetpack-mu-wpcom/changelog/add-ai-launchpad-wp-admin-completion @@ -1,4 +1,4 @@ Significance: patch Type: added -AI Launchpad: make tasks completable from wp-admin on Simple and Atomic — honor the catalog visibility gate, add local completion listeners (Jetpack Social, About page), retrieve real signals on Atomic (subscriber counts, memberships), and complete acknowledgment / no-signal tasks on CTA click (including a "Mark as complete" button and an SSH task). Point the social and design CTAs at wp-admin, and add an ?all_tasks=1 testing param that renders the full task catalog. +AI Launchpad: make tasks completable from wp-admin on Simple and Atomic — honor the catalog visibility gate, add local completion listeners (Jetpack Social, About page), retrieve real signals on Atomic (subscriber counts, memberships), and complete acknowledgment / no-signal tasks on CTA click (including a "Mark as complete" button and an SSH task). Point the social and design CTAs at wp-admin, hide the Jetpack Social tasks on private sites where their admin page isn't available, and add an ?all_tasks=1 testing param that renders the full task catalog. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php index e4811f51afdf..a5999a947aed 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-rest.php @@ -81,6 +81,32 @@ class AI_Launchpad_REST extends WP_REST_Controller { 'design_selected' => 'themes.php', ); + /** + * Jetpack Social tasks, hidden on private sites. wpcom does not load Publicize + * (and so the Jetpack Social admin page their CTA points to) on a private site, + * mirroring Publicize_Setup::should_load(). The AI Launchpad runs only on the + * wpcom platform, so the private-site flag (`blog_public = -1`) is the whole + * condition — Publicize's additional `is_wpcom_platform()` check is always true + * here. + */ + const SOCIAL_PAGE_TASK_IDS = array( + 'connect_social_media', + 'drive_traffic', + 'post_sharing_enabled', + ); + + /** + * Whether the site's visibility is set to private (`blog_public = -1`), the + * core of Jetpack's Status::is_private_site(). Read directly to avoid a hard + * dependency on the Status package in this read path; the launchpad is + * wpcom-only, where this option is the private-site signal. + * + * @return bool + */ + private function is_private_site() { + return '-1' === (string) get_option( 'blog_public' ); + } + /** * Class constructor. */ @@ -538,6 +564,8 @@ private function build_tasks( $tasks, $bypass_visibility = false ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } + $is_private_site = $this->is_private_site(); + foreach ( $tasks as $task ) { if ( ! is_array( $task ) || ! isset( $task['id'] ) || ! isset( $task['subtitle'] ) ) { continue; @@ -547,6 +575,12 @@ private function build_tasks( $tasks, $bypass_visibility = false ) { continue; } + // The Jetpack Social tasks point at an admin page wpcom doesn't load on + // a private site, so hide them there — their CTA would 404. + if ( $is_private_site && in_array( $task['id'], self::SOCIAL_PAGE_TASK_IDS, true ) ) { + continue; + } + $definition = $definitions[ $task['id'] ]; $definition['id'] = $task['id']; diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php index 63f3f577293e..707742ec9a10 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_REST_Test.php @@ -369,6 +369,38 @@ public function test_get_all_tasks_param_returns_full_catalog() { $this->assertContains( 'first_post_published', $ids ); } + /** + * Test that the Jetpack Social tasks are hidden on a private site, where wpcom + * doesn't load the Social admin page their CTA points to. + */ + public function test_get_hides_social_tasks_on_private_site() { + wp_set_current_user( $this->admin_id ); + $this->seed_ai_output_with_tasks( + array( 'connect_social_media', 'drive_traffic', 'post_sharing_enabled', 'first_post_published', 'site_launched' ) + ); + + $ids = function () { + return array_column( $this->call_api( Requests::GET )->get_data()['tasks'], 'id' ); + }; + + // Public site: the Social tasks show. + update_option( 'blog_public', '1' ); + $public_ids = $ids(); + $this->assertContains( 'connect_social_media', $public_ids ); + $this->assertContains( 'drive_traffic', $public_ids ); + $this->assertContains( 'post_sharing_enabled', $public_ids ); + + // Private site: the Social tasks are gone, the rest remain. + update_option( 'blog_public', '-1' ); + $private_ids = $ids(); + $this->assertNotContains( 'connect_social_media', $private_ids ); + $this->assertNotContains( 'drive_traffic', $private_ids ); + $this->assertNotContains( 'post_sharing_enabled', $private_ids ); + $this->assertContains( 'first_post_published', $private_ids ); + + update_option( 'blog_public', '1' ); + } + /** * Test that GET requires authentication. */