@@ -208,6 +208,114 @@ const setIframeImageFieldValue = async ( page, clientId, attachmentId ) => {
208208 ) ;
209209} ;
210210
211+ // Set the headline section's font <select> value WITHOUT focusing it, mirroring
212+ // setIframeImageFieldValue. On first insert the select holds only the PHP-rendered
213+ // <option value="default">, so the target option must be injected before .value is
214+ // set; otherwise .value = 'Arial' would not stick. Setting .value directly and
215+ // dispatching a bubbling 'change' event deliberately avoids the lazy .one('focus',...)
216+ // population path that the #84645 regression test must exercise.
217+ const setIframeFontFieldValue = async ( page , clientId , fontValue ) => {
218+ return page . evaluate (
219+ ( { blockClientId, value } ) => {
220+ const iframe = document . querySelector ( 'iframe[name="editor-canvas"], .edit-site-visual-editor__editor-canvas' ) ;
221+ const frameWindow = iframe && iframe . contentWindow ? iframe . contentWindow : window ;
222+ const frameDocument = frameWindow . document ;
223+ const form = frameDocument
224+ . querySelector ( `[data-block="${ blockClientId } "] .siteorigin-widget-form.siteorigin-widget-form-main` ) ;
225+
226+ if ( ! form ) {
227+ return null ;
228+ }
229+
230+ const fontSelect = Array . from (
231+ form . querySelectorAll ( 'select.siteorigin-widget-input' )
232+ ) . find ( ( select ) => / \[ h e a d l i n e \] \[ f o n t \] $ / . test ( select . name ) ) ;
233+
234+ if ( ! fontSelect ) {
235+ return null ;
236+ }
237+
238+ // Inject the target option if it is not already present, since the
239+ // select initially holds only the default option (full list is lazy).
240+ const hasOption = Array . from ( fontSelect . options ) . some ( ( option ) => option . value === value ) ;
241+ if ( ! hasOption ) {
242+ const option = frameDocument . createElement ( 'option' ) ;
243+ option . value = value ;
244+ option . textContent = value ;
245+ fontSelect . appendChild ( option ) ;
246+ }
247+
248+ // Set the value directly and dispatch a bubbling change event. No focus.
249+ fontSelect . value = value ;
250+ fontSelect . dispatchEvent ( new frameWindow . Event ( 'change' , { bubbles : true } ) ) ;
251+
252+ return fontSelect . value ;
253+ } ,
254+ {
255+ blockClientId : clientId ,
256+ value : fontValue ,
257+ }
258+ ) ;
259+ } ;
260+
261+ // Read the headline font <select> DOM state WITHOUT focusing it: how many options
262+ // are currently checked, the checked value, how many options match the target font,
263+ // and how many still carry the data-sow-saved-option marker.
264+ const getIframeFontFieldReport = async ( page , clientId , fontValue ) => {
265+ return page . evaluate (
266+ ( { blockClientId, value } ) => {
267+ const iframe = document . querySelector ( 'iframe[name="editor-canvas"], .edit-site-visual-editor__editor-canvas' ) ;
268+ const frameWindow = iframe && iframe . contentWindow ? iframe . contentWindow : window ;
269+ const frameDocument = frameWindow . document ;
270+ const form = frameDocument
271+ . querySelector ( `[data-block="${ blockClientId } "] .siteorigin-widget-form.siteorigin-widget-form-main` ) ;
272+
273+ if ( ! form ) {
274+ return null ;
275+ }
276+
277+ const fontSelect = Array . from (
278+ form . querySelectorAll ( 'select.siteorigin-widget-input' )
279+ ) . find ( ( select ) => / \[ h e a d l i n e \] \[ f o n t \] $ / . test ( select . name ) ) ;
280+
281+ if ( ! fontSelect ) {
282+ return null ;
283+ }
284+
285+ const checked = fontSelect . querySelector ( 'option:checked' ) ;
286+
287+ return {
288+ optionCount : fontSelect . options . length ,
289+ checkedCount : fontSelect . querySelectorAll ( 'option:checked' ) . length ,
290+ checkedValue : checked ? checked . value : null ,
291+ matchingCount : Array . from ( fontSelect . options ) . filter ( ( option ) => option . value === value ) . length ,
292+ savedMarkerCount : fontSelect . querySelectorAll ( 'option[data-sow-saved-option]' ) . length ,
293+ } ;
294+ } ,
295+ {
296+ blockClientId : clientId ,
297+ value : fontValue ,
298+ }
299+ ) ;
300+ } ;
301+
302+ // Focus the headline font <select> via a Playwright locator to trigger the lazy
303+ // .one('focus',...) population, then wait for the full fontList to have been
304+ // appended (more than the default + saved options).
305+ const focusIframeFontFieldAndWaitForList = async ( page , admin , blockName ) => {
306+ const widget = getWidgetBlock ( admin , blockName ) ;
307+ const fontSelect = widget . locator ( 'select.siteorigin-widget-input[name$="[headline][font]"]' ) ;
308+
309+ await fontSelect . focus ( ) ;
310+
311+ await expect
312+ . poll (
313+ async ( ) => fontSelect . evaluate ( ( select ) => select . options . length ) ,
314+ { timeout : 20000 }
315+ )
316+ . toBeGreaterThan ( 2 ) ;
317+ } ;
318+
211319const attachBlockDiagnostics = async ( testInfo , name , state ) => {
212320 await testInfo . attach ( name , {
213321 body : JSON . stringify ( state , null , 2 ) ,
@@ -944,3 +1052,93 @@ test(
9441052 }
9451053 }
9461054) ;
1055+
1056+ // ---------------------------------------------------------------------------
1057+ // Regression — #84645 font round-trip
1058+ // A saved, non-default font on a never-focused font <select> must survive the
1059+ // save/reopen round-trip (the field is read back via getWidgetFormValues without
1060+ // the user ever focusing it) and must not regress to a duplicate option once the
1061+ // full font list lazily loads on focus. This locks in both Slice-1 commits:
1062+ // the PHP pre-rendered, marked saved <option> and the JS marker strip in
1063+ // setupFontField.
1064+ // ---------------------------------------------------------------------------
1065+
1066+ test (
1067+ 'Headline widget saved font survives save and reopen without focusing the field, with no duplicate option after focus.' ,
1068+ async ( { page } , testInfo ) => {
1069+ const blockName = 'sowb/siteorigin-widget-headline-widget' ;
1070+ const fontValue = 'Arial' ;
1071+ const {
1072+ admin,
1073+ post,
1074+ requestUtils,
1075+ } = await setupPublishedPostEditor ( page , 'WB headline font round-trip' ) ;
1076+
1077+ try {
1078+ // B. Insert the widget and set the font to Arial WITHOUT focusing the select.
1079+ const widget = await insertDirectWidgetBlock ( admin , blockName ) ;
1080+ const clientId = await widget . getAttribute ( 'data-block' ) ;
1081+ expect ( clientId ) . toBeTruthy ( ) ;
1082+
1083+ const setFontValue = await setIframeFontFieldValue ( page , clientId , fontValue ) ;
1084+ expect ( setFontValue ) . toBe ( fontValue ) ;
1085+
1086+ // Dirty the post so Save is enabled (mirrors the image widget test).
1087+ await page . evaluate ( ( ) => {
1088+ window . wp . data . dispatch ( 'core/editor' ) . editPost ( {
1089+ title : 'WB headline font round-trip updated' ,
1090+ } ) ;
1091+ } ) ;
1092+
1093+ // C. Pre-save in-editor assertion: the data path before save reads Arial.
1094+ const formSnapshot = await getIframeWidgetFormValues ( page , clientId ) ;
1095+ expect ( formSnapshot . formCount ) . toBeGreaterThan ( 0 ) ;
1096+ expect ( formSnapshot . values . headline . font ) . toBe ( fontValue ) ;
1097+
1098+ // D. Save and capture content.
1099+ const savedContent = await clickSaveAndCaptureContent ( page , post . id ) ;
1100+ expect ( savedContent ) . toContain ( '"font":"Arial"' ) ;
1101+
1102+ const postSaveBlockState = await findDirectBlockState ( page , blockName ) ;
1103+ expect ( postSaveBlockState . attributes . widgetData . headline . font ) . toBe ( fontValue ) ;
1104+ await attachBlockDiagnostics ( testInfo , 'headline-font-attrs.json' , postSaveBlockState ) ;
1105+
1106+ // E. Reopen the post and the saved widget form — Slice-1's PHP pre-render
1107+ // is under test from here on.
1108+ await admin . editPost ( post . id ) ;
1109+ await reopenSavedWidgetForm ( page , admin , blockName ) ;
1110+ const reloadedBlockState = await findDirectBlockState ( page , blockName ) ;
1111+
1112+ // F. Assertion (1) — BEFORE focusing the font select, the DOM has exactly
1113+ // one selected option and it is Arial.
1114+ const preFocusReport = await getIframeFontFieldReport ( page , reloadedBlockState . clientId , fontValue ) ;
1115+ expect ( preFocusReport ) . not . toBeNull ( ) ;
1116+ expect ( preFocusReport . checkedCount ) . toBe ( 1 ) ;
1117+ expect ( preFocusReport . checkedValue ) . toBe ( fontValue ) ;
1118+
1119+ // G. Assertion (2) — the data round-trip that regressed: getWidgetFormValues
1120+ // returns Arial WITHOUT focusing the field.
1121+ const reloadedSnapshot = await getIframeWidgetFormValues ( page , reloadedBlockState . clientId ) ;
1122+ expect ( reloadedSnapshot . values . headline . font ) . toBe ( fontValue ) ;
1123+ expect ( reloadedSnapshot . values . headline . font ) . not . toBe ( 'default' ) ;
1124+
1125+ // H. Assertion (3) — AFTER focusing the reopened select, still exactly one
1126+ // selected, still Arial, no duplicate Arial option, marker stripped.
1127+ await focusIframeFontFieldAndWaitForList ( page , admin , blockName ) ;
1128+
1129+ const postFocusReport = await getIframeFontFieldReport ( page , reloadedBlockState . clientId , fontValue ) ;
1130+ expect ( postFocusReport ) . not . toBeNull ( ) ;
1131+ expect ( postFocusReport . checkedCount ) . toBe ( 1 ) ;
1132+ expect ( postFocusReport . checkedValue ) . toBe ( fontValue ) ;
1133+ expect ( postFocusReport . matchingCount ) . toBe ( 1 ) ;
1134+ expect ( postFocusReport . savedMarkerCount ) . toBe ( 0 ) ;
1135+ await attachBlockDiagnostics ( testInfo , 'headline-font-after-focus.json' , postFocusReport ) ;
1136+ } finally {
1137+ await requestUtils . rest ( {
1138+ method : 'DELETE' ,
1139+ path : `/wp/v2/posts/${ post . id } ` ,
1140+ params : { force : true } ,
1141+ } ) . catch ( ( ) => { } ) ;
1142+ }
1143+ }
1144+ ) ;
0 commit comments