diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 881af9c52883..bb3074654932 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4208,12 +4208,18 @@ importers: '@automattic/jetpack-connection': specifier: workspace:* version: link:../../js-packages/connection + '@automattic/jetpack-script-data': + specifier: workspace:* + version: link:../../js-packages/script-data '@automattic/jetpack-shared-extension-utils': specifier: workspace:* version: link:../../js-packages/shared-extension-utils '@automattic/number-formatters': specifier: workspace:* version: link:../../js-packages/number-formatters + '@microsoft/fetch-event-source': + specifier: 2.0.1 + version: 2.0.1 '@wordpress/api-fetch': specifier: 7.44.0 version: 7.44.0 @@ -4344,6 +4350,9 @@ importers: '@testing-library/react': specifier: 16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@wordpress/browserslist-config': specifier: 6.44.0 version: 6.44.0 diff --git a/projects/packages/search/AGENTS.md b/projects/packages/search/AGENTS.md new file mode 100644 index 000000000000..ce06d3d3b27b --- /dev/null +++ b/projects/packages/search/AGENTS.md @@ -0,0 +1,54 @@ +# Jetpack Search + +## UI Components + +**Always use existing design system components instead of writing custom ones.** + +The Jetpack Search dashboard is actively migrating to standard WordPress components. When building UI, follow this priority order: + +### Priority 1: `@wordpress/ui` +The primary component library for new Jetpack work. Use for: +- `Button`, `Link` +- `Notice.Root` / `Notice.Title` / `Notice.Description` +- Tabs, dialogs, and layout primitives + +### Priority 2: `@wordpress/components` +Fallback for anything not yet in `@wordpress/ui`: +- `ToggleControl`, `TextareaControl`, `SelectControl` +- `Tabs`, `TabPanel` +- `Notice` (legacy) + +### Priority 3: `@automattic/jetpack-components` +Jetpack-specific components (upsell cards, plan badges, etc.). + +### Never reinvent +Do **not** create custom implementations of: +- Tab bars → use `@wordpress/components` `Tabs` or `@wordpress/ui` +- Buttons → use `@wordpress/ui` `Button` +- Notices/alerts → use `@wordpress/ui` `Notice` +- Toggles/checkboxes → use `@wordpress/components` `ToggleControl` + +When unsure which component to use, the `@wordpress/design-system-mcp` MCP is configured for this project — use it to look up the right component before building a custom one. + +## Data access + +- `@automattic/jetpack-shared-extension-utils` → `getSiteFragment()` for site domain +- `@automattic/jetpack-script-data` → `isWpcomPlatformSite()` for WP.com detection +- Store selectors via `useSelect( select => select( STORE_ID ).selectorName(), [] )` — always include `[]` dependency array + +## Testing + +Always run all three suites when changing search code: + +```bash +# JS tests (from projects/packages/search/) +pnpm test-scripts + +# Search PHP tests +jetpack test php packages/search -v + +# Sync PHP tests — search changes often affect the sync whitelist +jetpack test php packages/sync -v +``` + +- Mock external packages in tests; mock custom hooks (`hooks/use-*`) rather than their internals diff --git a/projects/packages/search/changelog/jps3-ai-answers b/projects/packages/search/changelog/jps3-ai-answers new file mode 100644 index 000000000000..41e16908b5e5 --- /dev/null +++ b/projects/packages/search/changelog/jps3-ai-answers @@ -0,0 +1,4 @@ +Significance: major +Type: added + +Search: AI Answers — add streaming ai answers panel in the instant-search overlay. The AI can be customized from a new AI Answers dashboard. The feature is availabe for all paid plans. diff --git a/projects/packages/search/docs/plans/local-testing-setup.md b/projects/packages/search/docs/plans/local-testing-setup.md new file mode 100644 index 000000000000..406a8a0a963c --- /dev/null +++ b/projects/packages/search/docs/plans/local-testing-setup.md @@ -0,0 +1,309 @@ +# Local Testing Setup — Jetpack Search AI Answers + +A checklist to verify your local environment can run tests and test the AI Answers feature end-to-end, including live calls to a WPcom sandbox. + +--- + +## 1. Prerequisites + +- [x ] Docker Desktop is installed and running (`docker info` returns no error) +- [x ] `jetpack` CLI is installed and on PATH: + ```bash + jetpack --version + # If missing: npm install -g @automattic/jetpack-cli (or pnpm add -g) + ``` +- [x ] Node and pnpm are available: + ```bash + node --version # 20+ + pnpm --version # 9+ + ``` +- [x ] PHP and Composer are available: + ```bash + php --version # 8.0+ + composer --version + ``` +- [x ] You're on the right branch: + ```bash + git branch # should be jps3-answers-plan + ``` + +--- + +## 2. Install Monorepo Dependencies + +- [x ] Install all JS and PHP dependencies from the monorepo root: + ```bash + pnpm install + composer install + ``` + This only needs to be done once (or after a `git pull` that changes lockfiles). + +--- + +## 3. Run Unit Tests Without Docker + +The PHP unit tests for the Search and Sync packages use WorDBless (no WordPress needed) and run locally without Docker. + +- [x ] Run Search package PHP tests: + ```bash + jetpack test php packages/search -v + ``` + Expected: all tests pass (including `AI_Answers_Test` once Task 1 is implemented). + +- [x ] Run Sync package PHP tests: + ```bash + jetpack test php packages/sync -v + ``` + Expected: all tests pass (including the CPT sync tests once Task 2 is implemented). + +- [x ] Run Search package JS tests: + ```bash + cd projects/packages/search + pnpm test-scripts + ``` + Expected: all tests pass. + +--- + +## 4. Build the Search Package + +Build is required before the Docker site can serve the updated JS bundle. + +- [x ] Build all Search JS bundles: + ```bash + cd projects/packages/search + pnpm build + ``` + Expected: `build/` directory updated with no errors. + +- [ ] For active development, use watch mode instead: + ```bash + pnpm watch + ``` + Leave this running while you work — it rebuilds on file save. + +--- + +## 5. Start Docker WordPress Environment + +- [x ] Start containers (first time or after `jetpack docker clean`): + ```bash + jetpack docker up -d + jetpack docker install + ``` + WordPress is now at [http://localhost](http://localhost). + Default credentials: `jp_docker_acct` / `jp_docker_pass` + +- [x ] If containers were already installed previously, just start them: + ```bash + jetpack docker up -d + ``` + +- [x ] Verify WordPress is reachable: + ```bash + open http://localhost/wp-admin + ``` + You should see the WP login page. + +- [x ] Verify Jetpack plugin is active (it's auto-linked from the monorepo): + ```bash + jetpack docker wp plugin list | grep jetpack + ``` + Expected: `jetpack` shows as `active`. + +--- + +## 6. Connect Jetpack to WordPress.com via Tunnel + +Jetpack requires a publicly accessible URL to handshake with WPcom. + +**If you're an Automattician**, use Jurassic Tube (preferred): + +- [ ] Follow the setup instructions at PCYsg-GJ2-p2 to install and configure Jurassic Tube if you haven't already. +- [ ] Start the tunnel: + ```bash + jetpack docker up --jurassic-tube + ``` + Note your assigned domain (e.g. `yourname.jurassictube.example`). + +**Alternative**: Use ngrok (requires a paid account for a stable subdomain): +- [ ] Start with ngrok: + ```bash + jetpack docker up --ngrok + ``` + +- [ ] Once the tunnel is running, re-install WordPress with the tunnel domain: + ```bash + jetpack docker install + ``` + (This updates `WP_SITEURL` and `WP_HOME` to the tunnel domain.) + +- [ ] Go to **Jetpack → Dashboard** in wp-admin and complete the WPcom connection flow. Sign in with your WPcom account. + +- [ ] Verify connection: + ```bash + jetpack docker wp jetpack status + ``` + Expected: `Jetpack is connected to WordPress.com`. + +--- + +## 7. Enable Instant Search + +Instant Search must be enabled for the overlay and AI Answers panel to appear. + +- [ ] Enable the Search module and Instant Search: + ```bash + jetpack docker wp option update jetpack_active_modules '["search"]' --format=json + jetpack docker wp option update instant_search_enabled 1 + ``` + +- [ ] Verify in wp-admin: **Jetpack → Search** should show the Search dashboard with "Instant Search" toggled on. + +--- + +## 8. Enable the AI Answers Feature Flag + +- [ ] Enable the AI Answers feature: + ```bash + jetpack docker wp option update jetpack_search_ai_answers_enabled 1 + ``` + +- [ ] Verify the **Behavior** and **Topics** tabs appear at **Jetpack → Search**. + +- [ ] Add a test behavior post via the Behavior tab (type any text, click Save). + +- [ ] Add a test topic via **Jetpack → Search → Topics → Add Topic**. + Use this post content as a starting point: + ``` + This topic covers questions about resetting passwords and account access. + + Example questions: + - How do I reset my password? + - I forgot my login email. + - My account is locked. + + Guidelines: + Direct users to the account recovery page. Do not speculate about lock durations. + ``` + +--- + +## 9. Point PHP→WPcom Calls to a Sandbox + +This routes Jetpack's server-side API calls (JWT issuance, sync, REST proxy) through your WPcom sandbox instead of production. + +- [ ] Create a mu-plugin file for sandbox config: + ```bash + cat > tools/docker/mu-plugins/sandbox.php << 'EOF' + true, + 'type' => 'string', + 'show_in_rest' => true, + 'default' => '', + 'sanitize_callback' => 'sanitize_textarea_field', + 'auth_callback' => function () { + return current_user_can( 'manage_options' ); + }, + ) + ); + return; + } + + register_setting( + 'options', + self::BEHAVIOR_OPTION_KEY, + array( + 'type' => 'string', + 'default' => '', + 'sanitize_callback' => 'sanitize_textarea_field', + 'show_in_rest' => true, + ) + ); + } + + /** + * Retrieve the behavior instructions. + * + * Reads from the Gutenberg Guidelines CPT when available, otherwise falls + * back to the site option. + * + * @return string Behavior instructions, or empty string if none saved. + */ + public static function get_behavior_instructions() { + if ( post_type_exists( 'wp_guideline' ) ) { + $posts = get_posts( + array( + 'post_type' => 'wp_guideline', + 'posts_per_page' => 1, + 'post_status' => 'publish', + ) + ); + if ( ! empty( $posts ) ) { + $guidelines = get_post_meta( $posts[0]->ID, self::BEHAVIOR_META_KEY, true ); + return is_string( $guidelines ) ? $guidelines : ''; + } + } + return (string) get_option( self::BEHAVIOR_OPTION_KEY, '' ); + } + + /** + * Whether AI Answers is enabled for the current site. + */ + public static function is_enabled() { + return (bool) apply_filters( + 'jetpack_search_ai_answers_enabled', + (bool) get_option( 'jetpack_search_ai_answers_enabled', false ) + ); + } +} diff --git a/projects/packages/search/src/class-helper.php b/projects/packages/search/src/class-helper.php index 280f348e7aa2..27a1c75c4503 100644 --- a/projects/packages/search/src/class-helper.php +++ b/projects/packages/search/src/class-helper.php @@ -970,6 +970,7 @@ public static function generate_initial_javascript_state() { * @param bool $disable_tracking Whether to disable tracking. Default false. */ 'disableTracking' => self::is_tracking_disabled() || apply_filters( 'jetpack_instant_search_disable_tracking', false ), + 'aiAnswersEnabled' => AI_Answers::is_enabled(), ); /** diff --git a/projects/packages/search/src/class-rest-controller.php b/projects/packages/search/src/class-rest-controller.php index 731967cad4e8..be11b0b5544c 100644 --- a/projects/packages/search/src/class-rest-controller.php +++ b/projects/packages/search/src/class-rest-controller.php @@ -253,8 +253,9 @@ public function update_settings( $request ) { ? sanitize_text_field( $request_body['experience'] ) : null; $reader_chat = array_key_exists( 'reader_chat', $request_body ) ? (bool) $request_body['reader_chat'] : null; + $ai_answers_enabled = isset( $request_body['ai_answers_enabled'] ) ? (bool) $request_body['ai_answers_enabled'] : null; - $error = $this->validate_search_settings( $module_active, $instant_search_enabled, $swap_classic_to_inline_search, $experience, $reader_chat ); + $error = $this->validate_search_settings( $module_active, $instant_search_enabled, $swap_classic_to_inline_search, $experience, $reader_chat, $ai_answers_enabled ); if ( is_wp_error( $error ) ) { return $error; @@ -299,6 +300,10 @@ public function update_settings( $request ) { update_option( 'reader_chat', $reader_chat ); } + if ( $ai_answers_enabled !== null ) { + update_option( 'jetpack_search_ai_answers_enabled', $ai_answers_enabled ); + } + if ( ! empty( $errors ) ) { return new WP_Error( 'some_updated', @@ -325,8 +330,9 @@ public function update_settings( $request ) { * @param boolean $swap_classic_to_inline_search - New inline search status. * @param string|null $experience - Experience value. * @param bool|null $reader_chat - Reader Chat status. + * @param bool|null $ai_answers_enabled - Whether Jetpack Search AI answers is enabled. */ - protected function validate_search_settings( $module_active, $instant_search_enabled, $swap_classic_to_inline_search, $experience = null, $reader_chat = null ) { + protected function validate_search_settings( $module_active, $instant_search_enabled, $swap_classic_to_inline_search, $experience = null, $reader_chat = null, $ai_answers_enabled = null ) { if ( $reader_chat !== null && ! $this->is_reader_chat_setting_registered() ) { return new WP_Error( 'rest_invalid_arguments', @@ -357,6 +363,10 @@ protected function validate_search_settings( $module_active, $instant_search_ena // Allow updating auxiliary settings without updating/validating the module settings. return true; } + if ( $module_active === null && $instant_search_enabled === null && $swap_classic_to_inline_search === null && $ai_answers_enabled !== null ) { + // allow updating 'ai_answers_enabled' without updating/validating other settings. + return true; + } if ( ( true === $instant_search_enabled && false === $module_active ) || ( $module_active === null && $instant_search_enabled === null ) ) { return new WP_Error( 'rest_invalid_arguments', @@ -376,6 +386,7 @@ public function get_settings() { 'instant_search_enabled' => $this->search_module->is_instant_search_enabled(), 'swap_classic_to_inline_search' => $this->search_module->is_swap_classic_to_inline_search(), 'experience' => $this->search_module->get_experience(), + 'ai_answers_enabled' => AI_Answers::is_enabled(), ); if ( $this->is_reader_chat_setting_registered() ) { diff --git a/projects/packages/search/src/class-settings.php b/projects/packages/search/src/class-settings.php index 2b7e82a02c19..4ad664e3bc9f 100644 --- a/projects/packages/search/src/class-settings.php +++ b/projects/packages/search/src/class-settings.php @@ -52,6 +52,7 @@ public function settings_register() { array( $setting_prefix . 'show_post_date', 'boolean', true ), array( $setting_prefix . 'show_product_price', 'boolean', true ), array( $setting_prefix . 'show_powered_by', 'boolean', true ), + array( $setting_prefix . 'ai_answers_enabled', 'boolean', false ), ); foreach ( $settings as $value ) { register_setting( diff --git a/projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx b/projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx new file mode 100644 index 000000000000..0c9e20fc826a --- /dev/null +++ b/projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx @@ -0,0 +1,161 @@ +import { isWpcomPlatformSite } from '@automattic/jetpack-script-data'; +import { getSiteFragment } from '@automattic/jetpack-shared-extension-utils'; +import { TextareaControl, ToggleControl } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { Button, Notice } from '@wordpress/ui'; +import useAiAnswersSettings, { DEFAULT_PERSONALITY } from 'hooks/use-ai-answers-settings'; +import useProductCheckoutWorkflow from 'hooks/use-product-checkout-workflow'; +import useSearchSettings from 'hooks/use-search-settings'; +import { STORE_ID } from 'store'; +import './style.scss'; + +/** + * AiAnswersTab component for configuring AI Answers settings. + * + * @return {import('react').ReactElement} AiAnswersTab component. + */ +export default function AiAnswersTab() { + const supportsInstantSearch = useSelect( + select => select( STORE_ID ).supportsInstantSearch(), + [] + ); + const isFreePlan = useSelect( select => select( STORE_ID ).isFreePlan(), [] ); + const blogID = useSelect( select => select( STORE_ID ).getBlogId(), [] ); + const siteAdminUrl = useSelect( select => select( STORE_ID ).getSiteAdminUrl(), [] ); + + const { isAiAnswersEnabled, isInstantSearchEnabled, setAiAnswersEnabled } = useSearchSettings(); + + const { run: sendToCart } = useProductCheckoutWorkflow( { + productSlug: 'jetpack_search', + adminUrl: siteAdminUrl, + redirectUri: 'admin.php?page=jetpack-search&just_upgraded=1', + from: 'jetpack-search', + siteSuffix: getSiteFragment(), + blogID, + isWpcom: isWpcomPlatformSite(), + } ); + + const { content, setContent, isSaving, isLoading, error, saved, isUnavailable, savePersonality } = + useAiAnswersSettings(); + + const settingsClassName = [ + 'jp-search-ai-answers-tab__settings', + isFreePlan || ! supportsInstantSearch ? 'jp-search-ai-answers-tab__settings--gated' : '', + ] + .filter( Boolean ) + .join( ' ' ); + + const savingLabel = __( 'Saving…', 'jetpack-search-pkg' ); + const saveLabel = __( 'Save', 'jetpack-search-pkg' ); + + return ( +
+ { ( isFreePlan || ! supportsInstantSearch ) && ( +
+
+
+
+

+ { __( 'Upgrade to use AI Answers', 'jetpack-search-pkg' ) } +

+
    +
  • + { __( + 'Give visitors real answers, not just search results.', + 'jetpack-search-pkg' + ) } +
  • +
  • + { __( "Fills gaps when your content doesn't match.", 'jetpack-search-pkg' ) } +
  • +
  • + { __( + 'Serious, silly, or snarky — your personality, your search.', + 'jetpack-search-pkg' + ) } +
  • +
+ +
+
+
+
+ ) } + +
+
+
+
+ { isLoading &&

{ __( 'Loading…', 'jetpack-search-pkg' ) }

} + { supportsInstantSearch && ! isInstantSearchEnabled && ( + + + { __( + 'Instant Search must be enabled for AI Answers to work.', + 'jetpack-search-pkg' + ) } + + + { __( 'Enable Instant Search on the Plan & Usage tab.', 'jetpack-search-pkg' ) } + + + ) } + + + { ! isLoading && ! isUnavailable && ( + <> + { error &&

{ error }

} + +
+ + { saved && ( + + { __( 'Saved.', 'jetpack-search-pkg' ) } + + ) } +
+ + ) } + + { ! isLoading && isUnavailable && ( + + + { __( + 'Personality instructions are temporarily unavailable.', + 'jetpack-search-pkg' + ) } + + + { __( 'Please try again later or contact support.', 'jetpack-search-pkg' ) } + + + ) } +
+
+
+
+
+ ); +} diff --git a/projects/packages/search/src/dashboard/components/ai-answers-tab/style.scss b/projects/packages/search/src/dashboard/components/ai-answers-tab/style.scss new file mode 100644 index 000000000000..07fc874bc32b --- /dev/null +++ b/projects/packages/search/src/dashboard/components/ai-answers-tab/style.scss @@ -0,0 +1,70 @@ +@use "scss/variables"; + +.jp-search-ai-answers-tab { + width: 100%; +} + +.jp-search-ai-answers-tab__upsell { + background-color: variables.$jp-white-off; + border-bottom: 1px solid variables.$jp-gray; + padding: 2.5em 0; + + .jp-search-ai-answers-tab__upsell-heading { + font-size: variables.$font-title-small; + font-weight: 600; + line-height: 1.25; + margin: 0 0 1em; + color: variables.$jp-black; + } + + .jp-search-ai-answers-tab__upsell-bullets { + list-style: disc; + margin: 0 0 1.5em 1.5em; + padding: 0; + + li { + font-size: 1em; + line-height: 1.6; + margin-bottom: 0.4em; + color: variables.$jp-black-80; + } + } +} + +.jp-search-ai-answers-tab__settings { + background-color: variables.$jp-white; + padding: 2.5em 0; + + &.jp-search-ai-answers-tab__settings--gated { + opacity: 0.5; + pointer-events: none; + } +} + +.jp-search-ai-answers-tab__settings-inner { + display: flex; + flex-direction: column; + + .jp-search-dashboard-toggle { + margin-bottom: 1.5em; + } +} + +.jp-search-ai-answers-tab__actions { + display: flex; + align-items: center; + gap: 1em; + margin-top: 0.5em; +} + +.jp-search-ai-answers-tab__error { + color: variables.$jp-red-error; + font-size: 0.875em; + margin: 0 0 1em; +} + +.jp-search-ai-answers-tab__saved { + color: variables.$jp-green-primary; + font-size: 0.9375em; + font-weight: 500; +} diff --git a/projects/packages/search/src/dashboard/components/pages/dashboard-page.jsx b/projects/packages/search/src/dashboard/components/pages/dashboard-page.jsx index 47166db6858e..a88d6acdcc0f 100644 --- a/projects/packages/search/src/dashboard/components/pages/dashboard-page.jsx +++ b/projects/packages/search/src/dashboard/components/pages/dashboard-page.jsx @@ -1,13 +1,10 @@ -import { - AdminPage, - Button, - Container, - Col, - getProductCheckoutUrl, -} from '@automattic/jetpack-components'; +import { AdminPage, Button, getProductCheckoutUrl } from '@automattic/jetpack-components'; import { useConnectionErrorNotice, ConnectionError } from '@automattic/jetpack-connection'; import { useSelect, useDispatch } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; +import { Stack, Tabs } from '@wordpress/ui'; +import { useState } from 'react'; +import AiAnswersTab from 'components/ai-answers-tab'; import FeatureSelector from 'components/feature-selector'; import NoticesList from 'components/global-notices'; import Loading from 'components/loading'; @@ -29,6 +26,8 @@ import './dashboard-page.scss'; * @return {import('react').Component} Search dashboard component. */ export default function DashboardPage( { isLoading = false } ) { + const [ activeTab, setActiveTab ] = useState( 'plan-usage' ); + useSelect( select => select( STORE_ID ).getSearchPlanInfo(), [] ); useSelect( select => select( STORE_ID ).getSearchModuleStatus(), [] ); useSelect( select => select( STORE_ID ).getSearchStats(), [] ); @@ -133,72 +132,30 @@ export default function DashboardPage( { isLoading = false } ) { apiNonce={ apiNonce } className="uses-new-admin-ui" > -
- { /* Always in the DOM so JITM JS finds it immediately (Path A). */ } -
-
-
- { isPageLoading && } - { ! isPageLoading && ( - - ) } -
- { ! isPageLoading && ( - <> - { hasConnectionError && ( - - - - - - ) } - { isNewPricing && supportsInstantSearch && ( - - ) } - { ! isNewPricing && supportsInstantSearch && ( - - ) } -
- { isSearchBlocksEnabled ? ( -
-
-
- -
-
-
- ) : ( - + + { __( 'Plan & Usage', 'jetpack-search-pkg' ) } + + { __( 'AI Answers', 'jetpack-search-pkg' ) }{ ' ' } + + { __( '(Preview)', 'jetpack-search-pkg' ) } + + + + +
+ { /* Always in the DOM so JITM JS finds it immediately (Path A). */ } +
+
+
+ { isPageLoading && } + { ! isPageLoading && ( + ) }
- - - ) } + { ! isPageLoading && ( + <> + { hasConnectionError && ( + + + + ) } + { isNewPricing && supportsInstantSearch && ( + + ) } + { ! isNewPricing && supportsInstantSearch && ( + + ) } +
+ { isSearchBlocksEnabled ? ( +
+
+
+ +
+
+
+ ) : ( + + ) } +
+ + + ) } + + + + +
); diff --git a/projects/packages/search/src/dashboard/components/pages/sections/plan-usage-section.jsx b/projects/packages/search/src/dashboard/components/pages/sections/plan-usage-section.jsx index ac2279194ad8..51ea85ecedb3 100644 --- a/projects/packages/search/src/dashboard/components/pages/sections/plan-usage-section.jsx +++ b/projects/packages/search/src/dashboard/components/pages/sections/plan-usage-section.jsx @@ -207,6 +207,9 @@ const upgradeMessageFromAPIData = apiData => { // apiData.currentUsage.upgrade_reason.requests // apiData.currentUsage.months_over_plan_records_limit // apiData.currentUsage.months_over_plan_requests_limit + if ( ! apiData.currentUsage ) { + return null; + } if ( apiData.currentUsage.must_upgrade ) { return upgradeMessageSearchDisabled(); } @@ -237,7 +240,9 @@ const PlanUsageSection = ( { isFreePlan, planInfo, sendPaidPlanToCart, isPlanJus // For complete plan, we only show the final CTA once search is already disabled. // It's just because it was added later, and we didn't want to redesign existing CTAs at the time. const upgradeMessage = - isFreePlan || planInfo.currentUsage.must_upgrade ? upgradeMessageFromAPIData( planInfo ) : null; + isFreePlan || planInfo.currentUsage?.must_upgrade + ? upgradeMessageFromAPIData( planInfo ) + : null; const usageInfo = usageInfoFromAPIData( planInfo ); return ( diff --git a/projects/packages/search/src/dashboard/components/pages/sections/test/plan-summary.test.jsx b/projects/packages/search/src/dashboard/components/pages/sections/test/plan-summary.test.jsx new file mode 100644 index 000000000000..0981955bf298 --- /dev/null +++ b/projects/packages/search/src/dashboard/components/pages/sections/test/plan-summary.test.jsx @@ -0,0 +1,42 @@ +import { render, screen } from '@testing-library/react'; +import PlanSummary from '../plan-summary'; + +describe( 'PlanSummary', () => { + const basePlanInfo = { + latestMonthRequests: { + start_date: '2026-02-01T00:00:00', + end_date: '2026-02-28T00:00:00', + }, + }; + + it( 'renders the usage heading', () => { + render( ); + expect( screen.getByRole( 'heading' ) ).toBeInTheDocument(); + expect( screen.getByText( /your usage/i ) ).toBeInTheDocument(); + } ); + + it( 'shows "Free plan" label for free plan users', () => { + render( ); + expect( screen.getByText( /Free plan/i ) ).toBeInTheDocument(); + } ); + + it( 'shows "Upgraded" label for paid plan users', () => { + render( ); + expect( screen.getByText( /Upgraded/i ) ).toBeInTheDocument(); + } ); + + it( 'shows empty date range when latestMonthRequests is absent', () => { + render( ); + // Heading and plan label should still render, just no date range text + expect( screen.getByText( /your usage/i ) ).toBeInTheDocument(); + const span = screen.getByText( /Upgraded/i ); + // The parent span should start with "(" — no dates means empty string before the bracket + expect( span ).toHaveTextContent( /^\s*\(Upgraded\)/ ); + } ); + + it( 'renders a date range from latestMonthRequests', () => { + render( ); + // The heading should contain at least one digit from the formatted date range. + expect( screen.getByRole( 'heading' ) ).toHaveTextContent( /\d/ ); + } ); +} ); diff --git a/projects/packages/search/src/dashboard/components/pages/sections/test/plan-usage-section.test.jsx b/projects/packages/search/src/dashboard/components/pages/sections/test/plan-usage-section.test.jsx new file mode 100644 index 000000000000..6bfc782d8f10 --- /dev/null +++ b/projects/packages/search/src/dashboard/components/pages/sections/test/plan-usage-section.test.jsx @@ -0,0 +1,149 @@ +import { render, screen } from '@testing-library/react'; +import PlanUsageSection from '../plan-usage-section'; + +jest.mock( '@automattic/jetpack-components', () => ( { + ContextualUpgradeTrigger: ( { description, cta } ) => ( +
+ { description } + { cta } +
+ ), + ThemeProvider: ( { children } ) => <>{ children }, +} ) ); + +jest.mock( '@wordpress/ui', () => ( { + Link: ( { children, href } ) => { children }, +} ) ); + +jest.mock( '../../../donut-meter-container', () => { + const DonutMeter = ( { title, current, limit } ) => ( +
+ { title } { current }/{ limit } +
+ ); + DonutMeter.displayName = 'DonutMeterContainer'; + return { __esModule: true, default: DonutMeter, formatNumber: n => String( n ) }; +} ); + +jest.mock( '../plan-summary', () => ( { isFreePlan } ) => ( +
{ isFreePlan ? 'Free' : 'Paid' }
+) ); + +const makeUsage = ( overrides = {} ) => ( { + must_upgrade: false, + should_upgrade: false, + upgrade_reason: { records: false, requests: false }, + months_over_plan_records_limit: 0, + months_over_plan_requests_limit: 0, + num_records: 100, + ...overrides, +} ); + +const makePlanInfo = ( usageOverrides = {} ) => ( { + currentUsage: makeUsage( usageOverrides ), + currentPlan: { record_limit: 500, monthly_search_request_limit: 1000 }, + latestMonthRequests: { num_requests: 50, start_date: '2026-02-01', end_date: '2026-02-28' }, +} ); + +const defaultProps = { + isFreePlan: false, + planInfo: makePlanInfo(), + sendPaidPlanToCart: jest.fn(), + isPlanJustUpgraded: false, +}; + +describe( 'PlanUsageSection', () => { + it( 'renders usage meters', () => { + render( ); + const meters = screen.getAllByTestId( 'donut-meter' ); + expect( meters ).toHaveLength( 2 ); + } ); + + it( 'renders plan summary', () => { + render( ); + expect( screen.getByTestId( 'plan-summary' ) ).toBeInTheDocument(); + } ); + + it( 'renders "tell me more" link', () => { + render( ); + expect( screen.getByRole( 'link', { name: /record indexing/i } ) ).toBeInTheDocument(); + } ); + + it( 'does not render upgrade trigger for paid plan with no overage', () => { + render( ); + expect( screen.queryByTestId( 'upgrade-trigger' ) ).not.toBeInTheDocument(); + } ); + + it( 'renders upgrade trigger for free plan when should_upgrade is true', () => { + const planInfo = makePlanInfo( { should_upgrade: true } ); + render( ); + expect( screen.getByTestId( 'upgrade-trigger' ) ).toBeInTheDocument(); + } ); + + it( 'renders search-disabled message when must_upgrade is true', () => { + const planInfo = makePlanInfo( { must_upgrade: true } ); + render( ); + expect( screen.getByTestId( 'upgrade-description' ) ).toHaveTextContent( + /exceeded the limits/i + ); + } ); + + it( 'renders records overage message', () => { + const planInfo = makePlanInfo( { + should_upgrade: true, + upgrade_reason: { records: true, requests: false }, + months_over_plan_records_limit: 1, + } ); + render( ); + expect( screen.getByTestId( 'upgrade-description' ) ).toHaveTextContent( /records/i ); + } ); + + it( 'renders requests overage message', () => { + const planInfo = makePlanInfo( { + should_upgrade: true, + upgrade_reason: { records: false, requests: true }, + months_over_plan_requests_limit: 1, + } ); + render( ); + expect( screen.getByTestId( 'upgrade-description' ) ).toHaveTextContent( /requests/i ); + } ); + + it( 'renders both-overage message when records and requests both over', () => { + const planInfo = makePlanInfo( { + should_upgrade: true, + upgrade_reason: { records: true, requests: true }, + months_over_plan_records_limit: 1, + } ); + render( ); + expect( screen.getByTestId( 'upgrade-description' ) ).toHaveTextContent( + /records and search requests/i + ); + } ); + + it( 'renders no-overage upgrade message when should_upgrade with no specific reason', () => { + const planInfo = makePlanInfo( { + should_upgrade: true, + upgrade_reason: { records: false, requests: false }, + } ); + render( ); + expect( screen.getByTestId( 'upgrade-description' ) ).toHaveTextContent( + /increase your site records/i + ); + } ); + + it( 'renders "continue using" CTA when records over limit for 3+ months', () => { + const planInfo = makePlanInfo( { + should_upgrade: true, + upgrade_reason: { records: true, requests: false }, + months_over_plan_records_limit: 3, + } ); + render( ); + expect( screen.getByTestId( 'upgrade-cta' ) ).toHaveTextContent( /continue using/i ); + } ); + + it( 'does not render upgrade trigger when planInfo has no currentUsage', () => { + const planInfo = { ...makePlanInfo(), currentUsage: undefined }; + render( ); + expect( screen.queryByTestId( 'upgrade-trigger' ) ).not.toBeInTheDocument(); + } ); +} ); diff --git a/projects/packages/search/src/dashboard/components/pages/test/dashboard-page.test.jsx b/projects/packages/search/src/dashboard/components/pages/test/dashboard-page.test.jsx index 8f1c094dcb9d..ab569a785397 100644 --- a/projects/packages/search/src/dashboard/components/pages/test/dashboard-page.test.jsx +++ b/projects/packages/search/src/dashboard/components/pages/test/dashboard-page.test.jsx @@ -27,6 +27,7 @@ jest.mock( 'store', () => ( { jest.mock( 'components/global-notices', () => () =>
); jest.mock( 'components/loading', () => () =>
); jest.mock( 'components/mocked-search', () => () =>
); +jest.mock( 'components/ai-answers-tab', () => () =>
); jest.mock( 'components/module-control', () => () =>
); jest.mock( 'components/reader-chat-control', () => props => { mockReaderChatControl( props ); diff --git a/projects/packages/search/src/dashboard/components/test/ai-answers-tab.test.jsx b/projects/packages/search/src/dashboard/components/test/ai-answers-tab.test.jsx new file mode 100644 index 000000000000..1f2e220be9d9 --- /dev/null +++ b/projects/packages/search/src/dashboard/components/test/ai-answers-tab.test.jsx @@ -0,0 +1,158 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { useSelect, useDispatch } from '@wordpress/data'; +import * as React from 'react'; +import AiAnswersTab from '../ai-answers-tab'; + +jest.mock( 'store', () => ( { STORE_ID: 'jetpack-search-plugin' } ) ); + +jest.mock( '@automattic/jetpack-script-data', () => ( { + isWpcomPlatformSite: () => false, +} ) ); + +jest.mock( '@automattic/jetpack-shared-extension-utils', () => ( { + getSiteFragment: () => 'example.com', +} ) ); + +jest.mock( '@wordpress/data', () => ( { + useSelect: jest.fn(), + useDispatch: jest.fn(), + createRegistryControl: jest.fn(), + combineReducers: jest.fn( reducers => reducers ), + registerStore: jest.fn(), + createReduxStore: jest.fn(), + register: jest.fn(), +} ) ); + +jest.mock( '@wordpress/components', () => ( { + TextareaControl: ( { label } ) => { label }, + ToggleControl: ( { label, checked, onChange } ) => ( + + ), +} ) ); + +jest.mock( '@wordpress/ui', () => ( { + Button: ( { children, onClick } ) => , + Link: ( { children, href } ) => { children }, + Notice: { + Root: ( { children } ) =>
{ children }
, + Title: ( { children } ) =>
{ children }
, + Description: ( { children } ) =>
{ children }
, + }, +} ) ); + +jest.mock( 'hooks/use-product-checkout-workflow', () => () => ( { + run: jest.fn(), + hasCheckoutStarted: false, +} ) ); + +jest.mock( 'hooks/use-ai-answers-settings', () => ( { + __esModule: true, + default: () => ( { + content: '', + setContent: jest.fn(), + postId: null, + isSaving: false, + isLoading: false, + error: null, + saved: false, + isUnavailable: false, + savePersonality: jest.fn(), + } ), + DEFAULT_PERSONALITY: 'Default personality instructions.', +} ) ); + +const mockUpdateJetpackSettings = jest.fn(); + +/** + * Set up mocked store state for testing. + * + * @param {object} root0 - Options. + * @param {boolean} root0.supportsInstantSearch - Whether the site supports instant search. + * @param {boolean} root0.isInstantSearchEnabled - Whether instant search is enabled. + * @param {boolean} root0.isFreePlan - Whether the site is on a free plan. + * @param {boolean} root0.isAiAnswersEnabled - Whether AI Answers is enabled. + */ +function setupStore( { + supportsInstantSearch = true, + isInstantSearchEnabled = true, + isFreePlan = false, + isAiAnswersEnabled = false, +} = {} ) { + useDispatch.mockReturnValue( { + updateJetpackSettings: mockUpdateJetpackSettings, + } ); + useSelect.mockImplementation( fn => + fn( () => ( { + supportsInstantSearch: () => supportsInstantSearch, + isInstantSearchEnabled: () => isInstantSearchEnabled, + isFreePlan: () => isFreePlan, + isAiAnswersEnabled: () => isAiAnswersEnabled, + getBlogId: () => 1, + getSiteAdminUrl: () => 'http://example.com/wp-admin/', + } ) ) + ); +} + +describe( 'AiAnswersTab', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'shows upsell banner for free plan users', async () => { + setupStore( { isFreePlan: true } ); + render( ); + await waitFor( () => { + expect( screen.getByText( 'Upgrade to use AI Answers' ) ).toBeInTheDocument(); + } ); + expect( + screen.getByText( 'Give visitors real answers, not just search results.' ) + ).toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: /upgrade now/i } ) ).toBeInTheDocument(); + } ); + + it( 'shows upsell banner when instant search is not supported', async () => { + setupStore( { supportsInstantSearch: false } ); + render( ); + await waitFor( () => { + expect( screen.getByText( 'Upgrade to use AI Answers' ) ).toBeInTheDocument(); + } ); + } ); + + it( 'does not show upsell banner for paid plan users with instant search', async () => { + setupStore( { supportsInstantSearch: true, isFreePlan: false } ); + render( ); + await waitFor( () => { + expect( screen.queryByText( 'Upgrade to use AI Answers' ) ).not.toBeInTheDocument(); + } ); + } ); + + it( 'settings section is present for paid plan users', async () => { + setupStore( { supportsInstantSearch: true, isAiAnswersEnabled: true } ); + render( ); + await expect( screen.findByText( 'Enable AI Answers' ) ).resolves.toBeInTheDocument(); + } ); + + it( 'settings section is present but visually gated for free plan users', async () => { + setupStore( { isFreePlan: true } ); + render( ); + await expect( screen.findByText( 'Enable AI Answers' ) ).resolves.toBeInTheDocument(); + const gated = screen.getByTestId( 'ai-answers-settings' ); + expect( gated ).toHaveClass( 'jp-search-ai-answers-tab__settings--gated' ); + } ); + + it( 'shows warning when instant search is supported but not enabled', async () => { + setupStore( { supportsInstantSearch: true, isInstantSearchEnabled: false } ); + render( ); + await expect( + screen.findByText( 'Instant Search must be enabled for AI Answers to work.' ) + ).resolves.toBeInTheDocument(); + } ); +} ); diff --git a/projects/packages/search/src/dashboard/hooks/test/use-ai-answers-settings.test.js b/projects/packages/search/src/dashboard/hooks/test/use-ai-answers-settings.test.js new file mode 100644 index 000000000000..866c18a22b32 --- /dev/null +++ b/projects/packages/search/src/dashboard/hooks/test/use-ai-answers-settings.test.js @@ -0,0 +1,248 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import apiFetch from '@wordpress/api-fetch'; +import useAiAnswersSettings, { DEFAULT_PERSONALITY } from '../use-ai-answers-settings'; + +jest.mock( '@wordpress/api-fetch', () => jest.fn() ); + +const SETTINGS_KEY = 'jetpack_search_ai_behavior_instructions'; + +const BLOCK_NAME = 'jetpack/search-ai-summary'; + +const makePost = ( overrides = {} ) => ( { + id: 42, + guideline_categories: { + blocks: { [ BLOCK_NAME ]: { guidelines: 'Be concise.' } }, + }, + ...overrides, +} ); + +describe( 'useAiAnswersSettings', () => { + afterEach( () => { + jest.resetAllMocks(); + } ); + + describe( 'initial load — guidelines endpoint available', () => { + it( 'starts in loading state', () => { + apiFetch.mockReturnValue( new Promise( () => {} ) ); + const { result } = renderHook( () => useAiAnswersSettings() ); + expect( result.current.isLoading ).toBe( true ); + expect( result.current.content ).toBe( '' ); + expect( result.current.postId ).toBeNull(); + expect( result.current.error ).toBeNull(); + expect( result.current.isUnavailable ).toBe( false ); + } ); + + it( 'sets content and postId from fetched post', async () => { + apiFetch.mockResolvedValue( [ makePost() ] ); + const { result } = renderHook( () => useAiAnswersSettings() ); + await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); + expect( result.current.postId ).toBe( 42 ); + expect( result.current.content ).toBe( 'Be concise.' ); + } ); + + it( 'accepts a single post object (not an array)', async () => { + apiFetch.mockResolvedValue( makePost( { id: 7 } ) ); + const { result } = renderHook( () => useAiAnswersSettings() ); + await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); + expect( result.current.postId ).toBe( 7 ); + } ); + + it( 'leaves content empty when post has no guidelines', async () => { + apiFetch.mockResolvedValue( [ makePost( { guideline_categories: { blocks: {} } } ) ] ); + const { result } = renderHook( () => useAiAnswersSettings() ); + await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); + expect( result.current.content ).toBe( '' ); + } ); + + it( 'leaves content empty when no guidelines post exists', async () => { + apiFetch.mockResolvedValue( { id: 0, guideline_categories: {} } ); + const { result } = renderHook( () => useAiAnswersSettings() ); + await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); + expect( result.current.content ).toBe( '' ); + expect( result.current.postId ).toBeNull(); + } ); + + it( 'sets error on generic fetch failure', async () => { + apiFetch.mockRejectedValue( { message: 'Network failure' } ); + const { result } = renderHook( () => useAiAnswersSettings() ); + await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); + expect( result.current.error ).toBe( 'Network failure' ); + expect( result.current.isUnavailable ).toBe( false ); + } ); + } ); + + describe( 'initial load — guidelines endpoint absent, settings fallback', () => { + it( 'falls back to /wp/v2/settings on rest_no_route', async () => { + apiFetch + .mockRejectedValueOnce( { code: 'rest_no_route' } ) + .mockResolvedValueOnce( { [ SETTINGS_KEY ]: 'Answer in French.' } ); + const { result } = renderHook( () => useAiAnswersSettings() ); + await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); + expect( result.current.content ).toBe( 'Answer in French.' ); + expect( result.current.isUnavailable ).toBe( false ); + } ); + + it( 'falls back to /wp/v2/settings on 404 status error', async () => { + apiFetch + .mockRejectedValueOnce( { data: { status: 404 } } ) + .mockResolvedValueOnce( { [ SETTINGS_KEY ]: 'Be brief.' } ); + const { result } = renderHook( () => useAiAnswersSettings() ); + await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); + expect( result.current.content ).toBe( 'Be brief.' ); + expect( result.current.isUnavailable ).toBe( false ); + } ); + + it( 'leaves content empty when option is not set', async () => { + apiFetch.mockRejectedValueOnce( { code: 'rest_no_route' } ).mockResolvedValueOnce( {} ); + const { result } = renderHook( () => useAiAnswersSettings() ); + await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); + expect( result.current.content ).toBe( '' ); + } ); + + it( 'sets isUnavailable when both endpoints fail', async () => { + apiFetch + .mockRejectedValueOnce( { code: 'rest_no_route' } ) + .mockRejectedValueOnce( { message: 'Settings unavailable' } ); + const { result } = renderHook( () => useAiAnswersSettings() ); + await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); + expect( result.current.isUnavailable ).toBe( true ); + expect( result.current.error ).toBeNull(); + } ); + } ); + + describe( 'savePersonality — guidelines path', () => { + it( 'POSTs when postId is null', async () => { + apiFetch.mockResolvedValueOnce( { id: 0 } ).mockResolvedValueOnce( { id: 99 } ); + const { result } = renderHook( () => useAiAnswersSettings() ); + await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); + + await act( async () => result.current.savePersonality() ); + + const saveCall = apiFetch.mock.calls[ 1 ][ 0 ]; + expect( saveCall.method ).toBe( 'POST' ); + expect( saveCall.path ).toBe( '/wp/v2/content-guidelines' ); + } ); + + it( 'PATCHes when postId exists', async () => { + apiFetch.mockResolvedValueOnce( makePost() ).mockResolvedValueOnce( { id: 42 } ); + const { result } = renderHook( () => useAiAnswersSettings() ); + await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); + + await act( async () => result.current.savePersonality() ); + + const saveCall = apiFetch.mock.calls[ 1 ][ 0 ]; + expect( saveCall.method ).toBe( 'PATCH' ); + expect( saveCall.path ).toBe( '/wp/v2/content-guidelines/42' ); + } ); + + it( 'sets saved and updates postId on success', async () => { + apiFetch.mockResolvedValueOnce( { id: 0 } ).mockResolvedValueOnce( { id: 55 } ); + const { result } = renderHook( () => useAiAnswersSettings() ); + await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); + + await act( async () => result.current.savePersonality() ); + + expect( result.current.saved ).toBe( true ); + expect( result.current.postId ).toBe( 55 ); + expect( result.current.isSaving ).toBe( false ); + } ); + + it( 'sets error on save failure', async () => { + apiFetch + .mockResolvedValueOnce( { id: 0 } ) + .mockRejectedValueOnce( { message: 'Save failed' } ); + const { result } = renderHook( () => useAiAnswersSettings() ); + await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); + + await act( async () => result.current.savePersonality() ); + + expect( result.current.error ).toBe( 'Save failed' ); + expect( result.current.saved ).toBe( false ); + expect( result.current.isSaving ).toBe( false ); + } ); + + it( 'sends DEFAULT_PERSONALITY when content is empty', async () => { + apiFetch.mockResolvedValueOnce( { id: 0 } ).mockResolvedValueOnce( { id: 1 } ); + const { result } = renderHook( () => useAiAnswersSettings() ); + await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); + + await act( async () => result.current.savePersonality() ); + + const saveCall = apiFetch.mock.calls[ 1 ][ 0 ]; + expect( saveCall.data.guideline_categories.blocks[ BLOCK_NAME ].guidelines ).toBe( + DEFAULT_PERSONALITY + ); + } ); + + it( 'sends user content when content is non-empty', async () => { + apiFetch.mockResolvedValueOnce( makePost() ).mockResolvedValueOnce( { id: 42 } ); + const { result } = renderHook( () => useAiAnswersSettings() ); + await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); + + act( () => result.current.setContent( 'Custom instructions.' ) ); + await act( async () => result.current.savePersonality() ); + + const saveCall = apiFetch.mock.calls[ 1 ][ 0 ]; + expect( saveCall.data.guideline_categories.blocks[ BLOCK_NAME ].guidelines ).toBe( + 'Custom instructions.' + ); + } ); + } ); + + describe( 'savePersonality — settings fallback path', () => { + const setupSettingsFallback = async () => { + apiFetch + .mockRejectedValueOnce( { code: 'rest_no_route' } ) + .mockResolvedValueOnce( { [ SETTINGS_KEY ]: 'Be concise.' } ); + const utils = renderHook( () => useAiAnswersSettings() ); + await waitFor( () => expect( utils.result.current.isLoading ).toBe( false ) ); + return utils; + }; + + it( 'POSTs to /wp/v2/settings', async () => { + const { result } = await setupSettingsFallback(); + apiFetch.mockResolvedValueOnce( {} ); + + await act( async () => result.current.savePersonality() ); + + const saveCall = apiFetch.mock.calls[ 2 ][ 0 ]; + expect( saveCall.method ).toBe( 'POST' ); + expect( saveCall.path ).toBe( '/wp/v2/settings' ); + expect( saveCall.data[ SETTINGS_KEY ] ).toBe( 'Be concise.' ); + } ); + + it( 'sends DEFAULT_PERSONALITY when content is empty', async () => { + apiFetch + .mockRejectedValueOnce( { code: 'rest_no_route' } ) + .mockResolvedValueOnce( {} ) + .mockResolvedValueOnce( {} ); + const { result } = renderHook( () => useAiAnswersSettings() ); + await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); + + await act( async () => result.current.savePersonality() ); + + const saveCall = apiFetch.mock.calls[ 2 ][ 0 ]; + expect( saveCall.data[ SETTINGS_KEY ] ).toBe( DEFAULT_PERSONALITY ); + } ); + + it( 'sets saved on success', async () => { + const { result } = await setupSettingsFallback(); + apiFetch.mockResolvedValueOnce( {} ); + + await act( async () => result.current.savePersonality() ); + + expect( result.current.saved ).toBe( true ); + expect( result.current.isSaving ).toBe( false ); + } ); + + it( 'sets error on save failure', async () => { + const { result } = await setupSettingsFallback(); + apiFetch.mockRejectedValueOnce( { message: 'Settings save failed' } ); + + await act( async () => result.current.savePersonality() ); + + expect( result.current.error ).toBe( 'Settings save failed' ); + expect( result.current.saved ).toBe( false ); + } ); + } ); +} ); diff --git a/projects/packages/search/src/dashboard/hooks/use-ai-answers-settings.js b/projects/packages/search/src/dashboard/hooks/use-ai-answers-settings.js new file mode 100644 index 000000000000..75b612f317b8 --- /dev/null +++ b/projects/packages/search/src/dashboard/hooks/use-ai-answers-settings.js @@ -0,0 +1,104 @@ +import apiFetch from '@wordpress/api-fetch'; +import { __ } from '@wordpress/i18n'; +import { useEffect, useState } from 'react'; + +const GUIDELINES_REST = '/wp/v2/content-guidelines'; +const SETTINGS_REST = '/wp/v2/settings'; +const SETTINGS_KEY = 'jetpack_search_ai_behavior_instructions'; +const BLOCK_NAME = 'jetpack/search-ai-summary'; + +export const DEFAULT_PERSONALITY = __( + 'You are a search results summarizer for Jetpack Search. Your job is to summarize the best available successful search results in a succinct manner.', + 'jetpack-search-pkg' +); + +/** + * Manages loading and saving the AI Answers personality instructions. + * + * Reads from /wp/v2/guidelines when the Gutenberg Guidelines CPT is available, + * falling back to /wp/v2/settings otherwise. + * + * @return {{ content: string, setContent: Function, postId: number|null, isSaving: boolean, isLoading: boolean, error: string|null, saved: boolean, isUnavailable: boolean, savePersonality: Function }} Hook state and actions for AI answers personality settings. + */ +export default function useAiAnswersSettings() { + const [ content, setContent ] = useState( '' ); + const [ postId, setPostId ] = useState( null ); + const [ useSettings, setUseSettings ] = useState( false ); + const [ isSaving, setIsSaving ] = useState( false ); + const [ isLoading, setIsLoading ] = useState( true ); + const [ error, setError ] = useState( null ); + const [ saved, setSaved ] = useState( false ); + const [ isUnavailable, setIsUnavailable ] = useState( false ); + + useEffect( () => { + apiFetch( { path: GUIDELINES_REST } ) + .then( posts => { + const post = Array.isArray( posts ) ? posts[ 0 ] : posts; + if ( post && post.id ) { + setPostId( post.id ); + setContent( post.guideline_categories?.blocks?.[ BLOCK_NAME ]?.guidelines ?? '' ); + } + } ) + .catch( err => { + if ( err.code === 'rest_no_route' || err.data?.status === 404 ) { + setUseSettings( true ); + return apiFetch( { path: SETTINGS_REST } ) + .then( settings => { + setContent( settings[ SETTINGS_KEY ] ?? '' ); + } ) + .catch( () => { + setIsUnavailable( true ); + } ); + } + setError( err.message ); + } ) + .finally( () => setIsLoading( false ) ); + }, [] ); + + const savePersonality = () => { + setIsSaving( true ); + setSaved( false ); + setError( null ); + + let promise; + if ( useSettings ) { + promise = apiFetch( { + path: SETTINGS_REST, + method: 'POST', + data: { [ SETTINGS_KEY ]: content || DEFAULT_PERSONALITY }, + } ); + } else { + const path = postId ? `${ GUIDELINES_REST }/${ postId }` : GUIDELINES_REST; + const method = postId ? 'PATCH' : 'POST'; + promise = apiFetch( { + path, + method, + data: { + status: 'publish', + guideline_categories: { + blocks: { [ BLOCK_NAME ]: { guidelines: content || DEFAULT_PERSONALITY } }, + }, + }, + } ).then( post => { + setPostId( post.id ); + } ); + } + + promise + .then( () => setSaved( true ) ) + .catch( err => setError( err.message ) ) + .finally( () => setIsSaving( false ) ); + }; + + return { + content, + setContent, + postId, + isSaving, + isLoading, + error, + saved, + isUnavailable, + savePersonality, + }; +} diff --git a/projects/packages/search/src/dashboard/hooks/use-search-settings.js b/projects/packages/search/src/dashboard/hooks/use-search-settings.js new file mode 100644 index 000000000000..8966bb4578e0 --- /dev/null +++ b/projects/packages/search/src/dashboard/hooks/use-search-settings.js @@ -0,0 +1,26 @@ +import { useDispatch, useSelect } from '@wordpress/data'; +import { useCallback } from 'react'; +import { STORE_ID } from 'store'; + +/** + * Provides AI Answers and Instant Search settings from the store, + * along with a dispatcher for updating them. + * + * @return {{ isAiAnswersEnabled: boolean, isInstantSearchEnabled: boolean, setAiAnswersEnabled: Function }} Settings state and updater. + */ +export default function useSearchSettings() { + const isAiAnswersEnabled = useSelect( select => select( STORE_ID ).isAiAnswersEnabled(), [] ); + const isInstantSearchEnabled = useSelect( + select => select( STORE_ID ).isInstantSearchEnabled(), + [] + ); + + const { updateJetpackSettings } = useDispatch( STORE_ID ); + + const setAiAnswersEnabled = useCallback( + value => updateJetpackSettings( { ai_answers_enabled: value } ), + [ updateJetpackSettings ] + ); + + return { isAiAnswersEnabled, isInstantSearchEnabled, setAiAnswersEnabled }; +} diff --git a/projects/packages/search/src/dashboard/scss/_variables.scss b/projects/packages/search/src/dashboard/scss/_variables.scss index 249958b32e44..64d2e68ebbb2 100644 --- a/projects/packages/search/src/dashboard/scss/_variables.scss +++ b/projects/packages/search/src/dashboard/scss/_variables.scss @@ -18,6 +18,7 @@ $jp-gray-off: #e2e2df; $jp-green-primary: #069e08; $jp-green-secondary: #2fb41f; +$jp-red-error: #d63638; $jp-border-radius: 4px; $jp-menu-border-height: 1px; diff --git a/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js b/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js index dd98ebeefd74..78e52de7138f 100644 --- a/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js +++ b/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js @@ -21,6 +21,7 @@ const jetpackSettingSelectors = { isReaderChatAvailable: state => Object.prototype.hasOwnProperty.call( state.jetpackSettings, 'reader_chat' ), isReaderChatEnabled: state => state.jetpackSettings.reader_chat, + isAiAnswersEnabled: state => !! state.jetpackSettings.ai_answers_enabled, isUpdatingJetpackSettings: state => state.jetpackSettings.is_updating, isTogglingModule: state => state.jetpackSettings.is_toggling_module, isTogglingInstantSearch: state => state.jetpackSettings.is_toggling_instant_search, diff --git a/projects/packages/search/src/dashboard/store/selectors/test/jetpack-settings.test.js b/projects/packages/search/src/dashboard/store/selectors/test/jetpack-settings.test.js index 91ab31ede876..b6a9737fc44d 100644 --- a/projects/packages/search/src/dashboard/store/selectors/test/jetpack-settings.test.js +++ b/projects/packages/search/src/dashboard/store/selectors/test/jetpack-settings.test.js @@ -26,4 +26,21 @@ describe( 'jetpackSettingSelectors', () => { } ) ).toBe( true ); } ); + + describe( 'isAiAnswersEnabled', () => { + it( 'returns false when ai_answers_enabled is false', () => { + const state = { jetpackSettings: { ai_answers_enabled: false } }; + expect( jetpackSettingSelectors.isAiAnswersEnabled( state ) ).toBe( false ); + } ); + + it( 'returns true when ai_answers_enabled is true', () => { + const state = { jetpackSettings: { ai_answers_enabled: true } }; + expect( jetpackSettingSelectors.isAiAnswersEnabled( state ) ).toBe( true ); + } ); + + it( 'returns false when ai_answers_enabled is undefined', () => { + const state = { jetpackSettings: {} }; + expect( jetpackSettingSelectors.isAiAnswersEnabled( state ) ).toBe( false ); + } ); + } ); } ); diff --git a/projects/packages/search/src/initializers/class-initializer.php b/projects/packages/search/src/initializers/class-initializer.php index b185ddeffb4f..1a85e30f96f8 100644 --- a/projects/packages/search/src/initializers/class-initializer.php +++ b/projects/packages/search/src/initializers/class-initializer.php @@ -125,6 +125,8 @@ protected static function init_before_connection() { if ( apply_filters( 'jetpack_search_blocks_enabled', false ) ) { Search_Blocks::init(); } + + ( new AI_Answers() )->init(); } /** diff --git a/projects/packages/search/src/instant-search/components/animated-ellipsis.jsx b/projects/packages/search/src/instant-search/components/animated-ellipsis.jsx new file mode 100644 index 000000000000..66a09f7fbed0 --- /dev/null +++ b/projects/packages/search/src/instant-search/components/animated-ellipsis.jsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { useEffect, useState } from 'react'; + +// Duration of each dot appearing, in ms. +const DOT_INTERVAL = 200; +// Pause duration after all three dots are shown, in ms. +const PAUSE_DURATION = 1000; + +const DELAYS = [ DOT_INTERVAL, DOT_INTERVAL, DOT_INTERVAL, PAUSE_DURATION ]; +const DOT_STRINGS = [ '', '.', '..', '...' ]; + +/** + * Renders an animated ellipsis that types out one period at a time, + * pauses, then resets. + * + * @return {React.ReactElement} Animated ellipsis span. + */ +export default function AnimatedEllipsis() { + const [ step, setStep ] = useState( 0 ); + + useEffect( () => { + const timeout = setTimeout( () => { + setStep( s => ( s + 1 ) % 4 ); + }, DELAYS[ step ] ); + return () => clearTimeout( timeout ); + }, [ step ] ); + + return ( + + ); +} diff --git a/projects/packages/search/src/instant-search/components/answers-panel.jsx b/projects/packages/search/src/instant-search/components/answers-panel.jsx new file mode 100644 index 000000000000..e1e506993f67 --- /dev/null +++ b/projects/packages/search/src/instant-search/components/answers-panel.jsx @@ -0,0 +1,193 @@ +import { __, sprintf } from '@wordpress/i18n'; +import * as React from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { markdownToHtml } from '../lib/markdown'; +import AnimatedEllipsis from './animated-ellipsis'; +import './answers-panel.scss'; + +const ExternalLinkIcon = () => ( + +); + +/** + * AI Answers panel displayed above search results. + * + * @param {object} props - Component props. + * @param {string} props.status - 'idle' | 'loading' | 'streaming' | 'done' | 'error' + * @param {string} props.text - Accumulated answer text (markdown). + * @param {Array} props.citations - Array of { title, url, excerpt } citation objects. + * @param {object} props.error - Error info: { message, code, source } or null. + * @param {string|null} props.loadingHint - Placeholder text shown while the extended answer is loading. + * @param {Function|null} props.onShowMore - Function: show a "Show more" button to switch to the extended answer. null: extended mode active, render full content. undefined: standard overflow toggle. + * @return {React.ReactElement|null} The rendered panel or null. + */ +export default function AnswersPanel( { + status, + text, + citations = [], + error = null, + loadingHint = null, + onShowMore, +} ) { + const [ expanded, setExpanded ] = useState( false ); + const [ overflows, setOverflows ] = useState( false ); + const contentRef = useRef( null ); + + // Reset collapse state when the displayed answer switches (e.g. brief → extended loading). + useEffect( () => { + if ( status === 'loading' || status === 'streaming' ) { + setExpanded( false ); + setOverflows( false ); + } + }, [ status ] ); + + // useLayoutEffect fires synchronously after DOM update but before paint, + // so overflow detection and button render happen in the same frame as the collapse. + useLayoutEffect( () => { + if ( status === 'done' && contentRef.current ) { + setOverflows( contentRef.current.scrollHeight > contentRef.current.clientHeight ); + } + }, [ status, text ] ); + + if ( status === 'idle' ) { + return null; + } + + if ( status === 'error' ) { + return ( +
+

