qa-form-validation
Consolidated form validation skill. Tests BOTH real <form> elements AND form-less input groups (div+inputs+button, the common Angular/React case — so it runs on login/signup/forgot even without a <for
SKILL.mdskills/qa-form-validation/SKILL.md
GitHubqa-form-validation — Consolidated Form Validation Skill
Production-ready replacement for 6 separately-maintained skills that overlapped in detection. This skill owns EVERY validation-related bug type in the agent.
How the orchestrator runs this (ONE call drives the core — no hand-driving)
🚨 The core of this skill is an EXECUTABLE in-page probe. Do NOT drive empty-submit / per-field typing click-by-click with separate browser_click / browser_type / browser_press_key MCP calls. Instead make ONE call:
result = browser_evaluate(<the async function in "## Interactive Probe" below>)
The probe does Phase 1 (passive a11y scans), Phase 2 (per-field type-invalid / email / whitespace / oversize / maxlength / injection / boundary / format), the cross-field checks, and Phase 3 (submit-state) — all in-page in one round-trip. It installs a submit guard so no real submit/navigation/data-write can fire, types via the native value setter + input/change events, blurs, asserts inline feedback, and cleans up every field before returning. It self-skips (returns []) when there are zero testable inputs. Transcribe each returned finding verbatim; add only the envelope fields.
HONEST EXCEPTION — these stay MCP (they need real network responses or real navigation a page-context probe can't fake): doubleSubmitAllowed, backNavLosesFormData, noPostRedirectGet, serverValidationNotShown, noRateLimitOnSubmit (Phase 4) and happySubmitNoFeedback / validRejectedAsInvalid (Phase 5, gated). See "## MCP steps" below. That is why frontmatter is executable: partial.
What it checks (issue types)
| Issue type | Severity | What it catches | Where |
|---|---|---|---|
noValidationOnEmptySubmit |
high | Form with required fields submits empty without error |
probe |
noEmailValidation |
medium | input[type=email] accepts invalid format like "notanemail" |
probe |
noRealtimeValidation |
low | ONCE per form (advisory): form has NO validation at all — no inline feedback AND no on-submit validation. On-submit-only validation is correct and never flagged. | probe |
whitespaceAccepted |
medium | Required field accepts whitespace-only input | probe |
pageCrashOnLargeInput |
high | 10K character input crashes the page | probe |
maxLengthNotEnforced |
medium | Field has maxlength but accepts oversize input |
probe |
xssReflection |
high | XSS payload reflected unescaped in DOM | probe |
crashOnSpecialChars |
high | Special-char payload (SQL/XSS/Unicode) crashes page | probe |
noErrorSummary |
medium | Long form (5+ fields) lacks WCAG 3.3.1 error summary at top | probe (passive) |
helpTextNotAssociated |
low | Hint text near field not linked via aria-describedby |
probe (passive) |
ariaDescribedByBrokenRef |
medium | aria-describedby references non-existent ID |
probe (passive) |
submitNotDisabledWhenInvalid |
medium | Submit enabled while required fields empty | probe (Phase 3) |
submitNoLoadingState |
low | No loading/spinner/disabled state on submit click | probe (Phase 3) |
noBeforeUnloadWarning |
low | Dirty form has no beforeunload warning | probe (Phase 3) |
pastePreventedOnPassword |
medium | Password field prevents paste — breaks password managers | probe (passive) |
hiddenRequiredBlocksSubmit |
high | [required] field is hidden — submit fails silently |
probe (passive) |
untranslatedFormError |
medium | Error message displays raw i18n key | probe (passive) |
boundaryMaxNotEnforced |
medium | Number/date field accepts a value just OVER its max |
probe |
boundaryMinNotEnforced |
medium | Number/date field accepts a value just UNDER its min |
probe |
formatNotEnforced |
medium | Field with a pattern/known format accepts a malformed value |
probe |
passwordMismatchNotCaught |
high | Two password fields submit with different values, no error | probe |
dateRangeNotValidated |
medium | End date accepted before start date with no error | probe |
emailMismatchNotCaught |
medium | Email + confirm-email submit mismatched with no error | probe |
serverValidationNotShown |
high | Server returned validation error but no error UI rendered | MCP (Phase 4) |
doubleSubmitAllowed |
high | Rapid double-click on submit produces two requests | MCP (Phase 4) |
noRateLimitOnSubmit |
low | 10× rapid submit accepted without throttle/429 | MCP (Phase 4) |
backNavLosesFormData |
medium | Filling form, navigating away, then back loses data | MCP (Phase 4) |
noPostRedirectGet |
medium | After submit, refresh re-POSTs the form | MCP (Phase 4) |
happySubmitNoFeedback |
high | Valid data submitted but UI showed neither success nor error | MCP (Phase 5, gated) |
validRejectedAsInvalid |
high | Form rejected legitimate valid input | MCP (Phase 5, gated) |
Self-skip
Skip silently (return []) only when there are zero visible, non-disabled, non-readonly testable inputs (text/email/number/password/tel/url/search/date/textarea/select). 🚨 Do NOT skip just because there is no <form> element — form-less <div>+inputs+button groups are detected too.
Interactive Probe (browser_evaluate, async)
async () => {
const sleep = ms => new Promise(r => setTimeout(r, ms));
const out = [];
const add = o => out.push(Object.assign({ skill: 'qa-form-validation' }, o));
const vis = el => { if (!el) return false; const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.display !== 'none' && s.visibility !== 'hidden'; };
const sel = el => { if (!el) return null; if (el.id) return '#' + CSS.escape(el.id); if (el.name) return `${el.tagName.toLowerCase()}[name="${el.name}"]`; const c = (el.className && typeof el.className === 'string') ? el.className.trim().split(/\s+/).slice(0,2).join('.') : ''; return el.tagName.toLowerCase() + (c ? '.' + c : ''); };
const bb = el => { const r = el.getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) }; };
const setNative = (el, v) => { const p = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; try { Object.getOwnPropertyDescriptor(p, 'value').set.call(el, v); } catch (_) { el.value = v; } el.dispatchEvent(new Event('input', { bubbles: true })); };
const blur = el => { el.dispatchEvent(new Event('blur', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); };
const clear = el => setNative(el, '');
const pageHealth = () => {
const bodyText = (document.body && document.body.innerText || '').slice(0, 500);
return !!document.querySelector('.error-page, [data-error], #__next-error, .next-error-h1') ||
/Application error|Internal Server Error|500|something went wrong|Uncaught/i.test(bodyText);
};
const fieldError = field => {
const ariaInvalid = field.getAttribute('aria-invalid') === 'true';
let cssInvalid = false; try { cssInvalid = field.matches(':invalid'); } catch (_) {}
const c = field.closest('label, div, fieldset, .form-group, .field') || field.parentElement;
const near = c && c.querySelector('[role="alert"], .error, .invalid-feedback, [data-testid*="error"], .text-red-500, .text-danger, .field-error, .error-message, .form-error');
return ariaInvalid || cssInvalid || !!near;
};
const baselineHealth = pageHealth();
if (baselineHealth) return [];
// ── DISCOVER forms (real <form> + form-less groups) ──
const FIELD_SEL = 'input[type="text"],input[type="email"],input[type="number"],input[type="password"],input[type="search"],input[type="tel"],input[type="url"],input[type="date"],input[type="time"],input[type="datetime-local"],input[type="month"],input:not([type]),textarea,select';
const fvis = el => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && !el.disabled && !el.readOnly; };
const SUBMIT_TXT = /sign ?in|log ?in|sign ?up|sign ?on|register|submit|continue|next|save|send|create|update|apply|confirm|verify|search|add\b/i;
const findSubmit = root => { const cand = [...root.querySelectorAll('button,input[type="submit"],input[type="button"],[role="button"]')].filter(fvis); return cand.find(b => b.type === 'submit') || cand.find(b => SUBMIT_TXT.test(b.textContent || b.value || b.getAttribute('aria-label') || '')) || cand[cand.length - 1] || null; };
const forms = [];
const claimed = new Set();
for (const f of document.querySelectorAll('form')) {
const fields = [...f.querySelectorAll(FIELD_SEL)].filter(fvis);
fields.forEach(el => claimed.add(el));
if (!fields.length) continue;
forms.push({ root: f, isRealForm: true, submit: findSubmit(f), fields: fields.slice(0, 25) });
}
const loose = [...document.querySelectorAll(FIELD_SEL)].filter(el => fvis(el) && !claimed.has(el) && !el.closest('form'));
if (loose.length) {
const groups = new Map();
for (const el of loose) { const c = el.closest('[class*="form" i],[class*="card" i],[role="dialog"],[class*="modal" i],[class*="login" i],[class*="signup" i],[class*="auth" i],section,fieldset') || document.body; (groups.get(c) || groups.set(c, []).get(c)).push(el); }
for (const [c, fields] of groups) forms.push({ root: c, isRealForm: false, submit: findSubmit(c) || findSubmit(document.body), fields: fields.slice(0, 25) });
}
const allForms = forms.filter(f => f.fields.length > 0).slice(0, 6);
if (allForms.length === 0) return [];
const isReq = el => el.required || el.getAttribute('aria-required') === 'true';
// ── PHASE 1: PASSIVE SCANS ──
// error summary (forms with 5+ fields)
for (const f of document.querySelectorAll('form')) {
if (!vis(f)) continue;
const fieldCount = f.querySelectorAll('input:not([type="hidden"]):not([type="submit"]):not([type="button"]), select, textarea').length;
if (fieldCount < 5) continue;
const fc = f.firstElementChild;
const hasTop = !!f.querySelector(':scope > [role="alert"], :scope > .error-summary, :scope > [class*="error-summary"], :scope > [data-testid*="error-summary"]') || (fc && /error|invalid|alert/i.test(fc.className) && fc.querySelector('ul, ol, li'));
if (!hasTop) add({ issueType: 'noErrorSummary', severity: 'medium', selector: sel(f), bbox: bb(f), description: `Form with ${fieldCount} fields has no error-summary container — WCAG 3.3.1 recommends a top-of-form summary listing all errors`, evidence: { fieldCount } });
}
// aria-describedby integrity + help-text association
for (const input of document.querySelectorAll('input:not([type="hidden"]), select, textarea')) {
if (!vis(input) || input.type === 'submit' || input.type === 'button') continue;
const db = input.getAttribute('aria-describedby');
if (db && db.trim()) {
const missing = db.trim().split(/\s+/).filter(id => !document.getElementById(id));
if (missing.length) add({ issueType: 'ariaDescribedByBrokenRef', severity: 'medium', selector: sel(input), bbox: bb(input), description: `Field has aria-describedby="${db}" but ID(s) "${missing.join(', ')}" don't exist`, evidence: { missing } });
continue;
}
const c = input.closest('label, div, fieldset, .form-group, .field') || input.parentElement;
if (!c) continue;
for (const hint of c.querySelectorAll('small, .help-text, .hint, .form-text, .help-block, [class*="description"], p[class*="hint"]')) {
if (hint === input || input.contains(hint)) continue;
const ht = (hint.innerText || '').trim();
if (ht.length < 5 || ht.length > 200) continue;
add({ issueType: 'helpTextNotAssociated', severity: 'low', selector: sel(input), bbox: bb(input), description: `Hint text "${ht.slice(0, 60)}…" near field is not linked via aria-describedby — screen readers won't announce it`, evidence: {} });
break;
}
}
// paste-prevented password
for (const pwd of document.querySelectorAll('input[type="password"]')) {
const onpaste = pwd.getAttribute('onpaste');
if ((onpaste && /return\s+false|preventDefault/i.test(onpaste)) || pwd.dataset.preventPaste === 'true' || pwd.classList.contains('no-paste') || pwd.classList.contains('block-paste'))
add({ issueType: 'pastePreventedOnPassword', severity: 'medium', selector: sel(pwd), bbox: bb(pwd), description: 'Password field prevents paste — breaks password managers (1Password, Bitwarden, etc.). Known UX antipattern.', evidence: {} });
}
// hidden required blocks submit
for (const req of document.querySelectorAll('[required], [aria-required="true"]')) {
if (req.type === 'hidden' || req.type === 'submit' || req.type === 'button') continue;
let visible = true, cur = req;
while (cur && cur !== document.body) { const cs = getComputedStyle(cur); if (cs.display === 'none' || cs.visibility === 'hidden') { visible = false; break; } cur = cur.parentElement; }
if (!visible) add({ issueType: 'hiddenRequiredBlocksSubmit', severity: 'high', selector: sel(req), description: `<${req.tagName.toLowerCase()} required> is hidden but \`required\`. HTML5 validation blocks submit with no user-visible message — users get stuck.`, evidence: {} });
}
// untranslated i18n keys in error containers
for (const ec of document.querySelectorAll('[role="alert"], .error, .invalid-feedback, [class*="error"]:not([class*="errorless"]), [class*="invalid"], small.help-block.error, [data-error]')) {
const t = (ec.innerText || ec.getAttribute('data-error') || '').trim();
if (!t || t.length < 4 || t.length > 200) continue;
let kind = null;
if (/^[a-z][a-z0-9_]+(?:\.[a-z][a-z0-9_]+){2,}$/i.test(t)) kind = 'dot.notation';
else if (/^\{\{[\w.]+\}\}$/.test(t)) kind = 'handlebars';
else if (/^__MSG_\w+__$/.test(t)) kind = 'chrome-extension';
else if (/^\[[\w.]+\]$/.test(t) && /^\[[a-z]/.test(t)) kind = 'bracket-key';
if (kind) add({ issueType: 'untranslatedFormError', severity: 'medium', selector: sel(ec), bbox: bb(ec), description: `Error message displays raw i18n ${kind} key "${t.slice(0, 60)}" — translation pipeline did not resolve it.`, evidence: { kind } });
}
// ── SUBMIT GUARD — block real submit/fetch/XHR/navigation so nothing is written ──
window.__argusSubmitFired = false;
document.querySelectorAll('form').forEach(f => f.addEventListener('submit', e => { e.preventDefault(); window.__argusSubmitFired = true; }, { capture: true }));
const _fetch = window.fetch, _send = XMLHttpRequest.prototype.send, _bu = window.onbeforeunload;
window.fetch = function () { window.__argusSubmitFired = true; return Promise.reject(new Error('argus-blocked')); };
XMLHttpRequest.prototype.send = function () { window.__argusSubmitFired = true; };
window.onbeforeunload = e => { e.preventDefault(); return ''; };
const restoreGuard = () => { window.fetch = _fetch; XMLHttpRequest.prototype.send = _send; window.onbeforeunload = _bu; };
try {
// ── PHASE 2: EMPTY SUBMIT (one per form with required) ──
// Side-effect: record whether each form validates ON SUBMIT. Submit-time
// validation is correct, valid UX — a form that shows errors on submit must
// NEVER be flagged for "no realtime validation". We use this map below to gate
// the (advisory, once-per-form) noValidationAtAll check so we don't produce
// per-field realtime-validation noise.
const formValidatesOnSubmit = new WeakMap();
for (const form of allForms) {
const hasRequired = form.fields.some(isReq);
if (!hasRequired && form.fields.length < 2) continue;
if (!form.submit) continue;
if (form.submit.disabled || form.submit.getAttribute('aria-disabled') === 'true') { formValidatesOnSubmit.set(form, true); continue; } // disabled-until-valid IS validation; that's submitNotDisabled (Phase 3)
window.__argusSubmitFired = false;
form.submit.click(); await sleep(600);
const anyError = !!(form.root.querySelector('[role="alert"], .error, .invalid-feedback, [aria-invalid="true"], .field-error, [data-testid*="error"]') || form.fields.some(fieldError));
if (anyError) formValidatesOnSubmit.set(form, true);
if (!anyError && window.__argusSubmitFired && hasRequired)
add({ issueType: 'noValidationOnEmptySubmit', severity: 'high', selector: sel(form.submit), bbox: bb(form.submit), description: 'Form tried to submit with empty required fields and showed no validation feedback.', evidence: { isRealForm: form.isRealForm } });
}
// ── PHASE 2: PER-FIELD (every field up to 25) ──
let crashed = false;
for (const form of allForms) {
if (crashed) break;
const hasRequired = form.fields.some(isReq);
// Track whether ANY field in this form gives inline (on input/blur) feedback.
// Realtime/on-keystroke/on-blur validation is NOT required — validation on
// submit (or disabled-until-valid) is perfectly valid, often-better UX. So we
// do NOT flag per-field "noRealtimeValidation". We only note the absence of
// inline feedback here and emit ONE advisory finding per form below, and ONLY
// when the form has NO validation at all (not inline, not on submit).
let anyInlineFeedback = false;
let firstInvalidField = null;
for (const field of form.fields) {
if (crashed) break;
const ftype = (field.tagName === 'SELECT' || field.tagName === 'TEXTAREA') ? field.tagName.toLowerCase() : (field.type || 'text');
if (ftype === 'select') continue;
// 4a type-invalid → does the form give ANY inline feedback? (advisory only — see above)
clear(field);
setNative(field, ftype === 'email' ? 'abc' : ftype === 'number' ? 'xyz' : ftype === 'url' ? 'not a url' : ftype === 'tel' ? 'letters here' : 'x');
blur(field); await sleep(250);
if (fieldError(field)) anyInlineFeedback = true;
else if (!firstInvalidField) firstInvalidField = field;
// 4b email-specific
if (ftype === 'email') {
clear(field); setNative(field, 'notanemail'); blur(field); await sleep(200);
if (!fieldError(field)) add({ issueType: 'noEmailValidation', severity: 'medium', selector: sel(field), bbox: bb(field), description: 'Email input accepted invalid format "notanemail" with no error.', evidence: {} });
}
// 4c whitespace-only
clear(field); setNative(field, ' '); blur(field); await sleep(200);
if (hasRequired && isReq(field) && !fieldError(field)) add({ issueType: 'whitespaceAccepted', severity: 'medium', selector: sel(field), bbox: bb(field), description: 'Required field accepted whitespace-only input (no trim).', evidence: {} });
// 4d oversize 10K
clear(field); setNative(field, 'x'.repeat(10000)); blur(field); await sleep(250);
if (pageHealth()) { add({ issueType: 'pageCrashOnLargeInput', severity: 'high', selector: sel(field), bbox: bb(field), description: '10K character input crashed the page.', evidence: {} }); crashed = true; break; }
const maxLen = field.maxLength > 0 ? field.maxLength : null;
if (maxLen && field.value.length > maxLen) add({ issueType: 'maxLengthNotEnforced', severity: 'medium', selector: sel(field), bbox: bb(field), description: `Field has maxlength=${maxLen} but accepted ${field.value.length} chars.`, evidence: { maxLen, actual: field.value.length } });
// 4e injection payloads
const payloads = [{ id: 'xss', value: "<script>alert('argusXss')</script>", marker: 'argusXss' }, { id: 'sqlish', value: "O'Reilly'; DROP TABLE users;--" }, { id: 'unicode', value: "测试 🚀 ñ ü العربية" }];
for (const p of payloads) {
clear(field); setNative(field, p.value); blur(field); await sleep(150);
if (p.marker) {
let reflected = false, sev = 'high';
for (const s of document.querySelectorAll('script')) if (s.textContent && s.textContent.includes(p.marker)) { reflected = true; sev = 'critical'; break; }
if (!reflected) { const html = document.body.innerHTML || ''; const mi = html.indexOf(p.marker); if (mi > -1 && html.slice(Math.max(0, mi - 60), mi).includes('<')) reflected = true; }
if (reflected) add({ issueType: 'xssReflection', severity: sev, selector: sel(field), bbox: bb(field), description: 'XSS payload reflected unescaped into the DOM.', evidence: { payload: p.id } });
}
if (pageHealth()) { add({ issueType: 'crashOnSpecialChars', severity: 'high', selector: sel(field), bbox: bb(field), description: `Special-char payload (${p.id}) crashed or blanked the page.`, evidence: { payload: p.id } }); crashed = true; break; }
}
if (crashed) break;
// 4f boundary (number/date with min/max)
const step = field.step && field.step !== '' ? parseFloat(field.step) : 1;
if (field.max !== '' && field.max != null && (ftype === 'number')) {
clear(field); setNative(field, String(parseFloat(field.max) + (isNaN(step) ? 1 : step))); blur(field); await sleep(200);
if (!fieldError(field)) add({ issueType: 'boundaryMaxNotEnforced', severity: 'medium', selector: sel(field), bbox: bb(field), description: `Number field accepted a value over its max (${field.max}).`, evidence: { max: field.max } });
}
if (field.min !== '' && field.min != null && (ftype === 'number')) {
clear(field); setNative(field, String(parseFloat(field.min) - (isNaN(step) ? 1 : step))); blur(field); await sleep(200);
if (!fieldError(field)) add({ issueType: 'boundaryMinNotEnforced', severity: 'medium', selector: sel(field), bbox: bb(field), description: `Number field accepted a value under its min (${field.min}).`, evidence: { min: field.min } });
}
// 4g format / pattern
const n = ((field.name || '') + (field.id || '') + (field.placeholder || '')).toLowerCase();
if (field.pattern) {
clear(field); setNative(field, /\d/.test(field.pattern) ? 'abc' : '!!!'); blur(field); await sleep(200);
if (!fieldError(field)) add({ issueType: 'formatNotEnforced', severity: 'medium', selector: sel(field), bbox: bb(field), description: `Field has pattern="${field.pattern}" but accepted a non-matching value.`, evidence: { pattern: field.pattern } });
} else if (/phone|tel|zip|postal|card|cc|date/.test(n) && ftype !== 'date') {
const bad = /phone|tel/.test(n) ? 'abc123' : /zip|postal/.test(n) ? '!!!' : /card|cc/.test(n) ? '1234' : '99/99/9999';
clear(field); setNative(field, bad); blur(field); await sleep(200);
if (!fieldError(field)) add({ issueType: 'formatNotEnforced', severity: 'low', uncertain: true, selector: sel(field), bbox: bb(field), description: `Field (${n.slice(0,30)}) accepted a malformed value "${bad}" with no feedback.`, evidence: { value: bad } });
}
clear(field);
}
// 4a-summary: advisory, ONCE per form. Only emit when the form has NO
// validation at all — no inline (input/blur) feedback AND no submit-time
// validation. A form that validates on submit (or disables submit until
// valid) is correct and is NEVER flagged. Gated on a required field existing
// (no point asserting validation on a form with nothing to validate).
if (hasRequired && !anyInlineFeedback && !formValidatesOnSubmit.get(form) && firstInvalidField) {
add({ issueType: 'noRealtimeValidation', severity: 'low', uncertain: true, selector: sel(firstInvalidField), bbox: bb(firstInvalidField), description: 'Form has no client-side validation feedback at all — type-invalid values produced no inline error after blur AND empty submit produced no error. (Advisory: realtime/on-keystroke validation is not required; on-submit validation is fine — this fires only when no validation of any kind was observed.)', evidence: { fields: form.fields.length } });
}
// ── PHASE 2.5: CROSS-FIELD ──
if (crashed) break;
const pwds = form.fields.filter(f => f.type === 'password');
if (pwds.length >= 2) {
setNative(pwds[0], 'Argus#123'); setNative(pwds[1], 'Argus#124'); blur(pwds[1]);
if (form.submit && !form.submit.disabled) { form.submit.click(); await sleep(500); }
if (!fieldError(pwds[1]) && !(form.root.querySelector('[role="alert"], .error, .invalid-feedback') && /match|same|confirm|differ/i.test(form.root.innerText)))
add({ issueType: 'passwordMismatchNotCaught', severity: 'high', uncertain: true, selector: sel(pwds[1]), bbox: bb(pwds[1]), description: 'Two password fields submitted with different values and no "passwords don\'t match" error appeared.', evidence: {} });
clear(pwds[0]); clear(pwds[1]);
}
const dates = form.fields.filter(f => f.type === 'date' || f.type === 'datetime-local');
if (dates.length >= 2) {
const start = dates.find(d => /start|from/i.test(d.name + d.id)) || dates[0];
const end = dates.find(d => /end|to|until/i.test(d.name + d.id) && d !== start) || dates[1];
if (start !== end) {
setNative(start, '2025-12-31'); setNative(end, '2025-01-01'); blur(end);
if (form.submit && !form.submit.disabled) { form.submit.click(); await sleep(400); }
if (!fieldError(end)) add({ issueType: 'dateRangeNotValidated', severity: 'medium', uncertain: true, selector: sel(end), bbox: bb(end), description: 'End date accepted before start date with no error.', evidence: {} });
clear(start); clear(end);
}
}
const emails = form.fields.filter(f => f.type === 'email');
if (emails.length >= 2) {
const conf = emails.find(e => /confirm/i.test(e.name + e.id));
if (conf) {
const main = emails.find(e => e !== conf) || emails[0];
setNative(main, 'a@example.com'); setNative(conf, 'b@example.com'); blur(conf);
if (form.submit && !form.submit.disabled) { form.submit.click(); await sleep(400); }
if (!fieldError(conf)) add({ issueType: 'emailMismatchNotCaught', severity: 'medium', uncertain: true, selector: sel(conf), bbox: bb(conf), description: 'Email + confirm-email submitted mismatched with no error.', evidence: {} });
clear(main); clear(conf);
}
}
}
// ── PHASE 3: SUBMIT-STATE ──
const f0 = allForms[0];
if (f0 && f0.submit) {
const hasReqEmpty = f0.fields.some(el => { if (!isReq(el)) return false; if (el.type === 'checkbox' || el.type === 'radio') return !el.checked; return !el.value || el.value.trim() === ''; });
const subDisabled = f0.submit.disabled || f0.submit.getAttribute('aria-disabled') === 'true';
if (hasReqEmpty && !subDisabled)
add({ issueType: 'submitNotDisabledWhenInvalid', severity: 'medium', selector: sel(f0.submit), bbox: bb(f0.submit), description: 'Submit is enabled while required fields are empty/invalid.', evidence: {} });
// loading state: fill required, click submit (guarded), check immediate visual state
for (const el of f0.fields) { if (!isReq(el) || el.value) continue; if (el.type === 'checkbox' || el.type === 'radio') el.checked = true; else if (el.type === 'email') setNative(el, 'argus@example.com'); else if (el.type === 'tel') setNative(el, '5551234567'); else if (el.type === 'url') setNative(el, 'https://example.com'); else if (el.type === 'number') setNative(el, '1'); else setNative(el, 'argus'); }
if (!subDisabled) {
f0.submit.click(); await sleep(300);
const loading = f0.submit.disabled || f0.submit.getAttribute('aria-busy') === 'true' || !!f0.submit.querySelector('.spinner, .loader, [class*="loading"], svg.animate-spin, [data-loading="true"]') || /loading|please wait|submitting|saving|sending/i.test(f0.submit.innerText);
if (!loading && !f0.submit.disabled)
add({ issueType: 'submitNoLoadingState', severity: 'low', selector: sel(f0.submit), bbox: bb(f0.submit), description: 'No loading/disabled state on submit — invites double-submit.', evidence: {} });
}
// beforeunload warning on dirty form
const dirtyInput = f0.fields.find(el => el.type === 'text' || el.type === 'email' || el.tagName === 'TEXTAREA');
if (dirtyInput) {
setNative(dirtyInput, 'argusDirty'); blur(dirtyInput);
let handlerFired = false;
const probe = e => { if (e.defaultPrevented || (typeof e.returnValue === 'string' && e.returnValue !== '')) handlerFired = true; };
window.removeEventListener('beforeunload', null);
window.onbeforeunload = _bu; // temporarily restore app handler so we test the app, not our guard
window.addEventListener('beforeunload', probe, true);
const evt = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(evt);
window.removeEventListener('beforeunload', probe, true);
window.onbeforeunload = e => { e.preventDefault(); return ''; }; // re-arm guard
if (!(handlerFired || evt.defaultPrevented))
add({ issueType: 'noBeforeUnloadWarning', severity: 'low', selector: sel(f0.root === document.body ? dirtyInput : f0.root), bbox: bb(dirtyInput), description: 'Dirty form has no beforeunload warning — accidental data loss possible.', evidence: {} });
}
}
} finally {
// ── MANDATORY CLEANUP ──
for (const form of allForms) for (const el of form.fields) { try { if (el.tagName !== 'SELECT' && el.type !== 'checkbox' && el.type !== 'radio') { setNative(el, ''); } } catch (_) {} }
restoreGuard();
}
return out;
}
MCP steps (Phase 4 + Phase 5 — network/navigation-dependent, stay hand-driven)
These genuinely need real network responses or real navigation, which a guarded in-page probe cannot produce. Run them AFTER the probe (skip Phase 4 if the page has no form with a submit button, or customize.toml [form] enable_phase4 = false, or the probe emitted pageCrashOnLargeInput).
doubleSubmitAllowed— fill required validly, install a fetch/XHR counter,browser_clicksubmit twice within 100ms, wait 1000ms; if counter ≥ 2 → emit (high).backNavLosesFormData— fill fields with sentinelargus_test_42,browser_navigateto root,browser_navigateback; if sentinel gone → emit (medium).noPostRedirectGet— fill valid, install a response observer,browser_clicksubmit; if response is 200 ANDlocation.hrefunchanged → emit (medium).serverValidationNotShown— submit data the server will reject (e.g.server-reject@argus.test); if response haserrors/422 but DOM shows no[role=alert]/.error/.invalid-feedback→ emit (high). Skip if server accepts (inconclusive).noRateLimitOnSubmit—browser_clicksubmit ×10 in 1s; if all 10 returned 2xx with no 429/503 → emit (low). Only if same-origin POST.
Phase 5 — Happy-path success (GATED — OFF by default)
🚨 Runs ONLY when customize.toml [safe_testing] allow_form_submit = true (it creates a real record). Gate check FIRST; if off → skip, emit nothing, record happyPath: "skipped (gated off)".
When enabled: fill every required+visible field with a VALID value by type (argus.qa+{runId}@example.com, +15555550123, mid-range number, today's date, first non-placeholder select option, Argus QA test {runId}), confirm submit enabled, browser_click submit, wait 1500ms.
- success toast / confirmation redirect / form-clears → no finding (
happyPath: "success: {signal}"). - no success AND no error →
happySubmitNoFeedback(high). - validation error on VALID data →
validRejectedAsInvalid(high);uncertain: trueif plausibly a real business rule. - Cleanup: if
cleanup_after_submit = trueand a delete/undo for the{runId}record exists, attempt it; never fail the audit on cleanup.
Hard rules
- NEVER click submit without the guard — the probe installs a
preventDefault+ fetch/XHR block so submit can't navigate or write. Phase 4/5 MCP steps deliberately remove the guard because they MUST exercise the real network — only run them on a dev/sandbox or behind the gate. - ALWAYS run cleanup — the probe's
finallyblock clears every modified field and restoresfetch/XHR/onbeforeunloadeven on crash. - ALWAYS check page health between special-char payloads — done inside the probe; a crashing payload aborts the field loop.
- NEVER emit a finding without
selector— bug-filer can't attach evidence otherwise.
Issues
The complete issueType allowlist this skill can emit.
| issueType | severity | description |
|---|---|---|
| noValidationOnEmptySubmit | high | Required form submitted empty with no error shown |
| noEmailValidation | medium | Email input accepts invalid format |
| noRealtimeValidation | low | Once-per-form advisory: form has no validation at all (no inline AND no on-submit). On-submit validation is correct and never flagged. |
| whitespaceAccepted | medium | Required field accepts whitespace-only input (no trim) |
| pageCrashOnLargeInput | high | 10K character input crashes the page |
| maxLengthNotEnforced | medium | Field has maxlength but accepts oversize input |
| xssReflection | high | XSS payload reflected unescaped into the DOM |
| crashOnSpecialChars | high | SQL/XSS/Unicode payload crashes or blanks the page |
| boundaryMaxNotEnforced | medium | Number/date field accepts a value over its max |
| boundaryMinNotEnforced | medium | Number/date field accepts a value under its min |
| formatNotEnforced | medium | Field with pattern/known-format accepts a malformed value |
| passwordMismatchNotCaught | high | Mismatched password + confirm shows no error |
| dateRangeNotValidated | medium | End date accepted before start date with no error |
| emailMismatchNotCaught | medium | Email + confirm-email mismatch shows no error |
| doubleSubmitAllowed | high | Rapid double-click on submit fires two requests (duplicate records) |
| submitNotDisabledWhenInvalid | medium | Submit enabled while required fields are empty/invalid |
| submitNoLoadingState | low | No loading/disabled state on submit — invites double-submit |
| noBeforeUnloadWarning | low | Dirty form has no beforeunload warning |
| noErrorSummary | medium | No error summary region for screen-reader users |
| ariaDescribedByBrokenRef | medium | aria-describedby points at a missing id |
| helpTextNotAssociated | low | Help text not programmatically tied to its field |
| pastePreventedOnPassword | medium | Password field blocks paste (breaks password managers) |
| hiddenRequiredBlocksSubmit | high | A hidden required field silently blocks submit |
| untranslatedFormError | medium | Validation error shows a raw i18n key, not translated text |
| serverValidationNotShown | high | Server returned validation errors but UI did not display them |
| noRateLimitOnSubmit | low | 10 rapid submits all accepted — no rate limiting |
| backNavLosesFormData | medium | Form blank after browser back-navigation |
| noPostRedirectGet | medium | Submit 200 without redirect — refresh re-POSTs |
| happySubmitNoFeedback | high | Valid submit gave neither confirmation nor error |
| validRejectedAsInvalid | high | Form rejected legitimate valid input |
Notes on this conversion
- Phase 1 (passive a11y), Phase 2 (per-field: type-invalid, email, whitespace, oversize, maxlength, XSS/SQL/Unicode injection, boundary, format), Phase 2.5 (cross-field password/date/email) and Phase 3 (submit-state) are now ONE in-page async probe instead of ~50 MCP round-trips. The old separate probes (
passiveScan,findTestableForms,checkValidationVisible,checkInlineFeedback,checkFieldLength,checkXssReflection,checkPageHealth,clearAllFields,findSubmitContext,fillRequiredWithMarkers,checkSubmitLoadingState,makeFormDirty,testBeforeUnloadHandler,cleanupSubmitTest) are inlined. - Folded: the unbounded-number heuristic escalation in old Test 4f (try -1/0/huge with
uncertain) was dropped from the hot path; boundary checks now fire only on explicitmin/max. All named issueTypes preserved. - Kept MCP (
executable: partial): Phase 4 (doubleSubmitAllowed,backNavLosesFormData,noPostRedirectGet,serverValidationNotShown,noRateLimitOnSubmit) and Phase 5 gated happy-path (happySubmitNoFeedback,validRejectedAsInvalid) — these require real network responses / real navigation that the guarded probe cannot fake.