Skip to content

Commit abea71c

Browse files
committed
fix: resolve floating widget E2E failures — siteBuilder selectors and FreshInstallDetector
Root cause (discovered via Playwright trace analysis): 1. FreshInstallDetector::isFreshInstall() returns true in the wp-env test environment (only default 'Hello world!' post, 'Sample Page', and Twenty Twenty-Five theme). This causes FloatingWidget to set siteBuilderMode=true via wp_localize_script, which causes the JS to render SiteBuilderOverlay instead of the FAB. 2. SiteBuilderOverlay calls getSiteBuilderStep() and getSiteBuilderTotalSteps() selectors that were missing from the store, causing a TypeError that the ErrorBoundary caught — showing 'AI Agent widget encountered an unexpected error' instead of the FAB. 3. FloatingWidget.php was missing FreshInstallDetector and Settings imports (present in origin/main but not in this branch), causing the merge commit to use origin/main's version which still had the wp_ai_client_prompt guard. Fixes: - includes/Admin/FloatingWidget.php: align with origin/main structure (enqueue_assets_admin + enqueue_assets_frontend + enqueue_widget_assets, FreshInstallDetector + Settings imports) with guard removed so the mu-plugin stub satisfies it - includes/Core/FreshInstallDetector.php: add missing class (from origin/main) so FloatingWidget.php compiles without autoload errors - src/store/index.js: add siteBuilderMode/isFreshInstall/siteBuilderStep/ siteBuilderTotalSteps state, setSiteBuilderMode/Step/TotalSteps actions, isSiteBuilderMode/isFreshInstall/getSiteBuilderStep/getSiteBuilderTotalSteps selectors, and reducer cases — prevents TypeError in SiteBuilderOverlay - tests/mu-plugins/ai-agent-test-helpers.php: fix stub signature to match real wp_ai_client_prompt($prompt = null) — returns a fluent stub object so callers that chain methods do not throw fatal errors - Rebuild assets
1 parent bf69469 commit abea71c

12 files changed

Lines changed: 392 additions & 20 deletions

