Playwright and Elastic Synthetics
The same .journey file runs on Playwright: as a step-by-step Playwright Test, or as an
Elastic Synthetics browser monitor. The npm package
@nullean/journeys parses journeys and runs them on a Playwright
page; Nullean.Cdp stays the reference runner, so a journey that passes cdp run is the one a monitor runs.
npm install @nullean/journeys
It needs playwright-core (any Playwright Test or @elastic/synthetics install has it), Node 20 or later (tested on 26), and
nothing from .NET.
import { test } from '@playwright/test';
import { journeySteps } from '@nullean/journeys/test';
test('checkout', ({ page }) =>
journeySteps(page, `
open /
find label Search fill lamp
find role button click --name Search --exact
expect text #count "1 result"
`, { baseUrl: 'http://localhost:5000/', source: 'checkout.journey' }));
Each journey line is a test.step, named as the normalised line, so the report reads like the file. A failing step
fails the test at that step. journeySteps returns the checkpoints (what each read returned). The journey can as well
come from a file: readFileSync('journeys/checkout.journey', 'utf8'). expect lines use Playwright
Test's own expect.
// checkout.journey.ts
import { expect, journey, step } from '@elastic/synthetics';
import { journeySteps } from '@nullean/journeys/synthetics';
journey('checkout', ({ page, params }) =>
journeySteps({ page, step, params, expect }, `
open /
find label Search fill lamp
find role button click --name Search --exact
expect text #count "1 result"
`, { baseUrl: params.url }));
Each line is a Synthetics step(), so Kibana shows the journey as it was written. Things to know:
- The journey text lives in the file.
npx @elastic/synthetics pushbundles code, not data files, so paste the journey into the template literal, or generate the files. The text is parsed when the journey registers, so a bad line fails before anything runs. - Secrets (
$env:NAME) resolve from the journey'sparams, then from the environment.params.urlabove comes from--paramsorsynthetics.config.ts. uploaddoes not work: Synthetics does not supportsetInputFilesin pushed journeys.- Synthetics' own limits apply. Elastic's documentation gives a compressed bundle limit of 800 KB on one page and 1500 KB on another, and rules out native modules.
expectis Playwright's: Synthetics re-exports@playwright/test'sexpect, so pass it in as above. It does not supporttoHaveScreenshotor snapshot assertions, whichexpectlines never use.- The adapter is tested against Synthetics'
stepcontract with a stand-in, and generated files run under the real@elastic/syntheticsCLI in the tests. A push to Kibana has not been exercised yet.
Journeys that are marked @monitor become Synthetics files with one command:
npx journeys export-synthetics journeys --out synthetics/generated --features navigation-preview --env edge
npx @elastic/synthetics push synthetics/generated --id my-project
# journeys/landing/home.journey
# @tags navigation smoke
# @monitor schedule=10 locations=germany,us_east
# @alert pagerduty
open /docs
expect title "Elastic Docs"
| Option | |
|---|---|
<file, folder or glob>... |
what to export; a folder means every .journey file under it |
--out <folder> |
where the generated *.journey.ts files go, one per exported journey, named by its path below the arguments (landing/home.journey.ts) |
--features <a,b> |
the feature flags that are on: judged against each @requires, and embedded so feature blocks skip the way they do in any other run |
--build-type, --env |
the same for build-type: and env: conditions |
--paging |
turn @alert pagerduty into alert configuration (below) |
--base-url <url> |
embed the base URL; without it a generated file reads params.url, from --params or the Synthetics config |
--root <folder> |
names are paths under this folder; by default the folder the arguments start in |
- Which journeys export. A journey (or
.httpfile, see below) needs@monitor; its@requiresmust hold for the flags, build type and environment you give; and it must not be@mutates, because a monitor runs against a live site. Every journey that does not export is printed with the reason. A generated file also runs read-only (readOnly: true). - What the directives become.
scheduleandlocationsgo tomonitor.useand are checked against what Synthetics accepts, so a schedule of 7 minutes fails the export, naming the journey, and not the push.@tagsbecome the monitor's tags. - Alerting. Kibana routes alerts. Without
--pagingevery monitor keeps Kibana's default status alerting. With--paging, a journey that says@alert pagerdutygetsalert: { status: { enabled: true } }and the tagalert:pagerduty, which a Kibana rule can route to PagerDuty. The export never talks to Kibana or PagerDuty. - A skipped feature block shows as passed. Synthetics has no skipped steps for a step that ran as a no-op, so a block the run does not meet looks like a passing step in Kibana.
- The output depends only on the inputs: no timestamps, a stable order, so exporting twice changes nothing in git.
The files start with a generated-file header, and a later export removes generated files whose journey is no longer
exported, and only those.
pushreplaces the project's monitors, so removing a journey removes its monitor.
An endpoint that has no page, such as an MCP server or a JSON API, is a .http file: the format of Visual Studio, the
VS Code REST Client and the JetBrains HTTP Client, which nullean/snapshots
runs in dotnet test. httpSteps runs the same file as a Synthetics journey, one step per request:
import { journey, step } from '@elastic/synthetics';
import { httpSteps } from '@nullean/journeys/synthetics';
journey('mcp', ({ request, params }) =>
httpSteps({ request, step, params }, `
@host = ${params.url}
# @readonly
# @expect status 200
# @expect json $.result.tools[*].name contains "search_docs"
POST {{host}}/docs/_mcp
Content-Type: application/json
{"jsonrpc":"2.0","id":1,"method":"tools/list"}
`, { baseUrl: params.url }));
journeys export-synthetics exports a .http file with @monitor the same way as a journey, into
*.http.journey.ts. The header directives (@tags, @requires, @monitor, @alert, @mutates) go in the comments
before the first request, with the journey grammar; Nullean.HttpFileParser reads them as plain comments.
- A step is a request. Its name is the request line as written, with
{{variables}}unresolved, so a secret never reaches Kibana. A name from# @nameleads it. Requests run in file order, and a failed one skips the rest. - A 404 or 500 is a response. Only a request that cannot be resolved or sent, a read-only refusal, or an unmet
# @expectfails:status 200or2xx,header content-type ~ json(a case-insensitive substring), andjson $.items[*].name equals|contains "lamp"(equalsneeds every selected value,containsone). The messages are Nullean.HttpFileParser's. - Read-only. With
readOnly: true, which every exported monitor sets, only GET, HEAD and OPTIONS are sent. A request that reads although it is not a GET, such as an MCPtools/list, says so with# @readonly. - What is supported.
METHOD URL, headers, an inline body,###and### name,# @name,# @no-redirect,# @timeout, file variables (@host = ...), and references:{{host}},{{login.response.body.$.token}},{{login.response.headers.Location}},{{$guid}},{{$uuid}},{{$timestamp}},{{$isoTimestamp}},{{$randomInt}},{{$processEnv NAME}},{{$env.NAME}}and{{$secret NAME}}(from the journey'sparams, then the environment). - What is refused, with its line. File bodies (
< ./file),http-client.env.jsonenvironments,$datetimeand the other dynamic variables, references to a request, JetBrains scripts,>>redirection,@prompt, cURL and GraphQL. A URL that starts with/needsbaseUrl, or an absolute@host. - A difference from the .NET runner. A number a JSON expectation compares is JavaScript's reading of it, so
1.0is1. The two parsers are not run against shared fixtures yet.
expect lines (see the command reference) run on Playwright's own assertions when the adapter has
expect, which the Playwright Test adapter always does and the Synthetics adapter does when you pass it. Each matcher is
the Playwright assertion of the same name: expect visible #go is expect(locator).toBeVisible(), expect --not text #n "3" is .not.toHaveText("3"), expect class #go big --contains is toContainClass. Without expect (a bare
runJourney) a small built-in fallback checks the same things and reports in Nullean.Cdp's words, so the step fails with
Timed out after 5s waiting for css #count to have text "3": received "2 results". A failure message under Playwright's
expect is Playwright's own, without colour and cut to 14 lines.
await runJourney(page, journey, { expect });
- expect from @playwright/test or @elastic/synthetics
The two paths are tested against each other: 96 cases (every matcher, holding and not) run through both and must agree.
That is how the rule that only visible, hidden, attached and count can be asserted about a missing element was
settled: Playwright's other assertions need the element, even negated, and so do ours.
Still the same on both: the timeout is --timeout on the line, else actionTimeout, else Playwright's own 5 s; a regular
expression is JavaScript's here and .NET's in cdp run, so keep to syntax both read alike, and quote it ('^\d+ items$');
url takes the wait --url glob, turned into a regular expression because Playwright does not document its string form
as a glob. Not supported: soft assertions, toPass, toHaveScreenshot, toMatchAriaSnapshot (a tree template: the two
runners print different trees, and Synthetics supports no snapshot assertions).
import { parseJourney, runJourney } from '@nullean/journeys';
const result = await runJourney(page, parseJourney(text, 'checkout.journey'), { baseUrl: 'http://localhost:5000/' });
result.checkpoints;
result.failure;
- { line, command, url, output, kind } for every read
- { line, command, message } for the step it stopped at, or undefined
JourneyRun runs one step at a time (await run.step(step)) when a framework wants to own the loop, as the two
adapters do.
| Option | |
|---|---|
baseUrl |
what relative open URLs resolve against; its host is allowed |
allowedHosts |
host patterns allowed beyond the base URL's host and the journey's allow lines |
viewport, userAgent, headers |
the run's defaults, in place before the first step (see run defaults); userAgent needs Chromium |
secrets |
resolves $env:NAME; the process environment by default |
artifactsDir |
where screenshot writes; without it screenshot steps are skipped |
journeyDir |
the folder upload reads from |
actionTimeout, navigationTimeout |
milliseconds; applied to the page when given, Playwright's own otherwise |
expect |
Playwright's expect, for expect steps; the adapters pass it |
axe |
(await import('@axe-core/playwright')).AxeBuilder, for a11y |
warn, onStep |
called for skipped or different behaviour; before each step |
The package's parser is a port of the one in Nullean.Cdp, and the two are tested against each other: the package's
tests compare it with the JSON cdp parse prints for every corpus journey, and with a fixture of 65 invalid journeys
whose exact errors, lines included, the .NET tests check too. A journey that parses in one parses in the other.
cdp parse checkout.journey
- the steps as JSON, the contract between the two
The allow list works as it does in cdp run: the base URL's host, allow lines and allowedHosts; a refused
navigation fails the step and refused sub-resources are not loaded. Secrets are resolved when typed and replaced by
their references in everything the run reports. Reads (get, is, find … text) print what Nullean.Cdp prints; the
package's tests compare a real run in Chrome with Nullean.Cdp's own readings. network requests, links, timings,
vitals and metrics use the same output formats but are not yet tested against a browser.
These differ on purpose, because Playwright produces them differently or owns them:
| In Playwright | |
|---|---|
snapshot |
Playwright's aria tree, with kind: 'aria' on the checkpoint. It is not the tree Nullean.Cdp prints, so never compare it with a .verified.journey. -c -i -d -u are ignored with a warning. |
a11y |
axe-core, not Nullean.Cdp's own rules, so counts differ. Without axe the step fails and says how to pass it. |
network har start, stop |
skipped with a warning: use recordHar in the Playwright configuration. |
screenshot |
written only when artifactsDir is given; Playwright and Synthetics keep their own screenshots. |
set useragent |
Chromium only, through a DevTools session, for the page. Other browsers: set userAgent in the Playwright config. |
expect request |
from Playwright's request, response and requestfinished events; the body is request.postDataBuffer(). The same rules as in cdp run. |
expect same-document |
counts domcontentloaded, which a new document fires and pushState does not. |
set viewport … <scale>, set device |
the viewport only. Scale, user agent and touch are fixed when the context is created: use a Playwright project. |
set credentials |
refused. Set httpCredentials in the Playwright configuration: an extra header would go to every host. |
console |
type: text, one per line. |
Snapshots of the transcript stay with the .NET run: Nullean.Snapshots
compares what cdp run reads, and the Playwright runs prove the same journey still passes in Playwright and in
production.