qa-form-input-types

Consolidated input-widget testing. Owns every input-type-specific bug: date/time pickers, file upload, OTP boxes, comboboxes, tag inputs, formatted (credit card / phone / CVV), masked inputs, inline-e

SKILL.md

GitHub

qa-form-input-types — Consolidated Input-Widget Skill

Single skill owning every widget-specific bug type. Replaces 9 overlapping skills.

How the orchestrator runs this (ONE call drives all in-page widgets)

🚨 The widget tests are an EXECUTABLE in-page probe. Do NOT hand-drive each widget with separate browser_click / browser_type / browser_press_key calls. Instead make ONE call:

result = browser_evaluate(<the async function in "## Interactive Probe" below>)

The probe discovers every widget type, then drives each one in-page — date min/max + past-date + range, OTP auto-advance/backspace/paste, combobox open/filter/select/escape/aria, tag add/paste/remove, formatted card/expiry/cvv/phone/number, masked pattern/format/backspace, inline-edit click/focus/escape/enter, password strength/confirm — asserting each result and returning findings[]. It does its own waits via in-page setTimeout, self-skips per type when absent, and runs idempotent cleanup before returning. It returns [] when no widgets exist at all. Transcribe each returned finding verbatim.

HONEST EXCEPTION — file upload with a real file stays MCP (fileNoSelectionFeedback) because choosing a real file requires the OS file dialog via browser_file_upload, which a page-context probe cannot open. The file attribute checks (fileAcceptMissing, fileSizeHintMissing) and the progress/cancel UI checks (uploadNoProgressUI, uploadNoCancelButton) ARE in the probe (passive). That is why frontmatter is executable: partial.

What it checks (35 issue types — see table at bottom). Self-skip: probe returns [] if zero widgets of every type.

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-input-types' }, 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 fvis = el => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && !el.disabled && !el.readOnly; };
  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 })); el.dispatchEvent(new Event('change', { bubbles: true })); };
  const key = (el, k) => { ['keydown','keyup'].forEach(t => el.dispatchEvent(new KeyboardEvent(t, { key: k, code: k, bubbles: true }))); };
  const hasError = el => { if (!el) return false; const ai = el.getAttribute && el.getAttribute('aria-invalid') === 'true'; let ci = false; try { ci = el.matches(':invalid'); } catch (_) {} const c = el.closest('label, div, fieldset, .form-group, .field') || el.parentElement; const near = c && c.querySelector('[role="alert"], .error, .invalid-feedback, [data-testid*="error"], .text-red-500, .text-danger'); return ai || ci || !!near; };
  const esc = () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true }));

  // tracking attrs we add (cleaned at end)
  const tracked = { otp: [], cb: [], tag: [], mask: [], ie: [] };

  // ── DISCOVERY ──
  const dateInputs = [...document.querySelectorAll('input[type="date"], input[type="time"], input[type="datetime-local"]')].filter(fvis).slice(0, 4);
  const fileInputs = [...document.querySelectorAll('input[type="file"]')].filter(el => !el.disabled).slice(0, 2);
  const otpCands = [...document.querySelectorAll('input[maxlength="1"], input[inputmode="numeric"][maxlength="1"], input[type="tel"][maxlength="1"]')].filter(fvis);
  const cbCands = [...document.querySelectorAll('[role="combobox"], input[role="combobox"], [class*="select"][class*="search"]:not(select), [class*="autocomplete"]:not(select), [class*="typeahead"]:not(select), div[class*="Select"]:not(select), [data-testid*="combobox"], [data-testid*="select"]:not(select)')].filter(el => vis(el) && el.tagName.toLowerCase() !== 'select' && !el.disabled && el.getAttribute('aria-disabled') !== 'true').slice(0, 3);
  const tagCands = [...document.querySelectorAll('[class*="tag"][class*="input"], [class*="chip"][class*="input"], [class*="multiselect"], [class*="MuiAutocomplete"], [role="combobox"][aria-multiselectable="true"], [data-testid*="tag-input"], [data-testid*="chip-input"]')].filter(el => { const inp = el.querySelector('input[type="text"], input:not([type])'); return vis(el) && inp && !inp.disabled; }).slice(0, 3);
  const ieCands = [...document.querySelectorAll('[contenteditable="true"], [contenteditable=""], [data-editable], [data-inline-edit], [role="gridcell"][tabindex], [class*="editable"]:not(input):not(textarea), [class*="inline-edit"]:not(input):not(textarea)')].filter(vis).slice(0, 3);
  const pwds = [...document.querySelectorAll('input[type="password"]')].filter(fvis);
  const fieldsBy = pred => [...document.querySelectorAll('input, textarea')].filter(fvis).filter(pred).slice(0, 1);
  const cardF = fieldsBy(el => { const ac = el.getAttribute('autocomplete') || ''; const n = (el.name + el.id).toLowerCase(); return ac.includes('cc-number') || /card.?number|cardnumber|cc.?num/.test(n); })[0];
  const expF = fieldsBy(el => { const ac = el.getAttribute('autocomplete') || ''; const n = (el.name + el.id).toLowerCase(); return ac.includes('cc-exp') || /expir|cc.?exp/.test(n); })[0];
  const cvvF = fieldsBy(el => { const ac = el.getAttribute('autocomplete') || ''; const n = (el.name + el.id).toLowerCase(); return ac.includes('cc-csc') || /cvv|cvc|csc|security.?code/.test(n); })[0];
  const phoneF = fieldsBy(el => el.type === 'tel' || /phone|mobile|tel|whatsapp/.test((el.name + el.id).toLowerCase()))[0];
  const numF = [...document.querySelectorAll('input[type="number"]')].filter(fvis).slice(0, 1).map(el => { const ctx = (el.name + el.id + (el.labels && el.labels[0] ? el.labels[0].innerText : '')).toLowerCase(); return { el, positiveOnly: /price|amount|cost|age|quantity|qty|count|total/.test(ctx), integerOnly: !el.step || el.step === '1' || el.step === '' }; })[0];
  const maskCands = [];
  for (const el of [...document.querySelectorAll('input[type="text"], input[type="tel"], input[type="search"]')].filter(fvis)) {
    if (maskCands.length >= 2) break;
    const ph = el.placeholder || '', n = (el.name + el.id).toLowerCase(), ac = (el.getAttribute('autocomplete') || '').toLowerCase();
    const hasFormatPh = /[_(){}-]\s*[_({}\-)]|\(?___?\)?|####|####\s*####|XX\s*\/\s*XX|\d{2}\s*\/\s*\d{2}/.test(ph);
    const isPhone = /phone|mobile|tel/.test(n) || el.type === 'tel' || ac.includes('tel');
    const isCard = /card|cc.?num/.test(n) || ac.includes('cc-number');
    if (!hasFormatPh && !isPhone && !isCard && !/iban|account.?number|postal|zip/.test(n)) continue;
    maskCands.push({ el, maskHint: isPhone ? 'phone' : isCard ? 'card' : 'generic' });
  }

  if (!dateInputs.length && !fileInputs.length && otpCands.length < 4 && !cbCands.length && !tagCands.length && !ieCands.length && !pwds.length && !cardF && !expF && !cvvF && !phoneF && !numF && !maskCands.length) return [];

  try {
    // ── FILE (passive only: accept / size-hint / progress / cancel UI) ──
    for (const f of fileInputs) {
      const container = f.closest('label, div, fieldset, .form-group, .upload, .file-upload, .file-input, .drop-zone, .dropzone') || f.parentElement;
      const txt = container ? container.innerText : '';
      if (!(f.hasAttribute('accept') && (f.getAttribute('accept') || '').trim())) add({ issueType: 'fileAcceptMissing', severity: 'medium', selector: sel(f), bbox: bb(f), description: 'File input has no accept= attribute — any file type accepted.', evidence: {} });
      if (!(/max(imum)?\s*\d+\s*(kb|mb|bytes?)/i.test(txt) || /up\s*to\s*\d+\s*(kb|mb)/i.test(txt))) add({ issueType: 'fileSizeHintMissing', severity: 'low', selector: sel(f), bbox: bb(f), description: 'No visible "max N MB" hint near the file input.', evidence: {} });
      if (container) {
        if (!container.querySelector('progress, [role="progressbar"], .progress, .progress-bar, [class*="upload-progress"], [class*="upload-bar"], [data-testid*="progress"]')) add({ issueType: 'uploadNoProgressUI', severity: 'medium', selector: sel(f), bbox: bb(f), description: 'File input has no <progress>/[role=progressbar] in its container — large uploads appear frozen.', evidence: {} });
        if (!container.querySelector('button[class*="cancel" i], button[class*="abort" i], [aria-label*="cancel" i], [data-testid*="cancel"], .cancel-upload')) add({ issueType: 'uploadNoCancelButton', severity: 'low', selector: sel(f), bbox: bb(f), description: 'File input has no cancel/abort control near it — users stuck on bad uploads.', evidence: {} });
      }
    }

    // ── DATE ──
    for (const el of dateInputs) {
      const ctx = ((el.name || el.id || '') + ' ' + (el.labels && el.labels[0] ? el.labels[0].innerText : '')).toLowerCase();
      const futureOnly = /expir|due|appointment|schedule|reservation|booking|deliver|deadline/.test(ctx);
      if (futureOnly && !el.hasAttribute('min') && !el.hasAttribute('max')) add({ issueType: 'dateInputNoMinMax', severity: 'low', selector: sel(el), bbox: bb(el), description: 'Future-only date field has no min/max attribute.', evidence: { ctx: ctx.slice(0,40) } });
      if (futureOnly) { setNative(el, el.type === 'date' ? '2020-01-01' : '2020-01-01T00:00'); el.dispatchEvent(new Event('blur', { bubbles: true })); await sleep(200); if (!hasError(el)) add({ issueType: 'dateAllowsPastForFutureField', severity: 'medium', selector: sel(el), bbox: bb(el), description: 'Future-only field accepted a past date (2020-01-01) with no error.', evidence: {} }); setNative(el, ''); }
    }
    const rangeDates = [...document.querySelectorAll('input[type="date"], input[type="datetime-local"]')].filter(fvis);
    if (rangeDates.length >= 2 && rangeDates[0].form && rangeDates[0].form === rangeDates[1].form) {
      const s = rangeDates[0], e = rangeDates[1];
      setNative(s, '2025-12-31'); setNative(e, '2025-01-01'); e.dispatchEvent(new Event('blur', { bubbles: true })); await sleep(250);
      const c = e.closest('form') || e.parentElement; const anyErr = c && c.querySelector('[role="alert"], .error, .invalid-feedback, [data-testid*="error"]');
      if (!(e.getAttribute('aria-invalid') === 'true' || (anyErr && /(end|after|before|range|date)/i.test(anyErr.innerText || '')))) add({ issueType: 'dateRangeEndBeforeStart', severity: 'high', selector: sel(e), bbox: bb(e), description: 'Date range pair accepted end < start with no error.', evidence: {} });
      setNative(s, ''); setNative(e, '');
    }

    // ── OTP ──
    if (otpCands.length >= 4) {
      const boxes = otpCands.slice(0, 6);
      const dx = Math.abs(boxes[1].getBoundingClientRect().left - boxes[0].getBoundingClientRect().left);
      if (dx <= 200) {
        boxes.forEach((b, i) => { b.setAttribute('data-argus-otp', String(i)); tracked.otp.push(b); });
        boxes[0].focus(); setNative(boxes[0], '1'); key(boxes[0], '1'); await sleep(250);
        const focusedIdx = document.activeElement && document.activeElement.getAttribute('data-argus-otp');
        if (focusedIdx === null || parseInt(focusedIdx, 10) <= 0) add({ issueType: 'otpNoAutoAdvance', severity: 'medium', selector: sel(boxes[0]), bbox: bb(boxes[0]), description: 'Typing in an OTP box did not auto-advance focus to the next box.', evidence: {} });
        // backspace return
        const b2 = boxes[1]; b2.focus(); setNative(b2, ''); key(b2, 'Backspace'); await sleep(200);
        const fb = document.activeElement && document.activeElement.getAttribute('data-argus-otp');
        if (fb !== '0') add({ issueType: 'otpBackspaceNoReturn', severity: 'low', selector: sel(b2), bbox: bb(b2), description: 'Backspace on an empty OTP box did not return focus to the previous box.', evidence: {} });
        // paste distribution
        boxes.forEach(b => setNative(b, ''));
        boxes[0].focus();
        const dt = new DataTransfer(); dt.setData('text/plain', '123456'.slice(0, boxes.length));
        boxes[0].dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
        await sleep(250);
        let filled = 0; for (const b of boxes) if (b.value && b.value.length === 1) filled++;
        if (filled < boxes.length) add({ issueType: 'otpPasteNotDistributed', severity: 'medium', selector: sel(boxes[0]), bbox: bb(boxes[0]), description: 'Pasting a full code did not fill all OTP boxes.', evidence: { filled, total: boxes.length } });
        boxes.forEach(b => setNative(b, ''));
      }
    }

    // ── COMBOBOX ──
    cbCands.forEach((el, i) => { el.setAttribute('data-argus-cb', String(i)); tracked.cb.push(el); });
    for (const el of cbCands) {
      const hasAria = el.getAttribute('role') === 'combobox' || el.hasAttribute('aria-expanded') || el.hasAttribute('aria-haspopup') || !!el.querySelector('[role="combobox"]');
      if (!hasAria) add({ issueType: 'comboboxNoAria', severity: 'medium', selector: sel(el), bbox: bb(el), description: 'Combobox missing role=combobox / aria-expanded / aria-haspopup.', evidence: {} });
      const listVisible = () => { for (const c of document.querySelectorAll('[role="listbox"]:not([aria-hidden="true"]), [class*="menu"][class*="open"], [class*="dropdown"][class*="open"], [class*="listbox"]:not([hidden]), [data-state="open"][role="listbox"]')) { const r = c.getBoundingClientRect(); if (r.width > 50 && r.height > 20 && vis(c)) return true; } return false; };
      el.click(); await sleep(350);
      if (!listVisible()) { add({ issueType: 'comboboxWontOpen', severity: 'high', selector: sel(el), bbox: bb(el), description: 'Clicking the combobox did not open its listbox.', evidence: {} }); esc(); continue; }
      const optsBefore = [...document.querySelectorAll('[role="option"]:not([aria-hidden="true"])')].filter(vis).length;
      const input = el.querySelector('input') || (el.tagName.toLowerCase() === 'input' ? el : null);
      if (input) { input.focus(); setNative(input, 'a'); await sleep(350); const optsAfter = [...document.querySelectorAll('[role="option"]:not([aria-hidden="true"])')].filter(vis).length; if (optsAfter === optsBefore && optsBefore > 1) add({ issueType: 'comboboxNoTypeFilter', severity: 'medium', selector: sel(el), bbox: bb(el), description: 'Typing in the combobox did not filter the options.', evidence: { optsBefore, optsAfter } }); setNative(input, ''); await sleep(200); }
      const valBefore = input ? input.value : (el.innerText || '').trim().slice(0, 80);
      key(el, 'ArrowDown'); await sleep(150); (input || el).dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); await sleep(300);
      const valAfter = input ? input.value : (el.innerText || '').trim().slice(0, 80);
      if (valAfter === valBefore && optsBefore > 0) add({ issueType: 'comboboxEnterNoSelect', severity: 'high', selector: sel(el), bbox: bb(el), description: 'Enter on a highlighted option did not select it.', evidence: {} });
      el.click(); await sleep(250); // reopen to test escape
      if (listVisible()) { esc(); await sleep(250); if (listVisible()) add({ issueType: 'comboboxEscapeNoClose', severity: 'medium', selector: sel(el), bbox: bb(el), description: 'Escape did not close the combobox (keyboard trap).', evidence: {} }); }
      esc();
    }

    // ── TAG INPUT ──
    tagCands.forEach((el, i) => { el.setAttribute('data-argus-tag', String(i)); tracked.tag.push(el); });
    for (const container of tagCands) {
      const input = container.querySelector('input[type="text"], input:not([type])');
      if (!input) continue;
      const chipCount = () => [...container.querySelectorAll('[class*="chip"], [class*="tag"]:not(input), [role="button"][aria-label*="remove" i], [data-tag]')].filter(c => vis(c) && c.tagName.toLowerCase() !== 'input').length;
      const base = chipCount();
      input.focus(); setNative(input, 'argusTag1'); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', keyCode: 13, bubbles: true })); await sleep(300);
      if (chipCount() <= base) add({ issueType: 'tagInputEnterNoAdd', severity: 'high', selector: sel(input), bbox: bb(container), description: 'Pressing Enter after typing did not create a chip.', evidence: { base } });
      const beforePaste = chipCount();
      input.focus(); const dt = new DataTransfer(); dt.setData('text/plain', 'argusA, argusB, argusC');
      input.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
      await sleep(150);
      if (!input.value && chipCount() === beforePaste) { setNative(input, 'argusA, argusB, argusC'); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await sleep(200); }
      await sleep(200);
      if (chipCount() < beforePaste + 3) add({ issueType: 'tagInputPasteNoSplit', severity: 'medium', selector: sel(input), bbox: bb(container), description: 'Pasting "a, b, c" did not create 3 separate chips.', evidence: { added: chipCount() - beforePaste } });
      // remove
      const chips = [...container.querySelectorAll('[class*="chip"], [class*="tag"]:not(input), [data-tag]')];
      const ours = chips.find(c => /argus/i.test(c.innerText || ''));
      if (ours) { const beforeRemove = chipCount(); const rm = ours.querySelector('[aria-label*="remove" i], [class*="close"], [class*="remove"], button, svg'); try { (rm || ours).click(); } catch (_) {} await sleep(250); if (chipCount() >= beforeRemove) add({ issueType: 'tagInputRemoveBroken', severity: 'high', selector: sel(input), bbox: bb(container), description: 'Clicking X on a chip did not remove it.', evidence: {} }); }
    }

    // ── FORMATTED INPUTS ──
    if (cardF) { setNative(cardF, '1234567890123456'); cardF.dispatchEvent(new Event('blur', { bubbles: true })); await sleep(200); if (!hasError(cardF)) add({ issueType: 'cardAcceptsInvalidLuhn', severity: 'high', selector: sel(cardF), bbox: bb(cardF), description: 'Card field accepted a Luhn-fail number "1234567890123456".', evidence: {} }); setNative(cardF, ''); }
    if (expF) { setNative(expF, '01/20'); expF.dispatchEvent(new Event('blur', { bubbles: true })); await sleep(200); if (!hasError(expF)) add({ issueType: 'expiryAcceptsPastDate', severity: 'high', selector: sel(expF), bbox: bb(expF), description: 'Expiry field accepted a past date "01/20".', evidence: {} }); setNative(expF, ''); }
    if (cvvF) { setNative(cvvF, 'ab'); cvvF.dispatchEvent(new Event('blur', { bubbles: true })); await sleep(200); if (!hasError(cvvF) && /[a-z]/i.test(cvvF.value)) add({ issueType: 'cvvAcceptsLetters', severity: 'medium', selector: sel(cvvF), bbox: bb(cvvF), description: 'CVV field accepted letters instead of digits.', evidence: { value: cvvF.value } }); setNative(cvvF, ''); }
    if (phoneF) { setNative(phoneF, 'abc'); phoneF.dispatchEvent(new Event('blur', { bubbles: true })); await sleep(200); if (!hasError(phoneF) && /[a-z]/i.test(phoneF.value)) add({ issueType: 'phoneAcceptsLetters', severity: 'medium', selector: sel(phoneF), bbox: bb(phoneF), description: 'Phone field accepted letters "abc".', evidence: { value: phoneF.value } }); setNative(phoneF, ''); }
    if (numF && numF.positiveOnly) { setNative(numF.el, '-1'); numF.el.dispatchEvent(new Event('blur', { bubbles: true })); await sleep(200); if (!hasError(numF.el) && numF.el.value === '-1') add({ issueType: 'numberAcceptsNegative', severity: 'medium', selector: sel(numF.el), bbox: bb(numF.el), description: 'Positive-only number field accepted -1.', evidence: {} }); setNative(numF.el, ''); }
    if (numF && numF.integerOnly) { setNative(numF.el, '1.5'); numF.el.dispatchEvent(new Event('blur', { bubbles: true })); await sleep(200); if (!hasError(numF.el) && /\./.test(numF.el.value)) add({ issueType: 'numberAcceptsDecimal', severity: 'medium', selector: sel(numF.el), bbox: bb(numF.el), description: 'Integer (step=1) number field accepted 1.5.', evidence: {} }); setNative(numF.el, ''); }

    // ── MASKED INPUTS ──
    maskCands.forEach((m, i) => { m.el.setAttribute('data-argus-mask', String(i)); tracked.mask.push(m.el); });
    for (const m of maskCands) {
      const el = m.el;
      if (el.pattern) { setNative(el, /\d/.test(el.pattern) ? 'abc' : '!!!'); el.dispatchEvent(new Event('blur', { bubbles: true })); await sleep(200); if (!hasError(el)) add({ issueType: 'patternNotEnforced', severity: 'medium', selector: sel(el), bbox: bb(el), description: `pattern="${el.pattern}" present but a non-matching value was accepted.`, evidence: {} }); setNative(el, ''); }
      setNative(el, '1234567890'); await sleep(250);
      if (!/[\s().\-\/]/.test(el.value) && el.value.length >= 10) add({ issueType: 'maskNotApplied', severity: 'medium', selector: sel(el), bbox: bb(el), description: `${m.maskHint} mask did not apply format separators to raw digits.`, evidence: { value: el.value } });
      const lenBefore = el.value.length;
      el.focus(); for (let i = 0; i < 3; i++) { setNative(el, el.value.slice(0, -1)); key(el, 'Backspace'); await sleep(60); }
      if (lenBefore - el.value.length < 3) add({ issueType: 'maskBackspaceStuck', severity: 'low', selector: sel(el), bbox: bb(el), description: 'Backspace inside the mask got stuck on a separator char.', evidence: { lenBefore, lenAfter: el.value.length } });
      setNative(el, '');
    }

    // ── INLINE EDIT ──
    ieCands.forEach((el, i) => { el.setAttribute('data-argus-ie', String(i)); el.__argusOrig = (el.innerText || el.textContent || '').trim().slice(0, 200); tracked.ie.push(el); });
    for (const cell of ieCands) {
      const orig = cell.__argusOrig;
      cell.click(); await sleep(300);
      const inEdit = () => { if (cell.getAttribute('contenteditable') === 'true' && document.activeElement === cell) return { editing: true, focused: true }; const input = cell.querySelector('input:not([type="hidden"]), textarea'); if (input) return { editing: true, focused: document.activeElement === input, input }; if (/editing|active|focused/i.test(cell.className) || cell.getAttribute('data-state') === 'editing') return { editing: true, focused: !!cell.querySelector(':focus') }; return { editing: false, focused: false }; };
      const st = inEdit();
      if (!st.editing) { add({ issueType: 'inlineEditClickFails', severity: 'high', selector: sel(cell), bbox: bb(cell), description: 'Clicking the cell did not enter edit mode.', evidence: {} }); continue; }
      if (!st.focused) add({ issueType: 'inlineEditNoAutoFocus', severity: 'low', selector: sel(cell), bbox: bb(cell), description: 'Inline-edit input was not auto-focused.', evidence: {} });
      const target = st.input || cell;
      if (st.input) setNative(target, 'argusEditTest'); else { target.textContent = 'argusEditTest'; target.dispatchEvent(new Event('input', { bubbles: true })); }
      await sleep(150); key(target, 'Escape'); await sleep(250);
      const textNow = (cell.innerText || cell.textContent || '').trim().slice(0, 200);
      if (/argusEditTest/.test(textNow)) add({ issueType: 'inlineEditEscapeCommits', severity: 'high', selector: sel(cell), bbox: bb(cell), description: 'Escape did not cancel the edit — the modified value persisted.', evidence: {} });
      // re-enter + Enter commits / exits editing
      cell.click(); await sleep(250);
      const st2 = inEdit();
      if (st2.editing) { const t2 = st2.input || cell; if (st2.input) setNative(t2, orig || 'argusKeep'); t2.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); t2.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', keyCode: 13, bubbles: true })); await sleep(250); if (inEdit().editing) add({ issueType: 'inlineEditEnterNoCommit', severity: 'medium', selector: sel(cell), bbox: bb(cell), description: 'Enter did not commit — the cell stayed in editing mode.', evidence: {} }); }
      key(cell, 'Escape'); await sleep(100);
    }

    // ── PASSWORD ──
    if (pwds.length > 0) {
      const primary = pwds[0];
      setNative(primary, '123'); primary.dispatchEvent(new Event('blur', { bubbles: true })); await sleep(250);
      let hasStrength = false;
      const strengthEls = document.querySelectorAll('[class*="strength"], [data-testid*="strength"], [aria-label*="strength" i], [class*="password-meter"], [role="progressbar"][aria-label*="password" i]');
      if (strengthEls.length) hasStrength = true;
      else { const c = primary.closest('label, div, fieldset, .form-group, .field') || primary.parentElement; if (c && c.querySelector('[role="alert"], .error, .invalid-feedback, [data-testid*="error"], .text-danger, .password-error')) hasStrength = true; }
      if (!hasStrength) add({ issueType: 'passwordNoStrengthFeedback', severity: 'medium', selector: sel(primary), bbox: bb(primary), description: '"123" accepted with no password strength indicator or weak-password feedback.', evidence: {} });
      if (pwds.length >= 2) {
        const confirm = pwds[1];
        setNative(primary, 'Argus#123'); setNative(confirm, 'Argus#124'); confirm.dispatchEvent(new Event('blur', { bubbles: true })); await sleep(250);
        const c = confirm.closest('label, div, fieldset, .form-group, .field, form') || confirm.parentElement;
        const err = c && c.querySelector('[role="alert"], .error, .invalid-feedback, [data-testid*="error"], .text-danger, .field-error');
        if (!(confirm.getAttribute('aria-invalid') === 'true' || (err && /match|same|confirm|differ/i.test(err.innerText || '')))) add({ issueType: 'confirmPasswordNoMismatchError', severity: 'high', selector: sel(confirm), bbox: bb(confirm), description: 'Mismatching primary + confirm password shows no error.', evidence: {} });
        setNative(confirm, '');
      }
      setNative(primary, '');
    }
  } finally {
    // ── MANDATORY IDEMPOTENT CLEANUP ──
    esc();
    for (const el of tracked.otp) { try { setNative(el, ''); el.removeAttribute('data-argus-otp'); } catch (_) {} }
    for (const el of tracked.cb) { try { el.removeAttribute('data-argus-cb'); } catch (_) {} }
    for (const el of tracked.tag) { try { const chips = [...el.querySelectorAll('[class*="chip"], [class*="tag"]:not(input), [data-tag]')]; for (const c of chips) if (/argus/i.test(c.innerText || '')) { const rm = c.querySelector('[aria-label*="remove" i], [class*="close"], [class*="remove"], button, svg'); try { (rm || c).click(); } catch (_) {} } el.removeAttribute('data-argus-tag'); } catch (_) {} }
    for (const el of tracked.mask) { try { setNative(el, ''); el.removeAttribute('data-argus-mask'); } catch (_) {} }
    for (const el of tracked.ie) { try { el.removeAttribute('data-argus-ie'); delete el.__argusOrig; } catch (_) {} }
    for (const el of document.querySelectorAll('input[type="date"], input[type="time"], input[type="datetime-local"]')) { try { setNative(el, ''); } catch (_) {} }
    for (const p of document.querySelectorAll('input[type="password"]')) { try { setNative(p, ''); } catch (_) {} }
  }

  return out;
}

MCP steps (for file upload with a real file — fileNoSelectionFeedback only)

Choosing a real file needs the OS dialog, which a probe can't open. Run this when a file input exists AND a fixture file is available at {plugin-root}/.tmp/qa-fixtures/tiny.txt:

1. browser_file_upload({ paths: ['{plugin-root}/.tmp/qa-fixtures/tiny.txt'] })   // targets the focused/visible file input
2. browser_wait_for(time = 600)
3. r = browser_evaluate(probe.checkFilenameDisplayed, { expectedName: 'tiny.txt' })
4. If !r.displayed → emit fileNoSelectionFeedback (medium)
     description: 'After selecting a file, no UI shows the file name.'
5. browser_evaluate(probe.resetFileInputs)   // cleanup
// probe.checkFilenameDisplayed — args: { expectedName }
({expectedName}) => {
  const input = document.querySelector('input[type="file"]');
  if (!input) return { displayed: false };
  const inputHasFile = input.files && input.files.length > 0 && input.files[0].name.includes(expectedName);
  const container = input.closest('label, div, fieldset, .form-group, .upload, .file-input, form') || input.parentElement;
  const text = container ? container.innerText : '';
  return { displayed: inputHasFile && text.includes(expectedName), inputHasFile, visibleFileName: text.includes(expectedName) };
}
// probe.resetFileInputs
() => { for (const el of document.querySelectorAll('input[type="file"]')) { try { el.value = ''; el.dispatchEvent(new Event('change', { bubbles: true })); } catch (_) {} } return { ok: true }; }

