Add test case for nested form elements, refactor logic (#1252)

This commit is contained in:
Leendert de Borst
2025-09-19 12:28:55 +02:00
committed by Leendert de Borst
parent 74cb2eae7d
commit 09cfee2888
4 changed files with 195 additions and 9 deletions

View File

@@ -479,14 +479,18 @@ export class FormDetector {
excludeElements: HTMLInputElement[] = []
): HTMLInputElement | null {
const all = this.findAllInputFields(form, patterns, types, excludeElements);
// Filter out parent-child duplicates
const filtered = this.filterOutNestedDuplicates(all);
// if email type explicitly requested, prefer actual <input type="email">
if (types.includes('email')) {
const emailMatch = all.find(i => (i.type || '').toLowerCase() === 'email');
const emailMatch = filtered.find(i => (i.type || '').toLowerCase() === 'email');
if (emailMatch) {
return emailMatch;
}
}
return all.length > 0 ? all[0] : null;
return filtered.length > 0 ? filtered[0] : null;
}
/**
@@ -496,25 +500,32 @@ export class FormDetector {
primary: HTMLInputElement | null,
confirm: HTMLInputElement | null
} {
// Find primary email field
const primaryEmail = this.findInputField(
// Find all email fields first
const emailFields = this.findAllInputFields(
form,
CombinedFieldPatterns.email,
['text', 'email']
);
// Filter out parent-child relationships
const filteredEmailFields = this.filterOutNestedDuplicates(emailFields);
const primaryEmail = filteredEmailFields[0] ?? null;
/*
* Find confirmation email field if primary exists
* and ensure it's not the same as the primary email field.
*/
const confirmEmail = primaryEmail
? this.findInputField(
const confirmEmailFields = primaryEmail
? this.findAllInputFields(
form,
CombinedFieldPatterns.emailConfirm,
['text', 'email'],
[primaryEmail]
)
: null;
: [];
const filteredConfirmFields = this.filterOutNestedDuplicates(confirmEmailFields);
const confirmEmail = filteredConfirmFields[0] ?? null;
return {
primary: primaryEmail,
@@ -667,6 +678,56 @@ export class FormDetector {
};
}
/**
* Filter out nested duplicates where a parent element and its child are both detected.
* This happens with custom elements that contain actual input elements.
* We prefer the innermost actual input element over the parent custom element.
*/
private filterOutNestedDuplicates(fields: HTMLInputElement[]): HTMLInputElement[] {
if (fields.length <= 1) {
return fields;
}
const filtered: HTMLInputElement[] = [];
for (const field of fields) {
let shouldInclude = true;
// Check if this field is a parent of any other field in the list
for (const otherField of fields) {
if (field !== otherField) {
// Check if field contains otherField (field is parent)
if (field.contains(otherField)) {
shouldInclude = false;
break;
}
// Check if field's shadow DOM contains otherField
const fieldWithShadow = field as HTMLElement & { shadowRoot?: ShadowRoot };
if (fieldWithShadow.shadowRoot && fieldWithShadow.shadowRoot.contains(otherField)) {
shouldInclude = false;
break;
}
}
}
if (shouldInclude) {
// Also check if this field is not already represented by its actual input
const actualInput = this.getActualInputElement(field);
if (actualInput !== field) {
// If the actual input is also in the list, skip the parent
if (fields.includes(actualInput as HTMLInputElement)) {
continue;
}
}
filtered.push(field);
}
}
return filtered;
}
/**
* Find the password field in a form.
*/
@@ -676,9 +737,12 @@ export class FormDetector {
} {
const passwordFields = this.findAllInputFields(form, CombinedFieldPatterns.password, ['password']);
// Filter out parent-child relationships to avoid detecting the same field twice
const filteredFields = this.filterOutNestedDuplicates(passwordFields);
return {
primary: passwordFields[0] ?? null,
confirm: passwordFields[1] ?? null
primary: filteredFields[0] ?? null,
confirm: filteredFields[1] ?? null
};
}

View File

@@ -79,4 +79,75 @@ describe('FormDetector generic tests', () => {
expect(form).toBe(false);
});
});
describe('Nested custom elements (parent-child duplicate prevention)', () => {
describe('TrueNAS-style nested custom elements', () => {
const htmlFile = 'nested-custom-elements.html';
it('should not detect both parent custom element and child input as separate password fields', () => {
const dom = createTestDom(htmlFile);
const document = dom.window.document;
// Click on the actual password input element
const passwordInput = document.getElementById('password-field');
const formDetector = new FormDetector(document, passwordInput as HTMLElement);
// Get the detected form
const form = formDetector.getForm();
expect(form).toBeTruthy();
// Should detect only ONE password field
expect(form?.passwordField).toBeTruthy();
expect(form?.passwordConfirmField).toBeFalsy();
// The detected password field should be the actual input element
expect(form?.passwordField?.tagName.toLowerCase()).toBe('input');
expect(form?.passwordField?.type).toBe('password');
expect(form?.passwordField?.id).toBe('password-field');
});
it('should detect username field correctly without duplication', () => {
const dom = createTestDom(htmlFile);
const document = dom.window.document;
const usernameInput = document.getElementById('username-field');
const formDetector = new FormDetector(document, usernameInput as HTMLElement);
const form = formDetector.getForm();
expect(form).toBeTruthy();
// Should detect the username field
expect(form?.usernameField).toBeTruthy();
expect(form?.usernameField?.tagName.toLowerCase()).toBe('input');
expect(form?.usernameField?.id).toBe('username-field');
});
});
describe('Nested custom elements with actual password confirm field', () => {
const htmlFile = 'nested-custom-elements-confirm.html';
it('should correctly identify actual password confirm fields vs parent-child duplicates', () => {
const dom = createTestDom(htmlFile);
const document = dom.window.document;
const passwordElement = document.getElementById('password-field');
const formDetector = new FormDetector(document, passwordElement as HTMLElement);
const form = formDetector.getForm();
expect(form).toBeTruthy();
// Should correctly detect both password and confirm as separate fields
expect(form?.passwordField).toBeTruthy();
expect(form?.passwordConfirmField).toBeTruthy();
// Both should be actual input elements
expect(form?.passwordField?.tagName.toLowerCase()).toBe('input');
expect(form?.passwordConfirmField?.tagName.toLowerCase()).toBe('input');
// They should be different elements
expect(form?.passwordField?.id).toBe('password-field');
expect(form?.passwordConfirmField?.id).toBe('password-confirm-field');
});
});
});
});

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<title>Registration Form - Nested Custom Elements with Confirm</title>
</head>
<body>
<form id="registration-form">
<div class="field-group">
<ix-input formcontrolname="username" type="text" ix-label="Username" name="username">
<input id="username-field" type="text" aria-label="Username" name="username" class="mat-input-element">
</ix-input>
</div>
<div class="field-group">
<ix-input formcontrolname="password" type="password" ix-label="Password" name="password">
<input id="password-field" type="password" aria-label="Password" name="password" class="mat-input-element">
</ix-input>
</div>
<div class="field-group">
<ix-input formcontrolname="passwordConfirm" type="password" ix-label="Confirm Password" name="passwordConfirm">
<input id="password-confirm-field" type="password" aria-label="Confirm Password" name="passwordConfirm" class="mat-input-element">
</ix-input>
</div>
</form>
</body>
</html>

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<title>TrueNAS Login - Nested Custom Elements</title>
</head>
<body>
<form id="login-form">
<div class="field-group">
<ix-input id="username-wrapper" formcontrolname="username" type="text" ix-label="Username" name="username">
<ix-label><label><span>Username</span></label></ix-label>
<div class="input-container">
<input id="username-field" type="text" aria-label="Username" name="username" class="mat-input-element">
</div>
</ix-input>
</div>
<div class="field-group">
<ix-input id="password-wrapper" formcontrolname="password" type="password" ix-label="Password" name="password">
<ix-label><label><span>Password</span></label></ix-label>
<div class="input-container">
<input id="password-field" type="password" aria-label="Password" name="password" class="mat-input-element">
</div>
</ix-input>
</div>
</form>
</body>
</html>