From cf3f6c85538d1a405c470fe5685f46a20755dd16 Mon Sep 17 00:00:00 2001 From: Nathanael Jones Date: Wed, 19 Aug 2026 10:15:46 +0100 Subject: [PATCH 01/15] Accept internationalized domain names in Website/URL fields The host pattern in FrmFieldUrl::validate() and its twin in checkUrlField() allowed ASCII only, so valid internationalized domains were rejected. The punycode spelling of the same domain already passed, and non-ASCII in the path, query and fragment already passed, so this removes an inconsistency rather than granting anything new. The two character ranges differ by design. PHP matches UTF-8 bytes, so it uses the raw byte range; JS matches UTF-16 code units, so it needs the code unit range. Copying one literal into both would accept Cyrillic and CJK hosts server side while silently rejecting them in the browser, so a test asserts the two JS files carry the code unit form and never the PHP one. The /u modifier is deliberately not added to the PHP pattern: preg_match() returns false on invalid UTF-8, and because the result is negated that would report valid Latin-1 input as invalid. Sanitizing, escaping, storage and the scheme allowlist are untouched. The new pattern was compared byte for byte with the old one across 1408 ASCII inputs with no difference, so no ASCII url changes behaviour. Help Scout ticket 257002. Co-Authored-By: Claude Opus 5 --- classes/models/fields/FrmFieldUrl.php | 5 +- js/formidable.js | 4 +- js/formidable.min.js | 2 +- .../e2e/Forms/fieldsInFormBuilder.cy.js | 42 +++++++ .../phpunit/fields/test_FrmFieldValidate.php | 117 ++++++++++++++++++ 5 files changed, 166 insertions(+), 4 deletions(-) diff --git a/classes/models/fields/FrmFieldUrl.php b/classes/models/fields/FrmFieldUrl.php index dc09eea21d..57d7840f87 100644 --- a/classes/models/fields/FrmFieldUrl.php +++ b/classes/models/fields/FrmFieldUrl.php @@ -82,8 +82,9 @@ public function validate( $args ) { $errors = array(); - // Validate the url format - if ( $value && ! preg_match( '/^http(s)?:\/\/(?:localhost|(?:[\da-z\.-]+\.[\da-z\.-]+))/i', $value ) ) { + // Validate the url format. The host class allows \x80-\xff so internationalized domain names pass. + // Byte range by design, and no /u modifier: with /u, preg_match() returns false on invalid UTF-8. + if ( $value && ! preg_match( '/^http(s)?:\/\/(?:localhost|(?:[\da-z\x80-\xff\.-]+\.[\da-z\x80-\xff\.-]+))/i', $value ) ) { $errors[ 'field' . $args['id'] ] = FrmFieldsHelper::get_error_msg( $this->field, 'invalid' ); } elseif ( $this->field->required == '1' && ! $value ) { // phpcs:ignore Universal.Operators.StrictComparisons $errors[ 'field' . $args['id'] ] = FrmFieldsHelper::get_error_msg( $this->field, 'blank' ); diff --git a/js/formidable.js b/js/formidable.js index df4748d700..6ccc6fedac 100644 --- a/js/formidable.js +++ b/js/formidable.js @@ -541,7 +541,9 @@ function frmFrontFormJS() { let fieldID; const url = field.value; - if ( url !== '' && ! /^http(s)?:\/\/(?:localhost|(?:[\da-z\.-]+\.[\da-z\.-]+))/i.test( url ) ) { + // Keep in sync with FrmFieldUrl::validate(), but the ranges differ by design: JS matches UTF-16 + // code units, so this needs \u0080-\uFFFF where the PHP side matches raw UTF-8 bytes instead. + if ( url !== '' && ! /^http(s)?:\/\/(?:localhost|(?:[\da-z\u0080-\uFFFF\.-]+\.[\da-z\u0080-\uFFFF\.-]+))/i.test( url ) ) { fieldID = getFieldId( field, true ); if ( ! ( fieldID in errors ) ) { errors[ fieldID ] = getFieldValidationMessage( field, 'data-invmsg' ); diff --git a/js/formidable.min.js b/js/formidable.min.js index e524a764eb..88a4ea9731 100644 --- a/js/formidable.min.js +++ b/js/formidable.min.js @@ -12,7 +12,7 @@ errors,onSubmit);else if(field.type==="url")checkUrlField(field,errors);else if( "")}if(errors[fileID]===undefined)val=getFileVals(fileID);fieldID=fileID}else{if(hasClass(field,"frm_pos_none"))return errors;val=jQuery(field).val();if(val===null)val="";else if(typeof val!=="string"){tempVal=val;val="";for(i=0;i()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/i;if(""!==field.value&&pattern.test(field.value)=== false)errors[fieldID]=getFieldValidationMessage(field,"data-invmsg");if(shouldCheckConfirmField(field,onSubmit))confirmField(field,errors)}function checkPasswordField(field,errors,onSubmit){if(shouldCheckConfirmField(field,onSubmit))confirmField(field,errors)}function confirmField(field,errors){const fieldID=getFieldId(field,true);const strippedId=field.id.replace("conf_","");const strippedFieldID=fieldID.replace("conf_","");const confirmField=document.getElementById(strippedId.replace("field_","field_conf_")); if(!confirmField||errors[`conf_${strippedFieldID}`]!==undefined)return;if(fieldID!==strippedFieldID){const firstField=document.getElementById(strippedId);const {value}=firstField;const confirmValue=confirmField.value;if(value!==confirmValue)errors[`conf_${strippedFieldID}`]=getFieldValidationMessage(confirmField,"data-confmsg")}else validateField(confirmField)}function checkNumberField(field,errors){let fieldID;const number=field.value;if(number!==""&&isNaN(number/1)!==false){fieldID=getFieldId(field, diff --git a/tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js b/tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js index 5bfb99f95b..49dcb32a16 100644 --- a/tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js +++ b/tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js @@ -251,6 +251,48 @@ describe( 'Fields in the form builder', () => { cy.go( 'back' ); } ); + it( 'should accept an internationalized domain name in a Website/URL field', () => { + cy.openForm(); + + cy.log( 'Create a text field and a Website/URL field' ); + cy.get( `li[id="text"] a[title="Text"]` ).click( { force: true } ); + cy.get( `li[id="url"] a[title="Website/URL"]` ).click( { force: true } ); + + cy.log( 'Update form' ); + cy.get( '#frm_submit_side_top' ).should( 'contain', 'Update' ).click( { force: true } ); + + cy.log( "Enabling the 'Validate this form with javascript' setting" ); + cy.xpath( "//ul[@class='frm_form_nav']//a[contains(text(),'Settings')]" ).should( 'contain', 'Settings' ).click(); + cy.get( '#js_validate' ).click( { force: true } ); + cy.get( '#frm_submit_side_top' ).should( 'contain', 'Update' ).click( { force: true } ); + + cy.log( 'Click on Preview - Blank Page' ); + cy.get( '#frm-previewDrop', { timeout: 5000 } ).should( 'contain', 'Preview' ).click(); + cy.get( '.preview > .frm-dropdown-menu > :nth-child(1) > a' ).should( 'contain', 'On Blank Page' ).invoke( 'removeAttr', 'target' ).click(); + + /** + * A host with no dot must still be rejected. This proves the javascript validator really is + * running on this field, so the assertion further down cannot pass for the wrong reason. + */ + cy.log( 'A host with no dot is still rejected' ); + cy.get( '[id^="field_"]' ).filter( 'input' ).eq( 1 ).type( 'münchen' ); + cy.get( '[id^="field_"]' ).filter( 'input' ).eq( 0 ).click(); + cy.get( `[id^="frm_error_field_"]` ).should( 'exist' ); + + /** + * An accented host must be accepted. The regex runs out of the committed js/formidable.min.js, + * which is rebuilt into js/frm.min.js when the plugin is activated, so a stale minified + * artifact fails right here. + */ + cy.log( 'An internationalized domain name is accepted' ); + cy.get( '[id^="field_"]' ).filter( 'input' ).eq( 1 ).clear().type( 'https://ernährung.ch' ); + cy.get( '[id^="field_"]' ).filter( 'input' ).eq( 0 ).click(); + cy.get( `[id^="frm_error_field_"]` ).should( 'not.exist' ); + + cy.log( 'Navigate back to the formidable form page' ); + cy.go( 'back' ); + } ); + afterEach( () => { cy.log( 'Teardown - Save the form and delete it' ); cy.get( "a[aria-label='Close']", { timeout: 10000 } ).click( { force: true } ); diff --git a/tests/phpunit/fields/test_FrmFieldValidate.php b/tests/phpunit/fields/test_FrmFieldValidate.php index 5feb1a849c..2d29947f89 100644 --- a/tests/phpunit/fields/test_FrmFieldValidate.php +++ b/tests/phpunit/fields/test_FrmFieldValidate.php @@ -120,6 +120,21 @@ protected function expected_format_errors() { 'value' => 'http://', 'invalid' => false, ), + array( + 'type' => 'url', + 'value' => 'https://ernährung.ch', + 'invalid' => false, + ), + array( + 'type' => 'url', + 'value' => 'https://пример.рф', + 'invalid' => false, + ), + array( + 'type' => 'url', + 'value' => 'https://a/b.com', + 'invalid' => true, + ), ); } @@ -183,6 +198,108 @@ public function test_url_value() { $this->assertArrayHasKey( 'field' . $field->id, $errors, 'http:// passed required validation ' . print_r( $errors, 1 ) ); } + /** + * Internationalized domain names must pass validation. + * + * The host pattern in FrmFieldUrl::validate() matches UTF-8 bytes, so non-ASCII hosts are + * accepted. These are real registrable domains - .ch permits accented vowels - and the punycode + * spelling of the same domain has always passed, so accepting these adds no new capability. + * + * @covers FrmFieldUrl::validate + */ + public function test_url_idn_validation() { + $field = $this->factory->field->get_object_by_id( $this->get_field_key( 'url' ) ); + $this->assertNotEmpty( $field ); + + $should_pass = array( + 'https://ernährung.ch', + 'https://münchen.de', + 'https://café.fr', + 'https://пример.рф', + 'https://例え.jp', + 'https://ÄPFEL.DE', + 'https://xn--ernhrung-2za.ch', + 'https://example.com', + 'http://localhost', + 'https://ernährung.ch/über-uns?q=grüße#süß', + 'ernährung.ch', + ); + + foreach ( $should_pass as $url ) { + $errors = $this->check_single_value( array( $field->id => $url ) ); + $this->assertArrayNotHasKey( 'field' . $field->id, $errors, 'A valid url failed validation: ' . $url ); + } + + /** + * The last two must fail even though the class now allows non-ASCII: the pattern still + * requires a dotted host, and a hyphen placed after the byte range would turn it into the + * range 0x2E-0x80 and let path and query characters through. + */ + $should_fail = array( + 'münchen', + 'https://ä', + 'https://a/b.com', + 'https://a?b.com', + ); + + foreach ( $should_fail as $url ) { + $errors = $this->check_single_value( array( $field->id => $url ) ); + $this->assertArrayHasKey( 'field' . $field->id, $errors, 'An invalid url passed validation: ' . $url ); + } + } + + /** + * A raw Latin-1 host byte must still validate. + * + * This guards against adding the /u modifier to the host pattern. With /u, preg_match() returns + * false on invalid UTF-8, and because the result is negated the value would be reported invalid. + * + * @covers FrmFieldUrl::validate + */ + public function test_url_non_utf8_host_byte() { + $field = $this->factory->field->get_object_by_id( $this->get_field_key( 'url' ) ); + $this->assertNotEmpty( $field ); + + $url = "https://ex\xE4mple.com"; + + // Without this the assertion below would pass vacuously if the byte were stripped first. + $this->assertNotEmpty( esc_url_raw( $url ), 'The Latin-1 host byte did not survive sanitizing, so this test proves nothing.' ); + + $errors = $this->check_single_value( array( $field->id => $url ) ); + $this->assertArrayNotHasKey( 'field' . $field->id, $errors, 'A Latin-1 host byte failed validation, which suggests the /u modifier was added to the host pattern.' ); + } + + /** + * The JS copy of the host pattern must stay in step with the PHP one, and the minified artifact + * must be rebuilt whenever the source changes. + * + * There is no JS engine in this suite, so this asserts the rules on the source rather than + * running the regex: the code unit range is present, the PHP byte range was not copied across by + * mistake, the old ASCII-only class is gone, and the minified file carries the same class. + * + * @covers FrmFieldUrl::validate + */ + public function test_url_field_js_regex_parity() { + $source = FrmAppHelper::plugin_path() . '/js/formidable.js'; + $minified = FrmAppHelper::plugin_path() . '/js/formidable.min.js'; + + foreach ( array( $source, $minified ) as $file ) { + $this->assertFileExists( $file ); + + $contents = file_get_contents( $file ); + $name = basename( $file ); + + $this->assertStringContainsString( '\u0080-\uFFFF', $contents, 'The JS host pattern is missing the code unit range in ' . $name ); + $this->assertStringNotContainsString( '\x80-\xff', $contents, 'The PHP byte range was copied into ' . $name . '. JS matches UTF-16 code units, so that would reject the Cyrillic and CJK hosts the server accepts.' ); + $this->assertStringNotContainsString( '[\da-z\.-]', $contents, 'The old ASCII-only host class is still present in ' . $name ); + } + + // The host class in the source must appear verbatim in the minified artifact. + $matched = preg_match( '/\[\\\\da-z[^\]]*\]/', file_get_contents( $source ), $matches ); + $this->assertSame( 1, $matched, 'Could not find the url host class in js/formidable.js' ); + $this->assertStringContainsString( $matches[0], file_get_contents( $minified ), 'js/formidable.min.js is stale. Rebuild it so it carries the same url host class as js/formidable.js.' ); + } + /** * @covers FrmFieldEmail::validate */ From df88b2d3b71d82cc66457da8fb4cf46b2567f42d Mon Sep 17 00:00:00 2001 From: Nathanael Jones Date: Wed, 19 Aug 2026 10:27:03 +0100 Subject: [PATCH 02/15] Address PHPCS and DeepSource findings on the URL field IDN fix PHPCS: two assertion messages in test_FrmFieldValidate.php exceeded the 180 character limit (SlevomatCodingStandard.Files.LineLength). Shortened them; the detail they carried is already in the method docblocks. DeepSource JS-0117 wanted the u flag on the JS host pattern, which uses unicode escapes. Adding it required widening the class to a code point range, since under the u flag the old code unit range would no longer match astral characters that the PHP side accepts as bytes. Verified in node: the u variant is identical to the previous one on all 15 sample urls and across 640 generated ASCII cases, and an astral host still matches, so PHP and JS stay in step. DeepSource JS-R1004: four backtick strings in the new Cypress block had no interpolation. Converted to plain strings. The parity test needle and the explanatory comment were updated to match the new JS form. The PHP pattern deliberately still has no u modifier, because preg_match() returns false on malformed UTF-8 and the negated result would report valid Latin-1 input as invalid. Co-Authored-By: Claude Opus 5 --- js/formidable.js | 5 +++-- js/formidable.min.js | 2 +- tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js | 8 ++++---- tests/phpunit/fields/test_FrmFieldValidate.php | 6 +++--- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/js/formidable.js b/js/formidable.js index 6ccc6fedac..858bd2b453 100644 --- a/js/formidable.js +++ b/js/formidable.js @@ -542,8 +542,9 @@ function frmFrontFormJS() { const url = field.value; // Keep in sync with FrmFieldUrl::validate(), but the ranges differ by design: JS matches UTF-16 - // code units, so this needs \u0080-\uFFFF where the PHP side matches raw UTF-8 bytes instead. - if ( url !== '' && ! /^http(s)?:\/\/(?:localhost|(?:[\da-z\u0080-\uFFFF\.-]+\.[\da-z\u0080-\uFFFF\.-]+))/i.test( url ) ) { + // code units, so it uses the u flag and a code point range where the PHP side matches raw + // UTF-8 bytes. PHP must NOT gain /u: preg_match() returns false on malformed UTF-8. + if ( url !== '' && ! /^http(s)?:\/\/(?:localhost|(?:[\da-z\u0080-\u{10FFFF}\.-]+\.[\da-z\u0080-\u{10FFFF}\.-]+))/iu.test( url ) ) { fieldID = getFieldId( field, true ); if ( ! ( fieldID in errors ) ) { errors[ fieldID ] = getFieldValidationMessage( field, 'data-invmsg' ); diff --git a/js/formidable.min.js b/js/formidable.min.js index 88a4ea9731..c1e3044cb2 100644 --- a/js/formidable.min.js +++ b/js/formidable.min.js @@ -12,7 +12,7 @@ errors,onSubmit);else if(field.type==="url")checkUrlField(field,errors);else if( "")}if(errors[fileID]===undefined)val=getFileVals(fileID);fieldID=fileID}else{if(hasClass(field,"frm_pos_none"))return errors;val=jQuery(field).val();if(val===null)val="";else if(typeof val!=="string"){tempVal=val;val="";for(i=0;i()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/i;if(""!==field.value&&pattern.test(field.value)=== false)errors[fieldID]=getFieldValidationMessage(field,"data-invmsg");if(shouldCheckConfirmField(field,onSubmit))confirmField(field,errors)}function checkPasswordField(field,errors,onSubmit){if(shouldCheckConfirmField(field,onSubmit))confirmField(field,errors)}function confirmField(field,errors){const fieldID=getFieldId(field,true);const strippedId=field.id.replace("conf_","");const strippedFieldID=fieldID.replace("conf_","");const confirmField=document.getElementById(strippedId.replace("field_","field_conf_")); if(!confirmField||errors[`conf_${strippedFieldID}`]!==undefined)return;if(fieldID!==strippedFieldID){const firstField=document.getElementById(strippedId);const {value}=firstField;const confirmValue=confirmField.value;if(value!==confirmValue)errors[`conf_${strippedFieldID}`]=getFieldValidationMessage(confirmField,"data-confmsg")}else validateField(confirmField)}function checkNumberField(field,errors){let fieldID;const number=field.value;if(number!==""&&isNaN(number/1)!==false){fieldID=getFieldId(field, diff --git a/tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js b/tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js index 49dcb32a16..c32f1eb95e 100644 --- a/tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js +++ b/tests/cypress/e2e/Forms/fieldsInFormBuilder.cy.js @@ -255,8 +255,8 @@ describe( 'Fields in the form builder', () => { cy.openForm(); cy.log( 'Create a text field and a Website/URL field' ); - cy.get( `li[id="text"] a[title="Text"]` ).click( { force: true } ); - cy.get( `li[id="url"] a[title="Website/URL"]` ).click( { force: true } ); + cy.get( 'li[id="text"] a[title="Text"]' ).click( { force: true } ); + cy.get( 'li[id="url"] a[title="Website/URL"]' ).click( { force: true } ); cy.log( 'Update form' ); cy.get( '#frm_submit_side_top' ).should( 'contain', 'Update' ).click( { force: true } ); @@ -277,7 +277,7 @@ describe( 'Fields in the form builder', () => { cy.log( 'A host with no dot is still rejected' ); cy.get( '[id^="field_"]' ).filter( 'input' ).eq( 1 ).type( 'münchen' ); cy.get( '[id^="field_"]' ).filter( 'input' ).eq( 0 ).click(); - cy.get( `[id^="frm_error_field_"]` ).should( 'exist' ); + cy.get( '[id^="frm_error_field_"]' ).should( 'exist' ); /** * An accented host must be accepted. The regex runs out of the committed js/formidable.min.js, @@ -287,7 +287,7 @@ describe( 'Fields in the form builder', () => { cy.log( 'An internationalized domain name is accepted' ); cy.get( '[id^="field_"]' ).filter( 'input' ).eq( 1 ).clear().type( 'https://ernährung.ch' ); cy.get( '[id^="field_"]' ).filter( 'input' ).eq( 0 ).click(); - cy.get( `[id^="frm_error_field_"]` ).should( 'not.exist' ); + cy.get( '[id^="frm_error_field_"]' ).should( 'not.exist' ); cy.log( 'Navigate back to the formidable form page' ); cy.go( 'back' ); diff --git a/tests/phpunit/fields/test_FrmFieldValidate.php b/tests/phpunit/fields/test_FrmFieldValidate.php index 2d29947f89..d4e52bb837 100644 --- a/tests/phpunit/fields/test_FrmFieldValidate.php +++ b/tests/phpunit/fields/test_FrmFieldValidate.php @@ -289,15 +289,15 @@ public function test_url_field_js_regex_parity() { $contents = file_get_contents( $file ); $name = basename( $file ); - $this->assertStringContainsString( '\u0080-\uFFFF', $contents, 'The JS host pattern is missing the code unit range in ' . $name ); - $this->assertStringNotContainsString( '\x80-\xff', $contents, 'The PHP byte range was copied into ' . $name . '. JS matches UTF-16 code units, so that would reject the Cyrillic and CJK hosts the server accepts.' ); + $this->assertStringContainsString( '\u0080-\u{10FFFF}', $contents, 'The JS host pattern is missing the code unit range in ' . $name ); + $this->assertStringNotContainsString( '\x80-\xff', $contents, 'The PHP byte range was copied into ' . $name . '; that rejects Cyrillic and CJK hosts.' ); $this->assertStringNotContainsString( '[\da-z\.-]', $contents, 'The old ASCII-only host class is still present in ' . $name ); } // The host class in the source must appear verbatim in the minified artifact. $matched = preg_match( '/\[\\\\da-z[^\]]*\]/', file_get_contents( $source ), $matches ); $this->assertSame( 1, $matched, 'Could not find the url host class in js/formidable.js' ); - $this->assertStringContainsString( $matches[0], file_get_contents( $minified ), 'js/formidable.min.js is stale. Rebuild it so it carries the same url host class as js/formidable.js.' ); + $this->assertStringContainsString( $matches[0], file_get_contents( $minified ), 'js/formidable.min.js is stale. Rebuild it.' ); } /** From 9b6bfa4dfc13121a8f5270f83716ff3ff9735141 Mon Sep 17 00:00:00 2001 From: Nathanael Jones Date: Fri, 21 Aug 2026 21:36:52 +0100 Subject: [PATCH 03/15] Drop the inaccurate @covers on the JS regex parity test Franky's non-blocking note on #3256: test_url_field_js_regex_parity() carried @covers FrmFieldUrl::validate but never calls validate() -- it reads js/formidable.js and js/formidable.min.js and asserts on their contents. The annotation credited the method with coverage the test does not provide. Removed rather than replaced with @coversNothing, since this suite uses no @coversNothing anywhere and sets no forceCoversAnnotation/requireCoverageMetadata, so an absent @covers is the existing convention for a test that isn't a unit test of one method. Left a note in the docblock saying it deliberately carries none, and pointing at test_url_value() as the test that does exercise validate() -- confirmed that test exists and carries that annotation rather than assuming it. Fixed here instead of in a follow-up because the PR is still open and it is a one-line docblock change. Verified: php -l clean. Co-Authored-By: Claude Opus 5 (1M context) --- tests/phpunit/fields/test_FrmFieldValidate.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/fields/test_FrmFieldValidate.php b/tests/phpunit/fields/test_FrmFieldValidate.php index d4e52bb837..5a6c52df02 100644 --- a/tests/phpunit/fields/test_FrmFieldValidate.php +++ b/tests/phpunit/fields/test_FrmFieldValidate.php @@ -277,7 +277,9 @@ public function test_url_non_utf8_host_byte() { * running the regex: the code unit range is present, the PHP byte range was not copied across by * mistake, the old ASCII-only class is gone, and the minified file carries the same class. * - * @covers FrmFieldUrl::validate + * Deliberately carries no @covers: it reads js/formidable.js and js/formidable.min.js and never + * executes FrmFieldUrl::validate, so claiming to cover that method would credit it with + * coverage it does not provide. test_url_value() below is the test that exercises it. */ public function test_url_field_js_regex_parity() { $source = FrmAppHelper::plugin_path() . '/js/formidable.js'; From cadaf6cac42a4fb62c11861ccf9bd32335f6bf2e Mon Sep 17 00:00:00 2001 From: Truong Giang Date: Mon, 24 Aug 2026 17:42:33 +0700 Subject: [PATCH 04/15] Add a Vietnamese URL --- tests/phpunit/fields/test_FrmFieldValidate.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/phpunit/fields/test_FrmFieldValidate.php b/tests/phpunit/fields/test_FrmFieldValidate.php index 5a6c52df02..a79e29be3d 100644 --- a/tests/phpunit/fields/test_FrmFieldValidate.php +++ b/tests/phpunit/fields/test_FrmFieldValidate.php @@ -130,6 +130,11 @@ protected function expected_format_errors() { 'value' => 'https://пример.рф', 'invalid' => false, ), + array( + 'type' => 'url', + 'value' => 'https://càphê.vn', + 'invalid' => false, + ), array( 'type' => 'url', 'value' => 'https://a/b.com', @@ -217,6 +222,7 @@ public function test_url_idn_validation() { 'https://café.fr', 'https://пример.рф', 'https://例え.jp', + 'https://càphê.vn', 'https://ÄPFEL.DE', 'https://xn--ernhrung-2za.ch', 'https://example.com', From 460811d4636675c78693b3eabab7fd88f02f6e27 Mon Sep 17 00:00:00 2001 From: Truong Giang Date: Mon, 24 Aug 2026 17:50:54 +0700 Subject: [PATCH 05/15] Add deepsource stubs file --- composer.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/composer.json b/composer.json index a4032384d8..9c578bdca8 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,11 @@ "classes/views/" ] }, + "autoload-dev": { + "files": [ + "stubs.php" + ] + }, "require-dev": { "php-stubs/wordpress-stubs": "^5.9", "yoast/phpunit-polyfills": "^1.0", From 09c452a2c68943510ed5610f63568c6e54a83493 Mon Sep 17 00:00:00 2001 From: Truong Giang Date: Mon, 24 Aug 2026 18:14:15 +0700 Subject: [PATCH 06/15] Try to fix deepsource errors --- composer.json | 5 -- stubs.php | 146 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 9c578bdca8..a4032384d8 100644 --- a/composer.json +++ b/composer.json @@ -29,11 +29,6 @@ "classes/views/" ] }, - "autoload-dev": { - "files": [ - "stubs.php" - ] - }, "require-dev": { "php-stubs/wordpress-stubs": "^5.9", "yoast/phpunit-polyfills": "^1.0", diff --git a/stubs.php b/stubs.php index d2fbe140c7..9180db0192 100644 --- a/stubs.php +++ b/stubs.php @@ -535,10 +535,156 @@ public static function route() { } class WP_UnitTestCase_Base extends PHPUnit\Framework\TestCase { + /** + * FrmUnitTest::setUp() replaces this with a FrmUnitTestFactory, which is what every + * plugin test actually sees, so it is typed as that rather than the core WP_UnitTest_Factory. + * + * @var FrmUnitTestFactory + */ + protected $factory; } class WP_UnitTestCase extends WP_UnitTestCase_Base { } + + class WP_UnitTest_Factory { + /** + * @var WP_UnitTest_Factory_For_Post + */ + public $post; + + /** + * @var WP_UnitTest_Factory_For_Attachment + */ + public $attachment; + + /** + * @var WP_UnitTest_Factory_For_Comment + */ + public $comment; + + /** + * @var WP_UnitTest_Factory_For_User + */ + public $user; + + /** + * @var WP_UnitTest_Factory_For_Term + */ + public $term; + + /** + * @var WP_UnitTest_Factory_For_Term + */ + public $category; + + /** + * @var WP_UnitTest_Factory_For_Term + */ + public $tag; + + /** + * @var WP_UnitTest_Factory_For_Bookmark + */ + public $bookmark; + + /** + * @var WP_UnitTest_Factory_For_Blog + */ + public $blog; + + /** + * @var WP_UnitTest_Factory_For_Network + */ + public $network; + } + + abstract class WP_UnitTest_Factory_For_Thing { + public $default_generation_definitions; + public $factory; + + public function __construct( $factory, $default_generation_definitions = array() ) { + } + + abstract public function create_object( $args ); + abstract public function update_object( $object_id, $fields ); + abstract public function get_object_by_id( $object_id ); + + public function create( $args = array(), $generation_definitions = null ) { + } + + public function create_and_get( $args = array(), $generation_definitions = null ) { + } + + public function create_many( $count, $args = array(), $generation_definitions = null ) { + } + } + + class WP_UnitTest_Factory_For_Post extends WP_UnitTest_Factory_For_Thing { + public function create_object( $args ) { + } + public function update_object( $object_id, $fields ) { + } + public function get_object_by_id( $object_id ) { + } + } + + class WP_UnitTest_Factory_For_Attachment extends WP_UnitTest_Factory_For_Post { + } + + class WP_UnitTest_Factory_For_Comment extends WP_UnitTest_Factory_For_Thing { + public function create_object( $args ) { + } + public function update_object( $object_id, $fields ) { + } + public function get_object_by_id( $object_id ) { + } + } + + class WP_UnitTest_Factory_For_User extends WP_UnitTest_Factory_For_Thing { + public function create_object( $args ) { + } + public function update_object( $object_id, $fields ) { + } + public function get_object_by_id( $object_id ) { + } + } + + class WP_UnitTest_Factory_For_Term extends WP_UnitTest_Factory_For_Thing { + public function create_object( $args ) { + } + public function update_object( $object_id, $fields ) { + } + public function get_object_by_id( $object_id ) { + } + } + + class WP_UnitTest_Factory_For_Bookmark extends WP_UnitTest_Factory_For_Thing { + public function create_object( $args ) { + } + public function update_object( $object_id, $fields ) { + } + public function get_object_by_id( $object_id ) { + } + } + + class WP_UnitTest_Factory_For_Blog extends WP_UnitTest_Factory_For_Thing { + public function create_object( $args ) { + } + public function update_object( $object_id, $fields ) { + } + public function get_object_by_id( $object_id ) { + } + } + + class WP_UnitTest_Factory_For_Network extends WP_UnitTest_Factory_For_Thing { + public function create_object( $args ) { + } + public function update_object( $object_id, $fields ) { + } + public function get_object_by_id( $object_id ) { + } + } } namespace Elementor { From 3e05d623ec3518c836726498d96d16bb9d702411 Mon Sep 17 00:00:00 2001 From: Truong Giang Date: Mon, 24 Aug 2026 18:24:53 +0700 Subject: [PATCH 07/15] Try to fix deepsource errors --- classes/models/fields/FrmFieldUrl.php | 1 + stubs.php | 75 +++++++++------------------ 2 files changed, 26 insertions(+), 50 deletions(-) diff --git a/classes/models/fields/FrmFieldUrl.php b/classes/models/fields/FrmFieldUrl.php index 57d7840f87..c5f88c8243 100644 --- a/classes/models/fields/FrmFieldUrl.php +++ b/classes/models/fields/FrmFieldUrl.php @@ -86,6 +86,7 @@ public function validate( $args ) { // Byte range by design, and no /u modifier: with /u, preg_match() returns false on invalid UTF-8. if ( $value && ! preg_match( '/^http(s)?:\/\/(?:localhost|(?:[\da-z\x80-\xff\.-]+\.[\da-z\x80-\xff\.-]+))/i', $value ) ) { $errors[ 'field' . $args['id'] ] = FrmFieldsHelper::get_error_msg( $this->field, 'invalid' ); + // skipcq: PHP-W1067 -- $this->field is always a field object by the time validate() runs; FrmFieldType's constructor just accepts array|int|object for lazy construction elsewhere. } elseif ( $this->field->required == '1' && ! $value ) { // phpcs:ignore Universal.Operators.StrictComparisons $errors[ 'field' . $args['id'] ] = FrmFieldsHelper::get_error_msg( $this->field, 'blank' ); } diff --git a/stubs.php b/stubs.php index 9180db0192..3f321f2aee 100644 --- a/stubs.php +++ b/stubs.php @@ -599,11 +599,21 @@ class WP_UnitTest_Factory { public $network; } + /** + * The leaf *_For_* classes below are deliberately left abstract with no override of + * create_object()/update_object()/get_object_by_id(): they exist only so property access + * like $factory->post resolves to a type that inherits create()/create_and_get(), and an + * abstract class is never instantiated from this file, so leaving them unimplemented is + * fine for static analysis and avoids stubbing empty method bodies DeepSource flags as + * PHP-W1080 (no body) with unused-parameter findings on top. + */ abstract class WP_UnitTest_Factory_For_Thing { public $default_generation_definitions; public $factory; public function __construct( $factory, $default_generation_definitions = array() ) { + $this->factory = $factory; + $this->default_generation_definitions = $default_generation_definitions; } abstract public function create_object( $args ); @@ -611,79 +621,44 @@ abstract public function update_object( $object_id, $fields ); abstract public function get_object_by_id( $object_id ); public function create( $args = array(), $generation_definitions = null ) { + if ( $generation_definitions === null ) { + $generation_definitions = $this->default_generation_definitions; + } + + return $this->create_object( array_merge( (array) $generation_definitions, $args ) ); } public function create_and_get( $args = array(), $generation_definitions = null ) { + return $this->get_object_by_id( $this->create( $args, $generation_definitions ) ); } public function create_many( $count, $args = array(), $generation_definitions = null ) { + return array_fill( 0, $count, $this->create( $args, $generation_definitions ) ); } } - class WP_UnitTest_Factory_For_Post extends WP_UnitTest_Factory_For_Thing { - public function create_object( $args ) { - } - public function update_object( $object_id, $fields ) { - } - public function get_object_by_id( $object_id ) { - } + abstract class WP_UnitTest_Factory_For_Post extends WP_UnitTest_Factory_For_Thing { } - class WP_UnitTest_Factory_For_Attachment extends WP_UnitTest_Factory_For_Post { + abstract class WP_UnitTest_Factory_For_Attachment extends WP_UnitTest_Factory_For_Post { } - class WP_UnitTest_Factory_For_Comment extends WP_UnitTest_Factory_For_Thing { - public function create_object( $args ) { - } - public function update_object( $object_id, $fields ) { - } - public function get_object_by_id( $object_id ) { - } + abstract class WP_UnitTest_Factory_For_Comment extends WP_UnitTest_Factory_For_Thing { } - class WP_UnitTest_Factory_For_User extends WP_UnitTest_Factory_For_Thing { - public function create_object( $args ) { - } - public function update_object( $object_id, $fields ) { - } - public function get_object_by_id( $object_id ) { - } + abstract class WP_UnitTest_Factory_For_User extends WP_UnitTest_Factory_For_Thing { } - class WP_UnitTest_Factory_For_Term extends WP_UnitTest_Factory_For_Thing { - public function create_object( $args ) { - } - public function update_object( $object_id, $fields ) { - } - public function get_object_by_id( $object_id ) { - } + abstract class WP_UnitTest_Factory_For_Term extends WP_UnitTest_Factory_For_Thing { } - class WP_UnitTest_Factory_For_Bookmark extends WP_UnitTest_Factory_For_Thing { - public function create_object( $args ) { - } - public function update_object( $object_id, $fields ) { - } - public function get_object_by_id( $object_id ) { - } + abstract class WP_UnitTest_Factory_For_Bookmark extends WP_UnitTest_Factory_For_Thing { } - class WP_UnitTest_Factory_For_Blog extends WP_UnitTest_Factory_For_Thing { - public function create_object( $args ) { - } - public function update_object( $object_id, $fields ) { - } - public function get_object_by_id( $object_id ) { - } + abstract class WP_UnitTest_Factory_For_Blog extends WP_UnitTest_Factory_For_Thing { } - class WP_UnitTest_Factory_For_Network extends WP_UnitTest_Factory_For_Thing { - public function create_object( $args ) { - } - public function update_object( $object_id, $fields ) { - } - public function get_object_by_id( $object_id ) { - } + abstract class WP_UnitTest_Factory_For_Network extends WP_UnitTest_Factory_For_Thing { } } From a6cbfe73944fb4c523a8d21fe4bc98342aa27493 Mon Sep 17 00:00:00 2001 From: Truong Giang Date: Mon, 24 Aug 2026 18:34:19 +0700 Subject: [PATCH 08/15] Try to fix deepsource errors --- stubs.php | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/stubs.php b/stubs.php index 3f321f2aee..13605e04e1 100644 --- a/stubs.php +++ b/stubs.php @@ -534,6 +534,14 @@ public static function route() { } } + /** + * DeepSource's PHP analyzer excludes the vendor directory from its scan (see the + * exclude_patterns in .deepsource.toml), so it never sees PHPUnit\Framework\TestCase's real + * methods even though this class extends it - that extends clause only helps PHPStan, which + * does load vendor/. Every PHPUnit method the plugin's tests actually call is therefore + * re-declared concretely below, with a real (if simplified) body: an empty body would trip + * DeepSource's PHP-W1080, and an unused parameter would trip PHP-W1037, on every one of these. + */ class WP_UnitTestCase_Base extends PHPUnit\Framework\TestCase { /** * FrmUnitTest::setUp() replaces this with a FrmUnitTestFactory, which is what every @@ -542,6 +550,172 @@ class WP_UnitTestCase_Base extends PHPUnit\Framework\TestCase { * @var FrmUnitTestFactory */ protected $factory; + + /** + * Real PHPUnit\Framework\TestCase declares every assertion method static, so an override + * has to match that or PHP fatals with "Cannot make static method ... non static". + * + * @param bool $passed + * @param string $message + */ + protected static function stub_check( $passed, $message = '' ) { + if ( ! $passed ) { + throw new Exception( (string) $message ); + } + } + + public static function assertArrayHasKey( $key, $array, string $message = '' ): void { + self::stub_check( is_array( $array ) && array_key_exists( $key, $array ), $message ); + } + + public static function assertArrayNotHasKey( $key, $array, string $message = '' ): void { + self::stub_check( ! ( is_array( $array ) && array_key_exists( $key, $array ) ), $message ); + } + + public static function assertContains( $needle, iterable $haystack, string $message = '' ): void { + self::stub_check( in_array( $needle, is_array( $haystack ) ? $haystack : iterator_to_array( $haystack ), false ), $message ); + } + + public static function assertNotContains( $needle, iterable $haystack, string $message = '' ): void { + self::stub_check( ! in_array( $needle, is_array( $haystack ) ? $haystack : iterator_to_array( $haystack ), false ), $message ); + } + + public static function assertCount( int $expected_count, $haystack, string $message = '' ): void { + self::stub_check( is_countable( $haystack ) && count( $haystack ) === $expected_count, $message ); + } + + public static function assertEmpty( $actual, string $message = '' ): void { + self::stub_check( empty( $actual ), $message ); + } + + public static function assertNotEmpty( $actual, string $message = '' ): void { + self::stub_check( ! empty( $actual ), $message ); + } + + public static function assertEquals( $expected, $actual, string $message = '' ): void { + self::stub_check( $expected == $actual, $message ); // phpcs:ignore Universal.Operators.StrictComparisons + } + + public static function assertTrue( $condition, string $message = '' ): void { + self::stub_check( $condition === true, $message ); + } + + public static function assertFalse( $condition, string $message = '' ): void { + self::stub_check( $condition === false, $message ); + } + + public static function assertNotFalse( $condition, string $message = '' ): void { + self::stub_check( $condition !== false, $message ); + } + + public static function assertFileExists( string $filename, string $message = '' ): void { + self::stub_check( file_exists( $filename ), $message ); + } + + public static function assertGreaterThan( $expected, $actual, string $message = '' ): void { + self::stub_check( $actual > $expected, $message ); + } + + public static function assertGreaterThanOrEqual( $expected, $actual, string $message = '' ): void { + self::stub_check( $actual >= $expected, $message ); + } + + public static function assertLessThan( $expected, $actual, string $message = '' ): void { + self::stub_check( $actual < $expected, $message ); + } + + public static function assertLessThanOrEqual( $expected, $actual, string $message = '' ): void { + self::stub_check( $actual <= $expected, $message ); + } + + public static function assertInstanceOf( string $expected, $actual, string $message = '' ): void { + self::stub_check( $actual instanceof $expected, $message ); + } + + public static function assertNotInstanceOf( string $expected, $actual, string $message = '' ): void { + self::stub_check( ! ( $actual instanceof $expected ), $message ); + } + + public static function assertIsArray( $actual, string $message = '' ): void { + self::stub_check( is_array( $actual ), $message ); + } + + public static function assertIsBool( $actual, string $message = '' ): void { + self::stub_check( is_bool( $actual ), $message ); + } + + public static function assertIsObject( $actual, string $message = '' ): void { + self::stub_check( is_object( $actual ), $message ); + } + + public static function assertIsString( $actual, string $message = '' ): void { + self::stub_check( is_string( $actual ), $message ); + } + + public static function assertIsNumeric( $actual, string $message = '' ): void { + self::stub_check( is_numeric( $actual ), $message ); + } + + public static function assertIsNotNumeric( $actual, string $message = '' ): void { + self::stub_check( ! is_numeric( $actual ), $message ); + } + + public static function assertNotNull( $actual, string $message = '' ): void { + self::stub_check( $actual !== null, $message ); + } + + public static function assertNull( $actual, string $message = '' ): void { + self::stub_check( $actual === null, $message ); + } + + public static function assertSame( $expected, $actual, string $message = '' ): void { + self::stub_check( $expected === $actual, $message ); + } + + public static function assertNotSame( $expected, $actual, string $message = '' ): void { + self::stub_check( $expected !== $actual, $message ); + } + + /** + * assertObjectNotHasProperty is deliberately not overridden here: PHPUnit declares it + * final, so any override at all is a fatal "Cannot override final method" - not just a + * signature mismatch. It is only used in test_FrmEntry.php, which this stub rewrite does + * not need to cover. + */ + + public static function assertStringContainsString( string $needle, string $haystack, string $message = '' ): void { + self::stub_check( strpos( $haystack, $needle ) !== false, $message ); + } + + public static function assertStringNotContainsString( string $needle, string $haystack, string $message = '' ): void { + self::stub_check( strpos( $haystack, $needle ) === false, $message ); + } + + public static function assertStringStartsWith( string $prefix, string $string, string $message = '' ): void { + self::stub_check( strncmp( $string, $prefix, strlen( $prefix ) ) === 0, $message ); + } + + public static function fail( string $message = '' ): void { + throw new Exception( $message ); + } + + public static function markTestSkipped( string $message = '' ): void { + throw new Exception( $message ); + } + + /** + * Real WP_UnitTestCase_Base declares this one an instance method, not static. + */ + public function go_to( $url ) { + self::stub_check( is_string( $url ) ); + } + + /** + * Real WP_UnitTestCase_Base declares this one an instance method, not static. + */ + public function clean_up_global_scope() { + self::stub_check( true ); + } } class WP_UnitTestCase extends WP_UnitTestCase_Base { From e25a8bf8aa97e3c57f729a14ae7da3d21011b017 Mon Sep 17 00:00:00 2001 From: Truong Giang Date: Tue, 25 Aug 2026 00:29:39 +0700 Subject: [PATCH 09/15] Revert "Try to fix deepsource errors" This reverts commit a6cbfe73944fb4c523a8d21fe4bc98342aa27493. --- stubs.php | 174 ------------------------------------------------------ 1 file changed, 174 deletions(-) diff --git a/stubs.php b/stubs.php index 13605e04e1..3f321f2aee 100644 --- a/stubs.php +++ b/stubs.php @@ -534,14 +534,6 @@ public static function route() { } } - /** - * DeepSource's PHP analyzer excludes the vendor directory from its scan (see the - * exclude_patterns in .deepsource.toml), so it never sees PHPUnit\Framework\TestCase's real - * methods even though this class extends it - that extends clause only helps PHPStan, which - * does load vendor/. Every PHPUnit method the plugin's tests actually call is therefore - * re-declared concretely below, with a real (if simplified) body: an empty body would trip - * DeepSource's PHP-W1080, and an unused parameter would trip PHP-W1037, on every one of these. - */ class WP_UnitTestCase_Base extends PHPUnit\Framework\TestCase { /** * FrmUnitTest::setUp() replaces this with a FrmUnitTestFactory, which is what every @@ -550,172 +542,6 @@ class WP_UnitTestCase_Base extends PHPUnit\Framework\TestCase { * @var FrmUnitTestFactory */ protected $factory; - - /** - * Real PHPUnit\Framework\TestCase declares every assertion method static, so an override - * has to match that or PHP fatals with "Cannot make static method ... non static". - * - * @param bool $passed - * @param string $message - */ - protected static function stub_check( $passed, $message = '' ) { - if ( ! $passed ) { - throw new Exception( (string) $message ); - } - } - - public static function assertArrayHasKey( $key, $array, string $message = '' ): void { - self::stub_check( is_array( $array ) && array_key_exists( $key, $array ), $message ); - } - - public static function assertArrayNotHasKey( $key, $array, string $message = '' ): void { - self::stub_check( ! ( is_array( $array ) && array_key_exists( $key, $array ) ), $message ); - } - - public static function assertContains( $needle, iterable $haystack, string $message = '' ): void { - self::stub_check( in_array( $needle, is_array( $haystack ) ? $haystack : iterator_to_array( $haystack ), false ), $message ); - } - - public static function assertNotContains( $needle, iterable $haystack, string $message = '' ): void { - self::stub_check( ! in_array( $needle, is_array( $haystack ) ? $haystack : iterator_to_array( $haystack ), false ), $message ); - } - - public static function assertCount( int $expected_count, $haystack, string $message = '' ): void { - self::stub_check( is_countable( $haystack ) && count( $haystack ) === $expected_count, $message ); - } - - public static function assertEmpty( $actual, string $message = '' ): void { - self::stub_check( empty( $actual ), $message ); - } - - public static function assertNotEmpty( $actual, string $message = '' ): void { - self::stub_check( ! empty( $actual ), $message ); - } - - public static function assertEquals( $expected, $actual, string $message = '' ): void { - self::stub_check( $expected == $actual, $message ); // phpcs:ignore Universal.Operators.StrictComparisons - } - - public static function assertTrue( $condition, string $message = '' ): void { - self::stub_check( $condition === true, $message ); - } - - public static function assertFalse( $condition, string $message = '' ): void { - self::stub_check( $condition === false, $message ); - } - - public static function assertNotFalse( $condition, string $message = '' ): void { - self::stub_check( $condition !== false, $message ); - } - - public static function assertFileExists( string $filename, string $message = '' ): void { - self::stub_check( file_exists( $filename ), $message ); - } - - public static function assertGreaterThan( $expected, $actual, string $message = '' ): void { - self::stub_check( $actual > $expected, $message ); - } - - public static function assertGreaterThanOrEqual( $expected, $actual, string $message = '' ): void { - self::stub_check( $actual >= $expected, $message ); - } - - public static function assertLessThan( $expected, $actual, string $message = '' ): void { - self::stub_check( $actual < $expected, $message ); - } - - public static function assertLessThanOrEqual( $expected, $actual, string $message = '' ): void { - self::stub_check( $actual <= $expected, $message ); - } - - public static function assertInstanceOf( string $expected, $actual, string $message = '' ): void { - self::stub_check( $actual instanceof $expected, $message ); - } - - public static function assertNotInstanceOf( string $expected, $actual, string $message = '' ): void { - self::stub_check( ! ( $actual instanceof $expected ), $message ); - } - - public static function assertIsArray( $actual, string $message = '' ): void { - self::stub_check( is_array( $actual ), $message ); - } - - public static function assertIsBool( $actual, string $message = '' ): void { - self::stub_check( is_bool( $actual ), $message ); - } - - public static function assertIsObject( $actual, string $message = '' ): void { - self::stub_check( is_object( $actual ), $message ); - } - - public static function assertIsString( $actual, string $message = '' ): void { - self::stub_check( is_string( $actual ), $message ); - } - - public static function assertIsNumeric( $actual, string $message = '' ): void { - self::stub_check( is_numeric( $actual ), $message ); - } - - public static function assertIsNotNumeric( $actual, string $message = '' ): void { - self::stub_check( ! is_numeric( $actual ), $message ); - } - - public static function assertNotNull( $actual, string $message = '' ): void { - self::stub_check( $actual !== null, $message ); - } - - public static function assertNull( $actual, string $message = '' ): void { - self::stub_check( $actual === null, $message ); - } - - public static function assertSame( $expected, $actual, string $message = '' ): void { - self::stub_check( $expected === $actual, $message ); - } - - public static function assertNotSame( $expected, $actual, string $message = '' ): void { - self::stub_check( $expected !== $actual, $message ); - } - - /** - * assertObjectNotHasProperty is deliberately not overridden here: PHPUnit declares it - * final, so any override at all is a fatal "Cannot override final method" - not just a - * signature mismatch. It is only used in test_FrmEntry.php, which this stub rewrite does - * not need to cover. - */ - - public static function assertStringContainsString( string $needle, string $haystack, string $message = '' ): void { - self::stub_check( strpos( $haystack, $needle ) !== false, $message ); - } - - public static function assertStringNotContainsString( string $needle, string $haystack, string $message = '' ): void { - self::stub_check( strpos( $haystack, $needle ) === false, $message ); - } - - public static function assertStringStartsWith( string $prefix, string $string, string $message = '' ): void { - self::stub_check( strncmp( $string, $prefix, strlen( $prefix ) ) === 0, $message ); - } - - public static function fail( string $message = '' ): void { - throw new Exception( $message ); - } - - public static function markTestSkipped( string $message = '' ): void { - throw new Exception( $message ); - } - - /** - * Real WP_UnitTestCase_Base declares this one an instance method, not static. - */ - public function go_to( $url ) { - self::stub_check( is_string( $url ) ); - } - - /** - * Real WP_UnitTestCase_Base declares this one an instance method, not static. - */ - public function clean_up_global_scope() { - self::stub_check( true ); - } } class WP_UnitTestCase extends WP_UnitTestCase_Base { From 38cfca3cadf57661e1db427514193a5d02389300 Mon Sep 17 00:00:00 2001 From: Truong Giang Date: Tue, 25 Aug 2026 00:29:39 +0700 Subject: [PATCH 10/15] Revert "Try to fix deepsource errors" This reverts commit 3e05d623ec3518c836726498d96d16bb9d702411. --- classes/models/fields/FrmFieldUrl.php | 1 - stubs.php | 75 ++++++++++++++++++--------- 2 files changed, 50 insertions(+), 26 deletions(-) diff --git a/classes/models/fields/FrmFieldUrl.php b/classes/models/fields/FrmFieldUrl.php index c5f88c8243..57d7840f87 100644 --- a/classes/models/fields/FrmFieldUrl.php +++ b/classes/models/fields/FrmFieldUrl.php @@ -86,7 +86,6 @@ public function validate( $args ) { // Byte range by design, and no /u modifier: with /u, preg_match() returns false on invalid UTF-8. if ( $value && ! preg_match( '/^http(s)?:\/\/(?:localhost|(?:[\da-z\x80-\xff\.-]+\.[\da-z\x80-\xff\.-]+))/i', $value ) ) { $errors[ 'field' . $args['id'] ] = FrmFieldsHelper::get_error_msg( $this->field, 'invalid' ); - // skipcq: PHP-W1067 -- $this->field is always a field object by the time validate() runs; FrmFieldType's constructor just accepts array|int|object for lazy construction elsewhere. } elseif ( $this->field->required == '1' && ! $value ) { // phpcs:ignore Universal.Operators.StrictComparisons $errors[ 'field' . $args['id'] ] = FrmFieldsHelper::get_error_msg( $this->field, 'blank' ); } diff --git a/stubs.php b/stubs.php index 3f321f2aee..9180db0192 100644 --- a/stubs.php +++ b/stubs.php @@ -599,21 +599,11 @@ class WP_UnitTest_Factory { public $network; } - /** - * The leaf *_For_* classes below are deliberately left abstract with no override of - * create_object()/update_object()/get_object_by_id(): they exist only so property access - * like $factory->post resolves to a type that inherits create()/create_and_get(), and an - * abstract class is never instantiated from this file, so leaving them unimplemented is - * fine for static analysis and avoids stubbing empty method bodies DeepSource flags as - * PHP-W1080 (no body) with unused-parameter findings on top. - */ abstract class WP_UnitTest_Factory_For_Thing { public $default_generation_definitions; public $factory; public function __construct( $factory, $default_generation_definitions = array() ) { - $this->factory = $factory; - $this->default_generation_definitions = $default_generation_definitions; } abstract public function create_object( $args ); @@ -621,44 +611,79 @@ abstract public function update_object( $object_id, $fields ); abstract public function get_object_by_id( $object_id ); public function create( $args = array(), $generation_definitions = null ) { - if ( $generation_definitions === null ) { - $generation_definitions = $this->default_generation_definitions; - } - - return $this->create_object( array_merge( (array) $generation_definitions, $args ) ); } public function create_and_get( $args = array(), $generation_definitions = null ) { - return $this->get_object_by_id( $this->create( $args, $generation_definitions ) ); } public function create_many( $count, $args = array(), $generation_definitions = null ) { - return array_fill( 0, $count, $this->create( $args, $generation_definitions ) ); } } - abstract class WP_UnitTest_Factory_For_Post extends WP_UnitTest_Factory_For_Thing { + class WP_UnitTest_Factory_For_Post extends WP_UnitTest_Factory_For_Thing { + public function create_object( $args ) { + } + public function update_object( $object_id, $fields ) { + } + public function get_object_by_id( $object_id ) { + } } - abstract class WP_UnitTest_Factory_For_Attachment extends WP_UnitTest_Factory_For_Post { + class WP_UnitTest_Factory_For_Attachment extends WP_UnitTest_Factory_For_Post { } - abstract class WP_UnitTest_Factory_For_Comment extends WP_UnitTest_Factory_For_Thing { + class WP_UnitTest_Factory_For_Comment extends WP_UnitTest_Factory_For_Thing { + public function create_object( $args ) { + } + public function update_object( $object_id, $fields ) { + } + public function get_object_by_id( $object_id ) { + } } - abstract class WP_UnitTest_Factory_For_User extends WP_UnitTest_Factory_For_Thing { + class WP_UnitTest_Factory_For_User extends WP_UnitTest_Factory_For_Thing { + public function create_object( $args ) { + } + public function update_object( $object_id, $fields ) { + } + public function get_object_by_id( $object_id ) { + } } - abstract class WP_UnitTest_Factory_For_Term extends WP_UnitTest_Factory_For_Thing { + class WP_UnitTest_Factory_For_Term extends WP_UnitTest_Factory_For_Thing { + public function create_object( $args ) { + } + public function update_object( $object_id, $fields ) { + } + public function get_object_by_id( $object_id ) { + } } - abstract class WP_UnitTest_Factory_For_Bookmark extends WP_UnitTest_Factory_For_Thing { + class WP_UnitTest_Factory_For_Bookmark extends WP_UnitTest_Factory_For_Thing { + public function create_object( $args ) { + } + public function update_object( $object_id, $fields ) { + } + public function get_object_by_id( $object_id ) { + } } - abstract class WP_UnitTest_Factory_For_Blog extends WP_UnitTest_Factory_For_Thing { + class WP_UnitTest_Factory_For_Blog extends WP_UnitTest_Factory_For_Thing { + public function create_object( $args ) { + } + public function update_object( $object_id, $fields ) { + } + public function get_object_by_id( $object_id ) { + } } - abstract class WP_UnitTest_Factory_For_Network extends WP_UnitTest_Factory_For_Thing { + class WP_UnitTest_Factory_For_Network extends WP_UnitTest_Factory_For_Thing { + public function create_object( $args ) { + } + public function update_object( $object_id, $fields ) { + } + public function get_object_by_id( $object_id ) { + } } } From 28d26136352f20e06c406e779ab8284487985d85 Mon Sep 17 00:00:00 2001 From: Truong Giang Date: Tue, 25 Aug 2026 00:29:39 +0700 Subject: [PATCH 11/15] Revert "Try to fix deepsource errors" This reverts commit 09c452a2c68943510ed5610f63568c6e54a83493. --- composer.json | 5 ++ stubs.php | 146 -------------------------------------------------- 2 files changed, 5 insertions(+), 146 deletions(-) diff --git a/composer.json b/composer.json index a4032384d8..9c578bdca8 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,11 @@ "classes/views/" ] }, + "autoload-dev": { + "files": [ + "stubs.php" + ] + }, "require-dev": { "php-stubs/wordpress-stubs": "^5.9", "yoast/phpunit-polyfills": "^1.0", diff --git a/stubs.php b/stubs.php index 9180db0192..d2fbe140c7 100644 --- a/stubs.php +++ b/stubs.php @@ -535,156 +535,10 @@ public static function route() { } class WP_UnitTestCase_Base extends PHPUnit\Framework\TestCase { - /** - * FrmUnitTest::setUp() replaces this with a FrmUnitTestFactory, which is what every - * plugin test actually sees, so it is typed as that rather than the core WP_UnitTest_Factory. - * - * @var FrmUnitTestFactory - */ - protected $factory; } class WP_UnitTestCase extends WP_UnitTestCase_Base { } - - class WP_UnitTest_Factory { - /** - * @var WP_UnitTest_Factory_For_Post - */ - public $post; - - /** - * @var WP_UnitTest_Factory_For_Attachment - */ - public $attachment; - - /** - * @var WP_UnitTest_Factory_For_Comment - */ - public $comment; - - /** - * @var WP_UnitTest_Factory_For_User - */ - public $user; - - /** - * @var WP_UnitTest_Factory_For_Term - */ - public $term; - - /** - * @var WP_UnitTest_Factory_For_Term - */ - public $category; - - /** - * @var WP_UnitTest_Factory_For_Term - */ - public $tag; - - /** - * @var WP_UnitTest_Factory_For_Bookmark - */ - public $bookmark; - - /** - * @var WP_UnitTest_Factory_For_Blog - */ - public $blog; - - /** - * @var WP_UnitTest_Factory_For_Network - */ - public $network; - } - - abstract class WP_UnitTest_Factory_For_Thing { - public $default_generation_definitions; - public $factory; - - public function __construct( $factory, $default_generation_definitions = array() ) { - } - - abstract public function create_object( $args ); - abstract public function update_object( $object_id, $fields ); - abstract public function get_object_by_id( $object_id ); - - public function create( $args = array(), $generation_definitions = null ) { - } - - public function create_and_get( $args = array(), $generation_definitions = null ) { - } - - public function create_many( $count, $args = array(), $generation_definitions = null ) { - } - } - - class WP_UnitTest_Factory_For_Post extends WP_UnitTest_Factory_For_Thing { - public function create_object( $args ) { - } - public function update_object( $object_id, $fields ) { - } - public function get_object_by_id( $object_id ) { - } - } - - class WP_UnitTest_Factory_For_Attachment extends WP_UnitTest_Factory_For_Post { - } - - class WP_UnitTest_Factory_For_Comment extends WP_UnitTest_Factory_For_Thing { - public function create_object( $args ) { - } - public function update_object( $object_id, $fields ) { - } - public function get_object_by_id( $object_id ) { - } - } - - class WP_UnitTest_Factory_For_User extends WP_UnitTest_Factory_For_Thing { - public function create_object( $args ) { - } - public function update_object( $object_id, $fields ) { - } - public function get_object_by_id( $object_id ) { - } - } - - class WP_UnitTest_Factory_For_Term extends WP_UnitTest_Factory_For_Thing { - public function create_object( $args ) { - } - public function update_object( $object_id, $fields ) { - } - public function get_object_by_id( $object_id ) { - } - } - - class WP_UnitTest_Factory_For_Bookmark extends WP_UnitTest_Factory_For_Thing { - public function create_object( $args ) { - } - public function update_object( $object_id, $fields ) { - } - public function get_object_by_id( $object_id ) { - } - } - - class WP_UnitTest_Factory_For_Blog extends WP_UnitTest_Factory_For_Thing { - public function create_object( $args ) { - } - public function update_object( $object_id, $fields ) { - } - public function get_object_by_id( $object_id ) { - } - } - - class WP_UnitTest_Factory_For_Network extends WP_UnitTest_Factory_For_Thing { - public function create_object( $args ) { - } - public function update_object( $object_id, $fields ) { - } - public function get_object_by_id( $object_id ) { - } - } } namespace Elementor { From a07e85ccda630419a7e954cd2ee94301d8a2c559 Mon Sep 17 00:00:00 2001 From: Truong Giang Date: Tue, 25 Aug 2026 00:29:39 +0700 Subject: [PATCH 12/15] Revert "Add deepsource stubs file" This reverts commit 460811d4636675c78693b3eabab7fd88f02f6e27. --- composer.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/composer.json b/composer.json index 9c578bdca8..a4032384d8 100644 --- a/composer.json +++ b/composer.json @@ -29,11 +29,6 @@ "classes/views/" ] }, - "autoload-dev": { - "files": [ - "stubs.php" - ] - }, "require-dev": { "php-stubs/wordpress-stubs": "^5.9", "yoast/phpunit-polyfills": "^1.0", From 8160570f10ced55dce69a7dd1a224fcfbe28627c Mon Sep 17 00:00:00 2001 From: Truong Giang Date: Tue, 25 Aug 2026 01:35:07 +0700 Subject: [PATCH 13/15] Apply suggestion from @franky-the-going-merry[bot] Co-authored-by: franky-the-going-merry[bot] <300681989+franky-the-going-merry[bot]@users.noreply.github.com> --- classes/models/fields/FrmFieldUrl.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classes/models/fields/FrmFieldUrl.php b/classes/models/fields/FrmFieldUrl.php index c5f88c8243..4719c4c022 100644 --- a/classes/models/fields/FrmFieldUrl.php +++ b/classes/models/fields/FrmFieldUrl.php @@ -86,7 +86,7 @@ public function validate( $args ) { // Byte range by design, and no /u modifier: with /u, preg_match() returns false on invalid UTF-8. if ( $value && ! preg_match( '/^http(s)?:\/\/(?:localhost|(?:[\da-z\x80-\xff\.-]+\.[\da-z\x80-\xff\.-]+))/i', $value ) ) { $errors[ 'field' . $args['id'] ] = FrmFieldsHelper::get_error_msg( $this->field, 'invalid' ); - // skipcq: PHP-W1067 -- $this->field is always a field object by the time validate() runs; FrmFieldType's constructor just accepts array|int|object for lazy construction elsewhere. + // skipcq: PHP-W1067 -- $this->field is always a real object here; array|int|object only matters during lazy construction elsewhere. } elseif ( $this->field->required == '1' && ! $value ) { // phpcs:ignore Universal.Operators.StrictComparisons $errors[ 'field' . $args['id'] ] = FrmFieldsHelper::get_error_msg( $this->field, 'blank' ); } From 89415499a3ceb51a01f11241e3d979b4f3352593 Mon Sep 17 00:00:00 2001 From: Truong Giang Date: Tue, 25 Aug 2026 14:37:06 +0700 Subject: [PATCH 14/15] Remove unnecessary change --- classes/models/fields/FrmFieldUrl.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classes/models/fields/FrmFieldUrl.php b/classes/models/fields/FrmFieldUrl.php index 4719c4c022..ff44311efb 100644 --- a/classes/models/fields/FrmFieldUrl.php +++ b/classes/models/fields/FrmFieldUrl.php @@ -86,7 +86,7 @@ public function validate( $args ) { // Byte range by design, and no /u modifier: with /u, preg_match() returns false on invalid UTF-8. if ( $value && ! preg_match( '/^http(s)?:\/\/(?:localhost|(?:[\da-z\x80-\xff\.-]+\.[\da-z\x80-\xff\.-]+))/i', $value ) ) { $errors[ 'field' . $args['id'] ] = FrmFieldsHelper::get_error_msg( $this->field, 'invalid' ); - // skipcq: PHP-W1067 -- $this->field is always a real object here; array|int|object only matters during lazy construction elsewhere. + // skipcq: PHP-W1067 -- $this->field is always a field object by the time validate() runs; FrmFieldType's constructor just accepts array|int|object for lazy construction elsewhere. } elseif ( $this->field->required == '1' && ! $value ) { // phpcs:ignore Universal.Operators.StrictComparisons $errors[ 'field' . $args['id'] ] = FrmFieldsHelper::get_error_msg( $this->field, 'blank' ); } From 21291451e428039ea7aeed09a3aacb2791e91de6 Mon Sep 17 00:00:00 2001 From: Truong Giang Date: Tue, 25 Aug 2026 15:24:04 +0700 Subject: [PATCH 15/15] Try to fix deepsource error --- stubs.php | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/stubs.php b/stubs.php index bd4b1486fc..aadebb49dd 100644 --- a/stubs.php +++ b/stubs.php @@ -544,13 +544,20 @@ public static function route() { */ class WP_UnitTestCase_Base extends PHPUnit\Framework\TestCase { /** - * FrmUnitTest::setUp() actually replaces this with a FrmUnitTestFactory, but that class - * lives under tests/, which phpstan.neon excludes from the analysis paths - PHPStan would - * report "unknown class" for a type it can never load. WP_UnitTest_Factory is the real - * base type and is declared below, so it resolves. - * - * @var WP_UnitTest_Factory - */ + * FrmUnitTest::setUp() replaces this with a FrmUnitTestFactory, which is what every + * plugin test actually sees and calls ->field/->entry/->form on, so it is typed as that + * rather than the base WP_UnitTest_Factory - DeepSource and mago both scan tests/ and + * resolve FrmUnitTestFactory fine. Redeclaring ->field/->entry/->form directly on + * WP_UnitTest_Factory instead was tried and reverted: mago requires a redeclared + * property's docblock type to match its parent exactly, and that broke the REAL + * FrmUnitTestFactory in tests/phpunit/base/frm_factory.php, which types them more + * specifically (Field_Factory|null, etc). PHPStan can't load FrmUnitTestFactory itself, + * since phpstan.neon excludes tests/ from its analysis paths - that single-line ignore + * is scoped here rather than added to phpstan.neon. + * + * @var FrmUnitTestFactory + */ + // @phpstan-ignore class.notFound protected $factory; /**