# Kintsugi QA API Docs version: `33ea3cdcef34` > Kintsugi QA is a browser-based UI test automation platform. This file is the > complete capability spec of the Kintsugi QA public HTTP API for autonomous > AI agents. Given this file plus an API key (`X-API-Key: ksk_<64 hex chars>`), > an agent can — with no other documentation — discover the user's > organization and projects, write new test cases as JSON (single-actor > browser scenarios), run them, poll for results, and download videos, > screenshots, traces, network logs, and console logs. Every endpoint, > request/response field, step type, assertion type, status enum, and > variable placeholder usable via API key is documented below. Endpoints not > usable with API keys are listed explicitly so they are not attempted. ## 1. Authentication & conventions - **Header**: send `X-API-Key: ksk_<64 hex characters>` on every request. `Authorization: Bearer ksk_...` is accepted as an equivalent, for clients that only speak bearer tokens. A query parameter `?api_key=ksk_...` also works, but a header is preferred (it won't leak into logs/URLs). A `401 invalid API key` means the key is wrong or was deleted; API keys never expire on their own. - **Base URL**: `https://kintsugi-qa.com/api/v1`. - **Content-Type**: `application/json` for all JSON request bodies, except `POST /files` which is `multipart/form-data`. - **Org scoping**: an API key belongs to exactly one organization and grants read/write access to every project, test case, suite, run, artifact, secret, file, schedule, and webhook token within that organization. `GET /orgs` returns only the key's own org. `GET /orgs/{id}` works for that org (any other org ID is rejected by the authorization check). - **Pagination**: list endpoints that paginate return `{"items": [...], "count": N, "next_cursor": "...", "has_more": bool}`. Pass `?cursor=` to fetch the next page and `?limit=` to control page size. Some simple list endpoints (projects, secrets, files, schedules, webhook-tokens, orgs) return `{"items": [...], "count": N}` without cursor pagination (all results in one page). ### Error response shapes Most errors: ```json { "error": "human-readable message", "request_id": "abc-123", "docs": "https://kintsugi-qa.com/llms.txt", "docs_version": "" } ``` (`request_id` may be empty/omitted; `code` may also appear for some domain-level errors, e.g. `"code": "NOT_FOUND"`. `docs` and `docs_version` are present on every error — see "Staying current" below.) Structural scenario validation failures use a slightly different shape. Step ids must be unique within each actor for EVERY test case (single- and multi-actor — duplicates corrupt run results and self-healing patches); collaborative scenarios additionally get full barrier validation (see section 4.7): ```json { "error": "barrier \"key-ready\": must have at least 2 participating actors, got 1; ...", "code": "INVALID_SCENARIO" } ``` `error` is a `; `-joined list of every problem found, so a single response can report multiple issues at once. It also carries `docs` and `docs_version`. ### Field limits Six fields are capped, enforced server-side (counted in Unicode characters, not bytes): Names: - project `name` — max 100 characters. A short label; the longer explanation belongs in the description. - suite `name` — max 120 characters. A short label naming what the sequence covers. - test case `name` — max 120 characters. Named for the behaviour it proves, with the detail left to the description. A name the platform generates for you — an AI-generated test case name, or a suite name auto-generated from its test case names — is shortened to fit rather than rejected. Descriptions: - project `description` — max 300 characters. One or two sentences on what the project covers. It appears in the project list, so keep it short. - suite `description` — max 500 characters. One or two sentences on what the sequence of test cases covers. - test case `description` — max 2000 characters. What the test proves, what it assumes as a precondition, and any site quirk a future editor needs to know. **Route notes to the right field — this is the mistake these limits exist to catch.** Notes about how a particular test had to be written belong in that test case's own `description`, not in the project description. If a note concerns the whole site rather than one test (e.g. "this site defers all JavaScript until the first real interaction, so tests hover twice before touching a widget"), repeat the relevant sentence in the description of each test case it affects, rather than writing an essay on the project. Exceeding a limit is rejected with `400` and `code: "VALIDATION_ERROR"`; the error message states the limit and the actual length. Everything else you can write is bounded too. You will not meet these in normal use — they exist so that oversized input is refused cleanly instead of failing later as a storage error: - **Projects** — `slug` 2–63 characters, lowercase letters, digits and hyphens only, starting and ending with a letter or digit; at most 20 `environments` (name 60); at most 30 `custom_headers` (name 128, value 1024); at most 50 `vars` (name 64, value 2048); `user_agent` 512. - **Test cases** — `transition` 500; a scenario may hold at most 300 steps per actor and 1000 in total, and must serialise to under 300 KB; per step, `description` 500, `selector` and `target_selector` 2048, `value` 8192, `url` 2048, at most 10 `alternative_selectors`, `assertion.expected` 4096. A scenario that breaks one of these is reported as `INVALID_SCENARIO` (section 4.7), naming the actor and step, not as a plain length error. If a scenario is too large, split it into smaller test cases linked with `depends_on`, or group them in a suite — which is the better shape anyway (see "Design tests before you write them"). - **Suites** — at most 100 `steps`, each `transition` 500. - **Secrets** — `name` 100, `value` 8192, `description` 300. - **Files** — `name` 200, `description` 300, the uploaded file's own name 255, and the file itself at most 20 MB. - **Schedules** — `name` 100. **Notification channels** — `name` 100, `webhook_url` 2048. **API keys and webhook tokens** — `label` 100. Two rules are about safety rather than size, so they are worth knowing before you meet them: - A `base_url` — the project's own or an environment's — and a notification `webhook_url` must use `http` or `https`. No other scheme is accepted, not even one that otherwise parses correctly. - A notification `webhook_url` must point at a public address. Loopback, private and link-local addresses are refused when the channel is saved, and refused again when the request is actually made, so a hostname that resolves inward does not get through either. ### Staying current The API evolves, and your copy of this file can go stale. Two things to know: - **Every error includes `docs_version`.** If it differs from the `Docs version` on the first lines of your copy, your copy is outdated: re-fetch `https://kintsugi-qa.com/llms.txt` once and retry using the fresh copy. If the two still differ right after re-fetching, the docs and the API are mid-deploy — continue with the file you just fetched. - **Unknown request fields are ignored, not rejected.** A misspelled field, or one this API no longer has, produces no error: the request succeeds without it. If a call succeeds but a setting did not take effect, check each field name against the current version of this file. ### Common status codes - `200 OK` — successful read/update. - `201 Created` — resource created (test case, project, secret, file, schedule, webhook token, suite). - `202 Accepted` — async operation started; response body is the created run object (or, for webhook tokens with a body, also a run object). - `204 No Content` — successful delete. - `400 Bad Request` — invalid JSON, missing required fields, or (for API keys) a collaborative (multi-actor) scenario that fails structural validation on a test-case create/update endpoint (`code: "INVALID_SCENARIO"`, see section 4.7). - `401 Unauthorized` — missing/invalid API key. - `403 Forbidden` — endpoint not available with API-key authentication (see section "Endpoints NOT available with API keys" below), or the key's org lacks access to the requested project/org. Body: `{"error": "this endpoint is not available with API key authentication", "code": "FORBIDDEN"}` for blocked endpoints, or `{"error": "access denied"}` for authorization failures. - `404 Not Found` — resource does not exist or is not visible to this org. - `409 Conflict` — duplicate slug/name on create. - `413 Request Entity Too Large` — file upload exceeds 20 MB. - `500 Internal Server Error` — unexpected server error. ### Endpoints NOT available with API keys (do not call these) - `POST /test-cases/generate`, `POST /test-cases/{id}/regenerate` (AI test generation) - All `/probes` and `/authoring-sessions` routes (interactive browser-driven authoring) - `POST /suites/{id}/probe` (AI-generated suite transitions) - Organization mutations: `POST/PUT/DELETE /orgs`, all `/orgs/{id}/members*` and `/orgs/{id}/invites` routes - API key management: `POST/GET/DELETE /orgs/{id}/api-keys` - `/admin/*`, `/dev/login`, `/auth/*` All requests to these return `403 {"error":"this endpoint is not available with API key authentication","code":"FORBIDDEN"}` (or `401`/redirect for the auth/admin routes). **An agent writes test JSON itself — that is the entire point of this API.** --- ## 2. RECOMMENDED AGENT WORKFLOW Step-by-step, with `curl` examples. Replace `` and IDs with real values. Full request/response shapes for every endpoint used here are in section 7. ### Design tests before you write them Aim for **small tests that each verify one behavior, linked by what they share** — not one large script, and not the same setup copied into many tests. - **One test, one behavior.** Name it for what it proves ("Create an API key with one-year expiry") and end it with assertions that prove it. A test that only clicks through, with nothing asserted, can pass while the feature is broken. - **Write down why the test is shaped the way it is.** If a step exists because of a UI quirk (a hidden animation, a two-click reveal, a retry-prone dropdown), say so in that test's own `description` — next to the test it explains, so the reason survives with it instead of living in a project-level note no one reads again. - **Put shared setup in one test, and depend on it.** Logging in, accepting a cookie banner, reaching the dashboard: write it once as its own test, then set `depends_on` on every test that starts from there (section 4.1) instead of repeating those steps. When that UI changes, one test needs fixing — and self-healing writes a repaired dependency step back to that one ancestor, so every dependent test is fixed with it (section 4.6). Setup copied into ten tests breaks, and has to be repaired, in ten places. - **Know what `depends_on` does and doesn't save.** On a standalone run the ancestors' steps are replayed before the test every time, so it saves maintenance, not run time. To save run time too, put related tests in a **suite** (section 3): it runs them in one browser session, so shared setup runs once and each test continues where the previous one left off — which also means each test inherits the cookies and stored data of the one before it, so a test that needs a clean browser belongs outside the suite. - **Pass data along the chain, not by re-creating it.** Values captured with `extract` are available to later steps in the same run — so a test that depends on the one that created something can use its captured name or ID, whether it runs alone (the ancestor's steps are replayed first) or in a suite. - **Don't split too far, either.** Every test must be meaningful on its own — runnable alone with its `depends_on` chain — and assert something. Keep chains shallow, because when an ancestor fails nothing after it runs. - **Avoid one giant test.** A 60-step test covering sign-up, settings and billing stops at the first failure and says nothing about the rest. Split per feature, so each failure points at one thing. A typical shape: ``` Log in no depends_on — asserts the dashboard is visible ├── Create an API key depends_on: Log in — extracts the key's name │ └── Delete that API key depends_on: Create an API key — uses the extracted name └── Change the language depends_on: Log in Suite "Account": Log in → Create an API key → Delete that API key → Change the language ``` ### 2.1 Discover the organization ```bash curl https://kintsugi-qa.com/api/v1/orgs -H "X-API-Key: " # => {"items":[{"id":"5b3c2a1d-...","name":"Acme Inc","slug":"acme", ...}], "count":1} ``` Save `items[0].id` as ``. ### 2.2 List projects (create one if none exist) ```bash curl "https://kintsugi-qa.com/api/v1/projects?org_id=" -H "X-API-Key: " ``` Save `items[0].id` as ``. If `items` is empty, create a project: ```bash curl -X POST https://kintsugi-qa.com/api/v1/projects \ -H "X-API-Key: " -H "Content-Type: application/json" \ -d '{ "org_id": "", "slug": "marketing-site", "name": "Marketing Site", "description": "Public marketing site", "base_url": "https://staging.example.com", "environments": [ { "name": "staging", "base_url": "https://staging.example.com", "vars": { "API_DOCS_URL": "https://docs-staging.example.com" } }, { "name": "production", "base_url": "https://example.com", "custom_headers": { "X-Env": "prod" }, "vars": { "API_DOCS_URL": "https://docs.example.com" } } ], "vars": { "SUPPORT_EMAIL": "support@example.com" } }' ``` Required: `slug` (2–63 characters, lowercase letters, digits and hyphens, starting and ending with a letter or digit), `name` (max 100 characters — see "Field limits" in section 1), `org_id`, `base_url` (absolute `http(s)` URL with a host — what `{{BASE_URL}}` resolves to unless a run targets an environment). Optional: `description` (max 300 characters — see "Field limits" in section 1), `environments` (array of `{name, base_url, custom_headers?, vars?}` — see below), `custom_headers` (map of header name -> value, sent on every request the test runner makes), `vars` (map of variable name -> value, project-level defaults for `{{ENV:NAME}}` placeholders — see 4.5), `user_agent` (max 512 characters; overrides the browser user agent for every run — see "Browsers, devices and user agent" in 4.2). Response (`201 Created`) is the full project object — see 7.2. **Environment names** are case-insensitive and must be unique within a project; `default` and `custom` are reserved (they mean "no override" and "a literal URL was passed" elsewhere in the API). An empty/duplicate/reserved environment name, or a `base_url` (project- or environment-level) that isn't an absolute `http(s)` URL with a host, is rejected with `400` on both `POST /projects` and `PUT /projects/{id}`; the project's `base_url` is required on create and cannot be cleared on update. Each environment's optional `custom_headers` are merged **over** the project-level `custom_headers` for runs that target that environment — use them for env-specific auth headers, tenant IDs, etc. Setting an environment header to an **empty string removes** the inherited project-level header of the same name for that environment (e.g. a staging-only bypass token defined at project level can be suppressed on `production` with `{"x-bypass-token": ""}` so it is never sent to the production origin). Each environment's optional `vars` follow the identical merge-and-remove rule for `{{ENV:NAME}}` placeholders: the environment's `vars` are merged **over** the project-level `vars` for runs targeting that environment, and an empty string in an environment's `vars` removes the inherited project-level var of the same name for that environment. Var keys (both project-level `vars` and per-environment `vars`) must match `^[A-Za-z_][A-Za-z0-9_]*$`; a key that doesn't is rejected with `400`. See 4.5 for full placeholder resolution semantics, including the setup-time failure when a referenced `{{ENV:NAME}}` has no value from either source. ### 2.3 Create a test case `POST /test-cases` — see section 4 for the full scenario/step JSON schema. Minimal example: ```bash curl -X POST https://kintsugi-qa.com/api/v1/test-cases \ -H "X-API-Key: " -H "Content-Type: application/json" \ -d '{ "project_id": "", "name": "User can log in", "description": "Logs in with valid credentials and lands on the dashboard", "scenario": { "actors": [ { "id": "user", "name": "User", "browser": "chromium", "viewport": { "width": 1280, "height": 800 }, "steps": [ { "id": "step-1", "type": "navigate", "url": "{{BASE_URL}}/login", "description": "Go to login" }, { "id": "step-2", "type": "fill", "selector": "#email", "value": "demo@example.com", "description": "Enter email" }, { "id": "step-3", "type": "fill", "selector": "#password", "value": "{{SECRET_DEMO_PASSWORD}}", "description": "Enter password" }, { "id": "step-4", "type": "click", "selector": "button[type=submit]", "description": "Submit login form" }, { "id": "step-5", "type": "assert", "selector": "[data-testid=dashboard-heading]", "assertion": { "type": "visible" }, "description": "Dashboard heading visible" } ] } ] } }' ``` Response (`201 Created`) is the TestCase object (section 7.3); save `id` as ``. The example above has a single actor, which is the default and recommended shape for most tests. `scenario.actors` MAY also contain **2 or more actors** with `barriers` for collaborative (multi-actor) tests — see section 4.7 for the schema, validation rules, and a full worked example. ### 2.4 Trigger a run ```bash curl -X POST https://kintsugi-qa.com/api/v1/runs \ -H "X-API-Key: " -H "Content-Type: application/json" \ -d '{ "test_case_id": "", "project_id": "" }' ``` Fields: `test_case_id` (required), `project_id` (required), `test_case_version` (optional int — pin to a version, defaults to latest), `environment` (optional — **omit it unless you need to override the base URL**; if provided it must be either a full URL like `https://staging.example.com` or the name of an environment defined in the project's `environments[]`, which is resolved to its `base_url` at trigger time; an unknown name is rejected with 400. When omitted, `{{BASE_URL}}` resolves to the project's `base_url`). The response's `environment_name` field records which of these applied — see 6.2. Response (`202 Accepted`) is a TestRun object (section 6.2) with `status: "pending"`; save `id` as ``. ### 2.5 Wait for completion (prefer long-poll over busy polling) **Preferred — block until the run finishes** with the long-poll endpoint instead of hammering `/status`: ```bash curl https://kintsugi-qa.com/api/v1/runs//wait?timeout=30 -H "X-API-Key: " # Returns as soon as the run reaches a terminal state, or after `timeout` # seconds — whichever comes first. ``` `timeout` is in **seconds**, capped server-side at 50s (the request returns by then regardless). If the returned `status` is still non-terminal, simply call `/wait` again — this is the efficient way to wait on a long run without a tight poll loop. The response is the same slim shape as `/status` below. **Alternative — manual poll** every **3-5 seconds**: ```bash curl https://kintsugi-qa.com/api/v1/runs//status -H "X-API-Key: " # => {"id":"...", "status":"passed", "started_at":"...", "completed_at":"...", # "duration_ms":42000, "pending_reason":"", "error":null} ``` `completed_at`/`duration_ms` are `null` until the run finishes. Stop polling once `status` is one of the **terminal** values: `passed`, `failed`, `cancelled`, `timed_out` (`pending`, `running`, `healing` are non-terminal — see section 6.1). - `pending_reason` (string) — present while `status` is `pending`. Explains *why* the run hasn't started, e.g. `"waiting for 3 workers (0 idle of 1 registered), scaling out additional workers"`. A long pending time with this message means workers are scaling up (collaborative runs need one free worker per actor, all at once) — keep waiting; it is not stuck. - `error` (object|null) — populated once a run has `failed`. For a multi-actor run this names the **root cause** directly: `{"code":"ACTOR_STEP_FAILED","message":"actor \"ana\" failed at step \"a14\": ...","actor_id":"ana","step_id":"a14"}`. Other actors that were waiting at a barrier report `barrier "X" released before all actors arrived: actor "ana" failed at step "a14"` — so you never have to guess which actor brought the run down. For full details (per-step results, per-actor errors, artifact keys), use `GET /runs/` (section 6.2). ### 2.6 List and download artifacts ```bash curl https://kintsugi-qa.com/api/v1/runs//artifacts -H "X-API-Key: " # => {"run_id":"...", "artifacts": ["runs///video-.webm", "runs///video-.vtt", ...]} ``` `artifacts` is a flat array of S3 key strings. Key names include generated timestamps — **never construct artifact keys yourself; always copy the exact key strings from this response.** Download any key two ways: **A — presigned URL** (no auth header needed on the download itself, valid 15 minutes): ```bash curl "https://kintsugi-qa.com/api/v1/artifacts/presign/" -H "X-API-Key: " # => {"url": "https://kintsugi-prod-artifacts-529589820112.s3.us-east-1.amazonaws.com/...?X-Amz-...", "key": ""} curl -o video.webm "https://kintsugi-prod-artifacts-529589820112.s3.us-east-1.amazonaws.com/...&X-Amz-..." ``` **B — authenticated stream** (supports HTTP `Range` for video seeking): ```bash curl "https://kintsugi-qa.com/api/v1/artifacts/" -H "X-API-Key: " -o video.webm ``` ### 2.7 How long runs and artifacts are kept Runs expire on a per-run clock (roughly two weeks for a pass, a month for a failure), **except** that the newest 20 runs of every project are kept indefinitely, together with their artifacts. A project you stop running for months therefore still shows its last 20 runs rather than an empty history. Beyond that window, a run and its artifacts are deleted together once its own clock has lapsed — a run's video is never orphaned from the run, or the other way round. Nothing in the API changes because of this; it only bounds what `GET /runs?project_id=...` can return. ### 2.8 Stuck runs A run whose worker dies without reporting — the process is killed, the container is evicted, the orchestrator restarts mid-run — would otherwise sit in `running` forever. Any run left in a non-terminal state for more than an hour is swept up and marked `failed` with error code `REAPED`, so it reaches a terminal status and stops blocking anything that polls it. That is a backstop for runs nothing is looking after any more, not the normal failure path: an infrastructure failure the orchestrator *can* see is retried instead (section 4.6). --- ## 3. SUITES A suite is an ordered list of test cases executed sequentially **in a single browser session** (one browser context, not restarted between test cases). Run-scoped variables (from `extract` steps) carry over from one test case to the next within the suite run. So does everything else the browser is holding: cookies, `localStorage` and `sessionStorage`. That is the point of a suite — shared setup runs once, and each test continues where the previous one left off — but it also means a test written to assume a fresh browser behaves differently inside one. A test asserting "the list is empty when the page loads" passes on its own and fails in a suite, because the test before it left rows in `localStorage`. This is the opposite of how a Playwright test file behaves, where every test gets a fresh context. When converting an existing suite, that difference is the thing to check first. A test that needs a clean slate either runs standalone, or clears what it depends on in its own first steps. ### Create — `POST /suites` ```json { "project_id": "", "name": "Core checkout flow", "description": "Login, then place an order", "steps": [ { "test_case_id": "" }, { "test_case_id": "", "transition": "User is already logged in; navigate to /products" } ] } ``` - `project_id` (required), `steps` (required array of `{test_case_id, transition?}`, at most 100). - `name` (optional, max 120 characters — see "Field limits" in section 1) is auto-generated from the referenced test case names if omitted; an auto-generated name is shortened to fit. - `description` (optional, max 500 characters) — one or two sentences on what the sequence covers (see "Field limits" in section 1). - `transition` (optional string) describes how to bridge from the previous test case's end state to this one's start state. It is informational only via the API — `transition_steps` are not auto-generated for API-created suites (that requires `POST /suites/{id}/probe`, not available with API keys), but you may hand-author `transition_steps` via `PUT /suites/{id}`. Response (`201 Created`) is the full Suite object (section 7.5). ### Run — `POST /suites/{id}/run` ```bash curl -X POST https://kintsugi-qa.com/api/v1/suites//run \ -H "X-API-Key: " -H "Content-Type: application/json" \ -d '{}' ``` Body is optional (`environment` only — same semantics as on `POST /runs`: omit it, or pass a full URL / a project environment name). Response (`202 Accepted`) is a TestRun object identical in shape to a single-test-case run, but with `suite_id` set and `actor_runs`/`suite_snapshot` reflecting all test cases in the suite. Poll exactly as in 2.5. Suite runs also appear in `GET /runs?project_id=...&suite_id=`. --- ## 4. TEST DEFINITION SCHEMA ### 4.1 Test case create/update body `POST /test-cases` and `PUT /test-cases/{id}`: ```json { "project_id": "", // required (POST only; PUT resolves from URL) "name": "User can log in", // required, max 120 characters "description": "optional text", // optional, max 2000 characters "scenario": { "actors": [ ... ] }, // required — see 4.2 "depends_on": "", // optional, PUT only — prerequisite test case "transition": "description string" // optional, PUT only, max 500 — how to bridge from depends_on's end state } ``` `description` (max 2000 characters — see "Field limits" in section 1) is the right home for authoring notes: what the test proves, any precondition it assumes (e.g. "requires the demo account to already have an active subscription"), and any quirk of the site under test that a future editor needs to know to write a similar test correctly. Keep site-wide notes out of the project `description` — repeat them in every test case's `description` where they're relevant instead. There is no per-test-case base URL field. `{{BASE_URL}}` in step `url`/`value` resolves to the run's environment URL if one was chosen, otherwise the project's `base_url` (required on every project — see 2.2). To point a step at a different site, use an absolute URL in that step; to run the same test against staging vs production, define project `environments` and pass `environment` at trigger time (2.4) instead of duplicating the test case. `PUT /test-cases/{id}` creates a new version of the test case (response `version` increments). `id` in the URL may be a UUID or a slug; if it's a slug, pass `?project_id=` as a query parameter. **Version history and revert.** Every change that bumps `version` — a `PUT`, an AI regeneration, a self-healing patch — first archives the previous state, so `GET /test-cases/{id}/versions` lists what you can go back to (newest first; the payload is a summary, not the full scenario). `POST /test-cases/{id}/revert` with `{"version": n}` restores that version's content as a **new** version on top: history is never rewritten, the current state is archived first, and `id`/`slug`/`project_id` are preserved so URLs and references keep working. Reverting to the current version is rejected. Only the newest 10 archived versions are kept per test case; older ones are pruned as new versions accrue. That is a cap on history, not on the test — the current version is always intact. **Optimistic concurrency (avoid clobbering self-healing patches).** Self-healing bumps a test case's `version` whenever it patches a selector (section 4.6), so a `PUT` based on a copy you fetched earlier can silently overwrite those fixes. To guard against it, send the `version` you based your edit on — either as a body field `"expected_version": ` or the header `If-Match: ` (the body field wins if both are present). If it no longer matches the current version, the update is rejected: ``` 409 Conflict {"error":"version conflict: expected version 3 but current version is 5 — the test case was modified since you fetched it (possibly by self-healing selector patches); re-fetch it, re-apply your changes, and retry","code":"CONFLICT"} ``` On a 409, re-`GET` the test case, re-apply your changes onto the fresh `version`, and retry. Omitting both fields keeps the old last-write-wins behaviour. #### `depends_on` and `transition` (dependency chains) - `depends_on` (settable via **`PUT` only** — `POST /test-cases` does not accept it; create the test case first, then `PUT` to set `depends_on`) is the ID of a prerequisite test case. `transition` is a free-text description of how to bridge from that prerequisite's end state to this test case's start state (e.g. `"User is already logged in; navigate to /settings"`). `transition` is informational/documentation only — it is not turned into executable steps automatically. - **Standalone run** (`POST /runs` with this test case, no suite): the orchestrator walks the `depends_on` chain (grandparent → parent → this test case) and **prepends every ancestor's steps**, in execution order, to actor 0's steps for that run — all in **one browser session**. A `transition` on this test case (or on any ancestor) becomes an executable `navigate` step ONLY if its text contains an http(s) URL (the first URL is extracted and navigated to); otherwise the transition is purely informational and no step is inserted. A cycle in the `depends_on` chain is detected and silently broken (the chain stops at the repeated test case) rather than looping forever or erroring. - **Suite run**: dependency steps are **NOT** prepended. Instead, `POST /suites` and `PUT /suites/{id}` validate that every test case's full `depends_on` chain (including transitive ancestors) appears **earlier** in the suite's `steps` array — if not, the suite create/update is rejected with an error naming the missing prerequisite. Each test case may appear in a suite at most once for the purposes of this check. ### 4.2 Scenario ```json { "actors": [ { "id": "user", "name": "User", "description": "optional", "browser": "chromium", "viewport": { "width": 1280, "height": 800 }, "steps": [ /* ordered Step[] — see 4.4 */ ] } ] } ``` The example above shows the default single-actor shape. `actors` MAY instead contain **2 or more actors**, each with its own `steps`, plus a top-level `barriers` array for synchronization between them — see **section 4.7 (Multi-actor / collaborative tests)** for the full schema, validation rules, and a worked example. Everything in this section and 4.3-4.6 applies equally to each actor in a collaborative scenario. | field | type | required | notes | |---|---|---|---| | `id` | string | yes | stable identifier for the actor | | `name` | string | yes | human-readable name | | `description` | string | no | optional notes | | `browser` | `"chromium"` \| `"firefox"` \| `"webkit"` | no | defaults to `chromium` | | `channel` | `""` \| `"chrome"` \| `"msedge"` | no | real Google Chrome / Microsoft Edge (stable). Only with `browser` `chromium` or empty. Empty = Playwright's bundled Chromium | | `device` | string | no | a device name from `GET /meta/browser-options` (e.g. `"iPhone 15"`). Sets viewport, touch, mobile mode and user agent. Not allowed with `firefox`. Empty = desktop | | `viewport` | `{width: int, height: int}` | no | pixels, width 320-3840, height 320-2160. Default 1280x720. Ignored when `device` is set | | `steps` | Step[] | yes | ordered list of actions, executed top to bottom | #### Browsers, devices and user agent Leave `browser`, `channel`, `device` and `viewport` out unless the test needs them. The defaults are right for most tests. - `chromium` is Playwright's own Chromium build. `firefox` is Playwright's patched Firefox (not the branded one). `webkit` is the engine behind Safari, not Safari itself. `channel` `chrome` / `msedge` runs the real branded stable build instead of the bundled Chromium. - `GET /meta/browser-options` returns the supported values: `{"browsers":["chromium","firefox","webkit"],"channels":["chrome","msedge"],"devices":[{"name":"iPhone 15","engine_hint":"webkit"}, ...]}`. The device list can change, so read it from the endpoint. Use a device name exactly as returned. `engine_hint` is the engine that fits the device best; you can still set `browser` yourself (except `firefox`). - `viewport` is honored when no device is set. A device overrides it. - User agent, in order: the project's `user_agent` (section 7.2) wins; else the device's user agent; else plain `chromium` sends Kintsugi's fixed desktop Chrome user agent (so firewalls do not flag "HeadlessChrome"); `chrome` / `msedge` send their real user agent with the "Headless" marker removed; `firefox` / `webkit` send Playwright's own user agent. - Not supported: beta, dev or canary channels, real Safari, real mobile hardware. ### 4.3 Common step fields Every step is an object with at least `id` and `type`. These fields apply across step types (only the relevant ones are used per type — see 4.4): | field | type | applies to | notes | |---|---|---|---| | `id` | string | all | required, unique within the actor's steps. Used in run results and self-healing patches. | | `type` | string | all | required. One of: `navigate`, `click`, `dblclick`, `fill`, `select`, `hover`, `scroll`, `wait`, `assert`, `screenshot`, `upload`, `download`, `extract`, `press`, `drag`, `barrier_wait`, `barrier_open` (the last two are for collaborative/multi-actor scenarios — see section 4.7). | | `selector` | string | click, dblclick, fill, select, hover, scroll, assert, upload, extract, wait, press (optional), drag | CSS selector for the target element. Prefer stable selectors: `#id`, `[data-testid=...]`, `[name=...]`, `[aria-label=...]` over brittle nth-child/class chains. | | `alternative_selectors` | string[] | click, dblclick, fill, select, hover, scroll, assert, upload, drag, extract | optional fallback selectors tried by self-healing if the primary selector fails (see Self-healing, section 6). | | `value` | string | fill, select, scroll, wait, screenshot, upload, press | meaning depends on step type — see 4.4. For `press`, the key name (e.g. `"Escape"`, `"Control+A"`). Supports `{{...}}` variable placeholders (section 4.5). | | `url` | string | navigate | destination URL. Supports `{{BASE_URL}}` and other placeholders. | | `assertion` | object | assert | `{type, expected?}` — see 4.4 `assert`. | | `position` | `{x, y}` | click, dblclick, drag | optional pixel offset from the target element's top-left corner. Defaults to the element's center. For `drag`, this is the offset within the **source** element. | | `target_selector` | string | drag | destination element selector. Defaults to the source `selector` (same-element drag). | | `target_position` | `{x, y}` | drag | optional pixel offset within the target element. Defaults to the element's center. | | `timeout` | int (ms) | all (esp. navigate, wait, assert, extract) | per-step timeout in milliseconds. **Clamped server-side to 5000-120000 ms**; for `navigate` steps the effective minimum is **30000 ms** (a smaller value is raised to 30000). Values <= 0 or omitted default to 5000ms (or the type's own default — see per-step notes). Always specify timeouts explicitly in **milliseconds** (5000 = 5 seconds); never emit values like `30` meaning "30 seconds" — that becomes 30ms and is clamped up to 5000ms, likely causing the step to fail too fast. | | `description` | string | all | human-readable description shown in run results/UI. Strongly recommended on every step. | | `optional` | bool | all | if `true`, a failure on this step is tolerated and the run continues to the next step (e.g. for cookie-consent banners or one-time modals that may or may not appear). | | `variable` | string | extract | name of the run-scoped variable to store the extracted value in. | | `attribute` | string | extract, assert (with `assertion.type: "attribute"`) | which HTML attribute to read/compare. Empty/omitted means innerText (extract) or is required (assert attribute). | | `regex` | string | extract | regex with exactly one capture group; if set, the **first capture group** of the match against the extracted raw text is stored instead of the raw text. | | `barrier_id` | string | `barrier_wait`, `barrier_open` | required for these step types — references a `Barrier.id` from `scenario.barriers`. Single-actor scenarios omit this entirely. See section 4.7. | ### 4.4 Step types (full reference, one example each) #### navigate Navigates the page to a URL. - `url` (string, required) — supports `{{BASE_URL}}` and other placeholders. - Effective minimum timeout is 30000ms regardless of `timeout` value. ```json { "id": "step-1", "type": "navigate", "url": "{{BASE_URL}}/login", "description": "Go to the login page" } ``` #### click Clicks the element matched by `selector`. Includes built-in handling for custom dropdowns/comboboxes. - `selector` (string, required) - `alternative_selectors` (string[], optional) - `position` (`{x, y}`, optional) — click at this pixel offset from the element's top-left corner instead of its center. ```json { "id": "step-4", "type": "click", "selector": "button[type=submit]", "alternative_selectors": ["#login-submit"], "description": "Submit the login form" } ``` #### dblclick Double-clicks the element matched by `selector`. Takes the same fields as `click`: - `selector` (string, required) - `alternative_selectors` (string[], optional) - `position` (`{x, y}`, optional) — click at this pixel offset from the element's top-left corner instead of its center. Use this instead of two consecutive `click` steps: two `click`s do NOT produce a double-click event, so a UI that opens an inline editor (or similarly reacts) only on `dblclick` stays closed and the next step times out. ```json { "id": "step-4b", "type": "dblclick", "selector": "[data-testid=cell-name]", "description": "Open the inline editor for the name cell" } ``` #### fill Types `value` into the input/textarea matched by `selector` (clears existing content first). - `selector` (string, required) - `value` (string, required) — supports `{{SECRET_*}}`, `{{VAR:*}}`, `{{BASE_URL}}`, `{{FILE_*}}` placeholders. If the resolved value came from a `{{SECRET_*}}` placeholder it is treated as masked: the runner refuses to type it into anything but a `` and never logs it in plaintext (recorded as `"***"` in step results). - `alternative_selectors` (string[], optional) ```json { "id": "step-3", "type": "fill", "selector": "#password", "value": "{{SECRET_DEMO_PASSWORD}}", "description": "Enter password" } ``` #### select Chooses an option in a ``/`