-
Notifications
You must be signed in to change notification settings - Fork 2.6k
feat(page-controller): reject misused actions and verify input results #718
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -62,6 +62,12 @@ function blurLastClickedElement() { | |
| * @private Internal method, subject to change at any time. | ||
| */ | ||
| export async function clickElement(element: HTMLElement) { | ||
| if (isSelectElement(element)) { | ||
| throw new Error( | ||
| 'Clicking a native <select> cannot open its options. Select the option by its visible text instead.' | ||
| ) | ||
| } | ||
|
|
||
| blurLastClickedElement() | ||
|
|
||
| lastClickedElement = element | ||
|
|
@@ -125,14 +131,37 @@ export async function clickElement(element: HTMLElement) { | |
| await waitFor(0.2) | ||
| } | ||
|
|
||
| /** `<input>` types whose `value` is not user-typed text. */ | ||
| const NON_TEXT_INPUT_TYPES = new Set([ | ||
| 'button', | ||
| 'checkbox', | ||
| 'file', | ||
| 'image', | ||
| 'radio', | ||
| 'reset', | ||
| 'submit', | ||
| ]) | ||
|
|
||
| function assertAcceptsText(element: HTMLElement): void { | ||
| const acceptsText = | ||
| isTextAreaElement(element) || | ||
| element.isContentEditable || | ||
| (isInputElement(element) && !NON_TEXT_INPUT_TYPES.has(element.type)) | ||
| if (!acceptsText) { | ||
| const tag = isInputElement(element) | ||
| ? `<input type="${element.type}">` | ||
| : `<${element.tagName.toLowerCase()}>` | ||
| throw new Error(`${tag} does not accept text input.`) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * @private Internal method, subject to change at any time. | ||
| */ | ||
| export async function inputTextElement(element: HTMLElement, text: string) { | ||
| export async function inputTextElement(element: HTMLElement, text: string): Promise<string> { | ||
| assertAcceptsText(element) | ||
|
|
||
| const isContentEditable = element.isContentEditable | ||
| if (!isInputElement(element) && !isTextAreaElement(element) && !isContentEditable) { | ||
| throw new Error('Element is not an input, textarea, or contenteditable') | ||
| } | ||
|
|
||
| await clickElement(element) | ||
|
|
||
|
|
@@ -217,16 +246,18 @@ export async function inputTextElement(element: HTMLElement, text: string) { | |
|
|
||
| // Trigger blur for validation | ||
| element.blur() | ||
| } else { | ||
| getNativeValueSetter(element as HTMLInputElement | HTMLTextAreaElement).call(element, text) | ||
| } | ||
|
|
||
| // Only dispatch shared input event for non-contenteditable (contenteditable has its own) | ||
| if (!isContentEditable) { | ||
| element.dispatchEvent(new Event('input', { bubbles: true })) | ||
| await waitFor(0.1) | ||
| return element.innerText | ||
| } | ||
|
|
||
| const input = element as HTMLInputElement | HTMLTextAreaElement | ||
| getNativeValueSetter(input).call(input, text) | ||
| input.dispatchEvent(new Event('input', { bubbles: true })) | ||
|
|
||
| await waitFor(0.1) | ||
|
|
||
| return input.value | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -245,10 +276,19 @@ export async function selectOptionElement(selectElement: HTMLSelectElement, opti | |
| throw new Error(`Option with text "${optionText}" not found in select element`) | ||
| } | ||
|
|
||
| selectElement.value = option.value | ||
| // Assigning `value` would pick the first option with that value; index targets this exact option. | ||
| selectElement.selectedIndex = option.index | ||
| selectElement.dispatchEvent(new Event('input', { bubbles: true })) | ||
| selectElement.dispatchEvent(new Event('change', { bubbles: true })) | ||
|
|
||
| await waitFor(0.1) // Wait to ensure change event processing completes | ||
|
|
||
| if (selectElement.selectedIndex !== option.index) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an AGENTS.md reference: AGENTS.md:L151-L152 Useful? React with 👍 / 👎. |
||
| const current = selectElement.selectedOptions.item(0)?.textContent?.trim() ?? '' | ||
| throw new Error( | ||
| `The page discarded the selection. Expected option ${JSON.stringify(optionText)}, but the current option is ${JSON.stringify(current)}. Inspect the current page state before retrying.` | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| interface ScrollableElement extends Element { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a synchronous
inputhandler replaces this element,dispatchEventcompletes after the replacement, but this line still reads the detached input.PageController.inputTextcan therefore report the requested value as a successful current value while the live replacement is empty or normalized differently; detect a disconnected element and fail or resolve the live indexed element before reporting its value.AGENTS.md reference: AGENTS.md:L151-L152
Useful? React with 👍 / 👎.