+ { __( 'AI answer', 'jetpack-search-pkg' ) } +

+
+

+ { __( 'Sorry, an error occurred while generating an answer.', 'jetpack-search-pkg' ) } +

+ { error && ( +

+ { error.message } + { error.code !== null && ( + <> +
+ { + /* translators: %s: numeric error code */ sprintf( + __( 'Error code: %s', 'jetpack-search-pkg' ), + error.code + ) + } + + ) } +

+ ) } +
+
+ ); + } + + // onShowMore semantics: + // function → brief answer is done; show a "Show more" button to load the extended answer + // null → extended mode is active; render full combined content without collapse + // undefined → no dual-answer flow; use standard overflow expand/collapse toggle + const isCollapsible = status === 'done'; + // In extended mode (null) the panel is always fully expanded. + const isCollapsed = isCollapsible && onShowMore === undefined && ! expanded; + // Fixed height only during the loading placeholder and when collapsed after done. + // During streaming the panel grows naturally with the incoming content. + const isFixedHeight = status === 'loading' || isCollapsed; + const showMoreLabel = __( 'Show more', 'jetpack-search-pkg' ); + const showLessLabel = __( 'Show less', 'jetpack-search-pkg' ); + + return ( +
+

+ { __( 'AI answer', 'jetpack-search-pkg' ) } +

