Skip to main content

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):

  1. Test-declared variables: — values defined in the YAML itself
  2. Credentials — injected from the environment credential vault ({{username}}, {{password}})
  3. Target URL{{baseUrl}} and {{target}} (see below)
  4. 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. isSecret values 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).

Quote whole-value placeholders

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.

Quote text YAML would type-coerce

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:

VariableWhat it isFor a target whose home page is https://demo.example.com/web/index.php/auth/login
{{baseUrl}}the origin — scheme, host, porthttps://demo.example.com
{{target}}the target's full home page URLhttps://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.

Changed 2026-08-17

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

FieldUsed byDescription
actionAllAction to perform (see table below)
selectorMostCSS selector for the target element
valueInput actionsData to type, select, or navigate to
expectedTextValidation actionsText to assert (uses contains match)
timeoutWait actionsHow long to wait — see Timeout units. Default 30s.
attributeVALIDATE_ATTRIBUTEAttribute name to check
filenameTAKE_SCREENSHOTCustom screenshot filename
The step key is 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

ActionFieldsDescription
NAVIGATE_TOvalue (URL)Navigate to URL
NAVIGATE_BACKBrowser back button
NAVIGATE_FORWARDBrowser forward button
REFRESHReload the current page
SWITCH_FRAMEselectorSwitch into an iframe
SWITCH_TO_DEFAULT_CONTENTLeave every iframe, back to the page
SWITCH_TO_PARENT_FRAMELeave one iframe level (for a frame inside a frame)
SWITCH_TABindexSwitch to another browser tab

Interaction

ActionFieldsDescription
CLICKselectorClick element
DOUBLE_CLICKselectorDouble-click element
RIGHT_CLICKselectorRight-click (native context menu)
SEND_KEYSselector, valueType text into input field
CLEARselectorClear input field
SELECT_BY_TEXTselector, valueSelect dropdown option by visible text
SELECT_BY_VALUEselector, valueSelect dropdown option by value attribute
SELECT_BY_INDEXselector, indexSelect dropdown option by zero-based index
HOVERselectorHover over element
SCROLL_TO_ELEMENTselectorScroll element into view
SUBMITselector (form)Submit a form
UPLOAD_FILEselector, filePathAttach a file to a file input. The path is read on the machine running the test
DRAG_AND_DROPselector (what you pick up), targetSelector (where it lands)Drag one element onto another
FILL_FORMselector, 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

ActionFieldsMatch TypeDescription
VALIDATE_TEXTselector, expectedTextContainsVerify element contains text
VALIDATE_TITLEexpectedTextContainsVerify page title contains text
VALIDATE_URLexpectedTextContainsVerify current URL contains text
VALIDATE_VISIBLEselectorVerify element is visible
VALIDATE_ELEMENT_VISIBLEselectorAlias of VALIDATE_VISIBLE
VALIDATE_ELEMENT_EXISTSselectorVerify element exists in DOM
VALIDATE_NOT_VISIBLEselector, timeoutVerify element is gone (or never there)
VALIDATE_ATTRIBUTEselector, attribute, expectedTextExactVerify attribute equals value

Wait

ActionFieldsDescription
WAIT_FOR_ELEMENTselector, timeoutWait for element to appear
WAIT_FOR_VISIBLEselector, timeoutWait for element to be visible
WAIT_FOR_CLICKABLEselector, timeoutWait for element to be enabled/clickable
WAIT_FOR_TEXTselector, expectedText, timeoutWait for text to appear
WAIT_FOR_INVISIBLEselector, timeoutWait for element to disappear

Read element state

The captured value lands in the step's output and is available for later assertions.

ActionFieldsDescription
GET_TEXTselectorRead element innerText
GET_ATTRIBUTEselector, attributeRead a specific attribute
GET_VALUEselectorRead the value of a form control

Utility

ActionFieldsDescription
TAKE_SCREENSHOTfilename (optional)Capture page screenshot
MAXIMIZE_WINDOWMaximize browser window
EXECUTE_JSvalue (or script)Execute JavaScript — single line, or multi-line with value: | (see "Embedding multi-line JavaScript" below)

Dialogs (alert / confirm / prompt) — WEBDRIVER only

ActionFieldsDescription
ACCEPT_ALERTClick OK on the open dialog
DISMISS_ALERTClick Cancel
GET_ALERT_TEXTvariableRead the message, keep it for later steps, leave the dialog open
TYPE_IN_ALERTvalueAnswer 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.

These run on WEBDRIVER only, on purpose

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 SLEEP action. Blind sleeps are anti-pattern. Use WAIT_FOR_ELEMENT / WAIT_FOR_VISIBLE / WAIT_FOR_CLICKABLE / WAIT_FOR_TEXT / WAIT_FOR_INVISIBLE with a timeout instead — they wait for a real condition rather than a fixed duration. WAIT_FOR_INVISIBLE is 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.

There is a cliff at 600

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.

Mobile reads the same number the same way

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.

See Mobile YAML → timeout units.

Uploading a file

- action: UPLOAD_FILE
selector: "#file-upload"
filePath: "/tmp/invoice.pdf"
The file must already exist on the machine running the test

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.

Limits

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:

EngineWraps script asTop-level returnAsync / Promises
Selenium WebDriverfunction() { ... }✅ legal❌ does not await; the Promise is returned as-is and the test moves on
Playwright(() => { ... })() (IIFE)✅ legalpage.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 defaultlocator(".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:

  1. Navigate to a page
  2. Take a screenshot
  3. Click on elements in the screenshot
  4. Proofarc extracts the selector automatically