Issues

issueType severity description
dateInputNoMinMax low Date input missing min/max attrs in future-only context
dateAllowsPastForFutureField medium Future-only field accepted a past date
dateRangeEndBeforeStart high Date range pair accepted end < start
datePickerKeyboardTrap medium Date picker opens but keyboard nav doesn't move through cells
fileAcceptMissing medium No accept= attribute — any file type accepted
fileSizeHintMissing low No visible "max N MB" hint near file input
fileNoSelectionFeedback medium After upload, no UI shows the file name (MCP)
uploadNoProgressUI medium No progress element near the file input
uploadNoCancelButton low No cancel/abort control near the file input
otpNoAutoAdvance medium Keystroke does not advance focus to next box
otpBackspaceNoReturn low Backspace on empty box doesn't return to previous
otpPasteNotDistributed medium Pasting full code doesn't fill all boxes
comboboxWontOpen high Click doesn't open listbox
comboboxNoTypeFilter medium Typing doesn't filter options
comboboxEnterNoSelect high Enter on highlighted option doesn't select
comboboxEscapeNoClose medium Escape doesn't close (keyboard trap)
comboboxNoAria medium Missing role=combobox / aria-expanded / aria-haspopup
tagInputEnterNoAdd high Enter after typing doesn't create chip
tagInputPasteNoSplit medium Pasting "a, b, c" doesn't create 3 chips
tagInputRemoveBroken high Click X on chip doesn't remove it
cardAcceptsInvalidLuhn high Card field accepted Luhn-fail number
expiryAcceptsPastDate high Expiry field accepted past date
cvvAcceptsLetters medium CVV field accepted letters
phoneAcceptsLetters medium Phone field accepted letters
numberAcceptsNegative medium Positive-only number accepted -1
numberAcceptsDecimal medium Integer-only number accepted 1.5
patternNotEnforced medium pattern= present but non-matching value accepted
maskNotApplied medium Mask didn't apply format separators
maskBackspaceStuck low Backspace stuck on separator char
inlineEditClickFails high Click cell doesn't enter edit mode
inlineEditNoAutoFocus low Edit input not auto-focused
inlineEditEscapeCommits high Escape doesn't cancel — modified value persists
inlineEditEnterNoCommit medium Enter doesn't commit — cell stuck editing
passwordNoStrengthFeedback medium "123" accepted without strength indicator
confirmPasswordNoMismatchError high Mismatching primary + confirm shows no error