+ { status === 'loading' && ( +
+ { __( 'Finding an answer', 'jetpack-search-pkg' ) } + +
+ ) } + { ( status === 'streaming' || status === 'done' ) && ( +
+
+ { status === 'done' && citations.length > 0 && ( + + ) } +
+ ) } + { onShowMore === null && loadingHint && ( +

+ { loadingHint.endsWith( '…' ) ? loadingHint.slice( 0, -1 ) : loadingHint } + +

+ ) } + { isCollapsible && typeof onShowMore === 'function' && ( + + ) } + { isCollapsible && onShowMore === undefined && overflows && ( + + ) } +
+ ); +} diff --git a/projects/packages/search/src/instant-search/components/answers-panel.scss b/projects/packages/search/src/instant-search/components/answers-panel.scss new file mode 100644 index 000000000000..2ce6655483ac --- /dev/null +++ b/projects/packages/search/src/instant-search/components/answers-panel.scss @@ -0,0 +1,210 @@ +@use "../lib/styles/helper"; +@use "@wordpress/base-styles/mixins" as wp-mixins; + +.jp-search-answers-panel { + background: helper.$color-modal-background; + border: 1px solid helper.$color-border; + border-radius: helper.$border-radius-panel; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + margin: 0 helper.$results-margin-lg 16px; + overflow: hidden; + + &--fixed-height { + display: flex; + flex-direction: column; + height: 160px; + + .jp-search-answers-panel__content { + flex: 1; + max-height: none; // flex constrains height; max-height: 5em from --collapsed is overridden + min-height: 0; + overflow: hidden; + } + + .jp-search-answers-panel__toggle { + flex-shrink: 0; + margin-top: auto; + } + } + + @include helper.multiple-breaks-for-customberg( " { + const keys = { + status: statePrefix + 'Status', + text: statePrefix + 'Text', + citations: statePrefix + 'Citations', + error: statePrefix + 'Error', + }; + + this.setState( { + [ keys.status ]: 'loading', + [ keys.text ]: '', + [ keys.citations ]: [], + [ keys.error ]: null, + } ); + + const url = + 'https://public-api.wordpress.com/wpcom/v2/ai/agent/jetpack-workflow-search_summarizer'; + + const HTTP_STATUS_NAMES = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 429: 'Too Many Requests', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + }; + + // Captured by onopen before onerror fires, so .catch sees the real HTTP error. + let httpError = null; + + fetchEventSource( url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify( { + jsonrpc: '2.0', + id: `req-${ format }`, + method: 'message/stream', + constructor_arguments: {}, + params: { + ...( sessionId ? { sessionId } : {} ), + message: { + role: 'user', + parts: [ + { type: 'text', text: query }, + { + type: 'data', + data: { + clientContext: { + selectedSiteId: siteId, + site_url: options.homeUrl || '', + filters: this.props.filters, + locale: options.locale || 'en', + aiAnswersFormat: format, + }, + }, + metadata: {}, + }, + ], + kind: 'message', + messageId: `msg-${ format }`, + }, + }, + tokenStreaming: true, + } ), + signal: controller.signal, + onopen: async response => { + if ( ! response.ok ) { + httpError = { + message: HTTP_STATUS_NAMES[ response.status ] || `HTTP ${ response.status }`, + code: response.status, + source: 'http', + }; + throw new Error( `HTTP ${ response.status }` ); + } + }, + onmessage: event => { + try { + const data = JSON.parse( event.data ); + if ( data.result?.sessionId && onSessionId ) { + onSessionId( data.result.sessionId ); + } + if ( data.method === 'message/delta' && data.params?.delta?.deltaType === 'content' ) { + // flushSync forces a synchronous render for each token so the + // text streams visibly rather than batching into one update + // (React 18 automatic batching would otherwise collapse rapid + // SSE events into a single render). + flushSync( () => { + this.setState( state => ( { + [ keys.status ]: 'streaming', + [ keys.text ]: state[ keys.text ] + ( data.params.delta.content ?? '' ), + } ) ); + } ); + } else if ( + data.result?.type === 'TaskStatusUpdateEvent' && + data.result?.status?.state === 'completed' + ) { + const parts = data.result.status.message?.parts || []; + const dataPart = parts.find( p => p.type === 'data' ); + const citations = dataPart?.data?.sources || dataPart?.data?.strict_sources || []; + this.setState( { [ keys.status ]: 'done', [ keys.citations ]: citations } ); + } else if ( data.result?.status?.state === 'failed' || data.error ) { + const textPart = data.result?.status?.message?.parts?.find( p => p.type === 'text' ); + const message = data.error?.message || textPart?.text || 'Request failed'; + const code = data.error?.code ?? null; + this.setState( { + [ keys.status ]: 'error', + [ keys.error ]: { message, code, source: 'api' }, + } ); + } + } catch { + // Ignore unparseable events. + } + }, + onerror: () => { + // Rethrow without setting state — .catch handles all error state + // so httpError captured in onopen isn't overwritten. + throw new Error( 'onerror' ); + }, + } ).catch( () => { + if ( ! controller.signal.aborted ) { + this.setState( { + [ keys.status ]: 'error', + [ keys.error ]: httpError ?? { + message: 'Network request error', + code: null, + source: 'network', + }, + } ); + } + } ); + }; + + getAiAnswer = () => { + const query = this.props.searchQuery; + const options = window[ SERVER_OBJECT_NAME ] || {}; + const siteId = options.siteId; + + const idleState = { + aiBriefStatus: 'idle', + aiBriefText: '', + aiBriefCitations: [], + aiBriefError: null, + aiExtendedStatus: 'idle', + aiExtendedText: '', + aiExtendedCitations: [], + aiExtendedError: null, + aiExtendedLoadingText: '', + aiShowExtended: false, + aiSessionId: null, + }; + + if ( ! query || query.length < 3 ) { + this.setState( idleState ); + return; + } + + // Respect the admin's AI Answers toggle. + if ( options.aiAnswersEnabled === false ) { + this.setState( idleState ); + return; + } + + // Abort any in-flight requests from the previous query. + if ( this.aiBriefController ) { + this.aiBriefController.abort(); + } + if ( this.aiExtendedController ) { + this.aiExtendedController.abort(); + this.aiExtendedController = null; + } + this.aiBriefController = new AbortController(); + + this.setState( { aiShowExtended: false } ); + + this.streamAiAnswer( { + controller: this.aiBriefController, + statePrefix: 'aiBrief', + query, + siteId, + options, + format: 'brief', + onSessionId: id => this.setState( { aiSessionId: id } ), + } ); + }; + + handleShowMore = () => { + const query = this.props.searchQuery; + const options = window[ SERVER_OBJECT_NAME ] || {}; + const siteId = options.siteId; + + const loadingMessages = [ + __( 'Searching harder…', 'jetpack-search-pkg' ), + __( 'Looking deeper into this…', 'jetpack-search-pkg' ), + __( 'Finding a more complete answer…', 'jetpack-search-pkg' ), + __( 'Analyzing additional sources…', 'jetpack-search-pkg' ), + __( 'Gathering more details…', 'jetpack-search-pkg' ), + __( 'Pulling in more context…', 'jetpack-search-pkg' ), + __( 'Expanding the search…', 'jetpack-search-pkg' ), + __( 'Rolling up my virtual sleeves…', 'jetpack-search-pkg' ), + __( 'Digging through the archives…', 'jetpack-search-pkg' ), + __( 'Putting on my reading glasses…', 'jetpack-search-pkg' ), + __( 'Checking under the digital couch cushions…', 'jetpack-search-pkg' ), + __( 'Consulting the oracle…', 'jetpack-search-pkg' ), + __( 'Asking a smarter algorithm…', 'jetpack-search-pkg' ), + __( 'Brewing a fresh batch of insights…', 'jetpack-search-pkg' ), + __( 'Unleashing the full power of search…', 'jetpack-search-pkg' ), + ]; + const aiExtendedLoadingText = + loadingMessages[ Math.floor( Math.random() * loadingMessages.length ) ]; + + this.aiExtendedController = new AbortController(); + this.setState( { aiShowExtended: true, aiExtendedLoadingText } ); + this.streamAiAnswer( { + controller: this.aiExtendedController, + statePrefix: 'aiExtended', + query, + siteId, + options, + format: 'extended', + sessionId: this.state.aiSessionId, + } ); + }; + updateOverlayOptions = ( newOverlayOptions, callback ) => { this.setState( state => ( { @@ -248,6 +532,58 @@ class SearchApp extends Component { const noop = input => input; const resultFormat = this.getResultFormat(); + const { + aiBriefStatus, + aiBriefText, + aiBriefCitations, + aiBriefError, + aiExtendedStatus, + aiExtendedText, + aiExtendedCitations, + aiExtendedError, + aiExtendedLoadingText, + aiShowExtended, + } = this.state; + + // When showing extended: brief text stays visible; extended text is appended as it arrives. + // Use 'streaming' instead of 'loading' so the text area renders while extended is fetching. + let displayStatus; + if ( aiShowExtended ) { + displayStatus = aiExtendedStatus === 'loading' ? 'streaming' : aiExtendedStatus; + } else { + displayStatus = aiBriefStatus; + } + + let displayText; + if ( aiShowExtended ) { + displayText = aiExtendedText ? aiBriefText + '\n\n' + aiExtendedText : aiBriefText; + } else { + displayText = aiBriefText; + } + + let displayCitations; + if ( aiShowExtended ) { + displayCitations = aiExtendedStatus === 'done' ? aiExtendedCitations : aiBriefCitations; + } else { + displayCitations = aiBriefCitations; + } + + const displayError = aiShowExtended ? aiExtendedError : aiBriefError; + + // Show loading hint below brief answer while extended request hasn't started streaming yet. + const aiLoadingHint = + aiShowExtended && aiExtendedStatus === 'loading' ? aiExtendedLoadingText : null; + + // null = extended mode active — render full content without collapse + // fn = brief is done and extended not yet shown — render "Show more" button + // undef = no dual-answer flow — render standard overflow toggle + let onShowMoreAiAnswer; + if ( aiShowExtended ) { + onShowMoreAiAnswer = null; + } else if ( aiBriefStatus === 'done' ) { + onShowMoreAiAnswer = this.handleShowMore; + } + const portalFn = this.props.shouldCreatePortal ? createPortal : noop; return ( @@ -275,10 +611,19 @@ class SearchApp extends Component { closeColor={ this.state.overlayOptions.closeColor } closeOverlay={ this.hideResults } colorTheme={ this.state.overlayOptions.colorTheme } - hasOverlayWidgets={ this.props.hasOverlayWidgets } + hasOverlayWidgets={ + this.props.hasOverlayWidgets || + ( aiShowExtended ? aiExtendedCitations.length > 0 : aiBriefCitations.length > 0 ) + } isVisible={ this.state.isVisible } > l", ">xl" ) { - position: absolute; - right: helper.$sidebar-width; - } - - .jetpack-instant-search__overlay--no-sidebar & { - right: 0; } } diff --git a/projects/packages/search/src/instant-search/components/search-results.jsx b/projects/packages/search/src/instant-search/components/search-results.jsx index 16962f9a57dd..2240f6bc5771 100644 --- a/projects/packages/search/src/instant-search/components/search-results.jsx +++ b/projects/packages/search/src/instant-search/components/search-results.jsx @@ -6,6 +6,7 @@ import { getConstrastingColor } from '../lib/colors'; import { MULTISITE_NO_GROUP_VALUE, OVERLAY_FOCUS_ANCHOR_ID } from '../lib/constants'; import { getErrorMessage } from '../lib/errors'; import { getAvailableStaticFilters } from '../lib/filters'; +import AnswersPanel from './answers-panel'; import Gridicon from './gridicon'; import JetpackColophon from './jetpack-colophon'; import Notice from './notice'; @@ -152,9 +153,50 @@ class SearchResults extends Component { `, } } /> + -

{ this.getSearchTitle() }

+
+

+ { this.getSearchTitle() } +

+ + { ( this.hasFilterOptions() || this.props.hasNonSearchWidgets ) && ( +
+ { __( 'Filters', 'jetpack-search-pkg' ) } +
+ ) } +
+
{ hasResults && hasCorrectedQuery && (

@@ -264,36 +306,6 @@ class SearchResults extends Component {

- - { ( this.hasFilterOptions() || this.props.hasNonSearchWidgets ) && ( -
- { __( 'Filters', 'jetpack-search-pkg' ) } -
- ) } -
-
l", ">xl" ) { - // Account for .jetpack-instant-search__search-form-controls. - padding-right: 210px; - } -} - .jetpack-instant-search__search-results-list { color: helper.$color-text; list-style: none; diff --git a/projects/packages/search/src/instant-search/components/sidebar.jsx b/projects/packages/search/src/instant-search/components/sidebar.jsx index 5423c3bbdfbb..cdcd9419d74b 100644 --- a/projects/packages/search/src/instant-search/components/sidebar.jsx +++ b/projects/packages/search/src/instant-search/components/sidebar.jsx @@ -8,6 +8,32 @@ import './sidebar.scss'; const Sidebar = props => { return (
+ { props.citations?.length > 0 && ( + + ) } { /* If widgetOutsideOverlay doesn't contain any filters, this component will just show the title and clear filters button. */ } { + it( 'renders nothing when idle', () => { + const { container } = render( ); + expect( container ).toBeEmptyDOMElement(); + } ); + + it( 'shows loading message', () => { + render( ); + expect( screen.getByText( 'Finding an answer', { exact: false } ) ).toBeInTheDocument(); + } ); + + it( 'shows streamed text', () => { + render( ); + expect( screen.getByText( 'Here is how to…' ) ).toBeInTheDocument(); + } ); + + it( 'shows full text and citations when done', () => { + const citations = [ { title: 'Reset Password', url: '/reset', excerpt: '' } ]; + render( ); + expect( screen.getByText( 'Reset here.' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Reset Password' ) ).toBeInTheDocument(); + } ); + + describe( 'error state', () => { + it( 'shows error message when error prop is null', () => { + render( ); + expect( + screen.getByText( 'Sorry, an error occurred while generating an answer.' ) + ).toBeInTheDocument(); + } ); + + it( 'shows no detail line when error prop is null', () => { + render( ); + expect( screen.queryByTestId( 'answers-panel-error-detail' ) ).not.toBeInTheDocument(); + } ); + + it( 'shows network error message', () => { + const error = { message: 'Network request error', code: null, source: 'network' }; + render( ); + expect( screen.getByText( 'Network request error' ) ).toBeInTheDocument(); + } ); + + it( 'shows API error message', () => { + const error = { + message: 'An error occurred while processing the request. Please try again later.', + code: -32000, + source: 'api', + }; + render( ); + expect( + screen.getByText( /An error occurred while processing the request/ ) + ).toBeInTheDocument(); + } ); + + it( 'shows "Error code: X" when code is present', () => { + const error = { message: 'Request failed', code: -32000, source: 'api' }; + render( ); + expect( screen.getByText( /Error code: -32000/ ) ).toBeInTheDocument(); + } ); + + it( 'omits error code line when code is null', () => { + const error = { message: 'Network request error', code: null, source: 'network' }; + render( ); + expect( screen.queryByText( /Error code:/ ) ).not.toBeInTheDocument(); + } ); + + it( 'shows HTTP friendly name as message', () => { + const error = { message: 'Service Unavailable', code: 503, source: 'http' }; + render( ); + expect( screen.getByText( /Service Unavailable/ ) ).toBeInTheDocument(); + expect( screen.getByText( /Error code: 503/ ) ).toBeInTheDocument(); + } ); + + it( 'still shows the AI answer heading in error state', () => { + render( ); + expect( screen.getByText( 'AI answer' ) ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/projects/packages/search/src/instant-search/components/test/answers-sse.test.jsx b/projects/packages/search/src/instant-search/components/test/answers-sse.test.jsx new file mode 100644 index 000000000000..7dfdd9078ba1 --- /dev/null +++ b/projects/packages/search/src/instant-search/components/test/answers-sse.test.jsx @@ -0,0 +1,119 @@ +/** + * Smoke tests for the AI Answers SSE wiring in SearchApp. + * + * These tests verify that when aiAnswersEnabled is absent/false, + * fetchEventSource is never called, and the component mounts without errors. + */ + +// Mock fetchEventSource so no real network calls are made. +jest.mock( '@microsoft/fetch-event-source', () => ( { + fetchEventSource: jest.fn(), +} ) ); + +// Mock heavy child components with PALETTE / other build-time globals. +jest.mock( '../search-results', () => () =>
); +jest.mock( '../overlay', () => ( { children } ) =>
{ children }
); +jest.mock( '../customizer-event-handler', () => () => null ); +jest.mock( '../dom-event-handler', () => () => null ); + +// Mock react-redux connect to pass through as a no-op wrapper so we can +// test the inner SearchApp class directly without a full Redux store. +jest.mock( 'react-redux', () => ( { + connect: () => Component => Component, +} ) ); + +// Mock store actions — they are injected via connect; with connect mocked they +// won't be passed, so we supply them as props below. +jest.mock( '../../store/actions', () => ( {} ) ); +jest.mock( '../../store/selectors', () => ( {} ) ); + +import { fetchEventSource } from '@microsoft/fetch-event-source'; +import { render } from '@testing-library/react'; +import * as React from 'react'; +import SearchApp from '../search-app'; + +const noop = () => {}; + +const defaultProps = { + enableAnalytics: false, + shouldIntegrateWithDom: false, + shouldCreatePortal: false, + isInCustomizer: false, + initialIsVisible: false, + initialHref: '/', + options: { + siteId: 123, + locale: 'en', + postTypes: [], + widgets: [], + additionalBlogIds: [], + isPhotonEnabled: false, + isPrivateSite: false, + postsPerPage: 10, + adminQueryFilter: '', + highlightFields: [], + customResults: {}, + hasNonSearchWidgets: false, + }, + overlayOptions: { + colorTheme: 'light', + enableInfScroll: false, + enableFilteringOpensOverlay: false, + enableSort: false, + enablePostDate: false, + enableProductPrice: false, + overlayTrigger: 'immediate', + resultFormat: 'minimal', + showPoweredBy: false, + highlightColor: '#000', + closeColor: '#000', + defaultSort: 'relevance', + excludedPostTypes: [], + }, + themeOptions: { searchInputSelector: '.search-field' }, + aggregations: {}, + hasOverlayWidgets: false, + // Redux-injected props (normally provided by connect). + filters: {}, + staticFilters: {}, + hasActiveQuery: false, + hasError: false, + isHistoryNavigation: false, + hasNextPage: false, + isLoading: false, + response: {}, + searchQuery: '', + sort: 'relevance', + widgetOutsideOverlay: null, + // Redux-injected action creators. + clearQueryValues: noop, + disableQueryStringIntegration: noop, + initializeQueryValues: noop, + makeSearchRequest: noop, + setStaticFilter: noop, + setFilter: noop, + setSearchQuery: noop, + setSort: noop, +}; + +describe( 'SearchApp AI Answers SSE wiring', () => { + beforeEach( () => { + fetchEventSource.mockClear(); + window.JetpackInstantSearchOptions = { siteId: 123 }; + } ); + + afterEach( () => { + delete window.JetpackInstantSearchOptions; + } ); + + it( 'mounts without errors when aiAnswersEnabled is absent', () => { + expect( () => { + render( ); + } ).not.toThrow(); + } ); + + it( 'does not call fetchEventSource when aiAnswersEnabled is absent', () => { + render( ); + expect( fetchEventSource ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/projects/packages/search/src/instant-search/components/test/search-app-ai-gate.test.jsx b/projects/packages/search/src/instant-search/components/test/search-app-ai-gate.test.jsx new file mode 100644 index 000000000000..b2905b8379fd --- /dev/null +++ b/projects/packages/search/src/instant-search/components/test/search-app-ai-gate.test.jsx @@ -0,0 +1,38 @@ +const makeOptions = ( overrides = {} ) => ( { + siteId: 1, + aiAnswersEnabled: true, + ...overrides, +} ); + +describe( 'getAiAnswer aiAnswersEnabled gate', () => { + let originalOptions; + + beforeEach( () => { + originalOptions = window.JetpackInstantSearchOptions; + } ); + + afterEach( () => { + window.JetpackInstantSearchOptions = originalOptions; + } ); + + it( 'does not call fetchEventSource when aiAnswersEnabled is false', () => { + window.JetpackInstantSearchOptions = makeOptions( { aiAnswersEnabled: false } ); + const options = window.JetpackInstantSearchOptions; + const shouldSkip = options.aiAnswersEnabled === false; + expect( shouldSkip ).toBe( true ); + } ); + + it( 'allows fetchEventSource when aiAnswersEnabled is true', () => { + window.JetpackInstantSearchOptions = makeOptions( { aiAnswersEnabled: true } ); + const options = window.JetpackInstantSearchOptions; + const shouldSkip = options.aiAnswersEnabled === false; + expect( shouldSkip ).toBe( false ); + } ); + + it( 'allows fetchEventSource when aiAnswersEnabled is undefined (legacy)', () => { + window.JetpackInstantSearchOptions = makeOptions( { aiAnswersEnabled: undefined } ); + const options = window.JetpackInstantSearchOptions; + const shouldSkip = options.aiAnswersEnabled === false; + expect( shouldSkip ).toBe( false ); + } ); +} ); diff --git a/projects/packages/search/src/instant-search/components/test/search-app-ai-state.test.jsx b/projects/packages/search/src/instant-search/components/test/search-app-ai-state.test.jsx new file mode 100644 index 000000000000..67c853484c50 --- /dev/null +++ b/projects/packages/search/src/instant-search/components/test/search-app-ai-state.test.jsx @@ -0,0 +1,492 @@ +/** + * Tests for the AI Answers state management in SearchApp: + * - getAiAnswer guard conditions + * - streamAiAnswer SSE event handling + * - handleShowMore extended-answer flow + * - render() display-state derivation (brief vs. extended) + */ + +jest.mock( '@microsoft/fetch-event-source', () => ( { + fetchEventSource: jest.fn(), +} ) ); + +let lastSearchResultsProps = {}; +jest.mock( '../search-results', () => props => { + lastSearchResultsProps = props; + return
; +} ); +jest.mock( '../overlay', () => ( { children } ) =>
{ children }
); +jest.mock( '../customizer-event-handler', () => () => null ); +jest.mock( '../dom-event-handler', () => () => null ); + +jest.mock( 'react-redux', () => ( { + connect: () => Component => Component, +} ) ); + +jest.mock( '../../store/actions', () => ( {} ) ); +jest.mock( '../../store/selectors', () => ( {} ) ); + +import { fetchEventSource } from '@microsoft/fetch-event-source'; +import { act, render } from '@testing-library/react'; +import * as React from 'react'; +import SearchApp from '../search-app'; + +const noop = () => {}; + +const BASE_OPTIONS = { + siteId: 123, + aiAnswersEnabled: true, +}; + +const defaultProps = { + enableAnalytics: false, + shouldIntegrateWithDom: false, + shouldCreatePortal: false, + isInCustomizer: false, + initialIsVisible: false, + initialHref: '/', + options: { + siteId: 123, + locale: 'en', + postTypes: [], + widgets: [], + additionalBlogIds: [], + isPhotonEnabled: false, + isPrivateSite: false, + postsPerPage: 10, + adminQueryFilter: '', + highlightFields: [], + customResults: {}, + hasNonSearchWidgets: false, + }, + overlayOptions: { + colorTheme: 'light', + enableInfScroll: false, + enableFilteringOpensOverlay: false, + enableSort: false, + enablePostDate: false, + enableProductPrice: false, + overlayTrigger: 'immediate', + resultFormat: 'minimal', + showPoweredBy: false, + highlightColor: '#000', + closeColor: '#000', + defaultSort: 'relevance', + excludedPostTypes: [], + }, + themeOptions: { searchInputSelector: '.search-field' }, + aggregations: {}, + hasOverlayWidgets: false, + filters: {}, + staticFilters: {}, + hasActiveQuery: false, + hasError: false, + isHistoryNavigation: false, + hasNextPage: false, + isLoading: false, + response: {}, + searchQuery: '', + sort: 'relevance', + widgetOutsideOverlay: null, + clearQueryValues: noop, + disableQueryStringIntegration: noop, + initializeQueryValues: noop, + makeSearchRequest: noop, + setStaticFilter: noop, + setFilter: noop, + setSearchQuery: noop, + setSort: noop, +}; + +// Renders with an initial empty query then re-renders with the given query, +// advancing fake timers past the 500 ms getAiAnswer debounce. +const renderAndTriggerQuery = ( query, windowOpts = BASE_OPTIONS ) => { + window.JetpackInstantSearchOptions = windowOpts; + const utils = render( ); + utils.rerender( ); + act( () => { + jest.advanceTimersByTime( 600 ); + } ); + return utils; +}; + +// Returns the options object passed to the most recent fetchEventSource call. +const lastFetchOpts = () => fetchEventSource.mock.calls.at( -1 )[ 1 ]; + +// Manually fires an SSE onmessage event via the captured fetchEventSource callback. +const fireMessage = data => + act( () => { + lastFetchOpts().onmessage( { data: JSON.stringify( data ) } ); + } ); + +describe( 'SearchApp — getAiAnswer guard conditions', () => { + beforeEach( () => { + jest.useFakeTimers(); + fetchEventSource.mockClear(); + fetchEventSource.mockReturnValue( new Promise( () => {} ) ); + lastSearchResultsProps = {}; + } ); + + afterEach( () => { + jest.useRealTimers(); + delete window.JetpackInstantSearchOptions; + } ); + + it( 'does not call fetchEventSource when query is empty', () => { + window.JetpackInstantSearchOptions = BASE_OPTIONS; + render( ); + act( () => jest.advanceTimersByTime( 600 ) ); + expect( fetchEventSource ).not.toHaveBeenCalled(); + } ); + + it( 'does not call fetchEventSource when query is shorter than 3 characters', () => { + renderAndTriggerQuery( 'hi' ); + expect( fetchEventSource ).not.toHaveBeenCalled(); + } ); + + it( 'does not call fetchEventSource when aiAnswersEnabled is false', () => { + renderAndTriggerQuery( 'reset my password', { ...BASE_OPTIONS, aiAnswersEnabled: false } ); + expect( fetchEventSource ).not.toHaveBeenCalled(); + } ); + + it( 'calls fetchEventSource with the brief format for a valid query', () => { + renderAndTriggerQuery( 'reset my password' ); + expect( fetchEventSource ).toHaveBeenCalledTimes( 1 ); + const body = JSON.parse( lastFetchOpts().body ); + expect( body.params.message.parts[ 1 ].data.clientContext.aiAnswersFormat ).toBe( 'brief' ); + } ); + + it( 'posts the search query text to the AI endpoint', () => { + renderAndTriggerQuery( 'reset my password' ); + const body = JSON.parse( lastFetchOpts().body ); + expect( body.params.message.parts[ 0 ].text ).toBe( 'reset my password' ); + } ); + + it( 'passes the site ID from JetpackInstantSearchOptions', () => { + renderAndTriggerQuery( 'reset my password', { ...BASE_OPTIONS, siteId: 999 } ); + const body = JSON.parse( lastFetchOpts().body ); + expect( body.params.message.parts[ 1 ].data.clientContext.selectedSiteId ).toBe( 999 ); + } ); +} ); + +describe( 'SearchApp — streamAiAnswer SSE event handling', () => { + beforeEach( () => { + jest.useFakeTimers(); + fetchEventSource.mockClear(); + fetchEventSource.mockReturnValue( new Promise( () => {} ) ); + lastSearchResultsProps = {}; + } ); + + afterEach( () => { + jest.useRealTimers(); + delete window.JetpackInstantSearchOptions; + } ); + + it( 'sets aiStatus to streaming and accumulates text on token delta events', () => { + renderAndTriggerQuery( 'reset my password' ); + + fireMessage( { + method: 'message/delta', + params: { delta: { deltaType: 'content', content: 'Here is how ' } }, + } ); + fireMessage( { + method: 'message/delta', + params: { delta: { deltaType: 'content', content: 'to reset.' } }, + } ); + + expect( lastSearchResultsProps.aiStatus ).toBe( 'streaming' ); + expect( lastSearchResultsProps.aiText ).toBe( 'Here is how to reset.' ); + } ); + + it( 'sets aiStatus to done and populates citations on TaskStatusUpdateEvent completed', () => { + renderAndTriggerQuery( 'reset my password' ); + + fireMessage( { + result: { + type: 'TaskStatusUpdateEvent', + status: { + state: 'completed', + message: { + parts: [ + { + type: 'data', + data: { + sources: [ { title: 'Reset guide', url: 'https://example.com/reset' } ], + }, + }, + ], + }, + }, + }, + } ); + + expect( lastSearchResultsProps.aiStatus ).toBe( 'done' ); + expect( lastSearchResultsProps.aiCitations ).toHaveLength( 1 ); + expect( lastSearchResultsProps.aiCitations[ 0 ].title ).toBe( 'Reset guide' ); + } ); + + it( 'uses strict_sources when sources is absent', () => { + renderAndTriggerQuery( 'reset my password' ); + + fireMessage( { + result: { + type: 'TaskStatusUpdateEvent', + status: { + state: 'completed', + message: { + parts: [ + { + type: 'data', + data: { + strict_sources: [ { title: 'Guide', url: 'https://example.com' } ], + }, + }, + ], + }, + }, + }, + } ); + + expect( lastSearchResultsProps.aiCitations ).toHaveLength( 1 ); + } ); + + it( 'sets error state on task failed status', () => { + renderAndTriggerQuery( 'reset my password' ); + + fireMessage( { + result: { + status: { + state: 'failed', + message: { + parts: [ { type: 'text', text: 'Service unavailable' } ], + }, + }, + }, + } ); + + expect( lastSearchResultsProps.aiStatus ).toBe( 'error' ); + expect( lastSearchResultsProps.aiError.message ).toBe( 'Service unavailable' ); + expect( lastSearchResultsProps.aiError.source ).toBe( 'api' ); + } ); + + it( 'sets error state when data.error is present', () => { + renderAndTriggerQuery( 'reset my password' ); + + fireMessage( { error: { message: 'Bad request', code: 400 } } ); + + expect( lastSearchResultsProps.aiStatus ).toBe( 'error' ); + expect( lastSearchResultsProps.aiError.code ).toBe( 400 ); + } ); + + it( 'silently ignores unparseable SSE events', () => { + renderAndTriggerQuery( 'reset my password' ); + + // Should not throw. + act( () => { + lastFetchOpts().onmessage( { data: 'not-json{{' } ); + } ); + + // Status remains at loading (initial value set by streamAiAnswer). + expect( lastSearchResultsProps.aiStatus ).toBe( 'loading' ); + } ); + + it( 'sets network error state when fetchEventSource promise rejects', async () => { + fetchEventSource.mockReturnValueOnce( Promise.reject( new Error( 'net' ) ) ); + renderAndTriggerQuery( 'reset my password' ); + + // Let the rejected promise propagate through the microtask queue. + await act( async () => { + await Promise.resolve(); + } ); + + expect( lastSearchResultsProps.aiStatus ).toBe( 'error' ); + expect( lastSearchResultsProps.aiError.source ).toBe( 'network' ); + expect( lastSearchResultsProps.aiError.message ).toBe( 'Network request error' ); + } ); +} ); + +describe( 'SearchApp — handleShowMore extended-answer flow', () => { + beforeEach( () => { + jest.useFakeTimers(); + fetchEventSource.mockClear(); + fetchEventSource.mockReturnValue( new Promise( () => {} ) ); + lastSearchResultsProps = {}; + } ); + + afterEach( () => { + jest.useRealTimers(); + delete window.JetpackInstantSearchOptions; + } ); + + it( 'calls fetchEventSource a second time with extended format on Show More', () => { + renderAndTriggerQuery( 'reset my password' ); + expect( fetchEventSource ).toHaveBeenCalledTimes( 1 ); + + // Simulate brief answer completing, which makes onShowMoreAiAnswer a function. + fireMessage( { + result: { + type: 'TaskStatusUpdateEvent', + status: { state: 'completed', message: { parts: [] } }, + }, + } ); + + // onShowMoreAiAnswer should be a function at this point (brief is done). + act( () => { + lastSearchResultsProps.onShowMoreAiAnswer(); + } ); + + expect( fetchEventSource ).toHaveBeenCalledTimes( 2 ); + const extendedBody = JSON.parse( fetchEventSource.mock.calls[ 1 ][ 1 ].body ); + expect( extendedBody.params.message.parts[ 1 ].data.clientContext.aiAnswersFormat ).toBe( + 'extended' + ); + } ); + + it( 'passes the session ID from the brief request to the extended request', () => { + renderAndTriggerQuery( 'reset my password' ); + + // Simulate brief answer returning a session ID. + fireMessage( { result: { sessionId: 'sess-abc-123' } } ); + + // Finish brief answer. + fireMessage( { + result: { + type: 'TaskStatusUpdateEvent', + status: { state: 'completed', message: { parts: [] } }, + }, + } ); + + act( () => { + lastSearchResultsProps.onShowMoreAiAnswer(); + } ); + + const extendedBody = JSON.parse( fetchEventSource.mock.calls[ 1 ][ 1 ].body ); + expect( extendedBody.params.sessionId ).toBe( 'sess-abc-123' ); + } ); +} ); + +describe( 'SearchApp — render display-state derivation', () => { + beforeEach( () => { + jest.useFakeTimers(); + fetchEventSource.mockClear(); + fetchEventSource.mockReturnValue( new Promise( () => {} ) ); + lastSearchResultsProps = {}; + } ); + + afterEach( () => { + jest.useRealTimers(); + delete window.JetpackInstantSearchOptions; + } ); + + it( 'passes brief status and text to SearchResults before Show More is clicked', () => { + renderAndTriggerQuery( 'reset my password' ); + fireMessage( { + method: 'message/delta', + params: { delta: { deltaType: 'content', content: 'Brief answer.' } }, + } ); + + expect( lastSearchResultsProps.aiStatus ).toBe( 'streaming' ); + expect( lastSearchResultsProps.aiText ).toBe( 'Brief answer.' ); + expect( typeof lastSearchResultsProps.onShowMoreAiAnswer ).toBe( 'undefined' ); + } ); + + it( 'sets onShowMoreAiAnswer to a function when brief answer is done', () => { + renderAndTriggerQuery( 'reset my password' ); + fireMessage( { + result: { + type: 'TaskStatusUpdateEvent', + status: { state: 'completed', message: { parts: [] } }, + }, + } ); + + expect( typeof lastSearchResultsProps.onShowMoreAiAnswer ).toBe( 'function' ); + } ); + + it( 'sets onShowMoreAiAnswer to null (extended mode) after Show More is clicked', () => { + renderAndTriggerQuery( 'reset my password' ); + fireMessage( { + result: { + type: 'TaskStatusUpdateEvent', + status: { state: 'completed', message: { parts: [] } }, + }, + } ); + act( () => lastSearchResultsProps.onShowMoreAiAnswer() ); + + expect( lastSearchResultsProps.onShowMoreAiAnswer ).toBeNull(); + } ); + + it( 'shows brief text while extended answer is loading', () => { + renderAndTriggerQuery( 'reset my password' ); + fireMessage( { + method: 'message/delta', + params: { delta: { deltaType: 'content', content: 'Brief text.' } }, + } ); + fireMessage( { + result: { + type: 'TaskStatusUpdateEvent', + status: { state: 'completed', message: { parts: [] } }, + }, + } ); + act( () => lastSearchResultsProps.onShowMoreAiAnswer() ); + + // Extended request just started — status should be 'streaming' (converted from 'loading') + // and text should still be the brief text. + expect( lastSearchResultsProps.aiStatus ).toBe( 'streaming' ); + expect( lastSearchResultsProps.aiText ).toBe( 'Brief text.' ); + } ); + + it( 'combines brief and extended text once extended starts streaming', () => { + renderAndTriggerQuery( 'reset my password' ); + fireMessage( { + method: 'message/delta', + params: { delta: { deltaType: 'content', content: 'Brief.' } }, + } ); + fireMessage( { + result: { + type: 'TaskStatusUpdateEvent', + status: { state: 'completed', message: { parts: [] } }, + }, + } ); + act( () => lastSearchResultsProps.onShowMoreAiAnswer() ); + + // Simulate extended token arriving. + fireMessage( { + method: 'message/delta', + params: { delta: { deltaType: 'content', content: ' Extended.' } }, + } ); + + expect( lastSearchResultsProps.aiText ).toBe( 'Brief.\n\n Extended.' ); + } ); + + it( 'uses extended citations when extended answer is done', () => { + renderAndTriggerQuery( 'reset my password' ); + fireMessage( { + result: { + type: 'TaskStatusUpdateEvent', + status: { state: 'completed', message: { parts: [] } }, + }, + } ); + act( () => lastSearchResultsProps.onShowMoreAiAnswer() ); + + fireMessage( { + result: { + type: 'TaskStatusUpdateEvent', + status: { + state: 'completed', + message: { + parts: [ + { + type: 'data', + data: { + sources: [ { title: 'Extended source', url: 'https://example.com' } ], + }, + }, + ], + }, + }, + }, + } ); + + expect( lastSearchResultsProps.aiCitations[ 0 ].title ).toBe( 'Extended source' ); + } ); +} ); diff --git a/projects/packages/search/src/instant-search/components/test/sidebar.test.jsx b/projects/packages/search/src/instant-search/components/test/sidebar.test.jsx new file mode 100644 index 000000000000..c1949f8e55bd --- /dev/null +++ b/projects/packages/search/src/instant-search/components/test/sidebar.test.jsx @@ -0,0 +1,76 @@ +import { render, screen } from '@testing-library/react'; +import * as React from 'react'; +import Sidebar from '../sidebar'; + +jest.mock( '../search-filters', () => props => ( +
+) ); +jest.mock( '../widget-area-container', () => () =>
); +jest.mock( '../sidebar.scss', () => {} ); + +const defaultProps = { + citations: [], + filters: [], + staticFilters: {}, + isLoading: false, + locale: 'en', + postTypes: [], + response: {}, + widgetOutsideOverlay: null, + widgets: [], +}; + +describe( 'Sidebar', () => { + it( 'renders SearchFilters and WidgetAreaContainer', () => { + render( ); + expect( screen.getByTestId( 'search-filters' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'widget-area-container' ) ).toBeInTheDocument(); + } ); + + it( 'does not render citations section when citations is empty', () => { + render( ); + expect( screen.queryAllByRole( 'link' ) ).toHaveLength( 0 ); + } ); + + it( 'renders citation cards when citations are provided', () => { + const citations = [ + { title: 'How to reset your password', url: 'https://example.com/reset' }, + { title: 'Getting started guide', url: 'https://example.com/guide' }, + ]; + render( ); + expect( screen.getByText( 'How to reset your password' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Getting started guide' ) ).toBeInTheDocument(); + } ); + + it( 'shows the hostname of each citation URL', () => { + const citations = [ { title: 'Docs', url: 'https://docs.example.com/page' } ]; + render( ); + expect( screen.getByText( 'docs.example.com' ) ).toBeInTheDocument(); + } ); + + it( 'citation links open in a new tab with noopener', () => { + const citations = [ { title: 'Article', url: 'https://example.com/article' } ]; + render( ); + const link = screen.getByRole( 'link', { name: /Article/ } ); + expect( link ).toHaveAttribute( 'target', '_blank' ); + expect( link ).toHaveAttribute( 'rel', 'noopener noreferrer' ); + expect( link ).toHaveAttribute( 'href', 'https://example.com/article' ); + } ); + + it( 'creates a portal for each widget', () => { + const widgetId = 'jetpack-search-widget-1'; + const wrapper = document.createElement( 'div' ); + wrapper.id = `${ widgetId }-wrapper`; + document.body.appendChild( wrapper ); + + const widgets = [ { widget_id: widgetId } ]; + render( ); + + // The portaled SearchFilters mock carries the widget id as data-widget. + const allFilters = screen.getAllByTestId( 'search-filters' ); + const portaled = allFilters.find( el => el.getAttribute( 'data-widget' ) === widgetId ); + expect( portaled ).toBeInTheDocument(); + + document.body.removeChild( wrapper ); + } ); +} ); diff --git a/projects/packages/search/src/instant-search/lib/markdown.js b/projects/packages/search/src/instant-search/lib/markdown.js new file mode 100644 index 000000000000..5e0ce7c3c390 --- /dev/null +++ b/projects/packages/search/src/instant-search/lib/markdown.js @@ -0,0 +1,126 @@ +/** + * Minimal markdown-to-HTML converter for AI answer text. + * Handles: headings, bold, italic, inline links, bullet lists, paragraphs. + * All text content is HTML-escaped before insertion — safe for dangerouslySetInnerHTML. + */ + +/** + * Escape special HTML characters in a plain-text string. + * + * @param {string} text - Plain text to escape. + * @return {string} HTML-safe string. + */ +function escapeHtml( text ) { + return text + .replace( /&/g, '&' ) + .replace( //g, '>' ) + .replace( /"/g, '"' ); +} + +/** + * Format inline markdown within a single line: bold, italic, links. + * + * @param {string} text - Raw text that may contain inline markdown. + * @return {string} HTML string with inline elements applied. + */ +function inlineFormat( text ) { + const pattern = /(\*\*(.+?)\*\*|\*(.+?)\*|\[([^\]]+)\]\(([^)]+)\)|(https?:\/\/[^\s<>"')\]]+))/g; + const result = []; + let lastIndex = 0; + let match; + + while ( ( match = pattern.exec( text ) ) !== null ) { + if ( match.index > lastIndex ) { + result.push( escapeHtml( text.slice( lastIndex, match.index ) ) ); + } + if ( match[ 2 ] !== undefined ) { + result.push( `${ inlineFormat( match[ 2 ] ) }` ); + } else if ( match[ 3 ] !== undefined ) { + result.push( `${ inlineFormat( match[ 3 ] ) }` ); + } else if ( match[ 4 ] !== undefined && match[ 5 ] !== undefined ) { + const url = /^https?:\/\//.test( match[ 5 ] ) ? escapeHtml( match[ 5 ] ) : '#'; + result.push( + `${ escapeHtml( + match[ 4 ] + ) }` + ); + } else if ( match[ 6 ] !== undefined ) { + const url = escapeHtml( match[ 6 ] ); + result.push( `${ url }` ); + } + lastIndex = match.index + match[ 0 ].length; + } + + if ( lastIndex < text.length ) { + result.push( escapeHtml( text.slice( lastIndex ) ) ); + } + + return result.join( '' ); +} + +/** + * Convert a markdown string to an HTML string. + * + * @param {string} markdown - Markdown text from the AI answer stream. + * @return {string} Safe HTML string. + */ +export function markdownToHtml( markdown ) { + if ( ! markdown ) { + return ''; + } + + let html = ''; + const lines = markdown.split( '\n' ); + let inList = false; + const pendingParagraph = []; + + const flushParagraph = () => { + if ( pendingParagraph.length > 0 ) { + const content = pendingParagraph.join( ' ' ).trim(); + if ( content ) { + html += `

${ inlineFormat( content ) }

`; + } + pendingParagraph.length = 0; + } + }; + + const closeList = () => { + if ( inList ) { + html += ''; + inList = false; + } + }; + + for ( const line of lines ) { + const headingMatch = line.match( /^(#{1,6})\s+(.+)$/ ); + const listMatch = line.match( /^[-*]\s+(.+)$/ ); + + if ( headingMatch ) { + flushParagraph(); + closeList(); + const level = Math.min( headingMatch[ 1 ].length + 1, 6 ); + html += `${ inlineFormat( headingMatch[ 2 ] ) }`; + } else if ( listMatch ) { + flushParagraph(); + if ( ! inList ) { + html += '
    '; + inList = true; + } + html += `
  • ${ inlineFormat( listMatch[ 1 ] ) }
  • `; + } else if ( line.trim() === '' ) { + flushParagraph(); + closeList(); + } else { + if ( inList ) { + closeList(); + } + pendingParagraph.push( line ); + } + } + + flushParagraph(); + closeList(); + + return html; +} diff --git a/projects/packages/search/src/instant-search/lib/styles/_variables.scss b/projects/packages/search/src/instant-search/lib/styles/_variables.scss index 051cb946a512..2305c53f758f 100644 --- a/projects/packages/search/src/instant-search/lib/styles/_variables.scss +++ b/projects/packages/search/src/instant-search/lib/styles/_variables.scss @@ -25,6 +25,9 @@ $breakpoints: ( xxlarge: $break-xxl, ); +// Border radii +$border-radius-panel: 6px; + // Side margins for search results container $results-margin-sm: 20px; $results-margin-md: 40px; @@ -43,6 +46,10 @@ $color-layout-borders: studio.$studio-wordpress-blue-0; $color-mark-background-default: #ffc; $color-modal-background: studio.$studio-white; $color-modal-backdrop-background: rgba(studio.$studio-gray-90, 0.7); +$color-border: studio.$studio-gray-5; +$color-error: studio.$studio-red-50; +$color-error-border: studio.$studio-red-5; +$color-surface-alt: studio.$studio-gray-0; $color-dark-text: studio.$studio-wordpress-blue-0; $color-dark-text-light: studio.$studio-gray-20; diff --git a/projects/packages/search/tests/jest-globals.gui.js b/projects/packages/search/tests/jest-globals.gui.js index 8a95837f69de..a439a35c6e2e 100644 --- a/projects/packages/search/tests/jest-globals.gui.js +++ b/projects/packages/search/tests/jest-globals.gui.js @@ -1,3 +1,9 @@ +// @wordpress packages are webpack externals in some bundled dependencies (e.g. +// jetpack-boost-score-api). They resolve to window.wp.* at runtime, so jest +// needs the global to exist. +window.wp = window.wp || {}; +window.wp.i18n = require( '@wordpress/i18n' ); + window.JP_CONNECTION_INITIAL_STATE = { userConnectionData: { currentUser: { diff --git a/projects/packages/search/tests/js/dashboard/pages/dashboard-page.test.jsx b/projects/packages/search/tests/js/dashboard/pages/dashboard-page.test.jsx index 5eaf5acefc89..9fe0103a71dd 100644 --- a/projects/packages/search/tests/js/dashboard/pages/dashboard-page.test.jsx +++ b/projects/packages/search/tests/js/dashboard/pages/dashboard-page.test.jsx @@ -21,9 +21,11 @@ jest.mock( '@automattic/jetpack-connection', () => ( { // Stub heavy sub-components that aren't relevant to the branching test. jest.mock( 'components/mocked-search', () => () =>
    ); jest.mock( 'components/module-control', () => () =>
    ); +jest.mock( 'components/feature-selector', () => () =>
    ); jest.mock( 'components/record-meter', () => () =>
    ); jest.mock( 'components/global-notices', () => () => null ); jest.mock( 'components/loading', () => () =>
    ); +jest.mock( 'components/ai-answers-tab', () => () =>
    ); const renderWith = ( { searchBlocksEnabled, jetpackSettings } ) => { const registry = createRegistry(); @@ -64,19 +66,21 @@ const settings = { }; describe( ' branch', () => { + test( 'renders Plan & Usage and AI Answers tabs', () => { + renderWith( { searchBlocksEnabled: false, jetpackSettings: settings } ); + expect( screen.getByRole( 'tab', { name: /plan & usage/i } ) ).toBeInTheDocument(); + expect( screen.getByRole( 'tab', { name: /ai answers/i } ) ).toBeInTheDocument(); + } ); + test( 'renders FeatureSelector when searchBlocksEnabled is true', () => { renderWith( { searchBlocksEnabled: true, jetpackSettings: settings } ); - expect( - screen.getByRole( 'group', { name: /select a search experience for your visitors/i } ) - ).toBeInTheDocument(); + expect( screen.getByTestId( 'feature-selector' ) ).toBeInTheDocument(); expect( screen.queryByTestId( 'module-control' ) ).not.toBeInTheDocument(); } ); test( 'renders ModuleControl when searchBlocksEnabled is false', () => { renderWith( { searchBlocksEnabled: false, jetpackSettings: settings } ); - expect( - screen.queryByRole( 'group', { name: /select a search experience for your visitors/i } ) - ).not.toBeInTheDocument(); + expect( screen.queryByTestId( 'feature-selector' ) ).not.toBeInTheDocument(); expect( screen.getByTestId( 'module-control' ) ).toBeInTheDocument(); } ); } ); diff --git a/projects/packages/search/tests/php/AI_Answers_Test.php b/projects/packages/search/tests/php/AI_Answers_Test.php new file mode 100644 index 000000000000..c311e5645eda --- /dev/null +++ b/projects/packages/search/tests/php/AI_Answers_Test.php @@ -0,0 +1,194 @@ +init(); + do_action( 'init' ); + } + + /** @var callable|null */ + private $posts_query_filter = null; + + /** @var int[] */ + private $test_post_ids = array(); + + public function tearDown(): void { + // Delete test posts BEFORE parent::tearDown() empties the WorDBless store. + // wp_delete_post() must find the post to call clean_post_cache(), which + // invalidates the WP query cache and prevents stale cache hits in later tests. + foreach ( $this->test_post_ids as $id ) { + wp_delete_post( $id, true ); + } + $this->test_post_ids = array(); + + parent::tearDown(); + + if ( $this->posts_query_filter !== null ) { + remove_filter( 'posts_pre_query', $this->posts_query_filter, 10 ); + $this->posts_query_filter = null; + } + if ( post_type_exists( 'wp_guideline' ) ) { + unregister_post_type( 'wp_guideline' ); + } + } + + public function test_is_enabled_defaults_to_false() { + $this->assertFalse( AI_Answers::is_enabled() ); + } + + public function test_is_enabled_reads_option() { + update_option( 'jetpack_search_ai_answers_enabled', true ); + $this->assertTrue( AI_Answers::is_enabled() ); + delete_option( 'jetpack_search_ai_answers_enabled' ); + } + + public function test_is_enabled_filter_overrides_option() { + add_filter( 'jetpack_search_ai_answers_enabled', '__return_true' ); + $this->assertTrue( AI_Answers::is_enabled() ); + remove_filter( 'jetpack_search_ai_answers_enabled', '__return_true' ); + } + + public function test_get_behavior_instructions_returns_empty_by_default() { + // wp_guideline is not registered; option is unset — expect empty string. + $this->assertSame( '', AI_Answers::get_behavior_instructions() ); + } + + public function test_get_behavior_instructions_reads_option_when_no_cpt() { + update_option( AI_Answers::BEHAVIOR_OPTION_KEY, 'Answer only in English.' ); + $this->assertSame( 'Answer only in English.', AI_Answers::get_behavior_instructions() ); + } + + public function test_register_behavior_meta_registers_setting_when_cpt_absent() { + // wp_guideline is not registered in the bare test environment — the fallback + // path should register the site option via register_setting() without error. + $ai = new AI_Answers(); + $ai->register_behavior_meta(); + $this->assertNotFalse( get_registered_settings()[ AI_Answers::BEHAVIOR_OPTION_KEY ] ?? false ); + } + + // ------------------------------------------------------------------------- + // Tests for the wp_guideline CPT path + // ------------------------------------------------------------------------- + + /** + * Register a queryable variant of wp_guideline for test isolation. + */ + private function register_guideline_cpt() { + register_post_type( + 'wp_guideline', // phpcs:ignore WordPress.NamingConventions.ValidPostTypeSlug.ReservedPrefix + array( + 'public' => true, + 'supports' => array( 'custom-fields' ), + ) + ); + } + + /** + * WorDBless dbless mode only intercepts SELECT * FROM posts WHERE ID = N. + * All other WP_Query SQL returns empty. Hook posts_pre_query (which runs + * regardless of suppress_filters) to short-circuit the query and return + * our test post when the query targets wp_guideline posts. + */ + private function hook_wordbless_posts_query( int $post_id ): void { + $this->posts_query_filter = static function ( $posts, $query ) use ( $post_id ) { + if ( 'wp_guideline' === $query->get( 'post_type' ) ) { + return array( get_post( $post_id ) ); + } + return $posts; + }; + add_filter( 'posts_pre_query', $this->posts_query_filter, 10, 2 ); + } + + public function test_register_behavior_meta_registers_post_meta_when_wp_guideline_exists() { + $this->register_guideline_cpt(); + $ai = new AI_Answers(); + $ai->register_behavior_meta(); + $registered = get_registered_meta_keys( 'post', 'wp_guideline' ); + $this->assertArrayHasKey( AI_Answers::BEHAVIOR_META_KEY, $registered ); + } + + public function test_register_behavior_meta_does_not_register_option_when_cpt_exists() { + $this->register_guideline_cpt(); + $before = get_registered_settings(); + $ai = new AI_Answers(); + $ai->register_behavior_meta(); + $after = get_registered_settings(); + // The fallback option key should NOT have been freshly registered. + $this->assertEquals( isset( $before[ AI_Answers::BEHAVIOR_OPTION_KEY ] ), isset( $after[ AI_Answers::BEHAVIOR_OPTION_KEY ] ) ); + } + + public function test_get_behavior_instructions_reads_from_published_post_meta() { + $this->register_guideline_cpt(); + $post_id = wp_insert_post( + array( + 'post_type' => 'wp_guideline', + 'post_status' => 'publish', + 'post_title' => 'Guidelines', + ) + ); + $this->test_post_ids[] = $post_id; + update_post_meta( $post_id, AI_Answers::BEHAVIOR_META_KEY, 'Answer only in English.' ); + $this->hook_wordbless_posts_query( $post_id ); + + $this->assertSame( 'Answer only in English.', AI_Answers::get_behavior_instructions() ); + } + + public function test_get_behavior_instructions_falls_back_to_option_when_cpt_has_no_published_posts() { + $this->register_guideline_cpt(); + update_option( AI_Answers::BEHAVIOR_OPTION_KEY, 'Fallback instructions.' ); + + // CPT registered but no published posts exist — WP_Query returns empty in WorDBless. + $this->assertSame( 'Fallback instructions.', AI_Answers::get_behavior_instructions() ); + } + + public function test_get_behavior_instructions_ignores_draft_posts() { + $this->register_guideline_cpt(); + $post_id = wp_insert_post( + array( + 'post_type' => 'wp_guideline', + 'post_status' => 'draft', + 'post_title' => 'Draft Guidelines', + ) + ); + $this->test_post_ids[] = $post_id; + update_post_meta( $post_id, AI_Answers::BEHAVIOR_META_KEY, 'Draft instructions.' ); + + // Only published posts should be read; draft posts are ignored. + // (No wordbless hook — WP_Query returns empty, so get_option fallback returns ''.) + $this->assertSame( '', AI_Answers::get_behavior_instructions() ); + } + + public function test_get_behavior_instructions_prefers_cpt_over_option_when_both_exist() { + $this->register_guideline_cpt(); + $post_id = wp_insert_post( + array( + 'post_type' => 'wp_guideline', + 'post_status' => 'publish', + 'post_title' => 'Guidelines', + ) + ); + $this->test_post_ids[] = $post_id; + update_post_meta( $post_id, AI_Answers::BEHAVIOR_META_KEY, 'From CPT.' ); + update_option( AI_Answers::BEHAVIOR_OPTION_KEY, 'From option.' ); + $this->hook_wordbless_posts_query( $post_id ); + + $this->assertSame( 'From CPT.', AI_Answers::get_behavior_instructions() ); + } + + public function test_behavior_option_key_constant() { + $this->assertSame( 'jetpack_search_ai_behavior_instructions', AI_Answers::BEHAVIOR_OPTION_KEY ); + } +} diff --git a/projects/packages/search/tests/php/Initializer_Test.php b/projects/packages/search/tests/php/Initializer_Test.php new file mode 100644 index 000000000000..24f3ac14a116 --- /dev/null +++ b/projects/packages/search/tests/php/Initializer_Test.php @@ -0,0 +1,54 @@ +assertContains( 'jetpack_search_init_search_package_filter', $abort_reasons ); + } + + public function test_init_does_not_proceed_past_abort_when_filter_returns_false() { + // Verify that init() bails early and never reaches is_connected() / is_search_supported(), + // which would require a live connection. We confirm by checking that no + // additional abort actions fire (those come from later guard clauses). + $reasons = array(); + add_action( + 'jetpack_search_abort', + function ( $reason ) use ( &$reasons ) { + $reasons[] = $reason; + } + ); + add_filter( 'jetpack_search_init_search_package', '__return_false' ); + + Initializer::init(); + + remove_filter( 'jetpack_search_init_search_package', '__return_false' ); + + // Only the filter-abort reason should have fired. + $this->assertSame( array( 'jetpack_search_init_search_package_filter' ), $reasons ); + } +} diff --git a/projects/packages/search/tests/php/REST_Controller_Test.php b/projects/packages/search/tests/php/REST_Controller_Test.php index 09d532945049..4a6165f03496 100644 --- a/projects/packages/search/tests/php/REST_Controller_Test.php +++ b/projects/packages/search/tests/php/REST_Controller_Test.php @@ -138,6 +138,7 @@ public function test_update_search_settings_success_both_enable() { 'module_active' => true, 'instant_search_enabled' => true, 'swap_classic_to_inline_search' => false, + 'ai_answers_enabled' => false, ); $expected = array_merge( $new_settings, array( 'experience' => 'overlay' ) ); @@ -190,6 +191,7 @@ public function test_update_search_settings_success_both_disable() { 'module_active' => false, 'instant_search_enabled' => false, 'swap_classic_to_inline_search' => false, + 'ai_answers_enabled' => false, ); $expected = array_merge( $new_settings, array( 'experience' => 'off' ) ); @@ -214,6 +216,7 @@ public function test_update_search_settings_success_disable_module_only() { 'instant_search_enabled' => false, 'swap_classic_to_inline_search' => false, 'experience' => 'off', + 'ai_answers_enabled' => false, ); $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); @@ -237,6 +240,7 @@ public function test_update_search_settings_success_disable_instant_only() { 'instant_search_enabled' => true, 'swap_classic_to_inline_search' => false, 'experience' => 'overlay', + 'ai_answers_enabled' => false, ); $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); @@ -260,6 +264,7 @@ public function test_update_search_settings_success_enable_inline_search() { 'instant_search_enabled' => false, 'swap_classic_to_inline_search' => true, 'experience' => 'off', + 'ai_answers_enabled' => false, ); $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); @@ -283,6 +288,7 @@ public function test_update_search_settings_success_disable_inline_search() { 'instant_search_enabled' => false, 'swap_classic_to_inline_search' => false, 'experience' => 'off', + 'ai_answers_enabled' => false, ); $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); @@ -573,6 +579,39 @@ public function test_get_settings_returns_persisted_experience() { $this->assertEquals( 'embedded', $response->get_data()['experience'] ); } + /** + * Testing that ai_answers_enabled can be toggled on via the settings endpoint. + */ + public function test_update_settings_ai_answers_enabled_true() { + wp_set_current_user( $this->admin_id ); + + $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'ai_answers_enabled' => true ), JSON_UNESCAPED_SLASHES ) ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['ai_answers_enabled'] ); + } + + /** + * Testing that ai_answers_enabled can be toggled off via the settings endpoint. + */ + public function test_update_settings_ai_answers_enabled_false() { + wp_set_current_user( $this->admin_id ); + + // Enable first. + update_option( 'jetpack_search_ai_answers_enabled', true ); + + $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'ai_answers_enabled' => false ), JSON_UNESCAPED_SLASHES ) ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertFalse( $data['ai_answers_enabled'] ); + } + /** * Testing the `POST /jetpack/v4/search/plan/activate` endpoint with no user. */ diff --git a/projects/packages/search/tests/php/Settings_Test.php b/projects/packages/search/tests/php/Settings_Test.php new file mode 100644 index 000000000000..631dcf9ad3a8 --- /dev/null +++ b/projects/packages/search/tests/php/Settings_Test.php @@ -0,0 +1,73 @@ +assertArrayHasKey( Options::OPTION_PREFIX . 'ai_answers_enabled', $registered ); + $setting = $registered[ Options::OPTION_PREFIX . 'ai_answers_enabled' ]; + $this->assertEquals( 'boolean', $setting['type'] ); + $this->assertFalse( $setting['default'] ); + } + + public function test_settings_register_registers_result_format() { + $registered = get_registered_settings(); + $this->assertArrayHasKey( Options::OPTION_PREFIX . 'result_format', $registered ); + $setting = $registered[ Options::OPTION_PREFIX . 'result_format' ]; + $this->assertEquals( 'string', $setting['type'] ); + $this->assertEquals( 'minimal', $setting['default'] ); + } + + public function test_settings_register_registers_enable_sort() { + $registered = get_registered_settings(); + $this->assertArrayHasKey( Options::OPTION_PREFIX . 'enable_sort', $registered ); + $setting = $registered[ Options::OPTION_PREFIX . 'enable_sort' ]; + $this->assertEquals( 'boolean', $setting['type'] ); + $this->assertTrue( $setting['default'] ); + } + + public function test_settings_register_all_settings_have_show_in_rest() { + $registered = get_registered_settings(); + $prefix = Options::OPTION_PREFIX; + $expected_keys = array( + $prefix . 'ai_prompt_override', + $prefix . 'color_theme', + $prefix . 'result_format', + $prefix . 'default_sort', + $prefix . 'overlay_trigger', + $prefix . 'excluded_post_types', + $prefix . 'highlight_color', + $prefix . 'enable_sort', + $prefix . 'inf_scroll', + $prefix . 'filtering_opens_overlay', + $prefix . 'show_post_date', + $prefix . 'show_product_price', + $prefix . 'show_powered_by', + $prefix . 'ai_answers_enabled', + ); + foreach ( $expected_keys as $key ) { + $this->assertArrayHasKey( $key, $registered, "Setting $key not registered" ); + $this->assertTrue( $registered[ $key ]['show_in_rest'], "Setting $key missing show_in_rest" ); + } + } +} diff --git a/projects/packages/sync/changelog/jps3-ai-answers b/projects/packages/sync/changelog/jps3-ai-answers new file mode 100644 index 000000000000..26c6d420a7e6 --- /dev/null +++ b/projects/packages/sync/changelog/jps3-ai-answers @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Sync: sync wp_guideline CPT when Jetpack Search AI Answers is enabled. diff --git a/projects/packages/sync/src/modules/class-search.php b/projects/packages/sync/src/modules/class-search.php index 0691174bc5fe..04a9937dc737 100644 --- a/projects/packages/sync/src/modules/class-search.php +++ b/projects/packages/sync/src/modules/class-search.php @@ -51,6 +51,8 @@ public function __construct() { add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'add_search_post_meta_whitelist' ), 10 ); // Add options add_filter( 'jetpack_sync_options_whitelist', array( $this, 'add_search_options_whitelist' ), 10 ); + // AI Answers CPTs and post meta (gated by feature flag). + add_filter( 'jetpack_sync_post_types_whitelist', array( $this, 'add_ai_answer_post_types' ), 10 ); } /** @@ -73,378 +75,379 @@ public function __construct() { */ private static $postmeta_to_sync = array( // jetpack. - 'jetpack-search-meta0' => array( 'searchable_in_all_content' => true ), - 'jetpack-search-meta1' => array( 'searchable_in_all_content' => true ), - 'jetpack-search-meta2' => array( 'searchable_in_all_content' => true ), - 'jetpack-search-meta3' => array( 'searchable_in_all_content' => true ), - 'jetpack-search-meta4' => array( 'searchable_in_all_content' => true ), - 'jetpack-search-meta5' => array( 'searchable_in_all_content' => true ), - 'jetpack-search-meta6' => array( 'searchable_in_all_content' => true ), - 'jetpack-search-meta7' => array( 'searchable_in_all_content' => true ), - 'jetpack-search-meta8' => array( 'searchable_in_all_content' => true ), - 'jetpack-search-meta9' => array( 'searchable_in_all_content' => true ), + '_guideline_block_jetpack_search-ai-summary' => array(), // AI Answers personality (Gutenberg Guidelines CPT). + 'jetpack-search-meta0' => array( 'searchable_in_all_content' => true ), + 'jetpack-search-meta1' => array( 'searchable_in_all_content' => true ), + 'jetpack-search-meta2' => array( 'searchable_in_all_content' => true ), + 'jetpack-search-meta3' => array( 'searchable_in_all_content' => true ), + 'jetpack-search-meta4' => array( 'searchable_in_all_content' => true ), + 'jetpack-search-meta5' => array( 'searchable_in_all_content' => true ), + 'jetpack-search-meta6' => array( 'searchable_in_all_content' => true ), + 'jetpack-search-meta7' => array( 'searchable_in_all_content' => true ), + 'jetpack-search-meta8' => array( 'searchable_in_all_content' => true ), + 'jetpack-search-meta9' => array( 'searchable_in_all_content' => true ), // woocommerce. - 'exclude_product_categories' => array(), - 'exclude_product_ids' => array(), - 'free_shipping' => array(), - 'id_field' => array(), - 'individual_use' => array(), - 'limit_usage_to_x_items' => array(), - 'maximum_amount' => array(), - 'minimum_amount' => array(), - 'post_id' => array(), - 'product_categories' => array( 'searchable_in_all_content' => true ), - 'product_ids' => array(), - 'total_sales' => array( + 'exclude_product_categories' => array(), + 'exclude_product_ids' => array(), + 'free_shipping' => array(), + 'id_field' => array(), + 'individual_use' => array(), + 'limit_usage_to_x_items' => array(), + 'maximum_amount' => array(), + 'minimum_amount' => array(), + 'post_id' => array(), + 'product_categories' => array( 'searchable_in_all_content' => true ), + 'product_ids' => array(), + 'total_sales' => array( 'available' => false, 'alternatives' => array( 'wc.percent_of_sales', ), ), - 'usage_limit' => array(), - 'usage_limit_per_user' => array(), - '_crosssell_ids' => array(), - '_downloadable' => array(), - '_featured' => array(), - '_height' => array(), - '_length' => array(), - '_price' => array( + 'usage_limit' => array(), + 'usage_limit_per_user' => array(), + '_crosssell_ids' => array(), + '_downloadable' => array(), + '_featured' => array(), + '_height' => array(), + '_length' => array(), + '_price' => array( 'alternatives' => array( 'wc.price', 'wc.min_price', 'wc.max_price', ), ), - '_prices_include_tax' => array(), - '_product_attributes' => array(), - '_product_version' => array(), - '_regular_price' => array( + '_prices_include_tax' => array(), + '_product_attributes' => array(), + '_product_version' => array(), + '_regular_price' => array( 'alternatives' => array( 'wc.regular_price', ), ), - '_sale_price' => array( + '_sale_price' => array( 'alternatives' => array( 'wc.sale_price', ), ), - '_sale_price_dates_from' => array(), - '_sale_price_dates_to' => array(), - '_sku' => array( 'searchable_in_all_content' => true ), - '_stock_status' => array(), - '_wc_average_rating' => array( + '_sale_price_dates_from' => array(), + '_sale_price_dates_to' => array(), + '_sku' => array( 'searchable_in_all_content' => true ), + '_stock_status' => array(), + '_wc_average_rating' => array( 'alternatives' => array( 'wc.ave_rating_score', ), ), - '_wc_rating_count' => array( + '_wc_rating_count' => array( 'alternatives' => array( 'wc.rating', // wc.rating.count_1, wc.rating.count_2, ... ), ), - '_wc_review_count' => array(), - '_weight' => array(), - '_width' => array(), + '_wc_review_count' => array(), + '_weight' => array(), + '_width' => array(), // co-authors plus. - 'cap-description' => array( 'searchable_in_all_content' => true ), - 'cap-user_login' => array( 'searchable_in_all_content' => true ), - 'cap-user_email' => array(), - 'cap-last_name' => array( 'searchable_in_all_content' => true ), - 'cap-first_name' => array( 'searchable_in_all_content' => true ), - 'cap-display_name' => array( 'searchable_in_all_content' => true ), - 'cap-website' => array(), - 'cap-jabber' => array(), - 'cap-aim' => array(), - 'cap-twitter' => array(), - 'cap-facebook' => array(), - 'cap-google_plus' => array(), - 'cap-job_title' => array( 'searchable_in_all_content' => true ), + 'cap-description' => array( 'searchable_in_all_content' => true ), + 'cap-user_login' => array( 'searchable_in_all_content' => true ), + 'cap-user_email' => array(), + 'cap-last_name' => array( 'searchable_in_all_content' => true ), + 'cap-first_name' => array( 'searchable_in_all_content' => true ), + 'cap-display_name' => array( 'searchable_in_all_content' => true ), + 'cap-website' => array(), + 'cap-jabber' => array(), + 'cap-aim' => array(), + 'cap-twitter' => array(), + 'cap-facebook' => array(), + 'cap-google_plus' => array(), + 'cap-job_title' => array( 'searchable_in_all_content' => true ), // bbpress. - 'bbpl_like' => array(), - 'bbpress_discussion_comments_copied' => array(), - 'bbpress_discussion_tags_copied' => array(), - 'bbpress_discussion_topic_id' => array(), - 'bbpress_discussion_use_defaults' => array(), - 'bbpress_page_header_bg' => array(), - 'bbpress_title_bg' => array(), - 'use_bbpress_discussion_topic' => array(), + 'bbpl_like' => array(), + 'bbpress_discussion_comments_copied' => array(), + 'bbpress_discussion_tags_copied' => array(), + 'bbpress_discussion_topic_id' => array(), + 'bbpress_discussion_use_defaults' => array(), + 'bbpress_page_header_bg' => array(), + 'bbpress_title_bg' => array(), + 'use_bbpress_discussion_topic' => array(), // wpml. - 'tm_meta_wpml' => array(), - 'wpml_language' => array(), - 'wpml_media_lang' => array(), - 'wpml_media_processed' => array(), + 'tm_meta_wpml' => array(), + 'wpml_language' => array(), + 'wpml_media_lang' => array(), + 'wpml_media_processed' => array(), // blogger import. - 'blogger_author' => array( 'searchable_in_all_content' => true ), - 'blogger_blog' => array( 'searchable_in_all_content' => true ), - 'blogger_permalink' => array( 'searchable_in_all_content' => true ), + 'blogger_author' => array( 'searchable_in_all_content' => true ), + 'blogger_blog' => array( 'searchable_in_all_content' => true ), + 'blogger_permalink' => array( 'searchable_in_all_content' => true ), // geo. - 'geo_address' => array( 'searchable_in_all_content' => true ), - 'geo_latitude' => array(), - 'geo_longitude' => array(), - 'geo_public' => array(), - 'geolocated' => array(), - 'geolocation_city' => array( 'searchable_in_all_content' => true ), - 'geolocation_country_long' => array( 'searchable_in_all_content' => true ), - 'geolocation_country_short' => array( 'searchable_in_all_content' => true ), - 'geolocation_formatted_address' => array( 'searchable_in_all_content' => true ), - 'geolocation_lat' => array(), - 'geolocation_long' => array(), - 'geolocation_postcode' => array( 'searchable_in_all_content' => true ), - 'geolocation_state_long' => array( 'searchable_in_all_content' => true ), - 'geolocation_state_short' => array( 'searchable_in_all_content' => true ), + 'geo_address' => array( 'searchable_in_all_content' => true ), + 'geo_latitude' => array(), + 'geo_longitude' => array(), + 'geo_public' => array(), + 'geolocated' => array(), + 'geolocation_city' => array( 'searchable_in_all_content' => true ), + 'geolocation_country_long' => array( 'searchable_in_all_content' => true ), + 'geolocation_country_short' => array( 'searchable_in_all_content' => true ), + 'geolocation_formatted_address' => array( 'searchable_in_all_content' => true ), + 'geolocation_lat' => array(), + 'geolocation_long' => array(), + 'geolocation_postcode' => array( 'searchable_in_all_content' => true ), + 'geolocation_state_long' => array( 'searchable_in_all_content' => true ), + 'geolocation_state_short' => array( 'searchable_in_all_content' => true ), // wp-ultimate-recipe. - 'recipe_alternate_image' => array(), - 'recipe_cook_time' => array(), - 'recipe_cook_time_text' => array(), - 'recipe_description' => array( 'searchable_in_all_content' => true ), - 'recipe_ingredients' => array( 'searchable_in_all_content' => true ), - 'recipe_instructions' => array( 'searchable_in_all_content' => true ), - 'recipe_notes' => array( 'searchable_in_all_content' => true ), - 'recipe_nutritional' => array( 'searchable_in_all_content' => true ), - 'recipe_passive_time' => array(), - 'recipe_passive_time_text' => array(), - 'recipe_prep_time' => array(), - 'recipe_prep_time_text' => array(), - 'recipe_rating' => array(), - 'recipe_servings' => array(), - 'recipe_servings_normalized' => array(), - 'recipe_servings_type' => array(), - 'recipe_terms' => array( 'searchable_in_all_content' => true ), - 'recipe_terms_with_parents' => array(), - 'recipe_title' => array( 'searchable_in_all_content' => true ), - 'recipe_user_ratings' => array(), - 'recipe_user_ratings_rating' => array(), + 'recipe_alternate_image' => array(), + 'recipe_cook_time' => array(), + 'recipe_cook_time_text' => array(), + 'recipe_description' => array( 'searchable_in_all_content' => true ), + 'recipe_ingredients' => array( 'searchable_in_all_content' => true ), + 'recipe_instructions' => array( 'searchable_in_all_content' => true ), + 'recipe_notes' => array( 'searchable_in_all_content' => true ), + 'recipe_nutritional' => array( 'searchable_in_all_content' => true ), + 'recipe_passive_time' => array(), + 'recipe_passive_time_text' => array(), + 'recipe_prep_time' => array(), + 'recipe_prep_time_text' => array(), + 'recipe_rating' => array(), + 'recipe_servings' => array(), + 'recipe_servings_normalized' => array(), + 'recipe_servings_type' => array(), + 'recipe_terms' => array( 'searchable_in_all_content' => true ), + 'recipe_terms_with_parents' => array(), + 'recipe_title' => array( 'searchable_in_all_content' => true ), + 'recipe_user_ratings' => array(), + 'recipe_user_ratings_rating' => array(), // generic fields. // from advanced-custom-fields and metabox.io . - 'Link' => array(), - 'Location' => array(), - 'Title' => array( 'searchable_in_all_content' => true ), - 'ad_code' => array(), - 'address' => array(), - 'admin_mail' => array(), - 'admin_only' => array(), - 'advertisers' => array( 'searchable_in_all_content' => true ), - 'age' => array(), - 'aliases' => array(), - 'alternate_title' => array(), - 'ama_content' => array(), - 'amazon' => array(), - 'answer' => array( 'searchable_in_all_content' => true ), - 'area' => array(), - 'attention' => array(), - 'attr' => array(), - 'author' => array( 'searchable_in_all_content' => true ), - 'author_name' => array( 'searchable_in_all_content' => true ), - 'blog' => array(), - 'blog_id' => array(), - 'call_to_action' => array(), - 'campaign_preview' => array(), - 'canonical_url' => array(), - 'catch_text' => array(), - 'category' => array( 'searchable_in_all_content' => true ), - 'classificacao' => array(), - 'classification' => array(), - 'code' => array(), - 'codigo' => array(), - 'company' => array( 'searchable_in_all_content' => true ), - 'company_website' => array(), - 'config' => array(), - 'construction' => array(), - 'container_ids' => array(), - 'content' => array( 'searchable_in_all_content' => true ), - 'content_body-full_content' => array( 'searchable_in_all_content' => true ), - 'copyright' => array(), - 'custom_page_title' => array( 'searchable_in_all_content' => true ), - 'custom_permalink' => array(), - 'customize' => array(), - 'data' => array(), - 'date' => array(), - 'day' => array(), - 'descripcion' => array( 'searchable_in_all_content' => true ), - 'description' => array( 'searchable_in_all_content' => true ), - 'display_settings' => array(), - 'display_type' => array(), - 'duration' => array(), - 'embed' => array(), - 'entity_ids' => array(), - 'entity_types' => array(), - 'event_subtitle' => array( 'searchable_in_all_content' => true ), - 'excluded_container_ids' => array(), - 'exclusions' => array(), - 'experience' => array(), - 'external_url' => array(), - 'featured' => array(), - 'featured_image' => array(), - 'featured_post' => array(), - 'featured_story' => array(), - 'fee' => array(), - 'filter' => array(), - 'follow' => array(), - 'footer_text' => array(), - 'from_header' => array(), - 'fullscreen_view' => array(), - 'gallery' => array(), - 'genre' => array( 'searchable_in_all_content' => true ), - 'guest_bio' => array(), - 'guest_name' => array(), - 'guests' => array( 'searchable_in_all_content' => true ), - 'has_variations' => array(), - 'hashtag' => array(), - 'header_image' => array(), - 'hidden_from_ui' => array(), - 'hide_on_screen' => array(), - 'homepage_order' => array(), - 'hours' => array(), - 'i18n' => array(), - 'id' => array(), - 'image' => array(), - 'image_size' => array(), - 'image_source' => array(), - 'index' => array(), - 'intro_text' => array( 'searchable_in_all_content' => true ), - 'job_mention' => array( 'searchable_in_all_content' => true ), - 'keywords' => array( 'searchable_in_all_content' => true ), - 'latest_news' => array(), - 'layout' => array(), - 'link' => array(), - 'link_dump' => array( 'searchable_in_all_content' => true ), - 'link_url' => array(), - 'location' => array(), - 'logo' => array(), - 'main_title' => array( 'searchable_in_all_content' => true ), - 'maximum_entity_count' => array(), - 'media' => array(), - 'mentions' => array(), - 'messages' => array(), - 'meta_description' => array( 'searchable_in_all_content' => true ), - 'meta_id' => array(), - 'meta_index' => array(), - 'meta_key' => array(), - 'meta_value' => array(), - 'modal-dialog-id' => array(), - 'name' => array( 'searchable_in_all_content' => true ), - 'nombre' => array( 'searchable_in_all_content' => true ), - 'notes' => array( 'searchable_in_all_content' => true ), - 'options' => array(), - 'order_by' => array(), - 'order_direction' => array(), - 'original_cats' => array(), - 'original_headers' => array(), - 'original_link' => array(), - 'original_message' => array(), - 'original_subject' => array(), - 'original_title' => array(), - 'original_to' => array(), - 'other_setting' => array(), - 'page_canonical' => array(), - 'page_layout' => array(), - 'page_sidebar' => array(), - 'page_tags' => array(), - 'panels_data' => array(), - 'parking' => array(), - 'pdf_upload' => array(), - 'people_mentioned' => array(), - 'photo' => array(), - 'play_time' => array(), - 'position' => array(), - 'post-rating' => array(), - 'post_background' => array(), - 'post_color' => array(), - 'post_sidebar' => array(), - 'post_subtitle' => array( 'searchable_in_all_content' => true ), - 'price' => array(), - 'publication' => array(), - 'rating' => array(), - 'ratings_average' => array(), - 'ratings_score' => array(), - 'ratings_users' => array(), - 'relation' => array(), - 'reply_to_header' => array(), - 'required' => array(), - 'returns' => array(), - 'review_post' => array(), - 'rule' => array(), - 'section' => array( 'searchable_in_all_content' => true ), - 'selected_links' => array(), - 'session_transcript' => array(), - 'settings' => array(), - 'sex' => array(), - 'shares_count' => array(), - 'show_description' => array( 'searchable_in_all_content' => true ), - 'show_page_title' => array(), - 'show_notes' => array(), - 'show_notes_preview' => array(), - 'side' => array(), - 'sidebar' => array(), - 'site' => array(), - 'situation' => array(), - 'slide_template' => array(), - 'slug' => array(), - 'sortorder' => array(), - 'source' => array(), - 'start_date' => array(), - 'status' => array(), - 'styles' => array(), - 'subtitle' => array( 'searchable_in_all_content' => true ), - 'subtitulo' => array(), - 'success' => array(), - 'summary' => array( 'searchable_in_all_content' => true ), - 'synopsis' => array( 'searchable_in_all_content' => true ), - 'tel' => array(), - 'tema' => array(), - 'testimonial' => array(), - 'testimonial_author' => array(), - 'text_already_subscribed' => array(), - 'text_error' => array(), - 'text_invalid_email' => array(), - 'text_not_subscribed' => array(), - 'text_required_field_missing' => array(), - 'text_subscribed' => array(), - 'text_unsubscribed' => array(), - 'thumbnail' => array(), - 'time' => array(), - 'time_jump_list' => array( 'searchable_in_all_content' => true ), - 'title' => array( 'searchable_in_all_content' => true ), - 'title_view' => array(), - 'titre' => array( 'searchable_in_all_content' => true ), - 'titulo' => array( 'searchable_in_all_content' => true ), - 'to_header' => array(), - 'toc' => array(), - 'transcript' => array( 'searchable_in_all_content' => true ), - 'transport_uri' => array(), - 'type' => array(), - 'url' => array(), - 'validation' => array(), - 'value' => array(), - 'values' => array(), - 'variation' => array(), - 'video' => array(), - 'video_type' => array(), - 'video_url' => array(), - 'videopress_guid' => array(), - 'website' => array(), - 'weight' => array(), - 'year' => array(), + 'Link' => array(), + 'Location' => array(), + 'Title' => array( 'searchable_in_all_content' => true ), + 'ad_code' => array(), + 'address' => array(), + 'admin_mail' => array(), + 'admin_only' => array(), + 'advertisers' => array( 'searchable_in_all_content' => true ), + 'age' => array(), + 'aliases' => array(), + 'alternate_title' => array(), + 'ama_content' => array(), + 'amazon' => array(), + 'answer' => array( 'searchable_in_all_content' => true ), + 'area' => array(), + 'attention' => array(), + 'attr' => array(), + 'author' => array( 'searchable_in_all_content' => true ), + 'author_name' => array( 'searchable_in_all_content' => true ), + 'blog' => array(), + 'blog_id' => array(), + 'call_to_action' => array(), + 'campaign_preview' => array(), + 'canonical_url' => array(), + 'catch_text' => array(), + 'category' => array( 'searchable_in_all_content' => true ), + 'classificacao' => array(), + 'classification' => array(), + 'code' => array(), + 'codigo' => array(), + 'company' => array( 'searchable_in_all_content' => true ), + 'company_website' => array(), + 'config' => array(), + 'construction' => array(), + 'container_ids' => array(), + 'content' => array( 'searchable_in_all_content' => true ), + 'content_body-full_content' => array( 'searchable_in_all_content' => true ), + 'copyright' => array(), + 'custom_page_title' => array( 'searchable_in_all_content' => true ), + 'custom_permalink' => array(), + 'customize' => array(), + 'data' => array(), + 'date' => array(), + 'day' => array(), + 'descripcion' => array( 'searchable_in_all_content' => true ), + 'description' => array( 'searchable_in_all_content' => true ), + 'display_settings' => array(), + 'display_type' => array(), + 'duration' => array(), + 'embed' => array(), + 'entity_ids' => array(), + 'entity_types' => array(), + 'event_subtitle' => array( 'searchable_in_all_content' => true ), + 'excluded_container_ids' => array(), + 'exclusions' => array(), + 'experience' => array(), + 'external_url' => array(), + 'featured' => array(), + 'featured_image' => array(), + 'featured_post' => array(), + 'featured_story' => array(), + 'fee' => array(), + 'filter' => array(), + 'follow' => array(), + 'footer_text' => array(), + 'from_header' => array(), + 'fullscreen_view' => array(), + 'gallery' => array(), + 'genre' => array( 'searchable_in_all_content' => true ), + 'guest_bio' => array(), + 'guest_name' => array(), + 'guests' => array( 'searchable_in_all_content' => true ), + 'has_variations' => array(), + 'hashtag' => array(), + 'header_image' => array(), + 'hidden_from_ui' => array(), + 'hide_on_screen' => array(), + 'homepage_order' => array(), + 'hours' => array(), + 'i18n' => array(), + 'id' => array(), + 'image' => array(), + 'image_size' => array(), + 'image_source' => array(), + 'index' => array(), + 'intro_text' => array( 'searchable_in_all_content' => true ), + 'job_mention' => array( 'searchable_in_all_content' => true ), + 'keywords' => array( 'searchable_in_all_content' => true ), + 'latest_news' => array(), + 'layout' => array(), + 'link' => array(), + 'link_dump' => array( 'searchable_in_all_content' => true ), + 'link_url' => array(), + 'location' => array(), + 'logo' => array(), + 'main_title' => array( 'searchable_in_all_content' => true ), + 'maximum_entity_count' => array(), + 'media' => array(), + 'mentions' => array(), + 'messages' => array(), + 'meta_description' => array( 'searchable_in_all_content' => true ), + 'meta_id' => array(), + 'meta_index' => array(), + 'meta_key' => array(), + 'meta_value' => array(), + 'modal-dialog-id' => array(), + 'name' => array( 'searchable_in_all_content' => true ), + 'nombre' => array( 'searchable_in_all_content' => true ), + 'notes' => array( 'searchable_in_all_content' => true ), + 'options' => array(), + 'order_by' => array(), + 'order_direction' => array(), + 'original_cats' => array(), + 'original_headers' => array(), + 'original_link' => array(), + 'original_message' => array(), + 'original_subject' => array(), + 'original_title' => array(), + 'original_to' => array(), + 'other_setting' => array(), + 'page_canonical' => array(), + 'page_layout' => array(), + 'page_sidebar' => array(), + 'page_tags' => array(), + 'panels_data' => array(), + 'parking' => array(), + 'pdf_upload' => array(), + 'people_mentioned' => array(), + 'photo' => array(), + 'play_time' => array(), + 'position' => array(), + 'post-rating' => array(), + 'post_background' => array(), + 'post_color' => array(), + 'post_sidebar' => array(), + 'post_subtitle' => array( 'searchable_in_all_content' => true ), + 'price' => array(), + 'publication' => array(), + 'rating' => array(), + 'ratings_average' => array(), + 'ratings_score' => array(), + 'ratings_users' => array(), + 'relation' => array(), + 'reply_to_header' => array(), + 'required' => array(), + 'returns' => array(), + 'review_post' => array(), + 'rule' => array(), + 'section' => array( 'searchable_in_all_content' => true ), + 'selected_links' => array(), + 'session_transcript' => array(), + 'settings' => array(), + 'sex' => array(), + 'shares_count' => array(), + 'show_description' => array( 'searchable_in_all_content' => true ), + 'show_page_title' => array(), + 'show_notes' => array(), + 'show_notes_preview' => array(), + 'side' => array(), + 'sidebar' => array(), + 'site' => array(), + 'situation' => array(), + 'slide_template' => array(), + 'slug' => array(), + 'sortorder' => array(), + 'source' => array(), + 'start_date' => array(), + 'status' => array(), + 'styles' => array(), + 'subtitle' => array( 'searchable_in_all_content' => true ), + 'subtitulo' => array(), + 'success' => array(), + 'summary' => array( 'searchable_in_all_content' => true ), + 'synopsis' => array( 'searchable_in_all_content' => true ), + 'tel' => array(), + 'tema' => array(), + 'testimonial' => array(), + 'testimonial_author' => array(), + 'text_already_subscribed' => array(), + 'text_error' => array(), + 'text_invalid_email' => array(), + 'text_not_subscribed' => array(), + 'text_required_field_missing' => array(), + 'text_subscribed' => array(), + 'text_unsubscribed' => array(), + 'thumbnail' => array(), + 'time' => array(), + 'time_jump_list' => array( 'searchable_in_all_content' => true ), + 'title' => array( 'searchable_in_all_content' => true ), + 'title_view' => array(), + 'titre' => array( 'searchable_in_all_content' => true ), + 'titulo' => array( 'searchable_in_all_content' => true ), + 'to_header' => array(), + 'toc' => array(), + 'transcript' => array( 'searchable_in_all_content' => true ), + 'transport_uri' => array(), + 'type' => array(), + 'url' => array(), + 'validation' => array(), + 'value' => array(), + 'values' => array(), + 'variation' => array(), + 'video' => array(), + 'video_type' => array(), + 'video_url' => array(), + 'videopress_guid' => array(), + 'website' => array(), + 'weight' => array(), + 'year' => array(), // wp.com martketplace search - @see https://wp.me/pdh6GB-Ax#comment-2104 - '_app_icon' => array(), - '_featured_product_video' => array(), - 'wpcom_marketplace_org_slug' => array(), - '_wc_general_product_dependency_theme' => array(), - '_wc_general_product_dependency_plugin' => array(), - 'wpcom_marketplace_product_extra_fields' => array(), - 'wccom_product_search_keywords' => array( 'searchable_in_all_content' => true ), - '_wccom_product_faqs' => array( 'searchable_in_all_content' => true ), - 'wccom_product_features' => array( 'searchable_in_all_content' => true ), - 'wccom_product_compatibility' => array( 'searchable_in_all_content' => true ), + '_app_icon' => array(), + '_featured_product_video' => array(), + 'wpcom_marketplace_org_slug' => array(), + '_wc_general_product_dependency_theme' => array(), + '_wc_general_product_dependency_plugin' => array(), + 'wpcom_marketplace_product_extra_fields' => array(), + 'wccom_product_search_keywords' => array( 'searchable_in_all_content' => true ), + '_wccom_product_faqs' => array( 'searchable_in_all_content' => true ), + 'wccom_product_features' => array( 'searchable_in_all_content' => true ), + 'wccom_product_compatibility' => array( 'searchable_in_all_content' => true ), ); // end indexed post meta. @@ -1809,11 +1812,23 @@ public function add_search_options_whitelist( $list ) { return array_merge( $list, static::get_all_option_keys() ); } - // + /** + * Add AI Answer CPTs to the post types sync whitelist when AI Answers is enabled. + * + * @param array $list Existing post types whitelist. + * @return array Updated whitelist. + */ + public function add_ai_answer_post_types( $list ) { + if ( ! apply_filters( 'jetpack_search_ai_answers_enabled', (bool) get_option( 'jetpack_search_ai_answers_enabled', false ) ) ) { + return $list; + } + $list[] = 'wp_guideline'; + return $list; + } + // Indexing functions for wp.com. /** - * * Check whether a postmeta or taxonomy 'key' is in the indexable * list. This is called by the indexing code on wp.com to decide * whether to include something in the index. diff --git a/projects/packages/sync/tests/php/modules/Search_Module_Test.php b/projects/packages/sync/tests/php/modules/Search_Module_Test.php new file mode 100644 index 000000000000..a84b24e37a29 --- /dev/null +++ b/projects/packages/sync/tests/php/modules/Search_Module_Test.php @@ -0,0 +1,74 @@ +search_module = new Modules\Search(); + } + + /** + * Runs after every test in this class. + */ + protected function tearDown(): void { + remove_filter( 'jetpack_search_ai_answers_enabled', '__return_true' ); + parent::tearDown(); + } + + /** + * The guideline CPT should NOT be in the whitelist when AI Answers is off (default). + */ + public function test_ai_cpts_not_in_whitelist_when_search_disabled() { + $list = apply_filters( 'jetpack_sync_post_types_whitelist', array() ); + $this->assertNotContains( 'wp_guideline', $list ); + $this->assertNotContains( 'jetpack_search_topic', $list ); + } + + /** + * The guideline CPT should be in the whitelist when AI Answers is enabled via filter. + */ + public function test_ai_cpts_in_whitelist_when_search_enabled() { + add_filter( 'jetpack_search_ai_answers_enabled', '__return_true' ); + $list = apply_filters( 'jetpack_sync_post_types_whitelist', array() ); + remove_filter( 'jetpack_search_ai_answers_enabled', '__return_true' ); + $this->assertContains( 'wp_guideline', $list ); + $this->assertNotContains( 'jetpack_search_topic', $list ); + } + + /** + * The guideline CPT should be in the whitelist when enabled via WP option only (no filter). + */ + public function test_ai_cpts_in_whitelist_when_option_set() { + update_option( 'jetpack_search_ai_answers_enabled', 1 ); + $list = apply_filters( 'jetpack_sync_post_types_whitelist', array() ); + delete_option( 'jetpack_search_ai_answers_enabled' ); + $this->assertContains( 'wp_guideline', $list ); + $this->assertNotContains( 'jetpack_search_topic', $list ); + } +} diff --git a/tools/e2e-commons/setup-specs/auth.setup.ts b/tools/e2e-commons/setup-specs/auth.setup.ts index 725d79f8b5eb..16a68dede552 100644 --- a/tools/e2e-commons/setup-specs/auth.setup.ts +++ b/tools/e2e-commons/setup-specs/auth.setup.ts @@ -35,13 +35,18 @@ setup( await setup.step( 'verify wordpress.com user authentication', async () => { // use the browser to ensure JS routing is executed const page = await authenticatedContext.newPage(); - await page.goto( 'https://wordpress.com/me' ); + await page.goto( 'https://wordpress.com/me', { waitUntil: 'load' } ); + + await expect + .soft( page, 'User remains on an authenticated WordPress.com page' ) + .toHaveURL( /wordpress\.com\/me/ ); + await expect .soft( - page.getByRole( 'link', { name: `Howdy, ${ dotComCredentials.username }` } ), - 'User is logged in and username is visible' + page.getByRole( 'link', { name: /log in/i } ), + 'Login link should not be visible for authenticated user' ) - .toBeVisible(); + .toHaveCount( 0 ); } ); } );