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..584f6dcb6a0e --- /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, 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/ai-launchpad.php b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/ai-launchpad.php index 343c4cd0eacd..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,9 +16,13 @@ // 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'; +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-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/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..f20bc8ecbbad --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-memberships.php @@ -0,0 +1,73 @@ + 'admin.php?page=jetpack-social', + 'design_completed' => 'themes.php', + '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. */ @@ -41,6 +129,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, @@ -88,6 +185,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', @@ -159,24 +276,42 @@ 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 + // 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 @@ -291,6 +426,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. * @@ -339,16 +514,58 @@ 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(); + // 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'; + } + + $is_private_site = $this->is_private_site(); + foreach ( $tasks as $task ) { if ( ! is_array( $task ) || ! isset( $task['id'] ) || ! isset( $task['subtitle'] ) ) { continue; @@ -358,15 +575,49 @@ private function build_tasks( $tasks ) { 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']; + // 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 ( + ! $bypass_visibility + && ! in_array( $task['id'], self::FORCE_VISIBLE_TASK_IDS, true ) + && ! wpcom_launchpad_checklists()->is_visible( $definition ) + ) { + 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 ); + + $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' => wpcom_launchpad_checklists()->is_task_complete( $definition ), - 'calypso_path' => wpcom_launchpad_checklists()->load_calypso_path( $definition ), + 'completed' => $completed, + 'calypso_path' => $calypso_path, ); } 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..210b02b32dbd --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/ai-launchpad/class-ai-launchpad-social-listener.php @@ -0,0 +1,95 @@ +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/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/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/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/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..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 @@ -7,6 +7,7 @@ import { validateAgainstSchema } from '../lib/schema-validator.ts'; import { ctaKind, firstIncompleteIndex, + isCompleteOnClickTask, isTaskActionable, launchSiteUrl, resolveCtaUrl, @@ -156,6 +157,22 @@ 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 ); + 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', () => { + 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..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 @@ -73,6 +73,37 @@ export function ctaKind( taskId: string ): CtaKind { return 'deeplink'; } +/** + * 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 = [ + 'complete_profile', + 'manage_subscribers', + 'manage_paid_newsletter_plan', + 'earn_money', + 'start_building_your_audience', + 'site_monitoring_page', + 'setup_ssh', + 'share_site', +]; + +/** + * Whether a task should be marked complete client-side when its CTA is clicked, + * because it has no reachable completion signal on Atomic. + * + * @param taskId - The catalog task ID. + * @return True for complete-on-click 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..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 @@ -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 ); } @@ -194,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 ) ); }; @@ -212,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/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/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_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' ) ); + } +} 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 61ca6b1c19d6..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 @@ -8,6 +8,11 @@ //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'; +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'; use PHPUnit\Framework\Attributes\CoversClass; @@ -197,6 +202,205 @@ 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 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 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 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 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 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. */ @@ -517,6 +721,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() ); @@ -527,6 +734,104 @@ 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 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 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 + * 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. */ 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..868fc9d80539 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Social_Listener_Test.php @@ -0,0 +1,106 @@ + $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/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..7dd49d668fd8 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/ai-launchpad/AI_Launchpad_Subscribers_Listener_Test.php @@ -0,0 +1,221 @@ + $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' ) ); + } + + /** + * 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/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 @@ +