// docs
Importing tests & CI
Bring decks in without retyping: upload a .feature file, migrate from TestRail, or post to the token-authenticated machine-import API from CI.
Most decks start in the Case Editor — someone typing a title, a few steps, an expected result. This
page covers the other four ways a case or a deck arrives without that: uploading a Gherkin
.feature file, migrating a TestRail suite, posting to the token-authenticated machine-import API,
and drafting a case straight out of a caught fail's recording. All four feed the same case model.
All four obey the same rule, no exceptions: importing a deck does not run it. It lands inert on
the shelf until a person opens it and steps through it by hand, card by card.
Upload a .feature file
Admin-only, at /packages → Import from file (POST /api/packages/import, multipart). Teasynaer
parses the file with @cucumber/gherkin and turns each Scenario/Scenario Outline into a case:
Feature: Checkout
Background:
Given I am signed in as a customer
Scenario: Apply a valid discount code
Given my basket total is £50.00
When I apply the code "SAVE10"
Then the basket total is £45.00
And a "code applied" confirmation is shown
That produces a deck named Checkout with one case. The mapping, in short:
Feature:→ the deck name (overridable at upload time; falls back to "Untitled import").- Each
Scenario:/Scenario Outline:row → one case; the scenario title becomes the case title and its key. Given/When→ the case's Steps; everyThen(plus trailingAnd/But) → Expected result. A scenario with noThenis rejected outright — the outcome step is never inferred.Background:steps are prepended to every case in the file.@suite:<name>on a scenario sets that case's suite label (optional; any other tag is ignored).
The whole file is validated before anything is written. One over-limit scenario, one missing
Then, one Gherkin syntax error — the entire import is rejected, nothing partial gets created. The
hard limits:
| Limit | Value |
|---|---|
| Scenarios per file | 200 |
| File size | 512 KB |
| Scenario title | 500 characters |
| Each step | 2,000 characters |
Expected result (all Then lines joined) | 4,000 characters |
| Suite name | 64 characters |
| Deck name | 1–255 characters |
Every upload creates a new deck — re-uploading an edited file makes another deck, not an update to the old one. Retiring the previous version is a manual step.
Handing the authoring off to a coding agent instead of writing the .feature yourself? The repo
ships an agent skill, authoring-gherkin-for-teasynaer, that mirrors this exact contract (the
mapping table, the limits, the no-Then-rejection rule) — point the agent at it instead of
re-explaining the shape from scratch.
Validate before you spend a deck slot
Both the Gherkin route above and the TestRail route below accept ?validate=1. It runs the identical
parser against the identical limits — same rejections, same error text — but never writes to the
database. A clean file gets back the deck name, case count, and the limits it was checked against; a
bad one gets the same error the real import would have thrown. Use it as a pre-flight check in an
editor or a CI step before you commit to an actual import.
Bring over a TestRail suite
A different source, same destination: /packages → Import from TestRail, or
POST /api/packages/import/testrail (+ ?validate=1), takes a TestRail CSV export and turns each
case row into a steps_expected case — same 200-row cap, same 512 KB file cap, same all-or-nothing
validation as the Gherkin path. It reads either TestRail's plain "Steps" + "Expected Result" columns
or the step-templated "Steps (Step)" / "Steps (Expected Result)" pairs, tolerates the usual
Section/Suite and Expected Result header renames, and folds Priority/References into a labelled note
at the end of Expected — there's no dedicated field for them.
This is a one-off migration of existing TestRail case definitions, not an ongoing sync — it never talks to the TestRail API and imports nothing about runs or results, only the case text.
The machine-import API — for CI and coding agents
POST /api/packages/import/machine lets a pipeline or an agent post a .feature directly, skipping
the hand-off to a human uploader. It's off by default: with no token configured, the endpoint
returns an opaque 404 — indistinguishable from not existing at all.
Turning it on. An admin creates a token at /settings/integrations/machine-import (DB-managed,
hashed at rest, listed with a last-used timestamp, revocable without a restart), or an instance can
be bootstrapped with the env var TEASYNAER_MACHINE_IMPORT_TOKEN. Either way, store the token as a
CI secret — never in the repo, never in a log line.
The request:
curl -X POST https://your-teasynaer-host/api/packages/import/machine \
-H "Authorization: Bearer $TEASYNAER_MACHINE_IMPORT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"feature": "Feature: Checkout\n Scenario: Apply discount code\n Given a cart with items\n When I enter code \"SAVE10\"\n Then the total is reduced by 10%\n",
"name": "Checkout — regression",
"sourceRef": "repo@abc1234:tests/checkout.feature",
"idempotencyKey": "ci-run-8421"
}'
| Field | Required | Notes |
|---|---|---|
feature | yes | The raw .feature source, ≤ 512 KB — same parser, same limits as the human upload |
name | no | Deck-name override, 1–255 chars; falls back to the Feature: name |
sourceRef | no | Provenance string, ≤ 512 chars — e.g. repo@sha:path/to.feature — and the upsert key |
idempotencyKey | no | ≤ 128 chars — a retry with the same key is a no-op, not a duplicate |
Unknown fields are rejected outright, not silently dropped, so a typo'd key is a clean 400 instead
of a payload that quietly did less than you asked.
Upsert by sourceRef, dedup by idempotencyKey. First import of a sourceRef creates a deck
(201). Re-importing the same sourceRef replaces that deck's case set in place (200,
outcome: "updated") — it does not pile up a new deck per CI run. A hand-authored deck, or one
created under a different sourceRef, is never touched. If idempotencyKey matches an earlier
import, the endpoint returns the original result and writes nothing (200, outcome: "noop") — safe
for a CI job that retries on a flaky network step.
Limits and posture: request body ≤ 640 KB (checked before the JSON even gets parsed), 60 imports
an hour per instance with a burst allowance of 10 in any rolling minute, plus a separate budget that
locks out repeated wrong tokens before any database lookup happens — so guessing at a token costs
nothing beyond that lockout, and never eats into the real import quota above. Every import attempt
— success or failure — is audited (package.imported), and imported decks are attributed to a
dedicated, non-login service account (machine-import@service.teasynaer.invalid), never to a real
admin's identity.
The CLI. scripts/import-deck.mjs is a dependency-free wrapper around the same endpoint, reading
the token from the environment so it's never typed into a command line a shell history remembers:
export TEASYNAER_URL=https://your-teasynaer-host
export TEASYNAER_MACHINE_IMPORT_TOKEN=tsnmi_your-token-here
./scripts/import-deck.mjs --file checkout.feature \
--name "Checkout — regression" \
--source-ref "repo@$(git rev-parse HEAD):checkout.feature" \
--idempotency-key "ci-${CI_RUN_ID:-local}"
Wiring it into CI on merge, so decks stay current without anyone opening the app:
- name: Sync QA decks into Teasynaer
run: |
for f in qa/*.feature; do
jq -Rs --arg ref "${{ github.repository }}@${{ github.sha }}:$f" \
'{feature: ., sourceRef: $ref}' "$f" \
| curl -fsS -X POST "$TEASYNAER_URL/api/packages/import/machine" \
-H "Authorization: Bearer $TEASYNAER_MACHINE_IMPORT_TOKEN" \
-H "Content-Type: application/json" --data @-
done
env:
TEASYNAER_URL: ${{ vars.TEASYNAER_URL }}
TEASYNAER_MACHINE_IMPORT_TOKEN: ${{ secrets.TEASYNAER_MACHINE_IMPORT_TOKEN }}
A non-2xx fails the job — a malformed .feature blocks the merge instead of quietly landing a broken
or half-imported deck.
Turn a caught fail into a draft case
The fourth path doesn't build a deck — it grows one. When a case fails, its evidence bundle already
holds a recorded interaction trail (navigate/type/click). An admin viewing that fail — from the
On-Fail screen or the fail's card on the Run Summary — gets a "Draft scenario from trail →"
button that turns that trail into a new, editable case (case key REC-XXXXXXXX, suite recorded)
sitting in the run's own deck, ready to open in the Case Editor.
Keep the scope honest: this is fails-only, it's deterministic text mapping, not generation —
the interaction summaries are rendered as-captured, nothing is invented — and it is never
auto-filed or auto-run. It's a draft. A human still has to open the Case Editor, read it, edit it,
and save it before it's a real case anyone runs. If the trail has no captured interactions yet (the
Runner's own tab only logs "case shown" and verdict cues; richer click/type capture needs the
Connect extension linked to the app under test), the draft comes back with a plain placeholder step
telling you to write it by hand — never a fabricated one. Drafting the same fail twice hits the same
REC-… key rather than stacking duplicates.
Imported doesn't mean executed
Say this one plainly, because every path above makes it easy to assume otherwise: none of this runs a step. A machine-imported deck sits exactly as inert as one a human uploaded by hand — someone still has to open it and mark pass, fail, or skip by eye. There is no CI gate that imports a deck and then gates a build on whatever it "found", because nothing runs unattended to find anything.
The other direction — getting a completed run's human-recorded verdicts back onto the PR as a comment and a commit status — is a separate, already-documented recipe in the project repo (`/docs/integrations): a formatter, not an auto-poster, that a CI step calls once a tester has actually finished the deck.
And if you're picturing the ⌖-target chips mentioned on the writing cases page turning imports into an unattended test run: they don't, today or by design. The only thing built against them is a deterministic dry-run plan preview — a coverage estimate, not an execution engine — covered on the agent runs page. Nothing drives a browser against a live target yet, and when something eventually does, it will still hand you a provisional result for a human to confirm, never a verdict of its own.