Run browser tests in GitHub Actions using Haltija. Your tests run against a real Electron browser — same engine your users run, no headless quirks.
For most projects, install Haltija as a dev dependency and run tests against your app:
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y xvfb jq libnss3 libatk1.0-0 libatk-bridge2.0-0 \
libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2t64
- name: Start your app
run: |
npm start &
until curl -sf http://localhost:3000 > /dev/null; do sleep 1; done
- name: Launch Haltija
run: |
export PATH="$HOME/.local/bin:$PATH"
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
bunx haltija@latest --ci &
# --ci mode waits for server + browser to be ready
# and sets ELECTRON_DISABLE_SANDBOX automatically
- name: Run tests
run: |
export PATH="$HOME/.local/bin:$PATH"
hj navigate http://localhost:3000
hj test-run tests/my-test.json
The --ci flag is a convenience mode that:
ELECTRON_DISABLE_SANDBOX=1 automatically (required in containers)Use with xvfb-run on Linux to provide a virtual display for Electron.
sudo apt-get install -y xvfb jq libnss3 libatk1.0-0 libatk-bridge2.0-0 \
libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2t64
| Package | Purpose |
|---|---|
xvfb |
Virtual framebuffer (required for headless display) |
jq |
JSON parsing in shell scripts |
libnss3, libatk*, etc. |
Chromium/Electron runtime dependencies |
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
| Variable | Purpose | When needed |
|---|---|---|
ELECTRON_DISABLE_SANDBOX |
Disable Electron sandbox | Containers, some CI environments |
PATH |
Include ~/.local/bin for hj CLI |
When using hj commands |
Example:
env:
ELECTRON_DISABLE_SANDBOX: 1
run: |
export PATH="$HOME/.local/bin:$PATH"
hj tree
Haltija can drive one of two browser engines in CI, and they need different external browsers — picking by the words “for CI” alone leads people to the wrong one:
| You want | Use | Engine | External dependency |
|---|---|---|---|
| The default CI path | --ci |
Electron (Chromium) | Electron (auto-fetched via npx) — no Playwright |
| Isolated CI instance, own ephemeral port | --private --app |
Electron (Chromium) | Electron (as above) |
| Multi-engine coverage (Firefox/WebKit) or a lighter single-engine run | --headless |
Playwright Chromium | the playwright package (npm i playwright && npx playwright install) |
Neither engine is bundled in the npm package, so something is downloaded either way — the choice
is Electron vs. Playwright, not “deps vs. no deps”. The one genuine reason to reach for
--headless/Playwright is engines Electron can’t give you (Firefox, WebKit). For a straight
Chromium CI lane, --ci (Electron) needs no separate playwright install. --private is an
isolation modifier — it pairs with either engine (--private --app = Electron, --private
--headless = Playwright).
xvfb-run --auto-servernum bunx haltija@latest --ci &
This is the simplest and most realistic option. The --ci flag:
ELECTRON_DISABLE_SANDBOX=1 (required in containers)xvfb-run --auto-servernum bunx haltija@latest --app &
Same as --ci but doesn’t wait for ready state. You’ll need to poll /status yourself.
npm install playwright && npx playwright install chromium # required — not bundled
bunx haltija@latest --headless &
Uses Playwright’s Chromium instead of Electron, so it needs the playwright package installed
(the command above; without it, --headless exits with a “Playwright not installed” error that
points you back to --ci). Choose this when you want a lighter single-engine run or, the real
reason, multi-engine coverage — Playwright can also drive Firefox and WebKit, which Electron
cannot. For a plain Chromium lane with no extra install, prefer Option 1 (--ci).
If you’re testing a web app and don’t need the Electron shell:
bunx haltija@latest --server --headless &
This starts:
bun install && bun run build
cd apps/desktop && npm install --omit=dev
xvfb-run --auto-servernum npx electron . &
Don’t use sleep, and don’t gate on “the server answered” — that is the single most common
way a lane fails confusingly. A haltija server can be up with zero connected tabs: /status
returns 200, your lane decides haltija is available and skips starting its own browser, and then
navigate fails with “no browser reachable” — or worse, times out somewhere that looks like your
code’s fault. Server up ≠ drivable.
Use the built-in preflight, which exits non-zero on exactly that case:
# Wait until the target is actually drivable (not merely alive)
for i in $(seq 1 30); do
if hj doctor >/dev/null 2>&1; then break; fi
sleep 1
done
hj doctor # print the verdict; exits 1 if it's not drivable or the target is ambiguous
hj doctor checks, in the order they bite: server reachable → a tab is actually connected → the
target isn’t ambiguous (your cwd matches, or you chose explicitly) → tabs aren’t all hidden →
versions aligned. Add --json for machine-readable output.
Checking by hand instead? Gate on the ready field, not on the HTTP status:
curl -sf http://localhost:8700/status | jq -e '.ready' # true only when a tab is connected
And for a lane, add --strict (or HALTIJA_STRICT=1) to your hj calls so advisory warnings —
cross-project targeting, a hidden tab returning stale results — become non-zero exits instead of
stderr noise your script ignores:
hj --strict navigate "$URL"
Best of all, don’t share state at all: haltija --private --app (or --private --headless) gives
the lane its own isolated server and browser on an ephemeral port, which nothing else can adopt and
which tears down with the run.
The hj command is installed to ~/.local/bin when Haltija starts. Add it to PATH:
export PATH="$HOME/.local/bin:$PATH"
Then use it:
hj status # Check connection
hj tree # See page structure
hj click "#submit" # Click element
hj type "#email" user@example.com
hj test-run tests/login.json
Run hj --help for all commands.
Tests are JSON files with steps:
{
"version": 1,
"name": "Login flow",
"url": "http://localhost:3000/login",
"steps": [
{"action": "type", "selector": "#email", "text": "user@example.com"},
{"action": "type", "selector": "#password", "text": "secret123"},
{"action": "click", "selector": "button[type=submit]"},
{"action": "wait", "selector": ".dashboard"},
{"action": "assert", "assertion": {"type": "url", "pattern": "/dashboard"}}
]
}
| Action | Example | What It Does |
|---|---|---|
navigate |
{"action": "navigate", "url": "..."} |
Load a URL (waits for reconnect) |
click |
{"action": "click", "selector": "#btn"} |
Click element |
type |
{"action": "type", "selector": "#input", "text": "..."} |
Type text (realistic keystrokes) |
type (paste) |
{"action": "type", "selector": "#input", "text": "...", "paste": true} |
Paste text (fast, React-compatible) |
check |
{"action": "check", "selector": "#agree"} |
Toggle checkbox/radio |
key |
{"action": "key", "key": "Enter"} |
Press key |
wait |
{"action": "wait", "selector": ".loaded"} |
Wait for element |
assert |
{"action": "assert", "assertion": {...}} |
Check condition |
eval |
{"action": "eval", "code": "..."} |
Run JavaScript (auto-awaits promises) |
verify |
{"action": "verify", "eval": "...", "expect": {...}} |
Poll until expression matches |
tabs-open |
{"action": "tabs-open", "url": "..."} |
Open a new tab |
tabs-close |
{"action": "tabs-close", "window": "id"} |
Close a tab |
tabs-focus |
{"action": "tabs-focus", "window": "id"} |
Focus a tab |
wait (window) |
{"action": "wait", "forWindow": true} |
Wait for new tab to connect |
All selectors support custom pseudo-selectors for finding elements by visible text:
{"action": "click", "selector": "button:text(sign in)"}
{"action": "click", "selector": "a:text(forgot password)"}
{"action": "wait", "selector": "h1:text-is(Dashboard)"}
| Syntax | Behavior |
|---|---|
:text(str) |
Contains str (case-insensitive) |
:text-is(str) |
Exact text match (case-insensitive) |
:has-text(str) |
Alias for :text() |
:text(/regex/) |
Regex match (case-sensitive) |
:text(/regex/i) |
Regex match (case-insensitive) |
{"type": "exists", "selector": ".modal"}
{"type": "not-exists", "selector": ".error"}
{"type": "text", "selector": "h1", "text": "Welcome"}
{"type": "url", "pattern": "/dashboard"}
{"type": "visible", "selector": "#content"}
Add context to improve failure messages:
{
"action": "click",
"selector": "#checkout",
"description": "Click checkout button",
"purpose": "Button may be disabled if cart is empty"
}
hj test-run tests/login.json
hj test-suite tests/
hj test-run tests/login.json # JSON (default)
hj test-run tests/login.json --format human # Human-readable
hj test-run tests/login.json --format github # GitHub Actions annotations
Use the patience model for CI environments with variable timing:
{
"test": {...},
"patience": 5,
"patienceStreak": 3,
"timeout": 8000
}
patience: 5 — allow up to 5 step failures before bailingpatienceStreak: 3 — 3 consecutive failures bails immediatelytimeout: 8000 — per-step timeout in ms- name: Debug info
if: failure()
run: |
export PATH="$HOME/.local/bin:$PATH"
hj screenshot > failure-screenshot.json
hj console > console-logs.json
hj snapshot > failure-state.json
- uses: actions/upload-artifact@v4
if: failure()
with:
name: debug
path: "*.json"
hj tree # DOM structure
hj console # Browser console
hj screenshot # Visual capture
Requires xvfb and Electron dependencies. In container environments:
env:
ELECTRON_DISABLE_SANDBOX: 1
run: |
sudo apt-get install -y xvfb libnss3 libatk1.0-0 ...
xvfb-run --auto-servernum bunx haltija@latest &
No xvfb needed:
bunx haltija@latest &
Coming soon. For now, use WSL2 with the Linux instructions.
For apps that need emulators or other backend services:
name: E2E Tests
on: [push]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: |
npm ci
sudo apt-get update
sudo apt-get install -y xvfb jq libnss3 libatk1.0-0 libatk-bridge2.0-0 \
libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2t64
- name: Run tests with emulators
env:
ELECTRON_DISABLE_SANDBOX: 1
run: |
firebase emulators:exec --project='my-project' --only functions,firestore,auth,hosting '
export PATH="$HOME/.local/bin:$PATH"
# Start Haltija
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
bunx haltija@latest &
# Wait for ready
for i in $(seq 1 30); do
if curl -sf http://localhost:8700/status > /dev/null 2>&1; then
echo "Haltija ready"
break
fi
sleep 1
done
# Run tests
hj navigate http://127.0.0.1:5050
hj test-run tests/app-tests.json
'
For complex test suites, create helper functions:
#!/bin/bash
# lib.sh
export PATH="$HOME/.local/bin:$PATH"
wait_for_haltija() {
for i in $(seq 1 30); do
if curl -sf http://localhost:8700/status | jq -e '.serverVersion' > /dev/null 2>&1; then
return 0
fi
sleep 1
done
echo "Haltija failed to start"
return 1
}
wait_for_element() {
local selector="$1"
local timeout="${2:-10}"
hj wait --selector "$selector" --timeout "${timeout}000"
}
assert_url_contains() {
local pattern="$1"
local url=$(hj location | jq -r '.url')
if [[ "$url" == *"$pattern"* ]]; then
return 0
else
echo "URL '$url' does not contain '$pattern'"
return 1
fi
}
sleep — use wait loops that check for actual readinessjq to dependencies — essential for parsing JSON responses in scriptshj is installed to ~/.local/binELECTRON_DISABLE_SANDBOX — required in most container environmentspurpose on steps that might fail — explains intent on failureAdd ~/.local/bin to PATH:
export PATH="$HOME/.local/bin:$PATH"
Set the environment variable:
env:
ELECTRON_DISABLE_SANDBOX: 1
Make sure you’re waiting for the browser connection, not just the server:
WINDOWS=$(curl -sf http://localhost:8700/windows | jq '.windows | length')
Increase step timeout or use the patience model:
{"timeout": 10000}
Or in the test config:
{"patience": 5, "timeout": 8000}
For direct HTTP integration (scripts, other languages), see REST-API.md.
See test files in the Haltija repo: