Running on CI
Tests earn their keep when they run on every change without anyone asking. This guide explains what a CI job needs in order to run your cases, how to choose where the app runs, and how to leave a report on each pull request.
lynkeus does not ship a ready-made action for running cases. A job runs the same commands you run locally. The one workflow lynkeus writes for you, map ci, is covered at the end: it needs no app at all.
What every job needs
Section titled “What every job needs”Whatever the host, a job that runs cases needs five things.
- Node and your dependencies. The CLI and the headless host need Node 22 or newer. Install the project’s packages as usual;
lynkeusandlynkeus-agentare among them. - The app, running somewhere. Either hosted in Node by the headless host, or as a debug build on a simulator or an emulator. The next section helps you choose.
- A shared token.
LYNKEUS_TOKENmust hold the same value for the app and for the CLI. In a job that builds or hosts the app itself, any value works, as long as both sides read the same one. An app that passes a token and receives an empty one keeps its agent off, so a job that forgets the variable on the app’s side fails to attach rather than run unprotected. - A backend to talk to. The app under test makes real requests. Run the backend the job tests, with its database and cache, seeded for tests. No shared environment is needed, and no production data is involved.
- A fixtures command, if your cases use fixtures. It is started by
case runfromcases.fixturesinlynkeus.config.json, so it must be runnable in the job. See Fixtures.
The job’s result is the exit code of case run: 0 when no case failed, 1 otherwise. A case that cannot run on the chosen host is reported as unsupported and does not fail the job.
Choose a host
Section titled “Choose a host”The host is where the app runs during the test. The same cases run on all of them.
| Headless host | Simulator or emulator | |
|---|---|---|
| What runs | The app’s JavaScript in Node, native modules mocked | The real debug build |
| Needs | Node, a Linux container is enough | A machine that can run the simulator or emulator, and a build of the app |
| Start time | About 40 seconds | A build, an install and a boot |
| Screenshots and pixel snapshots | No | Yes |
Contrast in lint |
No | Yes |
| Camera, push delivery, WebView content, native gestures, native crashes | No (mocked or absent) | Yes |
| Parallel runs | One host per port, scales with cores | One host per port |
The two are not exclusive. One way to split the work is to run the suite on the headless host for every pull request, and the cases that need the native layer on a simulator or an emulator. Steps that only one side needs go under on: device or on: headless in the case, so the suite stays one set of files.
For what each host can and cannot do, see Headless, iOS and Android.
Run on the headless host
Section titled “Run on the headless host”This is the job with the fewest moving parts: no simulator, no build.
Before the first run
Section titled “Before the first run”Set up the headless host locally and commit the result. See Headless.
npx lynkeus headless init --auto # writes the headless entry, mounts the agentnpx lynkeus headless doctor # lists native modules that still need a mockLYNKEUS_TOKEN=secret npx lynkeus case run --hostWhen that passes on your machine, the job is the same command.
The command
Section titled “The command”npx lynkeus case run --host --out run--host starts lynkeus headless start for the run and stops it afterward. --out run writes the run as a directory, which you can keep as a build artifact or push as a report.
You should see host: up once the app is hosted, then each case. If the host has not come up within 180 seconds, the command stops with the host did not come up within 180 s followed by the last lines of its output.
A plain workflow
Section titled “A plain workflow”This GitHub Actions workflow runs the documented commands and nothing else. Adapt the backend steps to your project.
name: lynkeuson: pull_request
jobs: cases: runs-on: ubuntu-latest env: LYNKEUS_TOKEN: ci-token steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci
# Start the backend this job tests, seeded for tests, on localhost. # - run: docker compose up -d --wait
- run: npx lynkeus case run --host --out run --report run/report.jsonl
- if: always() uses: actions/upload-artifact@v4 with: name: lynkeus-run path: run--report <file> appends one JSON line per case, for a script that reads results.
Run cases in parallel
Section titled “Run cases in parallel”Each job in case run --jobs needs its own host on its own port. LYNKEUS_PORT moves a host and its driver together, so start one host per port and give the ports to the run:
LYNKEUS_PORT=8123 npx lynkeus headless start &LYNKEUS_PORT=8124 npx lynkeus headless start &LYNKEUS_PORT=8125 npx lynkeus headless start &
npx lynkeus case run --jobs 3 --ports 8123,8124,8125 --out runThe longest cases start first, so the jobs finish together. Hosts scale with the machine’s cores.
A long-lived host grows: Node’s heap gains a few dozen MB per full app reset. A runner that drives hundreds of cases on one host should restart it when npx lynkeus call memory reports too much.
Run on a simulator or an emulator
Section titled “Run on a simulator or an emulator”Use this for what the headless host cannot show: screenshots, contrast, and anything native. The device here is a simulator or an emulator; physical devices are not supported.
The job has more to prepare, and those steps are your platform’s, not lynkeus’s:
- Build the debug build of the app, with the agent enabled. lynkeus does not work in a release build: the code that synthesizes touches is not compiled into one.
- Give the app the token the job will use. In a React Native CLI app the token is written into the development bundle by the
lynkeus-agent/babelplugin, so start Metro withLYNKEUS_TOKENset in its environment. In an Expo app, start the development server withEXPO_PUBLIC_LYNKEUS_TOKENset to the same value. - Boot the simulator or emulator, install the app and launch it.
- Run the cases.
npx lynkeus launch com.example.myapp --clean --attach-timeout 60000npx lynkeus case run --out run --screenshotslaunch relaunches the app, by its bundle id or application id, and waits for the agent to connect. With --clean it also runs the app’s reset command. When it returns, the app is attached and the cases can start.
Three global flags are useful here. --app <bundleId> names the app for commands that launch it. --device <udid|name> picks a simulator. --attach-timeout <ms> gives the app longer to connect than the default 15 seconds. All three can live in lynkeus.config.json as app, device and attachTimeout.
How the app reaches the CLI differs by platform:
- On the iOS simulator, the app dials
localhost:8123. - On the Android emulator, the app dials
10.0.2.2:8123, the emulator’s address for the machine that runs it.
Nothing listens inside the app. It dials out and retries every 1.5 seconds, so the order in which the app and the CLI start does not matter.
Push a report from CI
Section titled “Push a report from CI”A report gives each run a page with its steps, screenshots and failures. See Reports for the service and its plans.
Store the organization’s API key as a secret and add --push:
- run: npx lynkeus case run --host --out run --push env: LYNKEUS_REPORTS_URL: https://lynkeus.io LYNKEUS_REPORTS_KEY: ${{ secrets.LYNKEUS_REPORTS_KEY }}The step prints the report’s link, and which cases broke or were fixed since the run before. The run records the branch and the commit it was made from, read from the checkout and from GitHub’s own variables.
On a simulator or an emulator, add --screenshots to keep a frame after every step. --video films each case only where the host offers a record command, which it names in hello; the built-in headless host and a simulator or emulator session offer none, so a run there has screenshots and no video. Where a video is recorded, it is kept on the Team plan.
Comment on the pull request
Section titled “Comment on the pull request”To leave the link on the pull request, keep the output and post the lines that matter:
- run: npx lynkeus case run --host --out run --push | tee lynkeus.log shell: bash- if: always() && github.event_name == 'pull_request' run: gh pr comment ${{ github.event.pull_request.number }} --body "$(grep -E '^(report:|newly failing|fixed since)' lynkeus.log)" env: GH_TOKEN: ${{ github.token }}if: always() matters: the comment is most useful when the cases failed.
Add backend traces
Section titled “Add backend traces”If the backend can export OpenTelemetry traces, add --traces and start the backend with its exporter pointed at the run:
- run: npx lynkeus case run --host --out run --traces --pushOTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 <your backend start command>Each step of the report then lists the requests the app sent and what the backend did for them. See Backend traces.
Checks that need no app: map ci
Section titled “Checks that need no app: map ci”One check runs on every pull request with no app, no device and no backend. map check reads the source and names the screens a diff can reach.
npx lynkeus map ciYou should see wrote .github/workflows/lynkeus-map-check.yml. On every pull request, that workflow builds the map from the sources, runs map check against the base branch, and keeps one comment on the pull request up to date with the screens the diff reaches. The analysis takes well under a second.
--depth <n> sets how many imports away the check looks (2 by default). --force overwrites an existing workflow file.
Reviewers use the comment to know where to look, and a pipeline can use the same list to decide which cases to run.
Other checks worth a step
Section titled “Other checks worth a step”- Lint against a baseline.
npx lynkeus lint --baseline <file> --strictfails when a branch adds a warning to a screen. See Lint and contrast. - Tree snapshots.
npx lynkeus snapshot <name>exits with code 1 when a screen’s structure differs from its baseline, on any host. See Recording and snapshots. - Analytics contracts.
npx lynkeus funnel check <name>exits with code 1 when a funnel is broken. See Analytics events.
Each of these acts on the screen the app is showing or on a recorded run, so the job must first bring the app there.
Common problems
Section titled “Common problems”The run hangs and then fails to attach. The app never connected. On the headless host, check that LYNKEUS_TOKEN is set: headless start refuses to run without it. On a simulator or an emulator, check that the build is a debug build, that the agent is enabled for the environment the build targets, that Metro (or Expo’s development server) was started with the token variable set, and that the app’s token equals the job’s LYNKEUS_TOKEN. If the app’s log says lynkeus: the app passed a token and it is empty, the variable did not reach the bundle. Then raise --attach-timeout.
the host failed to start: …. The headless host hit a Jest error; the message ends with the last lines of its output. Run npx lynkeus headless doctor locally to see which native modules still need a mock, and npx lynkeus headless doctor --explain "<first line of the error>" for a specific error.
The app never leaves the splash screen under the headless host. A native module lynkeus does not know, or Node resolving localhost to IPv6 while the backend listens on IPv4. Use 127.0.0.1 for the backend’s address.
An environment variable change has no effect under the headless host. If your Babel configuration inlines environment variables, Jest’s transform cache keeps the first value it saw. Remove the cache directory your Jest configuration points at.
port 8123 is held by another lynkeus case run. Two runs share a machine and a port. Give each run its own --ports, or its own LYNKEUS_PORT.
The job passes although cases were not run. Read the summary line. 3/5 passed, 2 cannot run on this host means two cases were unsupported on this host. They need the other kind of host.
set LYNKEUS_REPORTS_URL and LYNKEUS_REPORTS_KEY (the organisation’s API key). The variables are not set for this step. Check that the secret exists and that the step’s env passes it.
Next steps
Section titled “Next steps”- Reports: what the pushed report shows.
- Flaky tests: keep one unreliable test from blocking every pull request.
- Headless: set up and tune the headless host.
- Environment variables: every
LYNKEUS_*variable.