Playwright Testing
Set any trail to both mode and it runs as a Playwright regression test in CI from the same JSON file. The tour that guides new users through your product is also the test that verifies your product still works on every deploy.
No separate test suite. No duplicate work.
@trailguide/playwright requires @trailguide/core >= 0.1.8 and @playwright/test >= 1.40.0.
Setup
Install the runner
npm install --save-dev @trailguide/playwrightSet trail mode to both
Add mode: "both" to any trail you want to run as a test. Optionally add action and assert fields to steps:
{
"id": "welcome",
"title": "Welcome Tour",
"version": "1.0.0",
"mode": "both",
"steps": [
{
"id": "step-1",
"target": "[data-trail-id='create-btn']",
"placement": "bottom",
"title": "Create a project",
"content": "Click here to get started.",
"action": "click",
"assert": { "type": "visible" }
},
{
"id": "step-2",
"target": "[data-trail-id='project-name']",
"placement": "right",
"title": "Name it",
"content": "Give your project a name.",
"action": "fill",
"value": "My project",
"assert": { "type": "value", "expected": "My project" }
}
]
}Write a Playwright test
import { test } from '@playwright/test'
import { runTrail } from '@trailguide/playwright'
import welcomeTrail from './tours/welcome.json'
test('welcome tour', async ({ page }) => {
await page.goto(process.env.BASE_URL ?? 'http://localhost:3000')
await runTrail(page, welcomeTrail)
})Add to CI
# .github/workflows/test.yml
- name: Run trail tests
run: npx playwright testHow runTrail works
runTrail(page, trail, options?) walks each step in order. For each step:
- Navigates to
step.urlif set and the page is not already there - Waits for the target element to be visible, then executes
step.action - Runs
step.waitcondition if defined - Runs
step.assertif defined - Falls back to checking the element is attached to the DOM if neither
actionnorassertis set
If any step fails (missing selector, failed action, or failed assertion) the test fails and the deploy is blocked.
import { runTrail, type RunTrailOptions } from '@trailguide/playwright'
await runTrail(page, trail, {
baseUrl: 'https://staging.myapp.com', // prepended to step.url if relative
timeout: 8000, // per-step timeout in ms (default: 8000)
test, // Playwright test object — wraps each step in test.step()
notify: { // send alerts on failure
slack: process.env.SLACK_WEBHOOK_URL,
webhook: process.env.ALERT_WEBHOOK_URL,
trailUrl: 'https://app.gettrailguide.com',
},
reportUrl: process.env.TRAILGUIDE_REPORT_URL, // post run results to the test health dashboard
apiKey: process.env.TRAILGUIDE_API_KEY,
})Actions
The action field tells Playwright what to do on the step’s target element.
action | Playwright method | Notes |
|---|---|---|
'click' | locator.click() | Default interaction |
'rightClick' | locator.click({ button: 'right' }) | Opens context menus |
'dblclick' | locator.dblclick() | Double-click |
'hover' | locator.hover() | Mouse over, no click |
'focus' | locator.focus() | Keyboard focus without clicking |
'fill' | locator.fill(value) | Clears and types into an input |
'type' | locator.pressSequentially(value) | Types character by character |
'press' | locator.press(value) | Keyboard shortcut, e.g. "Enter", "Control+s" |
'select' | locator.selectOption(value) | Selects a <select> option |
'check' | locator.check() | Checks a checkbox or radio |
'uncheck' | locator.uncheck() | Unchecks a checkbox |
'scroll' | el.scrollBy(dx, dy) | value is "deltaX,deltaY", e.g. "0,300" |
'dragTo' | locator.dragTo(target) | value is the CSS selector of the drop target |
'setInputFiles' | locator.setInputFiles(value) | value is the file path |
'goto' | page.goto(value) | Navigate to value URL; no locator needed |
'evaluate' | page.evaluate(value) | Execute arbitrary JavaScript string |
Multi-tab support
When an action opens a new browser tab, set opensNewTab: true on that step. runTrail waits for the new page to open and switches context to it automatically.
{
"id": "open-tab",
"target": "[data-trail-id='external-link']",
"action": "click",
"opensNewTab": true
}To switch back to a previously opened tab, use tabContext: "main" (original page) or tabContext: "new" (most recently opened tab).
{
"id": "back-to-main",
"target": "[data-trail-id='header']",
"tabContext": "main",
"assert": { "type": "visible" }
}Assertions
The assert field runs a Playwright assertion after the action completes.
type | Assertion | Requires expected |
|---|---|---|
'visible' | Element is visible | No |
'hidden' | Element is hidden | No |
'enabled' | Element is enabled (not disabled) | No |
'disabled' | Element is disabled | No |
'checked' | Checkbox or radio is checked | No |
'empty' | Input or element has no content | No |
'text' | Element text matches exactly | Yes |
'containsText' | Element text contains substring | Yes |
'value' | Input value matches | Yes |
'hasClass' | Element has a CSS class | Yes |
'attribute' | Element attribute matches | attribute + expected |
'count' | Number of matching elements | Yes (number as string) |
'url' | Page URL matches | Yes |
'title' | Page title matches | Yes |
'screenshot' | Visual screenshot comparison | No |
'custom' | Run a custom JS assertion | Yes (JS expression) |
For 'attribute', set attribute to the attribute name and expected to the expected value:
{
"assert": {
"type": "attribute",
"attribute": "aria-expanded",
"expected": "true"
}
}Wait conditions
Use wait on a step to pause before running the assertion. This is useful after navigations, animations, or async data loads.
type | Waits for | value |
|---|---|---|
'networkIdle' | No network requests for 500ms | Not used |
'load' | load event | Not used |
'domcontentloaded' | DOMContentLoaded event | Not used |
'selector' | Element to appear in DOM | CSS selector |
'timeout' | Fixed delay | Milliseconds as string |
{
"id": "after-submit",
"target": "[data-trail-id='success-banner']",
"action": "click",
"wait": { "type": "networkIdle" },
"assert": { "type": "visible" }
}{
"id": "wait-for-modal",
"target": "[data-trail-id='open-modal']",
"action": "click",
"wait": { "type": "selector", "value": ".modal" },
"assert": { "type": "visible" }
}Steps without action or assert
Steps that have no action or assert still run in CI. runTrail checks that the target element is attached to the DOM. This catches the most common regression: a selector that no longer matches anything after a code change.
{
"id": "step-nav",
"target": "[data-trail-id='main-nav']",
"placement": "bottom",
"title": "Navigation",
"content": "Find everything you need here."
}No action. No assert. The test still fails if [data-trail-id='main-nav'] disappears.
Named steps in the reporter
Pass the Playwright test object to wrap each step in test.step(). Every step title appears as a named sub-step in the Playwright HTML reporter and CI output — no extra configuration needed.
import { test } from '@playwright/test'
import { runTrail } from '@trailguide/playwright'
import welcomeTrail from './tours/welcome.trail.json'
test('welcome tour', async ({ page }) => {
await runTrail(page, welcomeTrail, { test })
})When a step fails, the reporter highlights exactly which named step broke and shows the error inline.
Failure notifications
Get alerted immediately when a trail test fails in CI. Configure notify with a Slack webhook, a generic HTTP endpoint, or both:
await runTrail(page, trail, {
notify: {
slack: process.env.SLACK_WEBHOOK_URL,
webhook: process.env.ALERT_WEBHOOK_URL,
trailUrl: 'https://app.gettrailguide.com',
},
})On any step failure, Trailguide POSTs a payload before re-throwing the error so Playwright still fails the test:
{
"trailId": "onboarding",
"trailTitle": "Welcome Tour",
"stepIndex": 2,
"stepTitle": "Click the create button",
"error": "Timeout waiting for selector '[data-trail-id=\"create-btn\"]'",
"trailUrl": "https://app.gettrailguide.com"
}Slack
Pass any Slack incoming webhook URL. The message includes the trail name, failing step, and a link back to the trail if trailUrl is set.
Generic webhook
webhook receives the same JSON payload via POST. Works with PagerDuty, Datadog, custom Slack apps, or any HTTP endpoint.
GitHub Actions setup
- run: npx playwright test
env:
BASE_URL: ${{ secrets.BASE_URL }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}Test health dashboard (Pro)
Track pass rates, failure trends, and most-broken steps over time across your entire trail suite.
Generate an API key
Open Dashboard > Tests in the Pro Editor, click Generate API Key, and copy the key.
Add to CI secrets
GitHub Actions: Settings > Secrets and variables > Actions > New repository secret
- Name:
TRAILGUIDE_API_KEY - Value: your
tg_...key
GitLab CI: Settings > CI/CD > Variables > Add variable
- Key:
TRAILGUIDE_API_KEY - Value: your
tg_...key
Wire up runTrail
await runTrail(page, trail, {
reportUrl: 'https://app.gettrailguide.com/api/test-runs',
apiKey: process.env.TRAILGUIDE_API_KEY,
})After each run, the dashboard updates with pass/fail status, duration, which step failed, and the base URL that was tested. The Most broken steps panel shows which steps fail most often across all trails so you know where to focus fixes.
Using the Pro Editor
The Pro Editor has a mode toggle on every trail (Guide / Test / Both) and exposes action, value, assert, wait, and tab context fields per step when the mode is set to test or both. You can build and update test trails visually without touching JSON.
When recording in test or both mode, the extension automatically captures a wide range of interactions:
- Form fields (
input,textarea): click to focus, type your value, then Tab or click away. Captured asaction: "fill"with the typed value. - Dropdowns (
select): choose an option. Captured asaction: "select"with the selected value. - Right-click menus: right-click an element to capture it as
action: "rightClick", then click the menu item as a separateclickstep. - Double-click: double-click an element to capture it as
action: "dblclick". - Hover: Alt + click to capture as
action: "hover"without triggering a navigation. - Keyboard shortcuts: pressing Enter, Tab, arrow keys, F-keys, or Ctrl/Cmd combos is captured as
action: "press"with the key combination. - Scroll: scrolling the page or a scrollable container is captured as
action: "scroll"with the pixel offset.
Every trail in the Pro Editor is stored with a screenshot of each step. When your UI ships a redesign, anyone on your team can open the trail, see exactly what changed, fix the affected steps, and push the update to GitHub or GitLab as a pull request without an engineering ticket.