Add formdetector autocomplete tag detection (#1614)

This commit is contained in:
Leendert de Borst
2026-02-03 23:49:37 +01:00
committed by Leendert de Borst
parent 9d02ddaf9b
commit e1d2e7d8fb
4 changed files with 119 additions and 2 deletions

View File

@@ -360,11 +360,49 @@ export class FormDetector {
continue;
}
// Check autocomplete attribute for direct field type matching
const autocomplete = input.getAttribute('autocomplete')?.toLowerCase() ?? '';
// Direct autocomplete matches take highest priority (score -2, higher than type=email at -1)
if (autocomplete) {
// Match autocomplete="username" for username patterns
if (patterns === CombinedFieldPatterns.username && autocomplete === 'username') {
matches.push({ input: input as HTMLInputElement, score: -2 });
continue;
}
// Match autocomplete="email" for email patterns
if (patterns === CombinedFieldPatterns.email && autocomplete === 'email') {
matches.push({ input: input as HTMLInputElement, score: -2 });
continue;
}
// Match autocomplete="current-password" or "new-password" for password patterns
if (patterns === CombinedFieldPatterns.password &&
(autocomplete === 'current-password' || autocomplete === 'new-password')) {
matches.push({ input: input as HTMLInputElement, score: -2 });
continue;
}
}
/**
* Check aria-describedby ID for direct field type matching (e.g., aria-describedby="usernameMessage")
* Only match if it's a clear username indicator (not usernameConfirm, etc.)
*/
const ariaDescribedById = input.getAttribute('aria-describedby')?.toLowerCase() ?? '';
if (ariaDescribedById) {
// Match aria-describedby containing "username" for username patterns
if (patterns === CombinedFieldPatterns.username &&
ariaDescribedById.includes('username')) {
matches.push({ input: input as HTMLInputElement, score: -2 });
continue;
}
}
// Collect all text attributes to check
const attributesToCheck = [
input.id,
input.getAttribute('name'),
input.getAttribute('placeholder')
input.getAttribute('placeholder'),
autocomplete
]
.map(a => a?.toLowerCase() ?? '');
@@ -376,6 +414,25 @@ export class FormDetector {
}
}
// Check aria-describedby for additional field hints
const ariaDescribedBy = input.getAttribute('aria-describedby');
if (ariaDescribedBy) {
// aria-describedby can contain multiple space-separated IDs
const describedByIds = ariaDescribedBy.split(/\s+/);
for (const descId of describedByIds) {
const describedByElement = this.document.getElementById(descId);
if (describedByElement) {
attributesToCheck.push(describedByElement.textContent?.toLowerCase() ?? '');
}
}
}
// Check aria-label attribute
const ariaLabel = input.getAttribute('aria-label');
if (ariaLabel) {
attributesToCheck.push(ariaLabel.toLowerCase());
}
/**
* Check for slot-based labels (e.g., <span slot="label">Email or username</span>)
* Look for slot elements within the input's parent hierarchy

View File

@@ -92,4 +92,10 @@ describe('FormDetector English tests', () => {
testField(FormField.BirthYear, 'YearDropdown', htmlFile);
});
describe('French login form 1 detection (France Tax Authority)', () => {
const htmlFile = 'fr-login-form1.html';
testField(FormField.Username, 'spi_tmp', htmlFile);
});
});

View File

@@ -3,10 +3,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { Credential } from '@/utils/dist/core/models/vault';
import { FormDetector } from '../FormDetector';
import { FormFiller } from '../FormFiller';
import { FormFields } from '../types/FormFields';
import { setupTestDOM, createMockFormFields, createMockCredential, wasTriggerCalledFor, createDateSelects } from './TestUtils';
import { setupTestDOM, createMockFormFields, createMockCredential, wasTriggerCalledFor, createDateSelects, createTestDom } from './TestUtils';
const { window } = new JSDOM('<!DOCTYPE html>');
global.HTMLSelectElement = window.HTMLSelectElement;
@@ -62,4 +63,32 @@ describe('FormFiller English', () => {
expect(wasTriggerCalledFor(mockTriggerInputEvents, yearSelect)).toBe(true);
});
});
describe('French login form 1 (France Tax Authority)', () => {
it('should detect and fill username field with autocomplete="username" and name="spi_tmp"', async () => {
const dom = createTestDom('fr-login-form1.html');
const doc = dom.window.document;
// Get the input field
const usernameInput = doc.getElementById('spi_tmp') as HTMLInputElement;
expect(usernameInput).not.toBeNull();
// Create form detector to find the form fields
const formDetector = new FormDetector(doc, usernameInput);
const detectedFields = formDetector.getForm();
// The username field should be detected due to autocomplete="username"
expect(detectedFields?.usernameField).toBe(usernameInput);
// If the field is detected, test filling it
if (detectedFields) {
const triggerMock = vi.fn();
const filler = new FormFiller(detectedFields, triggerMock);
await filler.fillFields(mockCredential);
expect(usernameInput.value).toBe('testuser');
expect(wasTriggerCalledFor(triggerMock, usernameInput)).toBe(true);
}
});
});
});

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>France Tax Login</title>
</head>
<body>
<form id="loginForm">
<div class="fr-fieldset__element">
<div class="fr-input-group" id="usernameGroup">
<label class="fr-label" for="spi_tmp">
Numéro fiscal <span class="fr-hint-text">Format attendu : 13 chiffres</span>
</label>
<input class="fr-input form-control" autocomplete="username" aria-required="true" autocorrect="off" aria-describedby="usernameMessage" name="spi_tmp" id="spi_tmp" type="text" maxlength="13" inputmode="numeric" pattern="[0-9]*" data-original-title="13 chiffres" data-placement="top" data-mask="int" data-max="13" data-feedbackok="1/">
<div class="fr-messages-group" id="usernameMessage"></div>
</div>
<p class="fr-mt-n3v" id="lostFiscalNumberLink">
<button type="button" class="fr-btn fr-btn--tertiary-no-outline" data-fr-opened="false" aria-controls="numFiscal" style="text-decoration: underline; text-underline-offset: 0.25rem; text-decoration-thickness: 1px; background-color: transparent !important; transition: text-decoration-thickness 0.1s ease; margin: -17px;" onmouseover="this.style.textDecorationThickness='2px'; this.style.backgroundColor='transparent'" onmouseout="this.style.textDecorationThickness='1px'; this.style.backgroundColor='transparent'" data-fr-js-modal-button="true">
Numéro fiscal oublié ?
</button>
</p>
</div>
</form>
</body>
</html>