Skip to content

CI

Start a group from your pipeline, wait for it, and fail the job when tests fail.

The CI trigger

Every group can have a CI trigger (group page, then CI trigger). Any member can create or replace one, and the change is recorded in the team’s audit log. You get:

  • a URL, https://<app>/api/hooks/group/<hook id>;
  • a secret, shown once. Store it in your CI’s secret store as LCTESTER_SECRET, and the URL as LCTESTER_HOOK.

Calling the URL starts the group as one batch. Its runs count against your team’s runs like any other and start ahead of scheduled batches. The answer contains a status URL you can poll until the batch finishes, as JSON or as JUnit XML.

Start a batch

POST to the hook URL with Authorization: Bearer $LCTESTER_SECRET (or a signed request, below). The JSON body is optional:

  • environmentId: a saved environment of the project. Free-form addresses are not accepted.
  • mode: concurrent (default) or sequential.
  • concurrencyCap: 1 to 50.

The answer is 202 with {"batchId": "…", "status": "<status URL>"}. Errors:

  • 401: unknown hook or wrong secret.
  • 413: body over 16 KB.
  • 422: invalid body, or the team’s daily run limit would be exceeded. Nothing is started.
  • 429: too many runs or failed attempts. Retry after the Retry-After header.

Read the result

GET the status URL with the same Authorization header. passed is true only when the batch completed and every test passed. Add ?format=junit for a JUnit XML report with one test case per test and the failing step in the failure message.

Signed requests (optional)

Use a signature instead of sending the secret itself:

  • Send X-LCTester-Timestamp (unix seconds) and X-LCTester-Signature: sha256=<hex>, where the hex is HMAC-SHA256(secret, "<timestamp>.<body>").
  • Requests older than 5 minutes are refused.
  • Each signature works once, so a captured request cannot be replayed. Sign every call afresh.
TS=$(date +%s)
BODY='{"mode":"sequential"}'
SIG=$(printf %s "$TS.$BODY" | openssl dgst -sha256 -hmac "$LCTESTER_SECRET" -r | cut -d' ' -f1)
curl -fsS -X POST "$LCTESTER_HOOK" -H "Content-Type: application/json" \
  -H "X-LCTester-Timestamp: $TS" -H "X-LCTester-Signature: sha256=$SIG" -d "$BODY"

Plain curl

Start, wait, and fail the job unless every test passed:

#!/bin/sh
set -eu
AUTH="Authorization: Bearer $LCTESTER_SECRET"
STATUS=$(curl -fsS -X POST "$LCTESTER_HOOK" -H "$AUTH" | jq -r .status)
for i in $(seq 1 120); do              # up to 60 minutes
  RESULT=$(curl -fsS --retry 3 --retry-all-errors "$STATUS" -H "$AUTH")
  [ "$(echo "$RESULT" | jq -r .finished)" = true ] && break
  sleep 30
done
curl -fsS --retry 3 --retry-all-errors "$STATUS?format=junit" -H "$AUTH" -o lctester-junit.xml
echo "$RESULT" | jq .counts
[ "$(echo "$RESULT" | jq -r .passed)" = true ]

GitHub Actions

name: E2E (LCTester)
on:
  deployment_status:
  workflow_dispatch:
jobs:
  e2e:
    if: github.event_name == 'workflow_dispatch' || github.event.deployment_status.state == 'success'
    runs-on: ubuntu-latest
    timeout-minutes: 70
    steps:
      - name: Run the group and wait
        env:
          LCTESTER_HOOK: ${{ secrets.LCTESTER_HOOK }}
          LCTESTER_SECRET: ${{ secrets.LCTESTER_SECRET }}
        run: |
          AUTH="Authorization: Bearer $LCTESTER_SECRET"
          STATUS=$(curl -fsS -X POST "$LCTESTER_HOOK" -H "$AUTH" | jq -r .status)
          for i in $(seq 1 120); do
            RESULT=$(curl -fsS --retry 3 --retry-all-errors "$STATUS" -H "$AUTH")
            [ "$(echo "$RESULT" | jq -r .finished)" = true ] && break
            sleep 30
          done
          curl -fsS --retry 3 --retry-all-errors "$STATUS?format=junit" -H "$AUTH" -o lctester-junit.xml
          echo "$RESULT" | jq .counts
          [ "$(echo "$RESULT" | jq -r .passed)" = true ]
      - name: Keep the report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: lctester-junit
          path: lctester-junit.xml

Any JUnit reporter action can read lctester-junit.xml to show results in the run summary.

GitLab CI

lctester-e2e:
  stage: test
  image: alpine:3.20
  timeout: 70m
  before_script:
    - apk add --no-cache curl jq
  script:
    - AUTH="Authorization: Bearer $LCTESTER_SECRET"
    - STATUS=$(curl -fsS -X POST "$LCTESTER_HOOK" -H "$AUTH" | jq -r .status)
    - |
      for i in $(seq 1 120); do
        RESULT=$(curl -fsS --retry 3 --retry-all-errors "$STATUS" -H "$AUTH")
        [ "$(echo "$RESULT" | jq -r .finished)" = true ] && break
        sleep 30
      done
      curl -fsS --retry 3 --retry-all-errors "$STATUS?format=junit" -H "$AUTH" -o lctester-junit.xml
      echo "$RESULT" | jq .counts
      [ "$(echo "$RESULT" | jq -r .passed)" = true ]
  artifacts:
    when: always
    reports:
      junit: lctester-junit.xml

Set LCTESTER_HOOK and LCTESTER_SECRET as masked CI/CD variables. GitLab shows the JUnit report in the merge request.

Receiving notification webhooks

A webhook channel posts JSON for two events. batch.finished comes after each group run, with the batch, its counts, the first failure and a link. issue.confirmed comes when a person confirms a bug from review and that creates or reopens an issue; it carries the project, the issue (title, severity, link), who confirmed it (null for an API key), the run and the step, and it reaches every enabled channel, including those set to failures only. Ignore event types you do not know. Headers are X-LCTester-Event, X-LCTester-Timestamp and X-LCTester-Signature (sha256= plus the HMAC-SHA256 of "<timestamp>.<body>" with your signing secret). X-LCTester-Delivery stays the same when a delivery is retried, so use it to ignore deliveries you have already handled. Check the signature and refuse timestamps older than 5 minutes:

import { createHmac, timingSafeEqual } from 'node:crypto';

function isFromLCTester(rawBody, headers, secret) {
  const ts = Number(headers['x-lctester-timestamp']);
  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
  const expected = Buffer.from(
    createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex'),
  );
  const given = Buffer.from(String(headers['x-lctester-signature'] ?? '').replace(/^sha256=/, ''));
  return given.length === expected.length && timingSafeEqual(given, expected);
}