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 asLCTESTER_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) orsequential.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 theRetry-Afterheader.
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) andX-LCTester-Signature: sha256=<hex>, where the hex isHMAC-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.xmlAny 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.xmlSet 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);
}