In short: a page is a program that waits. It runs a little JavaScript, then waits for the next click, key press, timer or network answer, then runs a little more. A test that acts too early finds a page that has not finished, and a test that fakes input with script may not be believed. Good automation waits for the page to be ready and sends real input events.
The event loop#
JavaScript in a page runs on one thread. It cannot do two things at once, so the browser keeps a queue of work and a loop that takes one job at a time.
One turn of the loop: run a task, run its microtasks, maybe repaint, repeat.
Three consequences matter for tests.
- Nothing happens “in the middle” of a task. A long computation freezes the page: clicks are queued, and nothing repaints, until it ends.
setTimeout(fn, 500)means “no sooner than 500 ms”, not “at 500 ms”. The callback waits its turn.- An action’s effect usually arrives later. A click runs its handler in one task; what the handler asks for (a request, an animation) finishes in later ones. The click has returned long before the page has changed.
Loading things after the page: fetch and XHR#
A modern page rarely arrives complete. The first HTML is a shell, and scripts
then ask the server for the data and fill the page in. Two APIs do the asking,
XMLHttpRequest (old) and fetch (newer). Both are asynchronous: the call
returns at once and the answer arrives later as an event, which the loop turns
into a task. The page may fire a dozen of these after the “load” event.
This is why the page load events are only the
start of readiness. load says the document and its first files arrived; it says
nothing about the data that the scripts are still asking for. A test needs to
wait for the thing it wants (an element, a text, a finished request),
not for the page.
Single-page apps#
In a single-page application (SPA) the browser loads the app once, and
clicking a link does not load a new page. A script changes the address with the
History API, asks for data with fetch, and rewrites the DOM. React, Vue,
Angular and Svelte work this way. See
modern web technologies.
For automation this changes three things. There is no load event after the first, so “wait for the page to load” returns at once. The URL changes without a navigation. And elements are created and replaced all the time, so an element you found a moment ago can be removed from under you.
How a click or a key press becomes an event#
When you press a mouse button, the operating system reports it to the browser process, which finds out which page and which element is under the pointer and sends the input to that page’s renderer. The renderer builds an event object and delivers it to the DOM in three steps: the capture phase (from the top down to the target), the target itself, and the bubble phase (back up).
One physical click is several events, in a fixed order:
| Order | Events | Notes |
|---|---|---|
| 1 | pointermove, mousemove | The pointer arrives over the element |
| 2 | pointerdown, mousedown | The button goes down; focus moves here |
| 3 | pointerup, mouseup | The button comes up |
| 4 | click | Only if down and up were on the same element |
Typing is similar: keydown, then (for a character) keypress and a
beforeinput and input event as the text changes, then keyup. A page may
react to any of them, and a framework often listens to input to keep its
state in step with the field. A field filled by assigning element.value = "x"
raises none of these events, so the framework never learns about the text.
Trusted and synthetic events#
Every event object has a property, event.isTrusted.
truewhen the event came from the browser itself: a real input, or one injected through the browser’s own input path.falsewhen a script created it, withnew MouseEvent(...)anddispatchEvent, or withelement.click().
A script can only produce false. The page can read the flag, so it can tell. A
site can refuse an untrusted click on a payment button, or a cookie banner can
ignore it. Some actions are permitted only on a trusted event: opening a file
chooser, going fullscreen, playing sound, copying to the clipboard, and anything
that needs a user gesture.
A “JavaScript click” (element.click(), or an execute_script that clicks) is
not equivalent to a click. It skips the move, down and up sequence, it runs
even when the element is covered or off screen, it has isTrusted set to
false, and it does not scroll, focus or hover first. It is useful as a last
resort for an element that cannot otherwise be reached, and it hides the very
problems (a covered button, a missing wait) that a real click would reveal.
Auto-waiting and actionability#
Because of everything above, a click should not be fired the instant the selector matches. A good tool waits until the element is actionable: ready for a person to use it. The usual checks are that the element
- exists,
- is visible (it has a size and is not hidden),
- is not covered by another element at the click point,
- is enabled, and
- has stopped moving.
The tool re-checks until all hold or a timeout runs out, then fails with a
message saying which check failed. This is called auto-waiting, and it
replaces the sleeps (sleep(2)) that make a suite slow and still flaky.
In seleniumbase-rs:
- Pure CDP. A
Locatorwaits for its element to be visible, scrolls it into view and works out its centre before it acts (Locator::click,type_text,hoverand the other actions do this). The wait is bounded by the locator’s timeout, whichLocator::with_timeoutchanges. If the element never appears the error isElementNotFound; if it exists but never becomes visible,ElementNotInteractable; and a plain wait that runs out givesWaitTimeout. You can also wait yourself withlocator.wait_for(State::Visible), andStatehasPresent,Absent,VisibleandHidden. - WebDriver.
BaseCasemethods wait for the element, for examplewait_for_element_visible(selector, 10)takes a timeout in seconds, andwait_for_ready_state_complete()waits for the document’s ready state after a navigation. - Pages.
Page::gotowaits for the load event;Page::wait_for_loadwaits for a load that a click started.
Wait for the effect, not for a time. After a click, wait for the element
that proves the click worked (the success message, the next screen) with
wait_for or an expect() assertion, not for two seconds.
How seleniumbase-rs sends input#
The pure CDP engine (src/sb_cdp/input.rs) sends input through the DevTools
Input domain, the browser’s own input path, so every event is trusted. See
the DevTools Protocol.
A click
Page::mouse() returns a Mouse. Mouse::click(at) sends three
Input.dispatchMouseEvent commands in order: mouseMoved to the point, then
mousePressed and mouseReleased with the left Button. They go out together
in one ordered batch, and the page sees the same sequence as a person’s click.
double_click and right_click repeat or change the button, and drag,
wheel and move_to cover the rest.
A key press
Page::keyboard() returns a Keyboard. Keyboard::press(Key) sends a
keyDown (a rawKeyDown for keys that produce no text) and a keyUp through
Input.dispatchKeyEvent. Key has Enter, Tab, Escape, Backspace,
Delete, the arrows, Home, End, PageUp and PageDown. Enter also carries
the character, which is what submits a form.
Typing text
Keyboard::type_text(text) presses one key per character, with an 8 ms
pause between them, so the page sees a keydown, input and keyup for each
letter as a person’s typing would give. Keyboard::insert_text(text) sends the
whole string at once with Input.insertText, as a paste does: much faster, but
with no per-character key events.
Through a Locator
Locator::type_text(text) first clears the field (which also focuses it) and
then types through the keyboard. That is why recorded tests call it alone, with
nothing to empty the field first.
Input with a person’s pace#
Real input is trusted, but a script’s movement is still unlike a person’s: the
pointer jumps in a straight line to the exact centre, and letters arrive at the
same interval. Page::human(Behavior) returns a Human whose
move_to, click(&Locator), type_text(&Locator, text), type_focused and
scroll_by move the pointer along a curve, land the click somewhere inside the
element and type at an uneven rhythm. The events are the same trusted input;
only the path and timing differ. It is slower on purpose, so use it where a page
watches how input arrives. See Detection and spoofing and
the glossary entry Human-paced input.
Running your own JavaScript#
Sometimes you need to read something the DOM does not expose, or call a function
of the page. Page::evaluate("document.title") runs a script in the page and
returns its value, and Page::execute("Runtime.evaluate", ...) sends the raw
command (see Any CDP command). The
script runs in the page’s own world, with the page’s globals, and is bound by the
page’s rules: if the page redefined a function, your script sees the change.
Where to go next#
The first half of any action: pointing at the element.
The Input, Runtime and Page domains this builds on.
How a page tells a script from a person, and what to do about it.
Single-page apps, WebSockets, service workers and more.