UI Testing with YAML
Proofarc UI tests are defined in simple YAML — no page objects, no framework boilerplate. The same YAML runs on both Selenium and Playwright for cross-engine browser compatibility testing.
Example Test
steps:
- action: NAVIGATE_TO
value: "{{baseUrl}}"
- action: CLEAR
selector: "#username"
- action: SEND_KEYS
selector: "#username"
value: "{{username}}"
- action: CLEAR
selector: "#password"
- action: SEND_KEYS
selector: "#password"
value: "{{password}}"
- action: CLICK
selector: "button[type=submit]"
- action: VALIDATE_TEXT
selector: "body"
expectedText: "Dashboard"
- action: TAKE_SCREENSHOT
Variables
Both {{variable}} and ${variable} syntax are supported. Variables are resolved from
${...} is UI-only${variable} works in UI test YAML. It does not resolve in API scenarios — there, only
the {{name}} form works, and a ${x} is sent as the literal text ${x}.
(highest precedence first):
- Test-declared
variables:— values defined in the YAML itself - Credentials — injected from the environment credential vault (
{{username}},{{password}}) - Target URL —
{{baseUrl}}and{{target}}(see below) - Environment properties — any key stored via the environment's variables
(
set_env_variables/ environment editor) resolves as{{key}}. Lowest precedence: a same-named source above always wins.isSecretvalues are masked in results and logs.
Variable lookup is case-insensitive: {{userName}}, {{username}}, {{USERNAME}} all resolve the same.
An env property can also be referenced with the self-documenting alias {{env.key}} — {{env.myhost}}
resolves to the same property as {{myhost}}. Use whichever makes the test clearer; both work
everywhere placeholders do (UI tests, API scenarios, performance runs, and security scans).
When a field's entire value is a placeholder, it must be quoted — value: "{{baseUrl}}", never
value: {{baseUrl}}. The bare form is invalid YAML ({{ opens a flow mapping) and the validator
rejects it at create time. Placeholders embedded in a longer string (value: "{{baseUrl}}/login")
still need the quotes for the same reason.
Unquoted scalars are type-inferred: value: no becomes a boolean, value: 22:30 becomes the
number 1350 (YAML 1.1 sexagesimal), and value: 1.10 loses its trailing zero (1.1). The
validator rejects these with a quoting hint — write value: "no", value: "22:30",
value: "1.10". Plain integers (value: 1) are fine: engines compare stringified values.
Absolute URLs are rewritten to {{baseUrl}}
Write an absolute URL in a NAVIGATE_TO step and the platform rewrites it:
# what you write
- action: NAVIGATE_TO
value: "https://your-app.example.com/login"
# what is stored
- action: NAVIGATE_TO
value: "{{baseUrl}}/login"
This is what makes a test runnable in more than one environment — {{baseUrl}} resolves from the
target attached to whichever environment you run against. The create response tells you it happened.
If the project has no linked application, creation is refused rather than storing a test that only works in one place:
400 — This UI test navigates to a literal URL (https://…) but no application is
linked, so {{baseUrl}} cannot resolve and the URL would be baked into the step
Link an application so a target can hold the base URL, or write {{baseUrl}} yourself.
A bad URL fails at the navigation, not later
NAVIGATE_TO checks what came back. A 4xx or 5xx fails that step and names the URL and the
code, instead of passing and letting the next step time out against an error page. Full explanation
in Tips & gotchas.
{{baseUrl}} is the origin; {{target}} is the whole URL
Both come from the target the run resolves to, and they are not the same thing:
| Variable | What it is | For a target whose home page is https://demo.example.com/web/index.php/auth/login |
|---|---|---|
{{baseUrl}} | the origin — scheme, host, port | https://demo.example.com |
{{target}} | the target's full home page URL | https://demo.example.com/web/index.php/auth/login |
{{baseUrl}} is the one you append a path to, which is why the rewrite above produces
{{baseUrl}}/login rather than baking in the host. Use {{target}} when you want the home page
itself, whatever path it sits at.
When a target's home page is the site root — which is the usual case — the two are the same string and it makes no difference which you use.
Until this date {{baseUrl}} was set to the target's full home page URL on UI test runs, so a
target with a deep home page made {{baseUrl}}/login resolve to …/auth/login/login. That URL
returns a page, so the symptom was a step timing out on a selector rather than an obvious bad
address. API scenarios were always origin-based; UI tests now match them. If a test relied on
{{baseUrl}} meaning the home page, write {{target}} instead.
Step Fields
| Field | Used by | Description |
|---|---|---|
action | All | Action to perform (see table below) |
selector | Most | CSS selector for the target element |
value | Input actions | Data to type, select, or navigate to |
expectedText | Validation actions | Text to assert (uses contains match) |
timeout | Wait actions | How long to wait — see Timeout units. Default 30s. |
attribute | VALIDATE_ATTRIBUTE | Attribute name to check |
filename | TAKE_SCREENSHOT | Custom screenshot filename |
action: — never type:Every step must carry an action:. A step keyed with type: (- type: NAVIGATE_TO) is not a
supported alias: neither engine reads it, so the step cannot run. Validation rejects it at
create/update time, naming the step and the rename:
step 1: uses the legacy `type: NAVIGATE_TO` key — the step key is `action:`.
Nothing executes a `type:` step. Rename it to `action: NAVIGATE_TO`.
Only the key changes — url: is still accepted alongside value: for NAVIGATE_TO. If a test
authored before this check still has type: steps, the run now fails on that step with the same
message instead of dying with a null-action error.
Supported Actions
Navigation
| Action | Fields | Description |
|---|---|---|
NAVIGATE_TO | value (URL) | Navigate to URL |
NAVIGATE_BACK | — | Browser back button |
NAVIGATE_FORWARD | — | Browser forward button |
REFRESH | — | Reload the current page |
SWITCH_FRAME | selector | Switch into an iframe |
SWITCH_TO_DEFAULT_CONTENT | — | Leave every iframe, back to the page |
SWITCH_TO_PARENT_FRAME | — | Leave one iframe level (for a frame inside a frame) |
SWITCH_TAB | index | Switch to another browser tab |
Interaction
| Action | Fields | Description |
|---|---|---|
CLICK | selector | Click element |
DOUBLE_CLICK | selector | Double-click element |
RIGHT_CLICK | selector | Right-click (native context menu) |
SEND_KEYS | selector, value | Type text into input field |
CLEAR | selector | Clear input field |
SELECT_BY_TEXT | selector, value | Select dropdown option by visible text |
SELECT_BY_VALUE | selector, value | Select dropdown option by value attribute |
SELECT_BY_INDEX | selector, index | Select dropdown option by zero-based index |
HOVER | selector | Hover over element |
SCROLL_TO_ELEMENT | selector | Scroll element into view |
SUBMIT | selector (form) | Submit a form |
UPLOAD_FILE | selector, filePath | Attach a file to a file input. The path is read on the machine running the test |
DRAG_AND_DROP | selector (what you pick up), targetSelector (where it lands) | Drag one element onto another |
FILL_FORM | selector, plus formData (YAML map of field→value) or value (the same map as a JSON string) | Fill multiple fields in one step. Either map form works; it is field name → value, never a list of {selector, value}. See FILL_FORM |
Validation
| Action | Fields | Match Type | Description |
|---|---|---|---|
VALIDATE_TEXT | selector, expectedText | Contains | Verify element contains text |
VALIDATE_TITLE | expectedText | Contains | Verify page title contains text |
VALIDATE_URL | expectedText | Contains | Verify current URL contains text |
VALIDATE_VISIBLE | selector | — | Verify element is visible |
VALIDATE_ELEMENT_VISIBLE | selector | — | Alias of VALIDATE_VISIBLE |
VALIDATE_ELEMENT_EXISTS | selector | — | Verify element exists in DOM |
VALIDATE_NOT_VISIBLE | selector, timeout | — | Verify element is gone (or never there) |
VALIDATE_ATTRIBUTE | selector, attribute, expectedText | Exact | Verify attribute equals value |
Wait
| Action | Fields | Description |
|---|---|---|
WAIT_FOR_ELEMENT | selector, timeout | Wait for element to appear |
WAIT_FOR_VISIBLE | selector, timeout | Wait for element to be visible |
WAIT_FOR_CLICKABLE | selector, timeout | Wait for element to be enabled/clickable |
WAIT_FOR_TEXT | selector, expectedText, timeout | Wait for text to appear |
WAIT_FOR_INVISIBLE | selector, timeout | Wait for element to disappear |
Read element state
The captured value lands in the step's output and is available for later assertions.
| Action | Fields | Description |
|---|---|---|
GET_TEXT | selector | Read element innerText |
GET_ATTRIBUTE | selector, attribute | Read a specific attribute |
GET_VALUE | selector | Read the value of a form control |
Utility
| Action | Fields | Description |
|---|---|---|
TAKE_SCREENSHOT | filename (optional) | Capture page screenshot |
MAXIMIZE_WINDOW | — | Maximize browser window |
EXECUTE_JS | value (or script) | Execute JavaScript — single line, or multi-line with value: | (see "Embedding multi-line JavaScript" below) |
Dialogs (alert / confirm / prompt) — WEBDRIVER only
| Action | Fields | Description |
|---|---|---|
ACCEPT_ALERT | — | Click OK on the open dialog |
DISMISS_ALERT | — | Click Cancel |
GET_ALERT_TEXT | variable | Read the message, keep it for later steps, leave the dialog open |
TYPE_IN_ALERT | value | Answer a prompt, then accept it |
A dialog blocks the page, so before these existed a "Delete — are you sure?" did not just fail its own step: every step after it was unreachable.
Run the test with drivers: [WEBDRIVER]. On Playwright the step fails with a message saying so.
The two engines cannot express this the same way. Selenium leaves the dialog open until something answers it, so "click, then accept" is two steps in the order you would naturally write them. Playwright blocks the click until a handler answers, so the decision has to exist before the click. Auto-accepting on Playwright would be the silent option and the wrong one — a test asserting the Cancel path would pass while the record was actually deleted.
No
SLEEPaction. Blind sleeps are anti-pattern. UseWAIT_FOR_ELEMENT/WAIT_FOR_VISIBLE/WAIT_FOR_CLICKABLE/WAIT_FOR_TEXT/WAIT_FOR_INVISIBLEwith atimeoutinstead — they wait for a real condition rather than a fixed duration.WAIT_FOR_INVISIBLEis the one people reach for a sleep to fake: waiting out a spinner, an overlay closing, or a toast fading.
Timeout units
A timeout of 600 or less means seconds. Anything larger means milliseconds.
- action: WAIT_FOR_VISIBLE
selector: "#results"
timeout: 15 # 15 seconds
- action: WAIT_FOR_VISIBLE
selector: "#results"
timeout: 15000 # also 15 seconds
Both forms work because both are in wide use, and picking one would silently change what every test written the other way does.
timeout: 600 waits ten minutes. timeout: 700 waits 0.7 seconds.
A 17% larger number gives an 857× shorter wait. Nobody writes 700 meaning less than a second — but somebody writes it meaning eleven minutes, gets a step that fails instantly, and spends an afternoon blaming the selector. Nothing errors, which is what makes it expensive.
Anything between 601 and 4999 raises a validation warning telling you what it will actually do.
The safe habit: write seconds (timeout: 15), or a round millisecond value you'd recognise
(timeout: 15000). Avoid the middle.
timeout is the field to use. On a wait action value is accepted as a fallback because older
examples used it, but timeout is clearer. On any non-wait action value is the text to type and is
never read as a duration.
A mobile step used to treat anything above 100 as milliseconds, while web used 600. timeout: 300 was five minutes on a web step and three tenths of a second on a mobile one — and the mobile
failure said "element not found", which reads as a broken selector rather than a wait that was a
thousand times too short.
Fixed on 2026-08-18. One threshold, 600, on every surface: web, mobile, Selenium and Playwright alike. If you wrote a mobile timeout in the 101–600 range to work around the old behaviour, it now means seconds — check it says what you meant.
Uploading a file
- action: UPLOAD_FILE
selector: "#file-upload"
filePath: "/tmp/invoice.pdf"
There is no way to send a file up with the test yet. The path is read by the agent, so it has to be something already on that machine — a fixture baked into the image, or a file an earlier step created. Uploading your own file as part of a test is not supported.
Typing a path into a file input with SEND_KEYS also works, and now works on both engines —
it is the long-standing Selenium idiom, and Playwright used to refuse it outright
(Input of type "file" cannot be filled), so the identical test passed on one engine and failed on
the other. UPLOAD_FILE is the clearer spelling.
Shadow DOM
A selector reaches inside open shadow roots on both engines:
- action: CLICK
selector: "my-widget button.confirm" # button lives inside <my-widget>'s shadow root
Playwright's CSS has always pierced open shadow roots. Selenium's does not, so the same selector found nothing there and the error read like a bad selector rather than an engine difference. The Selenium agent now searches shadow roots when the ordinary lookup finds nothing — a page with no web components behaves exactly as before.
Closed shadow roots are unreachable by any tool, by design; nothing can change that.
Inside a shadow root the supported actions are CLICK, SEND_KEYS, CLEAR, SUBMIT, GET_TEXT, GET_VALUE, VALIDATE_TEXT, VALIDATE_VISIBLE, VALIDATE_ELEMENT_EXISTS and the element waits. Anything else says so by name rather than reporting "element not found" — if you hit one, tell us and it can be added.
Cross-Engine Testing
The same YAML runs on two browser engines simultaneously:
- Selenium WebDriver (via ui-test-agent)
- Playwright (via playwright-agent) — Chromium + WebKit + Firefox
If a test passes on Selenium but fails on Playwright (WebKit), that's a browser compatibility bug — not a test bug. Proofarc detects and classifies these automatically.
To run a single suite on both engines in one call, pass drivers in the run request — see the UI Test Suites page.
Embedding multi-line JavaScript
Use YAML's literal block scalar (| after the field name) for multi-line code. Newlines and indentation are preserved verbatim, no escaping needed:
- action: EXECUTE_JS
value: |
const token = localStorage.getItem('token');
return fetch('/api/findings/' + 60 + '/status', {
method: 'PUT',
headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token},
body: JSON.stringify({status: 'TRIAGED'})
}).then(function(r) { return r.json(); });
Engine differences for EXECUTE_JS
The two engines treat the EXECUTE_JS script slightly differently:
| Engine | Wraps script as | Top-level return | Async / Promises |
|---|---|---|---|
| Selenium WebDriver | function() { ... } | ✅ legal | ❌ does not await; the Promise is returned as-is and the test moves on |
| Playwright | (() => { ... })() (IIFE) | ✅ legal | ✅ page.evaluate awaits the returned Promise |
For tests that need to await asynchronous work (e.g. multiple fetch calls), use .then() chains that return a Promise; pin driver: PLAYWRIGHT if the test must wait on async results. To assert on async outcomes from a later step in either engine, write a marker to document.title from inside the .then() and use VALIDATE_TITLE afterwards.
Strict-mode selector caveat
Playwright is strict by default — locator(".MuiDrawer-root, nav") throws if the selector matches multiple elements. Selenium silently picks the first. The agents normalise this where possible (Playwright VALIDATE_VISIBLE uses .first), but if a test assertion fails on Playwright with strict mode violation, narrow the selector to a single element.
Visual Element Picker
Don't know CSS selectors? Use the visual picker:
- Navigate to a page
- Take a screenshot
- Click on elements in the screenshot
- Proofarc extracts the selector automatically