diff --git a/src/chrome/src/content/content.js b/src/chrome/src/content/content.js index 65726d841..8a2871705 100644 --- a/src/chrome/src/content/content.js +++ b/src/chrome/src/content/content.js @@ -3814,6 +3814,17 @@ return typeof el.innerText === 'string' ? el.innerText : (el.textContent || ''); } + // Pick the single commit path for set_field({submit:true}). Synthetic + // (isTrusted:false) Enter events never trigger native submission — + // they only reach the page's own keydown listeners. A non-combobox field + // inside a form that has requestSubmit therefore needs a native submit as + // its only reliable commit path; comboboxes are committed by page JS + // listeners instead, because submitting the enclosing form while a picker + // popup is open is usually wrong. + function _setFieldUsesNativeSubmit(isCombobox, isContentEditable, form) { + return !isCombobox && !isContentEditable && !!form && typeof form.requestSubmit === 'function'; + } + // The rich-text toolbar heuristic lives in one file shared by both builds // and by the CDP main-world probe — see // src/content/rich-text-toolbar-heuristic.js. Delegating keeps the scoring @@ -5711,7 +5722,10 @@ const actual = el.isContentEditable ? _editableTextValue(el) : (el.value || ''); const verified = _setFieldValueMatches(actual, prevValue, text, clear, el.isContentEditable); const fallbackAttempted = false; + let nativeSubmitAttempted = false; + let submissionOutcomeUnknown = false; if (submit && verified) { + submissionOutcomeUnknown = true; try { // Detect combobox/searchbox pattern: if the element is a searchbox, // has role=combobox, has aria-controls pointing to a listbox, or a @@ -5738,16 +5752,12 @@ } catch {} } const dispatchKey = (type, key, keyCode) => { - el.dispatchEvent(new KeyboardEvent(type, { key, code: key, keyCode, bubbles: true, cancelable: true })); + return el.dispatchEvent(new KeyboardEvent(type, { key, code: key, keyCode, bubbles: true, cancelable: true })); }; - if (isCombobox) { - // Give the listbox a tick to filter, then highlight the first - // option with ArrowDown, then commit with Enter. - await new Promise(r => setTimeout(r, 80)); - dispatchKey('keydown', 'ArrowDown', 40); - dispatchKey('keyup', 'ArrowDown', 40); - await new Promise(r => setTimeout(r, 30)); - } + const form = el.form || (el.closest && el.closest('form')); + const usesNativeSubmit = _setFieldUsesNativeSubmit(isCombobox, el.isContentEditable, form); + let submissionObserved = false; + let submissionCancelled = false; if (msg.params?.messageRecipientGuardRequired === true) { const recipientValidation = _consumeMessageRecipientDispatchBinding(msg.params, el); if (recipientValidation.success !== true) { @@ -5760,17 +5770,57 @@ }); } } - dispatchKey('keydown', 'Enter', 13); - dispatchKey('keypress', 'Enter', 13); - dispatchKey('keyup', 'Enter', 13); - // Form submission: only fall back to requestSubmit for non-combobox - // inputs. Submitting a form while a combobox popup is open is - // usually wrong and can prematurely post the enclosing form. - if (!isCombobox) { - const form = el.form || (el.closest && el.closest('form')); - if (form && typeof form.requestSubmit === 'function') form.requestSubmit(); + let removeSubmitObserver = () => {}; + let submitEvent = null; + if (form && typeof form.addEventListener === 'function') { + const onSubmit = event => { + submissionObserved = true; + submitEvent = event; + }; + form.addEventListener('submit', onSubmit, true); + removeSubmitObserver = () => form.removeEventListener?.('submit', onSubmit, true); } - } catch {} + try { + if (usesNativeSubmit) { + // Ordinary form controls use one native path. Dispatching a + // synthetic Enter first could make page code act and then + // make this fallback repeat the consequential action. + // requestSubmit performs interactive constraint validation and + // silently aborts on an invalid form; surface that instead of + // reporting a successful submission. + if (form.noValidate !== true && typeof form.checkValidity === 'function' && !form.checkValidity()) { + return failure( + 'The form did not submit: a required field is empty or a value is invalid. Fix the field and retry with a fresh ref_id.', + { verified: true, submitted: false, invalid: true, ref_id, rect }, + ); + } + try { + form.requestSubmit(); + } catch {} + } else { + // Comboboxes, contenteditables, and form-less widgets are + // committed by page-owned keyboard handlers. Never follow + // this path with requestSubmit: cancellation is not proof of + // submission, and an unobserved handler may already have acted. + if (isCombobox) { + await new Promise(r => setTimeout(r, 80)); + dispatchKey('keydown', 'ArrowDown', 40); + dispatchKey('keyup', 'ArrowDown', 40); + await new Promise(r => setTimeout(r, 30)); + } + dispatchKey('keydown', 'Enter', 13); + dispatchKey('keypress', 'Enter', 13); + dispatchKey('keyup', 'Enter', 13); + } + submissionCancelled = submitEvent?.defaultPrevented === true; + nativeSubmitAttempted = submissionObserved && !submissionCancelled; + submissionOutcomeUnknown = !nativeSubmitAttempted; + } finally { + removeSubmitObserver(); + } + } catch { + submissionOutcomeUnknown = true; + } } if (!verified) { return failure( @@ -5798,6 +5848,8 @@ verified: true, fieldMeta, fallbackAttempted, + submitted: nativeSubmitAttempted || undefined, + outcomeUnknown: submissionOutcomeUnknown || undefined, }; } catch (e) { return failure(e && e.message || String(e)); diff --git a/src/firefox/src/content/content.js b/src/firefox/src/content/content.js index e6803278e..385c55a2c 100644 --- a/src/firefox/src/content/content.js +++ b/src/firefox/src/content/content.js @@ -3080,6 +3080,16 @@ return typeof el.innerText === 'string' ? el.innerText : (el.textContent || ''); } + // Synthetic (isTrusted:false) Enter events never trigger native submission — + // they only reach the page's own keydown listeners. A non-combobox field + // inside a form that has requestSubmit therefore needs a native submit as + // its only reliable commit path; comboboxes are committed by page JS + // listeners instead, because submitting the enclosing form while a picker + // popup is open is usually wrong. + function _setFieldUsesNativeSubmit(isCombobox, isContentEditable, form) { + return !isCombobox && !isContentEditable && !!form && typeof form.requestSubmit === 'function'; + } + // Above this length the per-candidate rescan below stops being worth its // cost. The edit still succeeds; it is reported unproven, which the callers // treat as "no positive proof", not as a failure. @@ -4840,6 +4850,8 @@ let actual = el.isContentEditable ? _editableTextValue(el) : (el.value || ''); let verified = _setFieldValueMatches(actual, prevValue, text, clear, el.isContentEditable); let fallbackAttempted = false; + let nativeSubmitAttempted = false; + let submissionOutcomeUnknown = false; if (!verified) { fallbackAttempted = true; await _retryFieldWithExecCommand(el, (clear ? '' : prevValue) + text); @@ -4848,6 +4860,7 @@ } if (submit && verified) { + submissionOutcomeUnknown = true; try { const roleAttr = (el.getAttribute && el.getAttribute('role') || '').toLowerCase(); const controls = el.getAttribute && el.getAttribute('aria-controls'); @@ -4867,14 +4880,12 @@ } catch {} } const dispatchKey = (type, key, keyCode) => { - el.dispatchEvent(new KeyboardEvent(type, { key, code: key, keyCode, bubbles: true, cancelable: true })); + return el.dispatchEvent(new KeyboardEvent(type, { key, code: key, keyCode, bubbles: true, cancelable: true })); }; - if (isCombobox) { - await new Promise(r => setTimeout(r, 80)); - dispatchKey('keydown', 'ArrowDown', 40); - dispatchKey('keyup', 'ArrowDown', 40); - await new Promise(r => setTimeout(r, 30)); - } + const form = el.form || (el.closest && el.closest('form')); + const usesNativeSubmit = _setFieldUsesNativeSubmit(isCombobox, el.isContentEditable, form); + let submissionObserved = false; + let submissionCancelled = false; if (msg.params?.messageRecipientGuardRequired === true) { const recipientValidation = _consumeMessageRecipientDispatchBinding(msg.params, el); if (recipientValidation.success !== true) { @@ -4887,14 +4898,57 @@ }); } } - dispatchKey('keydown', 'Enter', 13); - dispatchKey('keypress', 'Enter', 13); - dispatchKey('keyup', 'Enter', 13); - if (!isCombobox) { - const form = el.form || (el.closest && el.closest('form')); - if (form && typeof form.requestSubmit === 'function') form.requestSubmit(); + let removeSubmitObserver = () => {}; + let submitEvent = null; + if (form && typeof form.addEventListener === 'function') { + const onSubmit = event => { + submissionObserved = true; + submitEvent = event; + }; + form.addEventListener('submit', onSubmit, true); + removeSubmitObserver = () => form.removeEventListener?.('submit', onSubmit, true); } - } catch {} + try { + if (usesNativeSubmit) { + // Ordinary form controls use one native path. Dispatching a + // synthetic Enter first could make page code act and then + // make this fallback repeat the consequential action. + // requestSubmit performs interactive constraint validation and + // silently aborts on an invalid form; surface that instead of + // reporting a successful submission. + if (form.noValidate !== true && typeof form.checkValidity === 'function' && !form.checkValidity()) { + return failure( + 'The form did not submit: a required field is empty or a value is invalid. Fix the field and retry with a fresh ref_id.', + { verified: true, submitted: false, invalid: true, ref_id, rect }, + ); + } + try { + form.requestSubmit(); + } catch {} + } else { + // Comboboxes, contenteditables, and form-less widgets are + // committed by page-owned keyboard handlers. Never follow + // this path with requestSubmit: cancellation is not proof of + // submission, and an unobserved handler may already have acted. + if (isCombobox) { + await new Promise(r => setTimeout(r, 80)); + dispatchKey('keydown', 'ArrowDown', 40); + dispatchKey('keyup', 'ArrowDown', 40); + await new Promise(r => setTimeout(r, 30)); + } + dispatchKey('keydown', 'Enter', 13); + dispatchKey('keypress', 'Enter', 13); + dispatchKey('keyup', 'Enter', 13); + } + submissionCancelled = submitEvent?.defaultPrevented === true; + nativeSubmitAttempted = submissionObserved && !submissionCancelled; + submissionOutcomeUnknown = !nativeSubmitAttempted; + } finally { + removeSubmitObserver(); + } + } catch { + submissionOutcomeUnknown = true; + } } if (!verified) { return failure( @@ -4921,6 +4975,8 @@ verified: true, fieldMeta, fallbackAttempted, + submitted: nativeSubmitAttempted || undefined, + outcomeUnknown: submissionOutcomeUnknown || undefined, }; } catch (e) { return failure(e && e.message || String(e)); diff --git a/test/run.js b/test/run.js index cbab07c8a..dadc12c9e 100644 --- a/test/run.js +++ b/test/run.js @@ -55026,13 +55026,148 @@ test('set_field waits for reconciliation and verifies the complete value', () => assert.match(branch, /(?:const|let) actual = el\.isContentEditable \? _editableTextValue\(el\)/, `${label}: rich-editor verification must use rendered text`); assert.match(branch, /_setFieldValueMatches\(actual, prevValue, text, clear, el\.isContentEditable\)/, `${label}: newline normalization must remain contenteditable-only`); assert.match(branch, /!el\.isConnected \|\| !rect \|\| rect\.w < 1 \|\| rect\.h < 1/, `${label}: stale or zero-sized targets must fail before typing`); - assert.match(branch, /if \(submit && verified\)/, `${label}: mismatched field values must not be submitted`); + assert.match(branch, /if \(submit && verified\)/, `${label}: mismatched field values must not be submitted`); + assert.match(branch, /addEventListener\('submit'/, `${label}: submit handling must observe actual submit events`); + assert.match(branch, /outcomeUnknown: submissionOutcomeUnknown/, `${label}: unproven submissions must be surfaced as unknown`); assert.match(branch, /if \(!verified\) \{[\s\S]*return failure\(/, `${label}: mismatched field values must be explicit failed actions`); assert.match(branch, /dispatched\s*\?\s*\{ dispatched: true \}/, `${label}: post-dispatch verification failures must preserve action evidence`); assert.doesNotMatch(branch, /actual\.includes\(text\)/, `${label}: substring matches must not count as verified field values`); } }); +test('set_field submit chooses exactly one native or page-owned commit path', async () => { + for (const [label, rel] of [ + ['chrome', 'src/chrome/src/content/content.js'], + ['firefox', 'src/firefox/src/content/content.js'], + ]) { + const source = fs.readFileSync(path.join(ROOT, rel), 'utf8'); + + // Pure boolean helper. + const helperStart = source.indexOf('function _setFieldUsesNativeSubmit('); + const helperEnd = source.indexOf('\n }\n', helperStart) + 4; + assert.ok(helperStart >= 0 && helperEnd > helperStart, `${label}: submit helper should remain independently testable`); + const usesNativeSubmit = vm.runInNewContext(`(${source.slice(helperStart, helperEnd)})`); + const formWithSubmit = { requestSubmit() {} }; + assert.equal(usesNativeSubmit(true, false, formWithSubmit), false, `${label}: combobox never uses native submit`); + assert.equal(usesNativeSubmit(false, true, formWithSubmit), false, `${label}: contenteditable never uses native submit`); + assert.equal(usesNativeSubmit(false, false, formWithSubmit), true, `${label}: plain field in a form uses native submit`); + assert.equal(usesNativeSubmit(false, false, null), false, `${label}: form-less field does not use native submit`); + assert.equal(usesNativeSubmit(false, false, {}), false, `${label}: form without requestSubmit does not use native submit`); + + // Behavioral slice of the submit block, run against stubs. + const blockStart = source.indexOf("const form = el.form || (el.closest && el.closest('form'));"); + const blockEnd = source.indexOf('\n } catch {\n submissionOutcomeUnknown = true;', blockStart); + assert.ok(blockStart >= 0 && blockEnd > blockStart, `${label}: submit block not found`); + const block = source.slice(blockStart, blockEnd); + const runner = vm.runInNewContext(`async (stubs) => { + const { dispatchKey, el, msg, failure, isCombobox, _setFieldUsesNativeSubmit, _consumeMessageRecipientDispatchBinding, ref_id, rect } = stubs; + let nativeSubmitAttempted = false; + let submissionOutcomeUnknown = true; + ${block} + return { nativeSubmitAttempted, submissionOutcomeUnknown }; + }`, { setTimeout: callback => callback() }); + + const exercise = async ({ + keydownCancelled = false, + checkValidity = () => true, + isCombobox = false, + isContentEditable = false, + hasForm = true, + pageSubmitsOnKeydown = false, + pageUsesDirectSubmitOnKeydown = false, + submitCancelled = false, + noValidate = false, + } = {}) => { + const calls = []; + const submitListeners = []; + const emitSubmit = () => { + const event = { defaultPrevented: false }; + for (const listener of submitListeners) listener(event); + if (submitCancelled) event.defaultPrevented = true; + }; + const form = { + requestSubmit: () => { calls.push('requestSubmit'); emitSubmit(); }, + checkValidity, + noValidate, + addEventListener: (type, listener) => { if (type === 'submit') submitListeners.push(listener); }, + removeEventListener: (type, listener) => { + if (type === 'submit') { + const index = submitListeners.indexOf(listener); + if (index >= 0) submitListeners.splice(index, 1); + } + }, + }; + const el = { + dispatchEvent: event => !(keydownCancelled && event.type === 'keydown' && event.key === 'Enter'), + form: hasForm ? form : null, + closest: () => null, + isContentEditable, + }; + const dispatchKey = (type, key) => { + calls.push(`${type}:${key}`); + if (type === 'keydown' && key === 'Enter' && pageSubmitsOnKeydown) emitSubmit(); + if (type === 'keydown' && key === 'Enter' && pageUsesDirectSubmitOnKeydown) calls.push('form.submit'); + return el.dispatchEvent({ type, key }); + }; + let failureResult = null; + const failure = (msg, data) => { failureResult = data; return { success: false, ...data }; }; + const result = await runner({ + dispatchKey, el, + msg: { params: {} }, + failure, + isCombobox, + _setFieldUsesNativeSubmit: usesNativeSubmit, + _consumeMessageRecipientDispatchBinding: () => ({ success: true }), + ref_id: 'ref_1', + rect: { x: 0, y: 0, w: 1, h: 1 }, + }); + return { calls, result, failureResult }; + }; + + const plain = await exercise(); + assert.deepEqual(plain.calls, ['requestSubmit'], `${label}: plain form field must use only native submission`); + assert.deepEqual({ ...plain.result }, { nativeSubmitAttempted: true, submissionOutcomeUnknown: false }); + assert.equal(plain.failureResult, null, `${label}: observed native submit must not fail`); + + const combobox = await exercise({ isCombobox: true, pageSubmitsOnKeydown: true }); + assert.deepEqual(combobox.calls, [ + 'keydown:ArrowDown', + 'keyup:ArrowDown', + 'keydown:Enter', + 'keypress:Enter', + 'keyup:Enter', + ], `${label}: combobox must use only its page-owned keyboard path`); + assert.deepEqual({ ...combobox.result }, { nativeSubmitAttempted: true, submissionOutcomeUnknown: false }); + + // form.submit() deliberately emits no submit event. The result remains + // unknown, but the native fallback must still not repeat the action. + const directSubmit = await exercise({ isCombobox: true, pageUsesDirectSubmitOnKeydown: true }); + assert.equal(directSubmit.calls.includes('form.submit'), true, `${label}: direct page submit was not exercised`); + assert.equal(directSubmit.calls.includes('requestSubmit'), false, `${label}: unobserved page action must not trigger a second submit`); + assert.deepEqual({ ...directSubmit.result }, { nativeSubmitAttempted: false, submissionOutcomeUnknown: true }); + + const contenteditable = await exercise({ isContentEditable: true, keydownCancelled: true }); + assert.deepEqual(contenteditable.calls, ['keydown:Enter', 'keypress:Enter', 'keyup:Enter'], `${label}: contenteditable must stay on the keyboard path`); + assert.deepEqual({ ...contenteditable.result }, { nativeSubmitAttempted: false, submissionOutcomeUnknown: true }); + + // A page can cancel a submit event after observing it; that is not proof + // that the consequential action reached the server. + const cancelledSubmit = await exercise({ submitCancelled: true }); + assert.deepEqual(cancelledSubmit.calls, ['requestSubmit'], `${label}: cancelled native submit must not dispatch Enter`); + assert.deepEqual({ ...cancelledSubmit.result }, { nativeSubmitAttempted: false, submissionOutcomeUnknown: true }); + + const noValidate = await exercise({ noValidate: true, checkValidity: () => false }); + assert.deepEqual(noValidate.calls, ['requestSubmit'], `${label}: novalidate forms must still use native submission`); + assert.deepEqual({ ...noValidate.result }, { nativeSubmitAttempted: true, submissionOutcomeUnknown: false }); + + // Invalid form: surface the silent requestSubmit abort. + const invalid = await exercise({ checkValidity: () => false }); + assert.deepEqual(invalid.calls, [], `${label}: invalid form must dispatch neither Enter nor requestSubmit`); + assert.equal(invalid.failureResult?.submitted, false, `${label}: invalid form must fail with submitted:false`); + assert.equal(invalid.failureResult?.invalid, true, `${label}: invalid form must flag invalid:true`); + } +}); + test('type_ax shares settled exact verification and explicit recovery contract', () => { for (const [label, rel] of [ ['chrome', 'src/chrome/src/content/content.js'],