haltija

E2E Testing with Haltija in CI

Run browser tests in GitHub Actions using Haltija. Your tests run against a real Electron browser — same engine your users run, no headless quirks.

Quick Start (Using npm package)

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:

Use with xvfb-run on Linux to provide a virtual display for Electron.

System Dependencies

Required packages (Ubuntu/Debian)

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

Bun setup

- uses: oven-sh/setup-bun@v2
  with:
    bun-version: latest

Environment Variables

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

Launch Options

Which engine? (read this first)

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:

Option 2: Electron app without wait

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.

Option 3: Playwright headless (multi-engine)

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

Option 4: Server-only mode (lighter weight)

If you’re testing a web app and don’t need the Electron shell:

bunx haltija@latest --server --headless &

This starts:

Option 5: From source (for Haltija development)

bun install && bun run build
cd apps/desktop && npm install --omit=dev
xvfb-run --auto-servernum npx electron . &

Waiting for Ready State

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 CLI

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.

Writing Tests

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"}}
  ]
}

Step Types

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

Text Selectors

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)

Assertions

{"type": "exists", "selector": ".modal"}
{"type": "not-exists", "selector": ".error"}
{"type": "text", "selector": "h1", "text": "Welcome"}
{"type": "url", "pattern": "/dashboard"}
{"type": "visible", "selector": "#content"}

Step Metadata

Add context to improve failure messages:

{
  "action": "click",
  "selector": "#checkout",
  "description": "Click checkout button",
  "purpose": "Button may be disabled if cart is empty"
}

Running Tests

Single test

hj test-run tests/login.json

Multiple tests

hj test-suite tests/

With output formats

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

Handling Flaky Tests

Use the patience model for CI environments with variable timing:

{
  "test": {...},
  "patience": 5,
  "patienceStreak": 3,
  "timeout": 8000
}

Debugging Failures

Capture state on failure

- 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"

Check what’s on the page

hj tree                    # DOM structure
hj console                 # Browser console
hj screenshot              # Visual capture

Platform Notes

Linux (GitHub Actions)

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 &

macOS

No xvfb needed:

bunx haltija@latest &

Windows

Coming soon. For now, use WSL2 with the Linux instructions.

Complete CI Example with Firebase Emulators

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
          '

Shell Script Helpers

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
}

Tips

Troubleshooting

“hj: command not found”

Add ~/.local/bin to PATH:

export PATH="$HOME/.local/bin:$PATH"

Electron sandbox errors

Set the environment variable:

env:
  ELECTRON_DISABLE_SANDBOX: 1

No browser connected

Make sure you’re waiting for the browser connection, not just the server:

WINDOWS=$(curl -sf http://localhost:8700/windows | jq '.windows | length')

Tests timing out

Increase step timeout or use the patience model:

{"timeout": 10000}

Or in the test config:

{"patience": 5, "timeout": 8000}

REST API

For direct HTTP integration (scripts, other languages), see REST-API.md.

Examples

See test files in the Haltija repo: