Skip to content

Commit 4ac394b

Browse files
committed
fixed HTMLFormElement.checkValidity()/reportValidity()
1 parent 5cf8b9a commit 4ac394b

3 files changed

Lines changed: 347 additions & 2 deletions

File tree

src/changes/changes.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88

99
<body>
1010
<release version="5.4.0" date="August xx, 2026" description="Firefox 153, Bugfixes">
11+
<action type="fix" dev="rbri">
12+
Fixed HTMLFormElement.checkValidity()/reportValidity(): the aggregate check over a form's controls
13+
now correctly fires on every failing control.
14+
</action>
1115
<action type="add" dev="rbri">
1216
Fixed checkValidity()/reportValidity(): a failing control now correctly fires a cancelable 'invalid'
1317
event (and reportValidity() additionally focuses the first invalid control).

src/main/java/org/htmlunit/javascript/host/html/HTMLFormElement.java

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -547,11 +547,26 @@ && getBrowserVersion().hasFeature(JS_FORM_DISPATCHEVENT_SUBMITS)) {
547547

548548
/**
549549
* Checks whether the element has any constraints and whether it satisfies them.
550-
* @return {@code true} if the element is valid
550+
* <p>
551+
* NOTE: this deliberately does NOT go through each control's JS peer/host
552+
* object -- the org.htmlunit.html (DOM) layer is not permitted to reach
553+
* back up into org.htmlunit.javascript.host.*. Instead,
554+
* {@link ValidatableHTMLElement#doCheckValidity(HtmlElement)} operates
555+
* purely on the Java HtmlElement side, firing the 'invalid' event directly
556+
* via the element itself; this still reaches any 'invalid' listeners
557+
* attached via addEventListener, since event dispatch doesn't require
558+
* going through a specific JS peer to notify listeners.
559+
* </p>
560+
* @return {@code true} if every one of the form's validatable controls is valid
551561
*/
552562
@JsxFunction
553563
public boolean checkValidity() {
554-
return getDomNodeOrDie().isValid();
564+
boolean allValid = true;
565+
for (final HtmlElement element : ((HtmlForm) getDomNodeOrDie()).getFormElements()) {
566+
final boolean elementValid = ValidatableHTMLElement.doCheckValidity(element);
567+
allValid = allValid && elementValid;
568+
}
569+
return allValid;
555570
}
556571

557572
/**

src/test/java/org/htmlunit/javascript/host/html/HTMLFormElementTest.java

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3645,4 +3645,330 @@ public void elementsForOf() throws Exception {
36453645

36463646
loadPageVerifyTitle2(html);
36473647
}
3648+
3649+
/**
3650+
* A control with a CUSTOM validity message (not a built-in constraint like
3651+
* required) must also block submission.
3652+
* @throws Exception if the test fails
3653+
*/
3654+
@Test
3655+
@Alerts("first")
3656+
public void submitBlockedByCustomValidityOnAnyControl() throws Exception {
3657+
final String html = DOCTYPE_HTML
3658+
+ "<html>\n"
3659+
+ "<head><title>first</title>\n"
3660+
+ "<script>\n"
3661+
+ " function markInvalid() {\n"
3662+
+ " document.getElementsByName('test')[0].setCustomValidity('custom error');\n"
3663+
+ " }\n"
3664+
+ "</script>\n"
3665+
+ "</head>\n"
3666+
+ "<body onload='markInvalid()'>\n"
3667+
+ " <form name='testForm' action='" + URL_SECOND + "'>\n"
3668+
+ " <input type='submit' id='submit'>\n"
3669+
+ " <input name='test' value='anything'>"
3670+
+ " </form>\n"
3671+
+ "</body></html>";
3672+
3673+
final String html2 = "<?xml version='1.0'?>\n"
3674+
+ "<html>\n"
3675+
+ "<head><title>second</title></head>\n"
3676+
+ "<body>OK</body></html>";
3677+
getMockWebConnection().setDefaultResponse(html2);
3678+
3679+
final WebDriver driver = loadPage2(html);
3680+
driver.findElement(By.id("submit")).click();
3681+
if (useRealBrowser()) {
3682+
Thread.sleep(400);
3683+
}
3684+
3685+
assertEquals(getExpectedAlerts()[0], driver.getTitle());
3686+
}
3687+
3688+
/**
3689+
* A DISABLED, required, empty field must NOT block submission.
3690+
* @throws Exception if the test fails
3691+
*/
3692+
@Test
3693+
@Alerts("second")
3694+
@HtmlUnitNYI(CHROME = "first",
3695+
EDGE = "first",
3696+
FF = "first",
3697+
FF_ESR = "first")
3698+
public void submitNotBlockedByDisabledRequiredEmptyField() throws Exception {
3699+
final String html = DOCTYPE_HTML
3700+
+ "<html>\n"
3701+
+ "<head><title>first</title></head>\n"
3702+
+ "<body>\n"
3703+
+ " <form name='testForm' action='" + URL_SECOND + "'>\n"
3704+
+ " <input type='submit' id='submit'>\n"
3705+
+ " <input name='test' value='' required='required' disabled>"
3706+
+ " </form>\n"
3707+
+ "</body></html>";
3708+
3709+
final String html2 = "<?xml version='1.0'?>\n"
3710+
+ "<html>\n"
3711+
+ "<head><title>second</title></head>\n"
3712+
+ "<body>OK</body></html>";
3713+
getMockWebConnection().setDefaultResponse(html2);
3714+
3715+
final WebDriver driver = loadPage2(html);
3716+
driver.findElement(By.id("submit")).click();
3717+
if (useRealBrowser()) {
3718+
Thread.sleep(400);
3719+
}
3720+
3721+
assertEquals(getExpectedAlerts()[0], driver.getTitle());
3722+
}
3723+
3724+
/**
3725+
* The form.checkValidity() must return false when a required field is empty.
3726+
* @throws Exception if an error occurs
3727+
*/
3728+
@Test
3729+
@Alerts("false")
3730+
public void formCheckValidityFalseWhenRequiredFieldEmpty() throws Exception {
3731+
final String html = DOCTYPE_HTML
3732+
+ "<html><head>\n"
3733+
+ "<script>\n"
3734+
+ LOG_TITLE_FUNCTION
3735+
+ " function test() {\n"
3736+
+ " log(document.getElementById('f').checkValidity());\n"
3737+
+ " }\n"
3738+
+ "</script></head>\n"
3739+
+ "<body onload='test()'>\n"
3740+
+ " <form id='f'>\n"
3741+
+ " <input name='test' value='' required>\n"
3742+
+ " </form>\n"
3743+
+ "</body></html>";
3744+
3745+
loadPageVerifyTitle2(html);
3746+
}
3747+
3748+
/**
3749+
* The form.checkValidity() must return true when all fields are individually
3750+
* valid.
3751+
* @throws Exception if an error occurs
3752+
*/
3753+
@Test
3754+
@Alerts("true")
3755+
public void formCheckValidityTrueWhenAllFieldsValid() throws Exception {
3756+
final String html = DOCTYPE_HTML
3757+
+ "<html><head>\n"
3758+
+ "<script>\n"
3759+
+ LOG_TITLE_FUNCTION
3760+
+ " function test() {\n"
3761+
+ " log(document.getElementById('f').checkValidity());\n"
3762+
+ " }\n"
3763+
+ "</script></head>\n"
3764+
+ "<body onload='test()'>\n"
3765+
+ " <form id='f'>\n"
3766+
+ " <input name='test' value='filled' required>\n"
3767+
+ " </form>\n"
3768+
+ "</body></html>";
3769+
3770+
loadPageVerifyTitle2(html);
3771+
}
3772+
3773+
/**
3774+
* A disabled, required, empty field must be SKIPPED by
3775+
* form.checkValidity() (since willValidate is false for it), not counted
3776+
* as either passing or failing -- so the form is valid overall as long as
3777+
* every OTHER, non-barred field is valid.
3778+
* @throws Exception if an error occurs
3779+
*/
3780+
@Test
3781+
@Alerts("true")
3782+
public void formCheckValidityIgnoresDisabledFields() throws Exception {
3783+
final String html = DOCTYPE_HTML
3784+
+ "<html><head>\n"
3785+
+ "<script>\n"
3786+
+ LOG_TITLE_FUNCTION
3787+
+ " function test() {\n"
3788+
+ " log(document.getElementById('f').checkValidity());\n"
3789+
+ " }\n"
3790+
+ "</script></head>\n"
3791+
+ "<body onload='test()'>\n"
3792+
+ " <form id='f'>\n"
3793+
+ " <input name='disabledField' value='' required disabled>\n"
3794+
+ " <input name='okField' value='filled'>\n"
3795+
+ " </form>\n"
3796+
+ "</body></html>";
3797+
3798+
loadPageVerifyTitle2(html);
3799+
}
3800+
3801+
/**
3802+
* A &lt;button type='button'&gt; with a custom validity message
3803+
* set must NOT cause form.checkValidity() to fail.
3804+
* @throws Exception if an error occurs
3805+
*/
3806+
@Test
3807+
@Alerts("true")
3808+
public void formCheckValidityIgnoresBarredButtonType() throws Exception {
3809+
final String html = DOCTYPE_HTML
3810+
+ "<html><head>\n"
3811+
+ "<script>\n"
3812+
+ LOG_TITLE_FUNCTION
3813+
+ " function test() {\n"
3814+
+ " document.getElementById('btn').setCustomValidity('button error');\n"
3815+
+ " log(document.getElementById('f').checkValidity());\n"
3816+
+ " }\n"
3817+
+ "</script></head>\n"
3818+
+ "<body onload='test()'>\n"
3819+
+ " <form id='f'>\n"
3820+
+ " <input name='okField' value='filled'>\n"
3821+
+ " <button id='btn' type='button'>not a submitter</button>\n"
3822+
+ " </form>\n"
3823+
+ "</body></html>";
3824+
3825+
loadPageVerifyTitle2(html);
3826+
}
3827+
3828+
/**
3829+
* The form.reportValidity() must return the same boolean as
3830+
* form.checkValidity() for the same invalid form.
3831+
* @throws Exception if an error occurs
3832+
*/
3833+
@Test
3834+
@Alerts({"false", "false"})
3835+
public void formReportValidityMatchesCheckValidity() throws Exception {
3836+
final String html = DOCTYPE_HTML
3837+
+ "<html><head>\n"
3838+
+ "<script>\n"
3839+
+ LOG_TITLE_FUNCTION
3840+
+ " function test() {\n"
3841+
+ " var f = document.getElementById('f');\n"
3842+
+ " log(f.checkValidity());\n"
3843+
+ " log(f.reportValidity());\n"
3844+
+ " }\n"
3845+
+ "</script></head>\n"
3846+
+ "<body onload='test()'>\n"
3847+
+ " <form id='f'>\n"
3848+
+ " <input name='test' value='' required>\n"
3849+
+ " </form>\n"
3850+
+ "</body></html>";
3851+
3852+
loadPageVerifyTitle2(html);
3853+
}
3854+
3855+
/**
3856+
* The form.reportValidity() -- interactive validation -- must focus
3857+
* the FIRST invalid control it finds when the form is invalid.
3858+
* @throws Exception if an error occurs
3859+
*/
3860+
@Test
3861+
@Alerts("invalid1")
3862+
@HtmlUnitNYI(CHROME = "",
3863+
EDGE = "",
3864+
FF = "",
3865+
FF_ESR = "")
3866+
public void formReportValidityFocusesFirstInvalidControl() throws Exception {
3867+
final String html = DOCTYPE_HTML
3868+
+ "<html><head>\n"
3869+
+ "<script>\n"
3870+
+ LOG_TITLE_FUNCTION
3871+
+ " function test() {\n"
3872+
+ " document.getElementById('f').reportValidity();\n"
3873+
+ " log(document.activeElement.id);\n"
3874+
+ " }\n"
3875+
+ "</script></head>\n"
3876+
+ "<body onload='test()'>\n"
3877+
+ " <form id='f'>\n"
3878+
+ " <input id='valid1' value='ok'>\n"
3879+
+ " <input id='invalid1' value='' required>\n"
3880+
+ " <input id='invalid2' value='' required>\n"
3881+
+ " </form>\n"
3882+
+ "</body></html>";
3883+
3884+
loadPageVerifyTitle2(html);
3885+
}
3886+
3887+
/**
3888+
* Contrast to the above: form.checkValidity() -- static validation -- must
3889+
* NOT move focus at all, even though the form is invalid.
3890+
* @throws Exception if an error occurs
3891+
*/
3892+
@Test
3893+
@Alerts({"true", "true"})
3894+
public void formCheckValidityDoesNotMoveFocus() throws Exception {
3895+
final String html = DOCTYPE_HTML
3896+
+ "<html><head>\n"
3897+
+ "<script>\n"
3898+
+ LOG_TITLE_FUNCTION
3899+
+ " function test() {\n"
3900+
+ " var before = document.activeElement === document.body;\n"
3901+
+ " document.getElementById('f').checkValidity();\n"
3902+
+ " var after = document.activeElement === document.body;\n"
3903+
+ " log(before);\n"
3904+
+ " log(after);\n"
3905+
+ " }\n"
3906+
+ "</script></head>\n"
3907+
+ "<body onload='test()'>\n"
3908+
+ " <form id='f'>\n"
3909+
+ " <input id='invalid1' value='' required>\n"
3910+
+ " </form>\n"
3911+
+ "</body></html>";
3912+
3913+
loadPageVerifyTitle2(html);
3914+
}
3915+
3916+
/**
3917+
* The form.checkValidity() must fire an 'invalid' event on the failing control.
3918+
* @throws Exception if an error occurs
3919+
*/
3920+
@Test
3921+
@Alerts("invalid fired")
3922+
public void formCheckValidityFiresInvalidEventOnFailingControl() throws Exception {
3923+
final String html = DOCTYPE_HTML
3924+
+ "<html><head>\n"
3925+
+ "<script>\n"
3926+
+ LOG_TITLE_FUNCTION
3927+
+ " function test() {\n"
3928+
+ " document.getElementById('i1').addEventListener('invalid', function() {\n"
3929+
+ " log('invalid fired');\n"
3930+
+ " });\n"
3931+
+ " document.getElementById('f').checkValidity();\n"
3932+
+ " }\n"
3933+
+ "</script></head>\n"
3934+
+ "<body onload='test()'>\n"
3935+
+ " <form id='f'>\n"
3936+
+ " <input id='i1' value='' required>\n"
3937+
+ " </form>\n"
3938+
+ "</body></html>";
3939+
3940+
loadPageVerifyTitle2(html);
3941+
}
3942+
3943+
/**
3944+
* With MULTIPLE invalid controls, form.checkValidity() must fire
3945+
* 'invalid' on EACH of them.
3946+
* @throws Exception if an error occurs
3947+
*/
3948+
@Test
3949+
@Alerts({"i1 invalid", "i2 invalid", "false"})
3950+
public void formCheckValidityFiresInvalidEventOnAllFailingControls() throws Exception {
3951+
final String html = DOCTYPE_HTML
3952+
+ "<html><head>\n"
3953+
+ "<script>\n"
3954+
+ LOG_TITLE_FUNCTION
3955+
+ " function test() {\n"
3956+
+ " document.getElementById('i1').addEventListener('invalid', function() {\n"
3957+
+ " log('i1 invalid');\n"
3958+
+ " });\n"
3959+
+ " document.getElementById('i2').addEventListener('invalid', function() {\n"
3960+
+ " log('i2 invalid');\n"
3961+
+ " });\n"
3962+
+ " log(document.getElementById('f').checkValidity());\n"
3963+
+ " }\n"
3964+
+ "</script></head>\n"
3965+
+ "<body onload='test()'>\n"
3966+
+ " <form id='f'>\n"
3967+
+ " <input id='i1' value='' required>\n"
3968+
+ " <input id='i2' value='' required>\n"
3969+
+ " </form>\n"
3970+
+ "</body></html>";
3971+
3972+
loadPageVerifyTitle2(html);
3973+
}
36483974
}

0 commit comments

Comments
 (0)