build/admin-page.asset.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<?php return array('dependencies' => array('react', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => 'bb3fc6c4057e11d88e3f');
1+
<?php return array('dependencies' => array('react', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '2bbe164a436a4bf78e7b');

build/admin-page.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

build/floating-widget.asset.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<?php return array('dependencies' => array('react', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '73cb08513dcf885ca046');
1+
<?php return array('dependencies' => array('react', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => 'e9508cb0edf5d1d33423');

build/floating-widget.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

build/settings-page.asset.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<?php return array('dependencies' => array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '2b9e7502f479d4d49eb1');
1+
<?php return array('dependencies' => array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n', 'wp-primitives'), 'version' => '32845b1e9445c767dd47');

build/settings-page.js

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

build/style-admin-page-rtl.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

build/style-admin-page.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

includes/Admin/FloatingWidget.php

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
declare(strict_types=1);
44
/**
5-
* Floating chat widget available on all admin pages.
5+
* Floating chat widget available on all admin pages and optionally on the frontend.
66
*
77
* Enqueues a lightweight React app that renders a FAB button
88
* and expandable chat panel in the bottom-right corner.
@@ -12,17 +12,21 @@
1212

1313
namespace GratisAiAgent\Admin;
1414

15+
use GratisAiAgent\Core\FreshInstallDetector;
16+
use GratisAiAgent\Core\Settings;
17+
1518
if ( ! defined( 'ABSPATH' ) ) {
1619
exit;
1720
}
1821

1922
class FloatingWidget {
2023

2124
/**
22-
* Register the admin_enqueue_scripts hook.
25+
* Register the admin_enqueue_scripts and (optionally) wp_enqueue_scripts hooks.
2326
*/
2427
public static function register(): void {
25-
add_action( 'admin_enqueue_scripts', [ __CLASS__, 'enqueue_assets' ] );
28+
add_action( 'admin_enqueue_scripts', [ __CLASS__, 'enqueue_assets_admin' ] );
29+
add_action( 'wp_enqueue_scripts', [ __CLASS__, 'enqueue_assets_frontend' ] );
2630
}
2731

2832
/**
@@ -32,7 +36,7 @@ public static function register(): void {
3236
*
3337
* @param string $hook_suffix The current admin page hook suffix.
3438
*/
35-
public static function enqueue_assets( string $hook_suffix ): void {
39+
public static function enqueue_assets_admin( string $hook_suffix ): void {
3640
// Skip the dedicated full-page admin page.
3741
if ( 'tools_page_' . AdminPage::SLUG === $hook_suffix ) {
3842
return;
@@ -47,6 +51,38 @@ public static function enqueue_assets( string $hook_suffix ): void {
4751
// The floating widget UI (FAB + panel) renders independently of the AI
4852
// client. The REST API handles provider availability at message-send time.
4953

54+
self::enqueue_widget_assets();
55+
}
56+
57+
/**
58+
* Enqueue the floating widget on frontend pages when enabled in settings.
59+
*
60+
* Only loads for logged-in users with manage_options capability.
61+
*/
62+
public static function enqueue_assets_frontend(): void {
63+
$settings = Settings::get();
64+
65+
// Only when the frontend display setting is enabled.
66+
if ( empty( $settings['show_on_frontend'] ) ) {
67+
return;
68+
}
69+
70+
// Only for users who can access the agent.
71+
if ( ! current_user_can( 'manage_options' ) ) {
72+
return;
73+
}
74+
75+
// Note: wp_ai_client_prompt() availability is NOT checked here.
76+
// The floating widget UI (FAB + panel) renders independently of the AI
77+
// client. The REST API handles provider availability at message-send time.
78+
79+
self::enqueue_widget_assets();
80+
}
81+
82+
/**
83+
* Shared asset enqueueing logic for both admin and frontend contexts.
84+
*/
85+
private static function enqueue_widget_assets(): void {
5086
$asset_file = GRATIS_AI_AGENT_DIR . '/build/floating-widget.asset.php';
5187

5288
if ( ! file_exists( $asset_file ) ) {
@@ -69,5 +105,46 @@ public static function enqueue_assets( string $hook_suffix ): void {
69105
$asset['version'],
70106
true
71107
);
108+
109+
// Detect fresh install and pass site-builder context to the widget.
110+
$is_fresh = FreshInstallDetector::isFreshInstall();
111+
$site_builder = (bool) Settings::get( 'site_builder_mode' );
112+
113+
// Auto-enable site_builder_mode on first detection of a fresh install.
114+
if ( $is_fresh && ! $site_builder ) {
115+
Settings::update( [ 'site_builder_mode' => true ] );
116+
$site_builder = true;
117+
}
118+
119+
wp_localize_script(
120+
'gratis-ai-agent-floating-widget',
121+
'gratisAiAgentSiteBuilder',
122+
[
123+
'isFreshInstall' => $is_fresh,
124+
'siteBuilderMode' => $site_builder,
125+
]
126+
);
127+
128+
// Pass white-label branding values to the widget (t075).
129+
$branding = Settings::get();
130+
wp_localize_script(
131+
'gratis-ai-agent-floating-widget',
132+
'gratisAiAgentBranding',
133+
array(
134+
'agentName' => (string) ( $branding['agent_name'] ?? '' ),
135+
'primaryColor' => (string) ( $branding['brand_primary_color'] ?? '' ),
136+
'textColor' => (string) ( $branding['brand_text_color'] ?? '' ),
137+
'logoUrl' => (string) ( $branding['brand_logo_url'] ?? '' ),
138+
'greetingMessage' => (string) ( $branding['greeting_message'] ?? '' ),
139+
)
140+
);
141+
142+
wp_localize_script(
143+
'gratis-ai-agent-floating-widget',
144+
'gratisAiAgentData',
145+
[
146+
'currentUserId' => get_current_user_id(),
147+
]
148+
);
72149
}
73150
}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* Detects whether the current WordPress installation is a fresh/empty site.
6+
*
7+
* A "fresh install" is defined as a site that has no meaningful user-created
8+
* content: no published posts beyond the default "Hello World" sample post,
9+
* no published pages beyond the default "Sample Page", and is still running
10+
* the default theme (Twenty* series). When all conditions are met the plugin
11+
* sets the `site_builder_mode` flag so the floating widget can open
12+
* automatically in expanded mode and guide the user through site setup.
13+
*
14+
* @package GratisAiAgent
15+
*/
16+
17+
namespace GratisAiAgent\Core;
18+
19+
if ( ! defined( 'ABSPATH' ) ) {
20+
exit;
21+
}
22+
23+
class FreshInstallDetector {
24+
25+
/**
26+
* Option name used to cache the fresh-install detection result.
27+
*
28+
* Stored as a transient so it is re-evaluated after content changes.
29+
*/
30+
const TRANSIENT_KEY = 'gratis_ai_agent_fresh_install';
31+
32+
/**
33+
* How long (in seconds) to cache the detection result.
34+
*
35+
* 5 minutes — short enough that adding real content quickly clears
36+
* site-builder mode, long enough to avoid per-request DB queries.
37+
*/
38+
const CACHE_TTL = 300;
39+
40+
/**
41+
* WordPress default post titles that ship with every fresh install.
42+
* Posts/pages whose titles match these are excluded from the "real content"
43+
* count.
44+
*/
45+
const DEFAULT_POST_TITLES = [
46+
'Hello world!',
47+
'Sample Page',
48+
'Privacy Policy',
49+
];
50+
51+
/**
52+
* Theme stylesheet slugs considered "default" WordPress themes.
53+
* A site still running one of these has not chosen a custom theme.
54+
*/
55+
const DEFAULT_THEME_SLUGS = [
56+
'twentytwentyfive',
57+
'twentytwentyfour',
58+
'twentytwentythree',
59+
'twentytwentytwo',
60+
'twentytwentyone',
61+
'twentytwenty',
62+
'twentynineteen',
63+
'twentyseventeen',
64+
'twentysixteen',
65+
'twentyfifteen',
66+
];
67+
68+
/**
69+
* Register hooks.
70+
*
71+
* Clears the cached detection result whenever content is published or
72+
* deleted so the widget state updates promptly.
73+
*/
74+
public static function register(): void {
75+
add_action( 'transition_post_status', [ __CLASS__, 'clearCache' ], 10, 3 );
76+
add_action( 'delete_post', [ __CLASS__, 'clearCache' ] );
77+
add_action( 'switch_theme', [ __CLASS__, 'clearCache' ] );
78+
}
79+
80+
/**
81+
* Clear the cached detection result.
82+
*
83+
* Accepts any number of arguments so it can be used as a hook callback
84+
* for actions with different signatures.
85+
*
86+
* @param mixed ...$args Ignored hook arguments.
87+
*/
88+
public static function clearCache( ...$args ): void {
89+
delete_transient( self::TRANSIENT_KEY );
90+
}
91+
92+
/**
93+
* Determine whether the current site qualifies as a fresh install.
94+
*
95+
* Returns true when ALL of the following are true:
96+
* - No published posts exist beyond the default "Hello world!" sample.
97+
* - No published pages exist beyond the default "Sample Page" and
98+
* "Privacy Policy" pages.
99+
* - The active theme is one of the built-in WordPress default themes.
100+
*
101+
* The result is cached in a short-lived transient to avoid repeated DB
102+
* queries on every admin page load.
103+
*
104+
* @return bool True when the site looks like a fresh install.
105+
*/
106+
public static function isFreshInstall(): bool {
107+
$cached = get_transient( self::TRANSIENT_KEY );
108+
109+
if ( false !== $cached ) {
110+
return (bool) $cached;
111+
}
112+
113+
$result = self::evaluate();
114+
115+
set_transient( self::TRANSIENT_KEY, $result ? '1' : '0', self::CACHE_TTL );
116+
117+
return $result;
118+
}
119+
120+
/**
121+
* Run the actual detection logic (no caching).
122+
*
123+
* @return bool True when the site looks like a fresh install.
124+
*/
125+
private static function evaluate(): bool {
126+
// Check for real published posts (post_type = post).
127+
if ( self::hasRealContent( 'post' ) ) {
128+
return false;
129+
}
130+
131+
// Check for real published pages (post_type = page).
132+
if ( self::hasRealContent( 'page' ) ) {
133+
return false;
134+
}
135+
136+
// Check whether the active theme is a default WordPress theme.
137+
if ( ! self::isDefaultTheme() ) {
138+
return false;
139+
}
140+
141+
return true;
142+
}
143+
144+
/**
145+
* Check whether any published content of the given post type exists beyond
146+
* the WordPress defaults.
147+
*
148+
* @param string $post_type Post type to query ('post' or 'page').
149+
* @return bool True when real (non-default) published content exists.
150+
*/
151+
private static function hasRealContent( string $post_type ): bool {
152+
$posts = get_posts(
153+
[
154+
'post_type' => $post_type,
155+
'post_status' => 'publish',
156+
'posts_per_page' => 20,
157+
'fields' => 'ids',
158+
'no_found_rows' => true,
159+
]
160+
);
161+
162+
if ( empty( $posts ) ) {
163+
return false;
164+
}
165+
166+
foreach ( $posts as $post_id ) {
167+
$title = get_the_title( (int) $post_id );
168+
if ( ! in_array( $title, self::DEFAULT_POST_TITLES, true ) ) {
169+
return true;
170+
}
171+
}
172+
173+
return false;
174+
}
175+
176+
/**
177+
* Check whether the active theme is one of the built-in WordPress defaults.
178+
*
179+
* @return bool True when the active theme is a default WordPress theme.
180+
*/
181+
private static function isDefaultTheme(): bool {
182+
$theme = wp_get_theme();
183+
$slug = $theme->get_stylesheet();
184+
185+
return in_array( $slug, self::DEFAULT_THEME_SLUGS, true );
186+
}
187+
188+
/**
189+
* Return a structured summary of the detection result for the REST API.
190+
*
191+
* @return array{is_fresh_install: bool, has_real_posts: bool, has_real_pages: bool, is_default_theme: bool, active_theme: string}
192+
*/
193+
public static function getStatus(): array {
194+
$has_real_posts = self::hasRealContent( 'post' );
195+
$has_real_pages = self::hasRealContent( 'page' );
196+
$is_default = self::isDefaultTheme();
197+
$is_fresh = ! $has_real_posts && ! $has_real_pages && $is_default;
198+
199+
return [
200+
'is_fresh_install' => $is_fresh,
201+
'has_real_posts' => $has_real_posts,
202+
'has_real_pages' => $has_real_pages,
203+
'is_default_theme' => $is_default,
204+
'active_theme' => wp_get_theme()->get_stylesheet(),
205+
];
206+
}
207+
}

0 commit comments

Comments
 (0)