Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/agentic-testing/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
| el-scroll | [Element Plus Scrollbar](https://element-plus.org/en-US/component/scrollbar.html) — vertical / horizontal | Scroll target container in both directions. | Correct region and direction. |
| mui-input | [MUI Autocomplete](https://mui.com/material-ui/react-autocomplete/) — Controlled states | Type, select, clear. | Displayed inputValue/value stay consistent. |
| radix-select | [Radix Select](https://www.radix-ui.com/primitives/docs/components/select) | Select, reopen, select another. | Value updates; focus returns to trigger. |
| imask-input | [IMask](https://imask.js.org/) — phone mask | Fill, repeat, clear, retype. | Formatting, clearing, and repeated input work. |

## References

Expand Down
7 changes: 5 additions & 2 deletions packages/page-controller/src/PageController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,11 +276,14 @@ export class PageController extends EventTarget {
this.assertIndexed()
const element = getElementByIndex(this.selectorMap, index)
const elemText = this.elementTextMap.get(index)
await inputTextElement(element, text)
const value = await inputTextElement(element, text)

return {
success: true,
message: `✅ Input text (${text}) into element (${elemText ?? index}).`,
message:
value === text
? `✅ Input text (${text}) into element (${elemText ?? index}).`
: `✅ Input action completed for element (${elemText ?? index}). Note: requested ${JSON.stringify(text)}; current value is ${JSON.stringify(value)}.`,
}
} catch (error) {
return {
Expand Down
62 changes: 51 additions & 11 deletions packages/page-controller/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject reads from inputs replaced by event handlers

When a synchronous input handler replaces this element, dispatchEvent completes after the replacement, but this line still reads the detached input. PageController.inputText can 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 👍 / 👎.

}

/**
Expand All @@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify against the current selected option

When an input or change handler rebuilds the options while preserving the requested choice, option refers to the removed node and its live index no longer represents the replacement option's position. This check can therefore throw “discarded” even though the current select still contains and selects the requested option; verify the current selectedOptions using stable expected text/value instead of the original node's index.

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 {
Expand Down