Migration

[detectors]
qa-form-input-types        = true   # NEW
qa-test-form-datetime      = false
qa-test-form-file-upload   = false
qa-test-form-otp           = false
qa-test-form-combobox      = false
qa-test-form-tag-input     = false
qa-test-form-formatted-inputs = false
qa-test-form-input-mask    = false
qa-test-form-inline-edit   = false
qa-test-form-password-rules = false

Notes on this conversion

  • 8 of 9 widget types (date, OTP, combobox, tag, formatted, masked, inline-edit, password) + the file PASSIVE checks now run as ONE in-page async probe instead of one discovery + dozens of per-widget MCP round-trips. All the old per-probe helpers (discoverAllWidgets, checkDateError, findDateRangePair, checkRangeError, checkOtpFocused, simulateOtpPaste, checkOtpAllFilled, checkListboxVisible, snapshotComboboxOptions, getActiveOptionText, getComboboxValue, countTagChips, simulateTagPaste, removeArgusTagChip, checkInputError, checkPatternRejection, getMaskedValue, checkInlineEditMode, checkInlineCellText, checkPasswordStrengthIndicator, checkConfirmPasswordMismatchError, and all cleanup probes) are inlined; cleanup runs in a finally block.
  • Folded out: datePickerKeyboardTrap — its recipe needs a real native picker opened via Alt+ArrowDown / OS calendar focus traversal that a probe can't reliably exercise; the issueType is preserved in the table but is not emitted by the probe (re-add as MCP steps if a custom (non-native) picker needs it).
  • Kept MCP (executable: partial): fileNoSelectionFeedback — selecting a real file requires browser_file_upload (OS file dialog). All other file checks are in the probe.
  • All 35 issueType names preserved exactly.