-
-
+ 'surecontact' === option && userEmail ? (
+
+ ) : null;
+
+ const options = (
+ {
+ setOption( next );
+ setError( '' );
+ } }
+ localSite={ localSite }
+ // Locked while installing or creating the account: a change then
+ // would not stop what the click started.
+ disabled={ busy }
+ />
+ );
+
+ let title;
+ let description;
+ let body = null;
+ // Shown with the actions, so on a short screen the box that enables the
+ // button is never scrolled out of view behind them.
+ let consentBox = null;
+ let continueProps;
+ let skipProps;
+ // The option cards replace the illustration: together they push the
+ // buttons below the fold on a laptop screen.
+ let showsOptions = false;
+
+ // A sign-up that succeeded is connected even if reading the status back
+ // straight after failed: offering "Create account" again would only meet
+ // SureMail's "already connected" error. Only then: a later "connect"
+ // means the connection was removed in SureMail.
+ const shownView =
+ 'created' === provision && unreadAfterSignUp && 'connect' === view
+ ? 'limited'
+ : view;
+ // The one-click sign-up worked: show "You're all set" in place of the
+ // status SureForms reads back.
+ const connected =
+ ( 'created' === provision || signedIn ) &&
+ ! [ 'install', 'connect', 'attention' ].includes( shownView );
+ // A new account starts unverified: SureContact accepts only a few emails a
+ // day until the owner confirms the address.
+ const needsVerify = connected && 'limited' === status?.state;
+
+ switch ( shownView ) {
+ case 'install':
+ title = __( 'Make Sure Your Emails Get Delivered', 'sureforms' );
+ description = __(
+ 'WordPress emails can sometimes fail to reach the inbox when they rely on the default mail setup. Using a reliable mail service helps improve delivery and makes your emails more dependable.',
+ 'sureforms'
+ );
+ if ( false === status?.can_install ) {
+ body = (
+
+ { __(
+ 'Your account cannot install plugins. Ask a site administrator to set up email delivery.',
+ 'sureforms'
+ ) }
+
+ );
+ continueProps = {
+ onClick: () => leave( 'cannot_install', { skipped: true } ),
+ text: __( 'Continue', 'sureforms' ),
+ };
+ } else {
+ showsOptions = true;
+ body = options;
+ consentBox = consent( true );
+ continueProps = {
+ onClick: handleInstall,
+ text: installLabel(),
+ // No address to create the account for: SureMail's own
+ // screens can still connect SureContact.
+ disabled:
+ 'surecontact' === option && Boolean( userEmail ) && ! consented,
+ };
+ }
+ skipProps = {
+ onClick: () => leave( 'skip', { skipped: true } ),
+ };
+ break;
+
+ case 'connect':
+ if ( 'already_registered' === provision ) {
+ // An existing account is never connected by email alone: anyone
+ // who knows an address could attach its account to their site, so
+ // the owner signs in once, in a window this step opens straight on
+ // SureContact's sign-in and closes when SureMail has the connection.
+ title = __(
+ 'You Already Have a SureContact Account',
+ 'sureforms'
+ );
+ description = signingIn
+ ? __(
+ 'Sign in to SureContact in the window that opened. This step finishes by itself once you are signed in.',
'sureforms'
- ) }
- />
-
+ )
+ : signInTabOpened
+ ? __(
+ 'Sign in to SureContact in the new tab. Come back here when done; this step updates by itself.',
+ 'sureforms'
+ )
+ : openedSureMail
+ ? __(
+ 'In the SureMail tab, click "Already have a SureContact account? Connect it instead" and sign in. Come back here when done; this step updates by itself.',
+ 'sureforms'
+ )
+ : sprintf(
+ /* translators: %s: the current user's email address. */
+ __(
+ '%s already has a SureContact account. Sign in once to connect it to this site.',
+ 'sureforms'
+ ),
+ userEmail
+ );
+ // Accounts made by the one-click sign-up have no password yet.
+ body = (
+ <>
+ { blockedSignIn && (
+
+ { __(
+ 'Your browser blocked the sign-in window.',
+ 'sureforms'
+ ) }{ ' ' }
+ {
+ if ( blockedSignIn.viaSureMail ) {
+ setOpenedSureMail( true );
+ } else {
+ setSignInTabOpened( true );
+ }
+ setBlockedSignIn( null );
+ } }
+ >
+ { blockedSignIn.viaSureMail
+ ? __( 'Open SureMail', 'sureforms' )
+ : __(
+ 'Open the SureContact sign-in',
+ 'sureforms'
+ ) }
+
+
+ ) }
+
+ { __(
+ 'Never set a password? Use "Forgot password" on the SureContact sign-in page, or continue with Google.',
+ 'sureforms'
+ ) }
+
+ >
+ );
+ continueProps = {
+ onClick: signInToSureContact,
+ text: signingIn
+ ? __( 'Waiting for Sign-In…', 'sureforms' )
+ : __( 'Sign In to SureContact', 'sureforms' ),
+ icon:
,
+ };
+ skipProps = {
+ onClick: () => leave( 'skip', { skipped: true } ),
+ text: __( 'Skip for now', 'sureforms' ),
+ };
+ break;
+ }
-
+ );
+ showsOptions = true;
+ body = options;
+ consentBox = consent( false );
+ if ( 'other_smtp' === option ) {
+ continueProps = {
+ onClick: openSureMail,
+ text: __( 'Set Up in SureMail', 'sureforms' ),
+ icon:
,
+ };
+ } else if ( canProvision( status ) && 'unavailable' !== provision ) {
+ continueProps = {
+ onClick: provisionSureContact,
+ disabled: ! consented,
+ text: busy
+ ? progressLabel()
+ : __( 'Connect SureContact', 'sureforms' ),
+ };
+ } else {
+ // SureMail too old for the one-click sign-up, or no address to
+ // create the account for: its own screens connect SureContact.
+ continueProps = {
+ onClick: openSureMail,
+ text: __( 'Connect in SureMail', 'sureforms' ),
+ icon:
,
+ };
+ }
+ skipProps = {
+ onClick: () => leave( 'skip', { skipped: true } ),
+ text: __( 'Skip for now', 'sureforms' ),
+ };
+ break;
-
+ );
+ body = (
+
+ );
+ } else {
+ title = __( 'Verify Your SureContact Account', 'sureforms' );
+ description =
+ status?.message ||
+ __(
+ 'SureContact is connected, but only a few emails a day are accepted until you verify your account email.',
+ 'sureforms'
+ );
+ }
+ continueProps = {
+ onClick: () => leave( 'continue_limited' ),
+ text: __( 'Continue', 'sureforms' ),
+ };
+ skipProps = connected
+ ? { onClick: () => leave( 'continue_limited' ) }
+ : {
+ onClick: openSureMail,
+ text: __( 'Finish in SureMail', 'sureforms' ),
+ icon:
,
+ };
+ break;
+
+ case 'attention':
+ // A notification failed and the fix is not to set up or connect
+ // SureMail: say so, and offer that fix (logs, or verifying SureContact).
+ title = status?.title || __( 'Email Delivery', 'sureforms' );
+ description = status?.message || '';
+ continueProps = {
+ onClick: () => leave( 'continue_attention' ),
+ text: __( 'Continue', 'sureforms' ),
+ };
+ skipProps = status?.action?.url
+ ? {
+ onClick: openSureMail,
+ text: status.action.label,
+ icon:
,
+ }
+ : {};
+ break;
+
+ default:
+ if ( connected ) {
+ title = __( "You're all set", 'sureforms' );
+ description = __(
+ 'SureMail is installed and SureContact is connected. Your forms can send email right now.',
+ 'sureforms'
+ );
+ body = (
+
+ );
+ } else {
+ title = status?.title || __( 'Email Delivery', 'sureforms' );
+ description = status?.message || '';
+ }
+ continueProps = {
+ onClick: () => leave( getDoneAction( status ) ),
+ text: __( 'Continue', 'sureforms' ),
+ };
+ skipProps = connected
+ ? { onClick: () => leave( getDoneAction( status ) ) }
+ : {};
+ }
- { installError && (
-
- { installError }
-
+ return (
+
+ { ! showsOptions && ! connected && (
+
+
+
) }
-
-
-
+ { connected ? (
+
+
+
+
+ ) : (
+
+ ) }
+ { /* The step changes in place (installing, connected); announce it. */ }
+
+ { title }
+
+
+ { body }
+
+ { /* Sticky so the actions, the consent that enables them and any
+ error stay in view on short laptop screens. */ }
+ { /* !-mb-6: the parent's space-y also sets margin-bottom and would win. */ }
+
+ { ( consentBox || error ) && (
+
+ { consentBox }
+ { error && (
+
+ { error }
+
+ ) }
+
+ ) }
+
+
+
+
);
};
diff --git a/src/admin/onboarding/test/email-delivery-view.test.js b/src/admin/onboarding/test/email-delivery-view.test.js
new file mode 100644
index 000000000..a40fbf97c
--- /dev/null
+++ b/src/admin/onboarding/test/email-delivery-view.test.js
@@ -0,0 +1,307 @@
+/* eslint-env jest */
+/**
+ * getView() decides which screen the onboarding email step shows. A failure
+ * must never fall through to the "done" screen, which is how an owner was
+ * told "Continue" with no way to fix a failing site.
+ */
+import {
+ getView,
+ getDoneAction,
+ getProvisionFailure,
+ isSiteRefused,
+ isRateLimited,
+ readProvisionError,
+ isLikelyLocalHost,
+ getDefaultOption,
+ canCreateAccount,
+ getSureContactSignInUrl,
+} from '../pages/email-delivery-view';
+
+const status = ( state, action = 'none' ) => ( {
+ state,
+ action: { type: action },
+} );
+
+describe( 'getView', () => {
+ it.each( [
+ [ status( 'not_configured', 'install_activate' ), 'install' ],
+ [ status( 'not_configured', 'activate' ), 'install' ],
+ [ status( 'attention', 'install_activate' ), 'install' ],
+ [ status( 'suremail_no_connection', 'open_suremail' ), 'connect' ],
+ [ status( 'attention', 'open_suremail' ), 'connect' ],
+ [ status( 'limited', 'open_suremail' ), 'limited' ],
+ // Without an action to match first, only the state check sends it here.
+ [ status( 'limited', 'none' ), 'limited' ],
+ [ status( 'attention', 'view_logs' ), 'attention' ],
+ [ status( 'attention', 'verify_surecontact' ), 'attention' ],
+ [ status( 'attention', 'none' ), 'attention' ],
+ [ status( 'ready' ), 'done' ],
+ [ status( 'other_mailer' ), 'done' ],
+ ] )( 'maps %o to %s', ( input, view ) => {
+ expect( getView( input ) ).toBe( view );
+ } );
+
+ it( 'offers no install when promotions are hidden (no action)', () => {
+ expect( getView( status( 'not_configured', 'none' ) ) ).toBe( 'done' );
+ } );
+
+ it( 'treats a missing status as done', () => {
+ expect( getView( null ) ).toBe( 'done' );
+ } );
+} );
+
+describe( 'getDoneAction', () => {
+ it.each( [
+ [ 'ready', 'continue_ready' ],
+ [ 'other_mailer', 'continue_other_mailer' ],
+ [ 'not_configured', 'continue_not_set_up' ],
+ ] )( 'records %s as %s', ( state, action ) => {
+ expect( getDoneAction( status( state ) ) ).toBe( action );
+ } );
+} );
+
+describe( 'getProvisionFailure', () => {
+ it( 'sends an existing account to sign in, never connects it', () => {
+ expect(
+ getProvisionFailure( {
+ success: false,
+ reason: 'email_already_registered',
+ } )
+ ).toBe( 'already_registered' );
+ } );
+
+ it( 'hands over to SureMail when the route is missing', () => {
+ expect( getProvisionFailure( { code: 'rest_no_route' } ) ).toBe(
+ 'unavailable'
+ );
+ } );
+
+ it.each( [ [ {} ], [ null ], [ { reason: 'site_unreachable' } ] ] )(
+ 'treats %o as a failure',
+ ( error ) => {
+ expect( getProvisionFailure( error ) ).toBe( 'failed' );
+ }
+ );
+} );
+
+describe( 'isSiteRefused', () => {
+ it.each( [
+ [ { reason: 'site_unreachable' }, true ],
+ [ { reason: 'site_not_verified' }, true ],
+ [
+ {
+ message:
+ 'site_url: The site url must not point to an internal or private network address.',
+ },
+ true,
+ ],
+ [ { reason: 'email_already_registered' }, false ],
+ [ null, false ],
+ ] )( '%o → %s', ( error, refused ) => {
+ expect( isSiteRefused( error ) ).toBe( refused );
+ } );
+} );
+
+describe( 'isLikelyLocalHost', () => {
+ it.each( [
+ 'localhost',
+ 'my-site.test',
+ 'shop.local',
+ 'wp.localhost',
+ '127.0.0.1',
+ '10.0.0.12',
+ '192.168.1.20',
+ '172.16.0.1',
+ '172.31.255.255',
+ '169.254.10.10',
+ '[::1]',
+ '',
+ '0.0.0.0',
+ '100.64.1.2',
+ '100.127.0.1',
+ 'fd12:3456::1',
+ 'fe80::1',
+ '::ffff:127.0.0.1',
+ ] )( 'treats %p as local', ( host ) => {
+ expect( isLikelyLocalHost( host ) ).toBe( true );
+ } );
+
+ it.each( [
+ 'example.com',
+ 'accessible-aristide-0ygqd.zipwp.link',
+ '172.32.0.1',
+ '8.8.8.8',
+ 'test.example.org',
+ 'localhost.example.com',
+ '100.128.0.1',
+ '2606:4700::6810:84e5',
+ '::ffff:8.8.8.8',
+ ] )( 'treats %p as public', ( host ) => {
+ expect( isLikelyLocalHost( host ) ).toBe( false );
+ } );
+} );
+
+describe( 'getDefaultOption', () => {
+ it( 'opens SureContact on a public site', () => {
+ expect( getDefaultOption( null, false ) ).toBe( 'surecontact' );
+ } );
+
+ it( 'opens another provider on a local site', () => {
+ expect( getDefaultOption( null, true ) ).toBe( 'other_smtp' );
+ } );
+
+ it( 'opens another provider when an active SureMail cannot connect SureContact', () => {
+ expect(
+ getDefaultOption(
+ {
+ suremail: {
+ plugin_status: 'Activated',
+ supports_surecontact: false,
+ },
+ },
+ false
+ )
+ ).toBe( 'other_smtp' );
+ } );
+
+ // Found on the QA site: an inactive SureMail reports false because its
+ // version cannot be read, which opened Other SMTP on a public site.
+ it( 'ignores the support flag while SureMail is not active', () => {
+ expect(
+ getDefaultOption(
+ {
+ suremail: {
+ plugin_status: 'Installed',
+ supports_surecontact: false,
+ },
+ },
+ false
+ )
+ ).toBe( 'surecontact' );
+ } );
+
+ it( 'treats an unknown SureMail version as able', () => {
+ expect( getDefaultOption( { suremail: {} }, false ) ).toBe(
+ 'surecontact'
+ );
+ } );
+} );
+
+describe( 'canCreateAccount', () => {
+ const ok = { consented: true, option: 'surecontact', email: 'a@b.co' };
+
+ it( 'allows it with consent, SureContact open and an address', () => {
+ expect( canCreateAccount( ok ) ).toBe( true );
+ } );
+
+ // Vansh's review: tick, click, then untick or switch while SureMail installs.
+ it( 'refuses once consent is withdrawn', () => {
+ expect( canCreateAccount( { ...ok, consented: false } ) ).toBe( false );
+ } );
+
+ it( 'refuses once another option is open', () => {
+ expect( canCreateAccount( { ...ok, option: 'other_smtp' } ) ).toBe(
+ false
+ );
+ } );
+
+ it( 'refuses without an address', () => {
+ expect( canCreateAccount( { ...ok, email: '' } ) ).toBe( false );
+ } );
+} );
+
+describe( 'getSureContactSignInUrl', () => {
+ it( 'opens SureMail on its SureContact screen', () => {
+ expect(
+ getSureContactSignInUrl( {
+ action: {
+ url: 'https://x.test/wp-admin/options-general.php?page=suremail#/onboarding/connection',
+ },
+ } )
+ ).toBe(
+ 'https://x.test/wp-admin/options-general.php?page=suremail#/onboarding/connect'
+ );
+ } );
+
+ it( 'falls back to the setup link and adds the screen', () => {
+ expect(
+ getSureContactSignInUrl( {
+ links: { setup: 'https://x.test/wp-admin/admin.php?page=suremail' },
+ } )
+ ).toBe( 'https://x.test/wp-admin/admin.php?page=suremail#/onboarding/connect' );
+ } );
+
+ it( 'returns nothing without a SureMail link', () => {
+ expect( getSureContactSignInUrl( null ) ).toBe( '' );
+ } );
+} );
+
+describe( 'isRateLimited', () => {
+ it( 'spots an HTTP 429 from SureMail, whatever the language', () => {
+ expect(
+ isRateLimited( {
+ status: 429,
+ success: false,
+ message: 'Demasiadas solicitudes de aprovisionamiento.',
+ reason: '',
+ } )
+ ).toBe( true );
+ expect( isRateLimited( { data: { status: 429 } } ) ).toBe( true );
+ } );
+
+ it( 'spots a rate-limit reason', () => {
+ expect( isRateLimited( { status: 400, reason: 'too_many_requests' } ) ).toBe(
+ true
+ );
+ expect( isRateLimited( { reason: 'rate_limited' } ) ).toBe( true );
+ } );
+
+ it( 'leaves other failures alone, including other "too many" messages', () => {
+ expect(
+ isRateLimited( {
+ status: 502,
+ message: 'Too many redirects.',
+ reason: '',
+ } )
+ ).toBe( false );
+ expect(
+ isRateLimited( { status: 409, reason: 'email_already_registered' } )
+ ).toBe( false );
+ expect( isRateLimited( {} ) ).toBe( false );
+ } );
+} );
+
+describe( 'readProvisionError', () => {
+ it( 'keeps the HTTP status next to SureMail\'s error body', async () => {
+ const response = {
+ status: 429,
+ json: () =>
+ Promise.resolve( {
+ success: false,
+ message: 'Too many provisioning requests.',
+ reason: '',
+ } ),
+ };
+ await expect( readProvisionError( response ) ).resolves.toEqual( {
+ success: false,
+ message: 'Too many provisioning requests.',
+ reason: '',
+ status: 429,
+ } );
+ } );
+
+ it( 'keeps the status when the body is not JSON', async () => {
+ const response = {
+ status: 502,
+ json: () => Promise.reject( new Error( 'not json' ) ),
+ };
+ await expect( readProvisionError( response ) ).resolves.toEqual( {
+ status: 502,
+ } );
+ } );
+
+ it( 'passes a network error through', async () => {
+ const error = { code: 'fetch_error', message: 'You are probably offline.' };
+ await expect( readProvisionError( error ) ).resolves.toBe( error );
+ } );
+} );
diff --git a/src/admin/settings/pages/MCP.js b/src/admin/settings/pages/MCP.js
index e89c41cc8..79daa2d77 100644
--- a/src/admin/settings/pages/MCP.js
+++ b/src/admin/settings/pages/MCP.js
@@ -1,11 +1,12 @@
import { __ } from '@wordpress/i18n';
-import { useState } from '@wordpress/element';
+import { createInterpolateElement, useState } from '@wordpress/element';
import { Badge, Button, Select, Switch } from '@bsf/force-ui';
import { CopyCheckIcon, CopyIcon, Download } from 'lucide-react';
import ContentSection from '../components/ContentSection';
-const MCP_ADAPTER_DOWNLOAD_URL =
- 'https://github.com/WordPress/mcp-adapter/releases/latest';
+const MCP_ADAPTER_SLUG = 'mcp-adapter';
+const MCP_ADAPTER_INIT = 'mcp-adapter/mcp-adapter.php';
+const MCP_ADAPTER_PLUGIN_URL = 'https://wordpress.org/plugins/mcp-adapter/';
const CopyButton = ( { textToCopy, isCopied, setIsCopied } ) => (
{
const McpAdapterPrompt = ( { adapterStatus: initialStatus } ) => {
const [ status, setStatus ] = useState( initialStatus );
+ const [ error, setError ] = useState( '' );
const isInstalled = 'installed' === status;
+ const isBusy = 'installing' === status || 'activating' === status;
+ // install_plugins is not granted on every site (multisite subsites, or a
+ // DISALLOW_FILE_MODS install). Activation only needs manage_options (see
+ // Admin_Ajax::required_plugin_activate), so an adapter already on disk can
+ // still be activated without it.
+ const canInstall = !! srfm_admin?.can_install_plugins;
+ const canSetUp = isInstalled || canInstall;
+
+ const postAction = ( body ) =>
+ wp.apiFetch( {
+ url: srfm_admin.ajax_url,
+ method: 'POST',
+ body,
+ } );
+
+ const installAdapter = async () => {
+ const formData = new window.FormData();
+ formData.append( 'action', 'sureforms_recommended_plugin_install' );
+ formData.append( '_ajax_nonce', srfm_admin.plugin_installer_nonce );
+ formData.append( 'slug', MCP_ADAPTER_SLUG );
+
+ const response = await postAction( formData );
+ if ( ! response.success ) {
+ throw new Error(
+ response?.data?.errorMessage ||
+ __( 'The plugin could not be installed.', 'sureforms' )
+ );
+ }
+ };
const activateAdapter = async () => {
- setStatus( 'activating' );
const formData = new window.FormData();
formData.append( 'action', 'sureforms_recommended_plugin_activate' );
formData.append(
@@ -246,102 +276,118 @@ const McpAdapterPrompt = ( { adapterStatus: initialStatus } ) => {
srfm_admin.sfPluginManagerNonce ??
srfm_admin.sf_plugin_manager_nonce
);
- formData.append( 'init', 'mcp-adapter/mcp-adapter.php' );
- formData.append( 'slug', 'mcp-adapter' );
+ formData.append( 'init', MCP_ADAPTER_INIT );
+ formData.append( 'slug', MCP_ADAPTER_SLUG );
- try {
- const response = await wp.apiFetch( {
- url: srfm_admin.ajax_url,
- method: 'POST',
- body: formData,
- } );
+ const response = await postAction( formData );
+ if ( ! response.success ) {
+ throw new Error(
+ response?.data?.message ||
+ __( 'The plugin could not be activated.', 'sureforms' )
+ );
+ }
+ };
- if ( response.success ) {
- setStatus( 'active' );
- window.location.reload();
- } else {
- setStatus( 'installed' );
+ const setUpAdapter = async () => {
+ setError( '' );
+ try {
+ if ( ! isInstalled ) {
+ setStatus( 'installing' );
+ await installAdapter();
}
- } catch {
- setStatus( 'installed' );
+ setStatus( 'activating' );
+ await activateAdapter();
+ setStatus( 'active' );
+ window.location.reload();
+ } catch ( e ) {
+ setError(
+ e?.message ||
+ __( 'Something went wrong. Please try again.', 'sureforms' )
+ );
+ // 'activating' is only reached once the plugin is on disk, so a
+ // retry must activate rather than install it a second time.
+ setStatus( ( prev ) =>
+ 'activating' === prev ? 'installed' : 'not_installed'
+ );
}
};
+ const buttonLabel = () => {
+ if ( 'installing' === status ) {
+ return __( 'Installing…', 'sureforms' );
+ }
+ if ( 'activating' === status ) {
+ return __( 'Activating…', 'sureforms' );
+ }
+ return isInstalled
+ ? __( 'Activate MCP Adapter', 'sureforms' )
+ : __( 'Install & Activate MCP Adapter', 'sureforms' );
+ };
+
+ const introText = () => {
+ if ( isInstalled ) {
+ return __(
+ 'The MCP Adapter plugin is installed but not active. Activate it to configure MCP settings.',
+ 'sureforms'
+ );
+ }
+ return canInstall
+ ? __(
+ 'The MCP Adapter plugin is required to connect AI clients to your forms. It is free on WordPress.org and can be installed from here.',
+ 'sureforms'
+ )
+ : __(
+ 'The MCP Adapter plugin is required to connect AI clients to your forms. It is free on WordPress.org.',
+ 'sureforms'
+ );
+ };
+
return (
-
- { isInstalled
- ? __(
- 'The MCP Adapter plugin is installed but not active. Activate it to configure MCP settings.',
- 'sureforms'
- )
- : __(
- 'The MCP Adapter plugin is required to connect AI clients to your forms. Download and install it from GitHub, then activate it.',
- 'sureforms'
- ) }
-
- { ! isInstalled && (
-
-
- { __(
- 'Download the latest release from',
- 'sureforms'
- ) }{ ' ' }
-
- { __( 'GitHub', 'sureforms' ) }
-
- .
-
-
- { __(
- 'Install the plugin via Plugins > Add New Plugin > Upload Plugin.',
- 'sureforms'
- ) }
-
-
- { __(
- 'Activate the MCP Adapter plugin.',
- 'sureforms'
- ) }
-
-
- ) }
-
- { isInstalled ? (
+
{ introText() }
+ { canSetUp ? (
+
+ }
+ iconPosition="left"
>
- { 'activating' === status
- ? __( 'Activating…', 'sureforms' )
- : __( 'Activate MCP Adapter', 'sureforms' ) }
+ { buttonLabel() }
- ) : (
- <>
- }
- iconPosition="left"
- tag="a"
- href={ MCP_ADAPTER_DOWNLOAD_URL }
- target="_blank"
- rel="noopener noreferrer"
- >
- { __( 'Download MCP Adapter', 'sureforms' ) }
-
- >
- ) }
-
+
+ ) : (
+
+ { createInterpolateElement(
+ __(
+ 'You do not have permission to install plugins on this site. Ask an administrator to install MCP Adapter .',
+ 'sureforms'
+ ),
+ {
+ a: (
+
+ { __( 'MCP Adapter', 'sureforms' ) }
+
+ ),
+ }
+ ) }
+
+ ) }
+ { error && (
+
+ { error }
+
+ ) }
);
};
diff --git a/src/admin/shared-components/compliance/ComplianceFields.js b/src/admin/shared-components/compliance/ComplianceFields.js
index 1bb350215..f2677600b 100644
--- a/src/admin/shared-components/compliance/ComplianceFields.js
+++ b/src/admin/shared-components/compliance/ComplianceFields.js
@@ -1,6 +1,23 @@
-import { __ } from '@wordpress/i18n';
+import { __, sprintf } from '@wordpress/i18n';
import { useState, useEffect } from '@wordpress/element';
-import { Input, Switch, Label, Container } from '@bsf/force-ui';
+import { Alert, Input, Switch, Label, Container, Text } from '@bsf/force-ui';
+import { AlertTriangle } from 'lucide-react';
+
+// States in which a mailer is known to be carrying wp_mail(). Anything else
+// means a failed notification is likely, and on a form that stores no entries
+// that failure loses the submission.
+const EMAIL_DELIVERY_OK_STATES = [ 'ready', 'other_mailer' ];
+
+/**
+ * Email delivery status localised for this screen: srfm_block_data in the form
+ * editor, srfm_admin on the global settings page.
+ *
+ * @return {Object|null} Status, or null when not provided.
+ */
+const getEmailDeliveryStatus = () =>
+ window?.srfm_block_data?.email_delivery ||
+ window?.srfm_admin?.email_delivery ||
+ null;
/**
* Debounced Number Input for Compliance
@@ -97,12 +114,55 @@ const ComplianceFields = ( { context, values, onChange } ) => {
},
];
+ // With entries never stored, the notification email is the only copy of each
+ // submission. Say so, and say it louder when email delivery is not set up.
+ const neverStores = !! ( values?.gdpr && values?.do_not_store_entries );
+ const emailDelivery = neverStores ? getEmailDeliveryStatus() : null;
+ const emailAtRisk =
+ !! emailDelivery?.state &&
+ ! EMAIL_DELIVERY_OK_STATES.includes( emailDelivery.state );
+
// Determine if auto_delete_days input should be shown.
const showAutoDeleteDays =
values?.auto_delete_entries &&
! values?.do_not_store_entries &&
values?.gdpr;
+ // On the global settings page this is the default for new forms, not one
+ // form, so the wording says so.
+ const isGlobal = 'global' === context;
+ const deliveryTitle = emailDelivery?.title || '';
+ let neverStoreWarning;
+ if ( emailAtRisk ) {
+ neverStoreWarning = isGlobal
+ ? sprintf(
+ /* translators: %s: current email delivery status, e.g. "Email delivery is not set up". */
+ __(
+ 'New forms will not store entries by default, so if a notification email is not sent, that submission is gone. Email delivery on this site needs attention first: %s.',
+ 'sureforms'
+ ),
+ deliveryTitle
+ )
+ : sprintf(
+ /* translators: %s: current email delivery status, e.g. "Email delivery is not set up". */
+ __(
+ 'This form will not store entries, so if a notification email is not sent, that submission is gone. Email delivery on this site needs attention first: %s.',
+ 'sureforms'
+ ),
+ deliveryTitle
+ );
+ } else {
+ neverStoreWarning = isGlobal
+ ? __(
+ 'New forms will not store entries by default. If a notification email ever fails to send, that submission cannot be recovered.',
+ 'sureforms'
+ )
+ : __(
+ 'This form will not store entries. If a notification email ever fails to send, that submission cannot be recovered.',
+ 'sureforms'
+ );
+ }
+
return (
{ switches.map(
@@ -116,6 +176,28 @@ const ComplianceFields = ( { context, values, onChange } ) => {
/>
)
) }
+ { neverStores && (
+
+ { neverStoreWarning }
+
+ }
+ icon={ }
+ />
+ ) }
{ showAutoDeleteDays && (
{
+ const status =
+ typeof srfm_block_data === 'undefined'
+ ? null
+ : srfm_block_data?.email_delivery;
+
+ if ( ! status?.state ) {
+ return null;
+ }
+
+ const ok = OK_STATES.includes( status.state );
+ const actionUrl = [
+ 'open_suremail',
+ 'view_logs',
+ 'verify_surecontact',
+ ].includes( status.action?.type )
+ ? status.action.url
+ : '';
+ // Verifying is the fix, but SureMail's logs still show the exact reason.
+ const logsUrl =
+ 'verify_surecontact' === status.action?.type ? status.links?.logs : '';
+
+ let message = status.message || '';
+ if ( 'ready' === status.state && 'suremail' === status.mailer?.slug ) {
+ message +=
+ ' ' +
+ __(
+ 'SureMail sends from the address set on its connection, so the From address set in these notifications may be replaced.',
+ 'sureforms'
+ );
+ } else if ( 'not_configured' === status.state ) {
+ message +=
+ ' ' +
+ __(
+ 'You can set it up from the SureForms dashboard.',
+ 'sureforms'
+ );
+ }
+
+ return (
+ : }
+ content={
+
+ { message }
+ { !! actionUrl && (
+
+ { status.action.label }
+
+ ) }
+ { !! logsUrl && (
+
+ { __( 'View SureMail logs', 'sureforms' ) }
+
+ ) }
+
+ }
+ />
+ );
+};
+
+export default EmailDeliveryStatus;
diff --git a/src/admin/single-form-settings/components/email-settings/EmailNotification.js b/src/admin/single-form-settings/components/email-settings/EmailNotification.js
index ef4ac78f6..147216e58 100644
--- a/src/admin/single-form-settings/components/email-settings/EmailNotification.js
+++ b/src/admin/single-form-settings/components/email-settings/EmailNotification.js
@@ -1,5 +1,6 @@
import { __ } from '@wordpress/i18n';
import EmailConfirmation from './EmailConfirmation';
+import EmailDeliveryStatus from './EmailDeliveryStatus';
import { useEffect, useState, forwardRef } from '@wordpress/element';
import {
srfmEditFormMeta,
@@ -289,6 +290,7 @@ const EmailNotification = ( {
showSaveButton={ false }
tabId="email_notification"
>
+
{ headerContent.map( ( header, index ) => (
diff --git a/src/blocks/multi-choice/edit.js b/src/blocks/multi-choice/edit.js
index c321751ac..e8d0f8613 100644
--- a/src/blocks/multi-choice/edit.js
+++ b/src/blocks/multi-choice/edit.js
@@ -71,13 +71,9 @@ const Edit = ( props ) => {
const prevSingleSelection = useRef( singleSelection );
const deleteOption = ( index ) => {
- const deleteOptions = options.map( ( item, thisIndex ) => {
- if ( index === thisIndex ) {
- options.splice( index, 1 );
- item = { options };
- }
- return item;
- } );
+ const newOptions = options.filter(
+ ( _, thisIndex ) => thisIndex !== index
+ );
// Update preselected options when an option is deleted
const newPreselected = preselectedOptions
@@ -85,7 +81,7 @@ const Edit = ( props ) => {
.map( ( i ) => ( i > index ? i - 1 : i ) ); // Adjust indices
setAttributes( {
- deleteOptions,
+ options: newOptions,
preselectedOptions: newPreselected,
} );
};
diff --git a/src/srfm-controls/addInitialAttr.js b/src/srfm-controls/addInitialAttr.js
index cb8c91063..9a21b9fbe 100644
--- a/src/srfm-controls/addInitialAttr.js
+++ b/src/srfm-controls/addInitialAttr.js
@@ -65,6 +65,23 @@ const addInitialAttr = ( ChildComponent ) => {
attributeObject.slug = '';
}
+ // Give the duplicated block its own copy of every array attribute
+ // (one level: arrays and plain objects inside them are copied,
+ // anything deeper is still shared) so editing one block's arrays,
+ // e.g. options in multi-choice, cannot change the other block.
+ Object.entries( attributes ).forEach( ( [ key, value ] ) => {
+ if ( Array.isArray( value ) ) {
+ attributeObject[ key ] = value.map( ( item ) => {
+ if ( Array.isArray( item ) ) {
+ return [ ...item ];
+ }
+ return typeof item === 'object' && item !== null
+ ? { ...item }
+ : item;
+ } );
+ }
+ } );
+
// Only copy conditional logic if pro is active (action is registered).
if ( hasAction( 'srfm.duplicateBlock' ) ) {
doAction( 'srfm.duplicateBlock', block_id, newBlockId );
diff --git a/src/utils/Helpers.js b/src/utils/Helpers.js
index 7a66d73ed..aca87e324 100644
--- a/src/utils/Helpers.js
+++ b/src/utils/Helpers.js
@@ -68,7 +68,8 @@ export const handleAddNewPost = async (
templateMetas,
isConversational = false,
formType = '',
- onError
+ onError,
+ extraData = {}
) => {
const reportError = ( message ) => {
if ( typeof onError === 'function' ) {
@@ -95,6 +96,8 @@ export const handleAddNewPost = async (
'X-WP-Nonce': srfm_admin.template_picker_nonce,
},
data: {
+ // First, so extra data can't overwrite the form itself.
+ ...extraData,
form_data: formData,
template_name: templateName,
template_metas: templateMetas,
@@ -1408,3 +1411,16 @@ export function useShouldIframe() {
);
}, [] );
}
+
+/**
+ * Free emails a new SureContact account comes with, as localized by the
+ * server (Status::get_offer()); 0 hides the offer.
+ *
+ * @since 2.12.9
+ * @return {number} Free emails.
+ */
+export const getFreeEmails = () =>
+ Math.max(
+ 0,
+ Number( window.srfm_admin?.email_delivery_offer?.free_emails ?? 0 ) || 0
+ );
diff --git a/src/utils/suremail-install.js b/src/utils/suremail-install.js
new file mode 100644
index 000000000..bcff39059
--- /dev/null
+++ b/src/utils/suremail-install.js
@@ -0,0 +1,56 @@
+/**
+ * Installing SureMail from the onboarding step and the SMTP page.
+ *
+ * Both screens get SureMail's install state with the page, and it goes stale:
+ * an install can land while its activation fails, or SureMail can be installed
+ * from another tab. Installing again then fails ("Destination folder already
+ * exists") until the owner reloads. So the state is read again, from the
+ * email-delivery status, right before the install starts.
+ *
+ * No imports, so it can be unit-tested without the admin bundle.
+ */
+
+// What Helper::get_plugin_status() reports, and what installAndActivatePlugin() reads.
+const PLUGIN_STATES = [ 'Install', 'Installed', 'Activated' ];
+
+/**
+ * SureMail's install state from an email-delivery status response.
+ *
+ * @since 2.12.9
+ * @param {Object|null} status Response of /sureforms/v1/email-delivery/status.
+ * @param {string} fallback State to use when the response has none.
+ * @return {string} 'Install' | 'Installed' | 'Activated'.
+ */
+export const getSureMailPluginStatus = ( status, fallback ) => {
+ const live = status?.suremail?.plugin_status;
+ return PLUGIN_STATES.includes( live ) ? live : fallback;
+};
+
+/**
+ * Install (only if it is not there) and activate SureMail, going by its
+ * current state rather than the one the page loaded with.
+ *
+ * @since 2.12.9
+ * @param {Object} args
+ * @param {Object} args.plugin Plugin descriptor; its status is the page-load fallback.
+ * @param {Function} args.readStatus Resolves with the current email-delivery status.
+ * @param {Function} args.install installAndActivatePlugin().
+ * @param {Function} args.onProgress Progress callback passed to install.
+ * @return {Promise} The state the install went by.
+ */
+export const installSureMail = async ( {
+ plugin,
+ readStatus,
+ install,
+ onProgress = () => {},
+} ) => {
+ let current = null;
+ try {
+ current = await readStatus();
+ } catch ( e ) {
+ // Unreadable: go by what the page loaded with, as before.
+ }
+ const pluginStatus = getSureMailPluginStatus( current, plugin?.status );
+ await install( { ...plugin, status: pluginStatus }, onProgress );
+ return pluginStatus;
+};
diff --git a/src/utils/test/suremail-install.test.js b/src/utils/test/suremail-install.test.js
new file mode 100644
index 000000000..1222c57c0
--- /dev/null
+++ b/src/utils/test/suremail-install.test.js
@@ -0,0 +1,87 @@
+/* eslint-env jest */
+/**
+ * SureMail's install state goes stale on the page. After an install that
+ * landed but failed to activate, installing again fails with "Destination
+ * folder already exists", so the state is read again before each attempt.
+ */
+import { getSureMailPluginStatus, installSureMail } from '../suremail-install';
+
+const statusWith = ( pluginStatus ) => ( {
+ state: 'not_configured',
+ suremail: { plugin_status: pluginStatus },
+} );
+
+describe( 'getSureMailPluginStatus', () => {
+ it.each( [ 'Install', 'Installed', 'Activated' ] )(
+ 'uses the live state %s',
+ ( live ) => {
+ expect(
+ getSureMailPluginStatus( statusWith( live ), 'Install' )
+ ).toBe( live );
+ }
+ );
+
+ it.each( [
+ [ null ],
+ [ {} ],
+ [ statusWith( 'not_installed' ) ],
+ [ statusWith( undefined ) ],
+ ] )( 'falls back when the response has no known state (%p)', ( status ) => {
+ expect( getSureMailPluginStatus( status, 'Installed' ) ).toBe(
+ 'Installed'
+ );
+ } );
+} );
+
+describe( 'installSureMail', () => {
+ const plugin = { slug: 'suremails', path: 'suremails/suremails.php' };
+
+ it( 'does not install again when the page says not installed but SureMail is installed', async () => {
+ const install = jest.fn().mockResolvedValue();
+ const used = await installSureMail( {
+ plugin: { ...plugin, status: 'Install' },
+ readStatus: () => Promise.resolve( statusWith( 'Installed' ) ),
+ install,
+ } );
+
+ expect( used ).toBe( 'Installed' );
+ expect( install ).toHaveBeenCalledTimes( 1 );
+ // 'Installed' sends only the activate request, never the install one.
+ expect( install.mock.calls[ 0 ][ 0 ].status ).toBe( 'Installed' );
+ } );
+
+ it( 'installs when SureMail is really not there', async () => {
+ const install = jest.fn().mockResolvedValue();
+ await installSureMail( {
+ plugin: { ...plugin, status: 'Installed' },
+ readStatus: () => Promise.resolve( statusWith( 'Install' ) ),
+ install,
+ } );
+
+ expect( install.mock.calls[ 0 ][ 0 ].status ).toBe( 'Install' );
+ } );
+
+ it( 'goes by the page-load state when the status cannot be read', async () => {
+ const install = jest.fn().mockResolvedValue();
+ await installSureMail( {
+ plugin: { ...plugin, status: 'Install' },
+ readStatus: () => Promise.reject( new Error( 'offline' ) ),
+ install,
+ } );
+
+ expect( install.mock.calls[ 0 ][ 0 ].status ).toBe( 'Install' );
+ } );
+
+ it( 'passes the install error on', async () => {
+ const install = jest
+ .fn()
+ .mockRejectedValue( new Error( 'Activation failed' ) );
+ await expect(
+ installSureMail( {
+ plugin: { ...plugin, status: 'Install' },
+ readStatus: () => Promise.resolve( statusWith( 'Installed' ) ),
+ install,
+ } )
+ ).rejects.toThrow( 'Activation failed' );
+ } );
+} );
diff --git a/sureforms.php b/sureforms.php
index 450d208aa..d7467047a 100644
--- a/sureforms.php
+++ b/sureforms.php
@@ -7,7 +7,7 @@
* Requires PHP: 7.4
* Author: SureForms
* Author URI: https://sureforms.com/
- * Version: 2.12.8
+ * Version: 2.12.9
* License: GPLv2 or later
* Text Domain: sureforms
*
@@ -25,7 +25,7 @@
define( 'SRFM_BASENAME', plugin_basename( SRFM_FILE ) );
define( 'SRFM_DIR', plugin_dir_path( SRFM_FILE ) );
define( 'SRFM_URL', plugins_url( '/', SRFM_FILE ) );
-define( 'SRFM_VER', '2.12.8' );
+define( 'SRFM_VER', '2.12.9' );
define( 'SRFM_SLUG', 'srfm' );
// ------ ADDITIONAL CONSTANTS ------- //
define( 'SRFM_FORMS_POST_TYPE', 'sureforms_form' );
@@ -35,7 +35,7 @@
define( 'SRFM_AI_MIDDLEWARE', 'https://credits.startertemplates.com/sureforms/' );
define( 'SRFM_MIDDLEWARE_BASE_URL', 'https://api.sureforms.com/' );
define( 'SRFM_BILLING_PORTAL', 'https://billing.sureforms.com/' );
-define( 'SRFM_PRO_RECOMMENDED_VER', '2.12.8' );
+define( 'SRFM_PRO_RECOMMENDED_VER', '2.12.9' );
define( 'SRFM_SURETRIGGERS_INTEGRATION_BASE_URL', 'https://app.ottokit.com/' );
diff --git a/tests/php/stubs/srfm-stubs.php b/tests/php/stubs/srfm-stubs.php
index 7be3c8ee0..ccfed6b67 100644
--- a/tests/php/stubs/srfm-stubs.php
+++ b/tests/php/stubs/srfm-stubs.php
@@ -189,6 +189,17 @@ public function __construct()
public function add_srfm_analytics_data($stats_data)
{
}
+ /**
+ * Track AI form creation, broken down by the starter card used (if any).
+ *
+ * @param string $starter_id Starter card id, or empty string for a free-text prompt.
+ * @param bool $edited Whether the starter prompt was edited before generating.
+ * @since 2.12.9
+ * @return void
+ */
+ public static function track_ai_starter_usage($starter_id, $edited)
+ {
+ }
/**
* Return total number of forms using instant forms.
*
@@ -7874,6 +7885,31 @@ public function register_custom_endpoint()
public static function get_form_markup($id, $show_title_current_page = true, $sf_classname = '', $post_type = 'post')
{
}
+ /**
+ * Render the form markup for get_form_markup().
+ *
+ * @param int|string $id Contains form ID.
+ * @param bool $show_title_current_page Boolean to srfm-show/srfm-hide form title.
+ * @param string $sf_classname additional class_name.
+ * @param string $post_type Contains post type.
+ * @param bool $do_blocks Boolean to enable/disable parsing dynamic blocks.
+ * @param array $block_attrs Block attributes for per-embed styling.
+ *
+ * @return string|false
+ * @since 2.12.9
+ */
+ public static function render_form_markup($id, $show_title_current_page, $sf_classname, $post_type, $do_blocks, $block_attrs)
+ {
+ }
+ /**
+ * Get the ID of the form whose content is being rendered.
+ *
+ * @return int Form ID, or 0 when no form content is rendering.
+ * @since 2.12.9
+ */
+ public static function get_rendering_form_id()
+ {
+ }
}
/**
* Activation Class.
diff --git a/tests/play/specs/ai-form-builder.spec.js b/tests/play/specs/ai-form-builder.spec.js
new file mode 100644
index 000000000..e9593f9b9
--- /dev/null
+++ b/tests/play/specs/ai-form-builder.spec.js
@@ -0,0 +1,451 @@
+/**
+ * Generate with AI screen — starter cards.
+ *
+ * The AI middleware can't verify local/CI domains, so the page would normally
+ * open behind a "limit reached / domain verification" popup. Each test forces
+ * the localized usage details to a healthy state before the app boots, and
+ * stubs the generate-form request so no AI credits are used.
+ */
+const { test, expect } = require( '@playwright/test' );
+const { loginAsAdmin } = require( '../utils/loginAsAdmin' );
+
+const AI_SCREEN = '/wp-admin/admin.php?page=add-new-form';
+const GENERATE_FORM_ROUTE = /sureforms(\/|%2F)v1(\/|%2F)generate-form/;
+
+const STARTERS = [
+ { title: 'Contact form', keyword: 'Contact form' },
+ { title: 'Quote request', keyword: 'Quote request form' },
+ { title: 'Event registration', keyword: 'Event registration form' },
+ { title: 'Feedback form', keyword: 'Customer feedback form' },
+];
+
+/**
+ * Open the AI screen with usable AI credits and a stubbed generate endpoint.
+ *
+ * @param {import('@playwright/test').Page} page
+ * @param {Object} options
+ * @param {boolean} options.hasAiForms Pretend the site already has an AI form.
+ * @param {string} options.query Extra query string, e.g. '&source=learn'.
+ * @return {Promise>} Request bodies sent to generate-form.
+ */
+async function openAiScreen( page, { hasAiForms = false, query = '' } = {} ) {
+ await page.addInitScript( ( siteHasAiForms ) => {
+ // srfm_admin is printed as `var srfm_admin = {...}`; intercept the
+ // assignment so the screen boots without the limit/verification popup.
+ let srfmAdmin;
+ Object.defineProperty( window, 'srfm_admin', {
+ configurable: true,
+ get: () => srfmAdmin,
+ set: ( value ) => {
+ srfmAdmin = {
+ ...value,
+ srfm_ai_usage_details: {
+ type: 'registered',
+ remaining: 5,
+ limit: 10,
+ },
+ has_ai_generated_forms: siteHasAiForms,
+ };
+ },
+ } );
+ }, hasAiForms );
+
+ const generateRequests = [];
+ await page.route( GENERATE_FORM_ROUTE, async ( route ) => {
+ generateRequests.push( route.request().postDataJSON() );
+ await route.fulfill( {
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify( {
+ success: false,
+ message: 'Stubbed in tests.',
+ } ),
+ } );
+ } );
+
+ await loginAsAdmin( page );
+ await page.goto( AI_SCREEN + query );
+ await expect(
+ page.getByRole( 'heading', { name: 'What form should we build?' } )
+ ).toBeVisible();
+
+ return generateRequests;
+}
+
+const starterCard = ( page, title ) =>
+ page.getByRole( 'button', { name: new RegExp( `^${ title }` ) } );
+
+const promptBox = ( page ) => page.locator( '#textarea' );
+
+const generateButton = ( page ) =>
+ page.getByRole( 'button', { name: 'Generate', exact: true } );
+
+test.describe( 'Generate with AI — starter cards', () => {
+ test( 'shows the starter cards and keeps Generate inactive while empty', async ( {
+ page,
+ } ) => {
+ const generateRequests = await openAiScreen( page );
+
+ for ( const { title } of STARTERS ) {
+ await expect( starterCard( page, title ) ).toHaveAttribute(
+ 'aria-pressed',
+ 'false'
+ );
+ }
+ await expect( page.locator( '#srfm-ai-prompt-counter' ) ).toHaveText(
+ '0/2000'
+ );
+ await expect( generateButton( page ) ).toHaveAttribute(
+ 'aria-disabled',
+ 'true'
+ );
+
+ // Generate uses aria-disabled (not the disabled attribute) so it stays
+ // clickable and can explain itself; Playwright treats aria-disabled as
+ // not actionable, hence force.
+ await generateButton( page ).click( { force: true } );
+
+ // The same text is also announced via wp.a11y.speak(), so target the hint by id.
+ await expect( page.locator( '#srfm-ai-empty-prompt-hint' ) ).toHaveText(
+ 'Describe your form or pick an example to continue.'
+ );
+ await expect( promptBox( page ) ).toBeFocused();
+ expect( generateRequests ).toHaveLength( 0 );
+ } );
+
+ for ( const { title, keyword } of STARTERS ) {
+ test( `"${ title }" card sends its prompt to the AI`, async ( {
+ page,
+ } ) => {
+ const generateRequests = await openAiScreen( page );
+
+ await starterCard( page, title ).click();
+
+ await expect( starterCard( page, title ) ).toHaveAttribute(
+ 'aria-pressed',
+ 'true'
+ );
+ const prompt = await promptBox( page ).inputValue();
+ expect( prompt.startsWith( keyword ) ).toBe( true );
+ await expect(
+ page.locator( '#srfm-ai-prompt-counter' )
+ ).toHaveText( `${ prompt.length }/2000` );
+ await expect( generateButton( page ) ).toHaveAttribute(
+ 'aria-disabled',
+ 'false'
+ );
+
+ await generateButton( page ).click();
+
+ await expect.poll( () => generateRequests.length ).toBe( 1 );
+ const body = generateRequests[ 0 ];
+ const lastMessage =
+ body.message_array[ body.message_array.length - 1 ];
+ expect( lastMessage ).toEqual( {
+ role: 'user',
+ content: prompt,
+ } );
+ expect( body.form_type ).toBe( 'simple' );
+ expect( body.is_conversional ).toBeFalsy();
+ } );
+ }
+
+ test( 'clicking a selected card again clears its untouched prompt', async ( {
+ page,
+ } ) => {
+ await openAiScreen( page );
+
+ await starterCard( page, 'Feedback form' ).click();
+ await expect( promptBox( page ) ).not.toHaveValue( '' );
+
+ await starterCard( page, 'Feedback form' ).click();
+
+ await expect( promptBox( page ) ).toHaveValue( '' );
+ await expect(
+ starterCard( page, 'Feedback form' )
+ ).toHaveAttribute( 'aria-pressed', 'false' );
+ } );
+
+ test( 'a card replacing typed text can be undone', async ( { page } ) => {
+ await openAiScreen( page );
+
+ await promptBox( page ).fill( 'Booking form for my yoga studio' );
+ await starterCard( page, 'Contact form' ).click();
+
+ await expect( promptBox( page ) ).toHaveValue( /^Contact form/ );
+ await page.getByRole( 'button', { name: 'Undo' } ).click();
+
+ await expect( promptBox( page ) ).toHaveValue(
+ 'Booking form for my yoga studio'
+ );
+ await expect(
+ starterCard( page, 'Contact form' )
+ ).toHaveAttribute( 'aria-pressed', 'false' );
+ } );
+
+ test( 'switching cards keeps the typed text for Undo', async ( { page } ) => {
+ await openAiScreen( page );
+
+ await promptBox( page ).fill( 'Booking form for my salon' );
+ await starterCard( page, 'Contact form' ).click();
+ await starterCard( page, 'Quote request' ).click();
+
+ await expect( promptBox( page ) ).toHaveValue( /^Quote request form/ );
+ await page.getByRole( 'button', { name: 'Undo' } ).click();
+
+ await expect( promptBox( page ) ).toHaveValue( 'Booking form for my salon' );
+ await expect(
+ starterCard( page, 'Quote request' )
+ ).toHaveAttribute( 'aria-pressed', 'false' );
+ } );
+
+ test( 'switching cards then deselecting gives back the typed text', async ( {
+ page,
+ } ) => {
+ await openAiScreen( page );
+
+ await promptBox( page ).fill( 'Booking form for my salon' );
+ await starterCard( page, 'Contact form' ).click();
+ await starterCard( page, 'Quote request' ).click();
+ await starterCard( page, 'Quote request' ).click();
+
+ await expect( promptBox( page ) ).toHaveValue( 'Booking form for my salon' );
+ } );
+
+ test( 'examples are hidden for a non-simple form type', async ( { page } ) => {
+ await openAiScreen( page, { query: '&form_type=quiz' } );
+
+ await expect( starterCard( page, 'Contact form' ) ).toHaveCount( 0 );
+ await expect(
+ page.getByRole( 'button', { name: 'Show examples' } )
+ ).toHaveCount( 0 );
+ } );
+
+ test( 'examples collapse once the site has an AI form and can be expanded', async ( {
+ page,
+ } ) => {
+ await openAiScreen( page, { hasAiForms: true } );
+
+ await expect( starterCard( page, 'Contact form' ) ).toHaveCount( 0 );
+ await page.getByRole( 'button', { name: 'Show examples' } ).click();
+
+ for ( const { title } of STARTERS ) {
+ await expect( starterCard( page, title ) ).toBeVisible();
+ }
+ await expect( starterCard( page, 'Contact form' ) ).toBeFocused();
+ } );
+
+ test( 'the Learn flow always shows the examples', async ( { page } ) => {
+ await openAiScreen( page, { hasAiForms: true, query: '&source=learn' } );
+
+ await expect( starterCard( page, 'Contact form' ) ).toBeVisible();
+ await expect(
+ page.getByRole( 'button', { name: 'Show examples' } )
+ ).toHaveCount( 0 );
+ } );
+
+ test( 'deselecting an untouched card gives back the text it replaced', async ( {
+ page,
+ } ) => {
+ await openAiScreen( page );
+
+ await promptBox( page ).fill( 'Booking form for my yoga studio' );
+ await starterCard( page, 'Contact form' ).click();
+ await starterCard( page, 'Contact form' ).click();
+
+ await expect( promptBox( page ) ).toHaveValue(
+ 'Booking form for my yoga studio'
+ );
+ } );
+
+ test( 'deselecting an edited card keeps the edits', async ( { page } ) => {
+ await openAiScreen( page );
+
+ await starterCard( page, 'Event registration' ).click();
+ const edited = ( await promptBox( page ).inputValue() ) + ' Add a T-shirt size dropdown.';
+ await promptBox( page ).fill( edited );
+ await starterCard( page, 'Event registration' ).click();
+
+ await expect( promptBox( page ) ).toHaveValue( edited );
+ await expect(
+ starterCard( page, 'Event registration' )
+ ).toHaveAttribute( 'aria-pressed', 'false' );
+ } );
+
+ test( 'the empty-prompt hint opens the collapsed examples it points to', async ( {
+ page,
+ } ) => {
+ await openAiScreen( page, { hasAiForms: true } );
+
+ await expect( starterCard( page, 'Contact form' ) ).toHaveCount( 0 );
+ await expect(
+ page.getByText( 'Describe the form you want to create.' )
+ ).toBeVisible();
+
+ await generateButton( page ).click( { force: true } );
+
+ await expect( page.locator( '#srfm-ai-empty-prompt-hint' ) ).toBeVisible();
+ await expect( starterCard( page, 'Contact form' ) ).toBeVisible();
+ } );
+
+ /**
+ * Let generation succeed (stubbed) and capture the create-new-form request.
+ *
+ * Routes added after openAiScreen() take precedence over its failing stub.
+ *
+ * @param {import('@playwright/test').Page} page
+ * @return {Promise>} Request bodies sent to create-new-form.
+ */
+ async function captureCreateForm( page ) {
+ await page.route( GENERATE_FORM_ROUTE, ( route ) =>
+ route.fulfill( {
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify( {
+ success: true,
+ data: { form: { formTitle: 'Stubbed form', formFields: [] } },
+ } ),
+ } )
+ );
+ await page.route( /sureforms(\/|%2F)v1(\/|%2F)map-fields/, ( route ) =>
+ route.fulfill( {
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify( '' ),
+ } )
+ );
+ const createRequests = [];
+ await page.route(
+ /sureforms(\/|%2F)v1(\/|%2F)create-new-form/,
+ async ( route ) => {
+ createRequests.push( JSON.parse( route.request().postData() ) );
+ // No id in the response, so the screen stays put instead of redirecting.
+ await route.fulfill( {
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify( { message: 'Stubbed in tests.' } ),
+ } );
+ }
+ );
+ return createRequests;
+ }
+
+ test( 'create-new-form receives the untouched starter card', async ( {
+ page,
+ } ) => {
+ await openAiScreen( page );
+ const createRequests = await captureCreateForm( page );
+
+ await starterCard( page, 'Contact form' ).click();
+ await generateButton( page ).click();
+
+ await expect.poll( () => createRequests.length ).toBe( 1 );
+ expect( createRequests[ 0 ] ).toMatchObject( {
+ ai_starter: 'contact',
+ ai_starter_edited: false,
+ } );
+ } );
+
+ test( 'create-new-form marks an edited starter prompt', async ( { page } ) => {
+ await openAiScreen( page );
+ const createRequests = await captureCreateForm( page );
+
+ await starterCard( page, 'Feedback form' ).click();
+ await promptBox( page ).fill(
+ ( await promptBox( page ).inputValue() ) + ' Add a star rating.'
+ );
+ await generateButton( page ).click();
+
+ await expect.poll( () => createRequests.length ).toBe( 1 );
+ expect( createRequests[ 0 ] ).toMatchObject( {
+ ai_starter: 'feedback',
+ ai_starter_edited: true,
+ } );
+ } );
+
+ test( 'create-new-form gets no starter once the card prompt is replaced', async ( {
+ page,
+ } ) => {
+ await openAiScreen( page );
+ const createRequests = await captureCreateForm( page );
+
+ await starterCard( page, 'Contact form' ).click();
+ // fill() selects all and replaces it, like a paste over the example.
+ await promptBox( page ).fill(
+ 'Job application form with full name, email address, phone number, a resume upload and a message to the hiring team'
+ );
+ await expect(
+ starterCard( page, 'Contact form' )
+ ).toHaveAttribute( 'aria-pressed', 'false' );
+ await generateButton( page ).click();
+
+ await expect.poll( () => createRequests.length ).toBe( 1 );
+ expect( createRequests[ 0 ] ).toMatchObject( {
+ ai_starter: '',
+ ai_starter_edited: false,
+ } );
+ } );
+
+ test( 'create-new-form gets no starter for a free-text prompt', async ( {
+ page,
+ } ) => {
+ await openAiScreen( page );
+ const createRequests = await captureCreateForm( page );
+
+ await promptBox( page ).fill( 'Booking form for my yoga studio' );
+ await generateButton( page ).click();
+
+ await expect.poll( () => createRequests.length ).toBe( 1 );
+ expect( createRequests[ 0 ] ).toMatchObject( {
+ ai_starter: '',
+ ai_starter_edited: false,
+ } );
+ } );
+
+ test( 'a short window can scroll down to Generate', async ( { page } ) => {
+ await page.setViewportSize( { width: 1366, height: 450 } );
+ await openAiScreen( page );
+
+ // Header.js hides body overflow, so scroll with the wheel the way a user
+ // would; scrollIntoView() would move even an overflow-hidden body.
+ await expect( generateButton( page ) ).not.toBeInViewport();
+ await page.mouse.move( 683, 225 );
+ await expect
+ .poll( async () => {
+ await page.mouse.wheel( 0, 100 );
+ const box = await generateButton( page ).boundingBox();
+ return box && box.y >= 0 && box.y + box.height <= 450;
+ } )
+ .toBe( true );
+ } );
+
+ test( 'picking a card while listening shows no voice error', async ( {
+ page,
+ } ) => {
+ // Fake recognition that reports 'aborted' on abort(), as browsers do.
+ await page.addInitScript( () => {
+ window.SpeechRecognition = class {
+ start() {}
+ stop() {}
+ abort() {
+ window.srfmTestAborts = ( window.srfmTestAborts || 0 ) + 1;
+ this.onerror?.( { error: 'aborted' } );
+ }
+ };
+ } );
+ await openAiScreen( page );
+
+ await page.locator( 'button:has(svg.lucide-mic-off)' ).click();
+ await expect( page.locator( 'button:has(svg.lucide-mic)' ) ).toBeVisible();
+ await starterCard( page, 'Contact form' ).click();
+
+ await expect( page.locator( 'button:has(svg.lucide-mic-off)' ) ).toBeVisible();
+ await expect
+ .poll( () => page.evaluate( () => window.srfmTestAborts ) )
+ .toBe( 1 );
+ // Check once: a retrying assertion would pass after the toast auto-closes.
+ expect(
+ await page.getByText( /Speech recognition is not supported/ ).count()
+ ).toBe( 0 );
+ } );
+} );
diff --git a/tests/unit/admin/test-admin.php b/tests/unit/admin/test-admin.php
index f5d8f67c9..9a6a30b64 100644
--- a/tests/unit/admin/test-admin.php
+++ b/tests/unit/admin/test-admin.php
@@ -23,7 +23,12 @@ protected function tearDown(): void {
// Without this, an assertion failure anywhere in this class leaves $_GET and
// the current user set for every test that runs after it, turning one real
// failure into a wall of unrelated ones.
- $_GET = [];
+ //
+ // $_REQUEST matters as much as $_GET: Helper::validate_request_context()
+ // reads $_REQUEST, so a leaked page=sureforms_menu makes every later test
+ // look like it is on the SureForms dashboard.
+ $_GET = [];
+ $_REQUEST = [];
wp_set_current_user( 0 );
parent::tearDown();
}
@@ -1211,6 +1216,31 @@ public function test_get_action_items_offers_a_guide_for_notification_failures()
$this->assertArrayNotHasKey( 'guide_label', $items['form_submission_error'] );
}
+ /**
+ * On a form that never stores entries, the failed email was the only copy of
+ * the submission, so the notice must not claim the entry was saved.
+ */
+ public function test_notification_failure_on_a_form_without_entries_says_the_submission_was_lost() {
+ wp_set_current_user( $this->make_user( 'administrator' ) );
+ delete_option( Client_Logger::FAILURES_OPTION );
+
+ Client_Logger::record_failure( 'notification', 42, 'Private Form', [ 'entry_saved' => false ] );
+
+ $items = [];
+ foreach ( Admin::get_instance()->get_action_items() as $item ) {
+ $items[ $item['id'] ] = $item;
+ }
+
+ $this->assertArrayHasKey( 'notification_error', $items );
+ $this->assertStringContainsString( 'Private Form', $items['notification_error']['title'] );
+ $this->assertStringContainsString( 'not stored', $items['notification_error']['title'] );
+ $this->assertStringNotContainsString( 'The entry was saved', $items['notification_error']['message'] );
+ $this->assertStringContainsString( 'Never store entry data', $items['notification_error']['message'] );
+ $this->assertSame( 'help_me_fix', $items['notification_error']['guide_action'] ?? '', 'The guide stays: setting up email is the fix.' );
+
+ delete_option( Client_Logger::FAILURES_OPTION );
+ }
+
/**
* The guide click is a distinct analytics event, and is accepted by the
* allowlist that gates every notice response.
@@ -1269,6 +1299,61 @@ public function test_handle_notice_response_accepts_the_notification_guide() {
$this->assertStringContainsString( '"success":true', $body, 'The guide button must be on the allowlist.' );
}
+ /**
+ * The setup reminder's Connect SureContact press is accepted, and counted as
+ * the Connect in SureMail press it replaces.
+ */
+ public function test_handle_notice_response_accepts_connect_surecontact_on_the_setup_reminder() {
+ wp_set_current_user( $this->make_user( 'administrator' ) );
+
+ // check_ajax_referer() reads $_REQUEST, so $_POST alone fails the nonce.
+ $nonce = wp_create_nonce( 'srfm_notice_response' );
+ $_POST['nonce'] = $nonce;
+ $_REQUEST['nonce'] = $nonce;
+ $_POST['notice_id'] = 'email_delivery_setup';
+ $_REQUEST['notice_id'] = 'email_delivery_setup';
+ $_POST['button'] = 'connect_surecontact';
+ $_REQUEST['button'] = 'connect_surecontact';
+
+ // wp_send_json_*() terminates via wp_die(); make the handlers throw so the
+ // runner survives and the emitted body can be asserted on. Asserting on the
+ // body rather than "did it die" is what separates accept from reject here --
+ // both end the request.
+ $throw_handler = static function () {
+ return static function () {
+ throw new \WPDieException( 'srfm_test_die' );
+ };
+ };
+ // wp_send_json() only routes through wp_die() when wp_doing_ajax() is true;
+ // otherwise it calls a bare die() that no filter can intercept, which takes
+ // the test runner with it.
+ add_filter( 'wp_doing_ajax', '__return_true' );
+ add_filter( 'wp_die_ajax_handler', $throw_handler );
+ add_filter( 'wp_die_handler', $throw_handler );
+
+ ob_start();
+ try {
+ Admin::get_instance()->handle_notice_response();
+ } catch ( \WPDieException $e ) {
+ // Expected.
+ } finally {
+ $body = (string) ob_get_clean();
+ remove_filter( 'wp_doing_ajax', '__return_true' );
+ remove_filter( 'wp_die_ajax_handler', $throw_handler );
+ remove_filter( 'wp_die_handler', $throw_handler );
+ unset(
+ $_POST['nonce'],
+ $_POST['notice_id'],
+ $_POST['button'],
+ $_REQUEST['nonce'],
+ $_REQUEST['notice_id'],
+ $_REQUEST['button']
+ );
+ }
+
+ $this->assertStringContainsString( '"success":true', $body, 'Connect SureContact on the setup reminder must be on the allowlist.' );
+ }
+
/**
* Each failure describes the failure that actually happened.
*
@@ -1402,6 +1487,10 @@ public function test_get_support_log_block_keeps_the_newest_entries() {
*/
public function test_render_action_item_notices_puts_the_guide_before_contact_support() {
wp_set_current_user( $this->make_user( 'administrator' ) );
+ // Without a mailer, Connect SureContact would lead; that order is covered
+ // by test_render_action_item_notices_leads_with_connect_surecontact().
+ add_filter( 'srfm_hide_promotions', '__return_true' );
+ Admin::reset_action_items_cache();
delete_option( Client_Logger::FAILURES_OPTION );
Client_Logger::record_failure( 'notification', 42, 'Contact Form' );
Client_Logger::record_failure( 'submission', 42, 'Contact Form' );
@@ -1469,6 +1558,8 @@ public function test_render_action_item_notices_puts_the_guide_before_contact_su
$alone,
'A stale list would still carry the notification guide.'
);
+
+ remove_filter( 'srfm_hide_promotions', '__return_true' );
}
/**
@@ -1614,7 +1705,7 @@ public function test_handle_notice_response_accepts_every_pair_the_items_emit()
$pairs = [];
foreach ( $items as $item ) {
- foreach ( [ 'cta_action', 'guide_action' ] as $key ) {
+ foreach ( [ 'connect_action', 'cta_action', 'guide_action' ] as $key ) {
if ( ! empty( $item[ $key ] ) ) {
$pairs[] = [ $item['id'], $item[ $key ] ];
}
@@ -2567,6 +2658,104 @@ static function ( $data ) use ( &$captured ) {
Admin::get_instance()->enqueue_scripts();
$this->assertFalse( $captured[0]['hide_promotions'], 'Control: off by default.' );
}
+
+ /**
+ * Test that the MCP settings screen is told whether the user may install plugins.
+ *
+ * @return void
+ */
+ public function test_enqueue_scripts_localizes_can_install_plugins() {
+ wp_set_current_user( (int) wp_insert_user( [ 'user_login' => 'srfm_cip_' . wp_rand(), 'user_pass' => 'password', 'user_email' => 'srfm_cip_' . wp_rand() . '@example.com', 'role' => 'administrator' ] ) );
+ set_current_screen( 'toplevel_page_sureforms_menu' );
+ $_GET['page'] = 'sureforms_menu';
+ $_REQUEST['page'] = 'sureforms_menu';
+
+ $captured = [];
+ add_filter(
+ 'srfm_admin_filter',
+ static function ( $data ) use ( &$captured ) {
+ $captured[] = $data;
+ return $data;
+ }
+ );
+
+ Admin::get_instance()->enqueue_scripts();
+ $this->assertNotEmpty( $captured, 'The dashboard localization must run.' );
+ $this->assertTrue( $captured[0]['can_install_plugins'] );
+
+ // Same as a multisite subsite admin or a DISALLOW_FILE_MODS site.
+ $deny = static function ( $caps, $cap ) {
+ return 'install_plugins' === $cap ? [ 'do_not_allow' ] : $caps;
+ };
+ add_filter( 'map_meta_cap', $deny, 10, 2 );
+ $captured = [];
+ Admin::get_instance()->enqueue_scripts();
+ remove_filter( 'map_meta_cap', $deny, 10 );
+ $this->assertFalse( $captured[0]['can_install_plugins'] );
+ }
+
+ /**
+ * Connect SureContact is offered while nothing is sending, and not once
+ * SureMail has a connection of its own.
+ */
+ public function test_get_connect_surecontact_action() {
+ $admin = Admin::get_instance();
+ $method = new \ReflectionMethod( $admin, 'get_connect_surecontact_action' );
+ $method->setAccessible( true );
+
+ $plugins = static function () {
+ return [];
+ };
+ add_filter( 'pre_option_active_plugins', $plugins );
+ \SRFM\Inc\Email_Delivery\Status::reset();
+
+ $action = $method->invoke( $admin );
+ $this->assertSame( 'connect_surecontact', $action['connect_action'] ?? '' );
+ $this->assertStringContainsString( 'page=sureforms_smtp', $action['connect_url'] ?? '' );
+
+ add_filter( 'srfm_hide_promotions', '__return_true' );
+ $this->assertSame( [], $method->invoke( $admin ) );
+
+ remove_filter( 'srfm_hide_promotions', '__return_true' );
+ remove_filter( 'pre_option_active_plugins', $plugins );
+ \SRFM\Inc\Email_Delivery\Status::reset();
+ }
+
+ /**
+ * With nothing sending, the classic notice leads with Connect SureContact
+ * and the guide and Contact Support follow as secondary buttons.
+ */
+ public function test_render_action_item_notices_leads_with_connect_surecontact() {
+ wp_set_current_user( $this->make_user( 'administrator' ) );
+ $plugins = static function () {
+ return [];
+ };
+ add_filter( 'pre_option_active_plugins', $plugins );
+ \SRFM\Inc\Email_Delivery\Status::reset();
+ Admin::reset_action_items_cache();
+ delete_option( Client_Logger::FAILURES_OPTION );
+ Client_Logger::record_failure( 'notification', 42, 'Contact Form' );
+
+ global $pagenow;
+ $previous_pagenow = $pagenow;
+ $pagenow = 'plugins.php';
+
+ ob_start();
+ Admin::get_instance()->render_action_item_notices();
+ $html = (string) ob_get_clean();
+
+ $pagenow = $previous_pagenow;
+ remove_filter( 'pre_option_active_plugins', $plugins );
+ delete_option( Client_Logger::FAILURES_OPTION );
+ \SRFM\Inc\Email_Delivery\Status::reset();
+ Admin::reset_action_items_cache();
+
+ $this->assertMatchesRegularExpression( '/class="button button-primary srfm-connect-surecontact"[^>]*data-srfm-button="connect_surecontact"/', $html );
+ $this->assertStringContainsString( 'srfm-connect-surecontact-styles', $html, 'Connect SureContact is SureContact purple here too.' );
+ $this->assertMatchesRegularExpression( '/class="button"[^>]*data-srfm-button="help_me_fix"/', $html );
+ $this->assertMatchesRegularExpression( '/class="button"[^>]*data-srfm-button="contact_support"/', $html );
+ $this->assertLessThan( strpos( $html, 'help_me_fix' ), strpos( $html, 'connect_surecontact' ) );
+ }
}
/**
@@ -4084,4 +4273,6 @@ public function test_enqueue_admin_pointer_respects_hide_promotions() {
$pagenow = $previous_pagenow;
}
+
+
}
diff --git a/tests/unit/admin/test-analytics.php b/tests/unit/admin/test-analytics.php
index fcdf1558c..fc31bb373 100644
--- a/tests/unit/admin/test-analytics.php
+++ b/tests/unit/admin/test-analytics.php
@@ -53,6 +53,9 @@ protected function tearDown(): void {
// Clear object cache.
wp_cache_flush();
+ // Reset the AI starter counters so they don't leak into other tests.
+ Helper::update_srfm_option( 'ai_starter_usage', [] );
+
// Clean up any test posts.
$posts = get_posts(
[
@@ -297,6 +300,148 @@ public function test_onboarding_completed_reports_onboarding_v2_no_without_a_blo
$this->assertSame( 'no', $props['onboarding_v2'] );
}
+ /**
+ * The email delivery step reports the delivery state on arrival and on
+ * leaving, what the owner did, and whether SureMail was opened.
+ */
+ public function test_onboarding_completed_maps_the_email_delivery_step() {
+ $props = $this->detect_onboarding_completed_props(
+ [
+ 'onboardingV2' => true,
+ 'emailDelivery' => [
+ 'stateAtStep' => 'not_configured',
+ 'finalState' => 'suremail_no_connection',
+ 'action' => 'skip',
+ 'suremailOpened' => true,
+ 'consented' => true,
+ 'provision' => 'already_registered',
+ 'option' => 'surecontact',
+ ],
+ ]
+ );
+
+ $this->assertSame( 'not_configured', $props['email_delivery_state_at_step'] );
+ $this->assertSame( 'suremail_no_connection', $props['email_delivery_state_at_exit'] );
+ $this->assertSame( 'skip', $props['email_delivery_action'] );
+ $this->assertSame( 'yes', $props['email_delivery_suremail_opened'] );
+ $this->assertSame( 'yes', $props['email_delivery_consented'] );
+ $this->assertSame( 'already_registered', $props['email_delivery_provision'] );
+ $this->assertSame( 'surecontact', $props['email_delivery_option'] );
+ }
+
+ /**
+ * Choosing another provider installs SureMail and moves on, which is its
+ * own action.
+ */
+ public function test_onboarding_completed_maps_the_other_smtp_choice() {
+ $props = $this->detect_onboarding_completed_props(
+ [
+ 'onboardingV2' => true,
+ 'emailDelivery' => [
+ 'action' => 'install_other_smtp',
+ 'option' => 'other_smtp',
+ ],
+ ]
+ );
+
+ $this->assertSame( 'install_other_smtp', $props['email_delivery_action'] );
+ $this->assertSame( 'other_smtp', $props['email_delivery_option'] );
+ }
+
+ /**
+ * The blob is whatever the wizard POSTed: unknown states and actions are
+ * dropped rather than forwarded.
+ */
+ public function test_onboarding_completed_drops_unknown_email_delivery_values() {
+ $props = $this->detect_onboarding_completed_props(
+ [
+ 'onboardingV2' => true,
+ 'emailDelivery' => [
+ 'stateAtStep' => 'delivered'
- . '';
-
- $preserved = $this->call_protected( 'strip_form_for_preservation', [ $html ] );
-
- $this->assertStringContainsString( 'Free eBook ', $preserved );
- $this->assertStringContainsString( 'Thanks for signing up', $preserved );
- $this->assertStringNotContainsString( '