# Test runs

A test run is one `clanker run` of a product's Playwright tests against its deployment in an environment, numbered per workspace. Start a run, upload each test's files, then finish it with every test's first attempt. A retry runs the failed tests again on the same commit, and a failed test that passes every retry attempt counts as flaky.

## The TestRun object

A test run with its retries and every test with its attempts.

### Attributes

- `id` (string, required): Unique ID of the test run.
- `number` (number, required): The run's number, counting up from 1 in each workspace, such as 1289.
- `url` (string, required): The run's page on the website.
- `state` (string, one of: `running`, `finished`, `interrupted`, required): `running` until its publisher finishes it as `finished` or `interrupted`. One still running after 6 hours becomes `interrupted`.
- `result` (nullable string, one of: `passed`, `failed`, required): `passed` when the run has no error and no failed test, otherwise `failed`. Flaky tests count as passed. Null while running and for interrupted runs.
- `counts` (object, required): How many tests have each status.
  - `total` (number, required): Every test in the run.
  - `passed` (number, required): Tests that passed on attempt 1.
  - `flaky` (number, required): Tests that failed on attempt 1 and then passed every attempt of a retry.
  - `failed` (number, required): Tests that are still failed.
  - `skipped` (number, required): Tests that were skipped.
- `passRate` (nullable number, required): (passed + flaky) ÷ (tests − skipped), from 0 to 1. Null while running or when no test counts.
- `firstAttemptPassRate` (nullable number, required): Tests that passed on attempt 1 ÷ (tests − skipped), from 0 to 1. Retries never change it. Null while running or when no test counts.
- `error` (nullable string, required): What failed outside the tests, such as a web server that did not start, or `No tests ran.` A run with an error is failed and cannot be retried.
- `product` (object, required): The product the run tested.
  - `slug` (string, required): The product's slug, such as storefront.
  - `name` (string, required): The product's display name.
- `environment` (object, required): The environment the run tested.
  - `slug` (string, required): The environment's slug, such as staging.
  - `name` (string, required): The environment's display name.
  - `class` (string, required): The environment's class: production or non-production.
- `baseUrl` (string, required): The URL the tests ran against.
- `deploymentBaseUrl` (nullable string, required): The deployment's URL when the run started, or null. It differs from `baseUrl` when `clanker run --base-url` overrode it.
- `deploymentVersion` (nullable string, required): The deployment's version when the run started, or null.
- `git` (object, required): The commit the run tested.
  - `branch` (nullable string, required): The checked-out branch, or null.
  - `sha` (nullable string, required): The full commit SHA, or null outside a git checkout.
  - `dirty` (boolean, required): Whether the checkout had uncommitted changes.
  - `subject` (nullable string, required): The commit's subject line, or null.
- `machine` (object, required): The computer that ran the tests.
  - `hostname` (string, required): The computer's hostname, such as maya-mbp.
  - `platform` (string, required): The operating system as Node.js names it, such as darwin, linux or win32.
- `coverage` (nullable string, one of: `critical`, `acceptance`, `regression`, `exhaustive`, required): The coverage level the run selected, or null when it selected none.
- `cleanup` (nullable object, required): The run's scope and what deleting it reported, or null when the deployment had no Fixture API.
  - `scope` (string, required): The scope's ID: `scp_` and 12 lowercase letters or digits, such as scp_7h2k9q2m4x1a.
  - `expiresAt` (string, required): When the scope expires, as an ISO 8601 timestamp. A later teardown on the deployment deletes what is left.
  - `status` (nullable string, one of: `complete`, `incomplete`, `kept`, required): How the cleanup ended, or null until the run reports it.
  - `deleted` (nullable number, required): How many records the teardown deleted, or null when it did not answer.
  - `remaining` (nullable number, required): How many records are left in the scope, or null when the teardown did not answer.
- `publishedBy` (object, required): Who published a run or a retry: a person, an account API key, or one since removed.
  - When `kind` is `user`
    - `kind` (string, required): A person published it. Always `user`.
    - `name` (string, required): The person's name.
    - `email` (string, required): The person's email address.
  - When `kind` is `account_key`
    - `kind` (string, required): An account API key published it, such as one CI uses. Always `account_key`.
    - `name` (string, required): The account API key's name.
  - When `kind` is `removed`
    - `kind` (string, required): The person or account API key no longer exists. Always `removed`.
- `startedAt` (string, required): When the run started, as an ISO 8601 timestamp.
- `finishedAt` (nullable string, required): When the run finished or was marked interrupted, as an ISO 8601 timestamp, or null while running.
- `durationMs` (number, required): How long the run took, in milliseconds, as its publisher reported. 0 while running.
- `retries` (array of objects, required): The run's retries, oldest first.
  - `number` (number, required): The retry's number within the run, from 1.
  - `state` (string, one of: `running`, `finished`, `interrupted`, required): `running` until its publisher finishes it as `finished` or `interrupted`. One still running after 6 hours becomes `interrupted`.
  - `requiredPasses` (number, required): How many passing attempts a failed test needed in this retry to count as flaky.
  - `testCount` (number, required): How many failed tests it ran again.
  - `flakyCount` (number, required): How many of them it turned flaky.
  - `baseUrl` (string, required): The URL the retry ran against.
  - `gitSha` (nullable string, required): The commit it ran on, which is always the run's commit.
  - `cleanup` (nullable object, required): The retry's own scope and what deleting it reported, or null when the run had no Fixture API.
    - `scope` (string, required): The scope's ID: `scp_` and 12 lowercase letters or digits, such as scp_7h2k9q2m4x1a.
    - `expiresAt` (string, required): When the scope expires, as an ISO 8601 timestamp. A later teardown on the deployment deletes what is left.
    - `status` (nullable string, one of: `complete`, `incomplete`, `kept`, required): How the cleanup ended, or null until the run reports it.
    - `deleted` (nullable number, required): How many records the teardown deleted, or null when it did not answer.
    - `remaining` (nullable number, required): How many records are left in the scope, or null when the teardown did not answer.
  - `publishedBy` (object, required): Who published a run or a retry: a person, an account API key, or one since removed.
    - When `kind` is `user`
      - `kind` (string, required): A person published it. Always `user`.
      - `name` (string, required): The person's name.
      - `email` (string, required): The person's email address.
    - When `kind` is `account_key`
      - `kind` (string, required): An account API key published it, such as one CI uses. Always `account_key`.
      - `name` (string, required): The account API key's name.
    - When `kind` is `removed`
      - `kind` (string, required): The person or account API key no longer exists. Always `removed`.
  - `startedAt` (string, required): When the retry started, as an ISO 8601 timestamp.
  - `finishedAt` (nullable string, required): When the retry finished or was marked interrupted, as an ISO 8601 timestamp, or null while running.
- `tests` (array of objects, required): Every test, failed first, then flaky, passed and skipped, by title.
  - `caseId` (string, required): The case ID: the spec's `@id` tag, or one derived from its file and title, such as checkout-applies-coupon.
  - `title` (string, required): The test's title path as Playwright reports it, such as checkout › applies a coupon.
  - `file` (nullable string, required): The spec file the test is in, such as checkout.spec.ts, or null.
  - `coverage` (nullable string, one of: `critical`, `acceptance`, `regression`, `exhaustive`, required): The case's coverage level, or null when it has none.
  - `testKind` (nullable string, one of: `behavior`, `journey`, required): Whether the case is a `behavior` or a `journey`, or null.
  - `priority` (nullable string, required): The case's priority, such as P1, or null.
  - `status` (string, one of: `failed`, `flaky`, `passed`, `skipped`, required): Attempt 1 decides `passed`, `failed` or `skipped`. A failed test becomes `flaky` when one retry runs it at least `requiredPasses` times and every attempt passes.
  - `durationMs` (number, required): How long its latest attempt took, in milliseconds.
  - `errorMessage` (nullable string, required): The error of its latest failed attempt, or null.
  - `files` (array of strings, required): The case files uploaded to `results/{case}/`: case.md, spec.ts or both.
  - `attempts` (array of objects, required): Every attempt in order: attempt 1 from the run, later ones from retries.
    - `number` (number, required): The attempt's number for its test, from 1. Attempt 1 comes from the run.
    - `retry` (nullable number, required): The number of the retry that ran this attempt, or null for the run itself.
    - `status` (string, one of: `passed`, `failed`, `timed_out`, `interrupted`, `skipped`, required): How the attempt ended, as Playwright reported it.
    - `durationMs` (number, required): How long the attempt took, in milliseconds.
    - `startedAt` (nullable string, required): When the attempt started, as an ISO 8601 timestamp, or null.
    - `errorMessage` (nullable string, required): The error Playwright reported, up to 4,000 characters, or null.
    - `errorStack` (nullable string, required): The error's stack trace, up to 20,000 characters, or null.
    - `files` (array of strings, required): The evidence uploaded to `results/{case}/attempts/{n}/`: video.webm, trace.zip and screenshot-{k}.png.

### Example

```json
{
  "id": "Tr8KcW3nPx5Q",
  "number": 1289,
  "url": "https://clankertest.com/w/acme/test-runs/1289",
  "state": "finished",
  "result": "passed",
  "counts": {
    "total": 2,
    "passed": 1,
    "flaky": 1,
    "failed": 0,
    "skipped": 0
  },
  "passRate": 1,
  "firstAttemptPassRate": 0.5,
  "error": null,
  "product": {
    "slug": "storefront",
    "name": "Storefront"
  },
  "environment": {
    "slug": "staging",
    "name": "Staging",
    "class": "non-production"
  },
  "baseUrl": "https://staging.acme.dev",
  "deploymentBaseUrl": "https://staging.acme.dev",
  "deploymentVersion": "9f2c41e",
  "git": {
    "branch": "web-31-docs-search",
    "sha": "9f2c41e7b8a3d5c6f1e0b2a4d6c8e0f1a3b5c7d9",
    "dirty": false,
    "subject": "Add docs search"
  },
  "machine": {
    "hostname": "maya-mbp",
    "platform": "darwin"
  },
  "coverage": null,
  "cleanup": {
    "scope": "scp_7h2k9q2m4x1a",
    "expiresAt": "2026-10-14T15:40:00.000Z",
    "status": "complete",
    "deleted": 214,
    "remaining": 0
  },
  "publishedBy": {
    "kind": "user",
    "name": "Maya Patel",
    "email": "maya@acme.dev"
  },
  "startedAt": "2026-10-14T09:40:00.000Z",
  "finishedAt": "2026-10-14T09:42:31.000Z",
  "durationMs": 151230,
  "retries": [
    {
      "number": 1,
      "state": "finished",
      "requiredPasses": 2,
      "testCount": 1,
      "flakyCount": 1,
      "baseUrl": "https://staging.acme.dev",
      "gitSha": "9f2c41e7b8a3d5c6f1e0b2a4d6c8e0f1a3b5c7d9",
      "cleanup": {
        "scope": "scp_q8w3e5r7t9y2",
        "expiresAt": "2026-10-14T15:40:00.000Z",
        "status": "complete",
        "deleted": 12,
        "remaining": 0
      },
      "publishedBy": {
        "kind": "user",
        "name": "Maya Patel",
        "email": "maya@acme.dev"
      },
      "startedAt": "2026-10-14T09:44:05.000Z",
      "finishedAt": "2026-10-14T09:44:22.000Z"
    }
  ],
  "tests": [
    {
      "caseId": "checkout-applies-coupon",
      "title": "checkout › applies a coupon",
      "file": "checkout.spec.ts",
      "coverage": "critical",
      "testKind": "behavior",
      "priority": null,
      "status": "flaky",
      "durationMs": 4105,
      "errorMessage": "Timed out 5000ms waiting for expect(locator).toHaveText(\"$45.00\")",
      "files": [
        "case.md",
        "spec.ts"
      ],
      "attempts": [
        {
          "number": 1,
          "retry": null,
          "status": "failed",
          "durationMs": 30412,
          "startedAt": "2026-10-14T09:40:12.000Z",
          "errorMessage": "Timed out 5000ms waiting for expect(locator).toHaveText(\"$45.00\")",
          "errorStack": "Error: Timed out 5000ms waiting for expect(locator).toHaveText(\"$45.00\")\n    at checkout.spec.ts:24:41",
          "files": [
            "video.webm",
            "trace.zip",
            "screenshot-1.png"
          ]
        },
        {
          "number": 2,
          "retry": 1,
          "status": "passed",
          "durationMs": 4210,
          "startedAt": "2026-10-14T09:44:07.000Z",
          "errorMessage": null,
          "errorStack": null,
          "files": []
        },
        {
          "number": 3,
          "retry": 1,
          "status": "passed",
          "durationMs": 4105,
          "startedAt": "2026-10-14T09:44:13.000Z",
          "errorMessage": null,
          "errorStack": null,
          "files": []
        }
      ]
    },
    {
      "caseId": "docs-search-finds-guides",
      "title": "docs › search finds guides",
      "file": "docs.spec.ts",
      "coverage": "acceptance",
      "testKind": "journey",
      "priority": null,
      "status": "passed",
      "durationMs": 6820,
      "errorMessage": null,
      "files": [
        "case.md",
        "spec.ts"
      ],
      "attempts": [
        {
          "number": 1,
          "retry": null,
          "status": "passed",
          "durationMs": 6820,
          "startedAt": "2026-10-14T09:40:12.000Z",
          "errorMessage": null,
          "errorStack": null,
          "files": []
        }
      ]
    }
  ]
}
```

## List test runs

`GET /v1/workspaces/{slug}/test-runs`

Returns the workspace's runs, newest first, 50 per page. Send the response's `nextCursor` as `cursor` for the next page.

### Parameters

- `slug` (string, in path, required): The workspace's slug or ID, such as acme.
- `product` (string, in query): Only runs of this product, by slug.
- `environment` (string, in query): Only runs in this environment, by slug.
- `result` (string, one of: `passed`, `failed`, `flaky`, in query): Only runs that `passed` or `failed`, or `flaky` for runs with at least one flaky test.
- `q` (string, in query): Matches part of the branch or the commit subject, or the start of a commit SHA.
- `commit` (string, in query): Only runs of this full commit SHA.
- `mine` (string, in query): `true` for only the runs you published. Always `true`.
- `cursor` (string, in query): The `nextCursor` of the previous page.

### Returns

Returns an object with these fields. The status is 200.

- `runs` (array of objects, required): Up to 50 runs, newest first.
  - `id` (string, required): Unique ID of the test run.
  - `number` (number, required): The run's number, counting up from 1 in each workspace, such as 1289.
  - `url` (string, required): The run's page on the website.
  - `state` (string, one of: `running`, `finished`, `interrupted`, required): `running` until its publisher finishes it as `finished` or `interrupted`. One still running after 6 hours becomes `interrupted`.
  - `result` (nullable string, one of: `passed`, `failed`, required): `passed` when the run has no error and no failed test, otherwise `failed`. Flaky tests count as passed. Null while running and for interrupted runs.
  - `counts` (object, required): How many tests have each status.
    - `total` (number, required): Every test in the run.
    - `passed` (number, required): Tests that passed on attempt 1.
    - `flaky` (number, required): Tests that failed on attempt 1 and then passed every attempt of a retry.
    - `failed` (number, required): Tests that are still failed.
    - `skipped` (number, required): Tests that were skipped.
  - `passRate` (nullable number, required): (passed + flaky) ÷ (tests − skipped), from 0 to 1. Null while running or when no test counts.
  - `firstAttemptPassRate` (nullable number, required): Tests that passed on attempt 1 ÷ (tests − skipped), from 0 to 1. Retries never change it. Null while running or when no test counts.
  - `error` (nullable string, required): What failed outside the tests, such as a web server that did not start, or `No tests ran.` A run with an error is failed and cannot be retried.
  - `product` (object, required): The product the run tested.
    - `slug` (string, required): The product's slug, such as storefront.
    - `name` (string, required): The product's display name.
  - `environment` (object, required): The environment the run tested.
    - `slug` (string, required): The environment's slug, such as staging.
    - `name` (string, required): The environment's display name.
    - `class` (string, required): The environment's class: production or non-production.
  - `baseUrl` (string, required): The URL the tests ran against.
  - `deploymentBaseUrl` (nullable string, required): The deployment's URL when the run started, or null. It differs from `baseUrl` when `clanker run --base-url` overrode it.
  - `deploymentVersion` (nullable string, required): The deployment's version when the run started, or null.
  - `git` (object, required): The commit the run tested.
    - `branch` (nullable string, required): The checked-out branch, or null.
    - `sha` (nullable string, required): The full commit SHA, or null outside a git checkout.
    - `dirty` (boolean, required): Whether the checkout had uncommitted changes.
    - `subject` (nullable string, required): The commit's subject line, or null.
  - `machine` (object, required): The computer that ran the tests.
    - `hostname` (string, required): The computer's hostname, such as maya-mbp.
    - `platform` (string, required): The operating system as Node.js names it, such as darwin, linux or win32.
  - `coverage` (nullable string, one of: `critical`, `acceptance`, `regression`, `exhaustive`, required): The coverage level the run selected, or null when it selected none.
  - `cleanup` (nullable object, required): The run's scope and what deleting it reported, or null when the deployment had no Fixture API.
    - `scope` (string, required): The scope's ID: `scp_` and 12 lowercase letters or digits, such as scp_7h2k9q2m4x1a.
    - `expiresAt` (string, required): When the scope expires, as an ISO 8601 timestamp. A later teardown on the deployment deletes what is left.
    - `status` (nullable string, one of: `complete`, `incomplete`, `kept`, required): How the cleanup ended, or null until the run reports it.
    - `deleted` (nullable number, required): How many records the teardown deleted, or null when it did not answer.
    - `remaining` (nullable number, required): How many records are left in the scope, or null when the teardown did not answer.
  - `publishedBy` (object, required): Who published a run or a retry: a person, an account API key, or one since removed.
    - When `kind` is `user`
      - `kind` (string, required): A person published it. Always `user`.
      - `name` (string, required): The person's name.
      - `email` (string, required): The person's email address.
    - When `kind` is `account_key`
      - `kind` (string, required): An account API key published it, such as one CI uses. Always `account_key`.
      - `name` (string, required): The account API key's name.
    - When `kind` is `removed`
      - `kind` (string, required): The person or account API key no longer exists. Always `removed`.
  - `startedAt` (string, required): When the run started, as an ISO 8601 timestamp.
  - `finishedAt` (nullable string, required): When the run finished or was marked interrupted, as an ISO 8601 timestamp, or null while running.
  - `durationMs` (number, required): How long the run took, in milliseconds, as its publisher reported. 0 while running.
- `nextCursor` (nullable number, required): The `cursor` for the next page, or null on the last page.

### Request

```bash
curl https://api.qa.clankership.net/v1/workspaces/{slug}/test-runs \
  -H "Authorization: Bearer $CLANKER_API_KEY"
```

### Response

```json
{
  "runs": [
    {
      "id": "Tr8KcW3nPx5Q",
      "number": 1289,
      "url": "https://clankertest.com/w/acme/test-runs/1289",
      "state": "finished",
      "result": "passed",
      "counts": {
        "total": 2,
        "passed": 1,
        "flaky": 1,
        "failed": 0,
        "skipped": 0
      },
      "passRate": 1,
      "firstAttemptPassRate": 0.5,
      "error": null,
      "product": {
        "slug": "storefront",
        "name": "Storefront"
      },
      "environment": {
        "slug": "staging",
        "name": "Staging",
        "class": "non-production"
      },
      "baseUrl": "https://staging.acme.dev",
      "deploymentBaseUrl": "https://staging.acme.dev",
      "deploymentVersion": "9f2c41e",
      "git": {
        "branch": "web-31-docs-search",
        "sha": "9f2c41e7b8a3d5c6f1e0b2a4d6c8e0f1a3b5c7d9",
        "dirty": false,
        "subject": "Add docs search"
      },
      "machine": {
        "hostname": "maya-mbp",
        "platform": "darwin"
      },
      "coverage": null,
      "cleanup": {
        "scope": "scp_7h2k9q2m4x1a",
        "expiresAt": "2026-10-14T15:40:00.000Z",
        "status": "complete",
        "deleted": 214,
        "remaining": 0
      },
      "publishedBy": {
        "kind": "user",
        "name": "Maya Patel",
        "email": "maya@acme.dev"
      },
      "startedAt": "2026-10-14T09:40:00.000Z",
      "finishedAt": "2026-10-14T09:42:31.000Z",
      "durationMs": 151230
    }
  ],
  "nextCursor": null
}
```

### Errors

- `400` `InvalidInput`: The request is malformed, or a value is not allowed.
- `401` `Unauthorized`: The request has no valid API key.
- `403` `Forbidden`: The key's user or role may not do this.
- `404` `NotFound`: The workspace or object does not exist, or the caller cannot see it.
- `409` `Conflict`: The change conflicts with current data, such as a slug that is already in use.
- `500` `Internal`: Clanker Ship failed to handle the request. Retry, or report the response's x-request-id.

## Start a test run

`POST /v1/workspaces/{slug}/test-runs`

Starts a `running` run of a product's deployment in an environment and records the deployment's URL and version. When the deployment has a Fixture API, the run gets a scope and a fixture token to create its test data with. While it runs, its publisher uploads each file as the raw body of `PUT /v1/workspaces/{slug}/test-runs/{run}/files/{path}`, with `Content-Length` and at most 100 MB, deletes the scope, then finishes the run. Viewers cannot publish runs, and an archived product or environment or a missing deployment fails the request.

### Parameters

- `slug` (string, in path, required): The workspace's slug or ID, such as acme.
- `product` (string, required): The product to test, by slug, such as storefront.
- `environment` (string, required): The environment it is deployed to, by slug, such as staging.
- `baseUrl` (string, required): The URL the tests run against, usually the deployment's `baseUrl`.
- `fixtureUrl` (nullable string): Use this Fixture API instead of the deployment's, such as a local server on another port. The fixture token is only valid there.
- `git` (object, required): The commit being tested.
  - `branch` (nullable string, required): The checked-out branch, or null.
  - `sha` (nullable string, required): The full commit SHA, or null outside a git checkout.
  - `dirty` (boolean, required): Whether the checkout had uncommitted changes.
  - `subject` (nullable string, required): The commit's subject line, or null.
- `machine` (object, required): The computer running the tests.
  - `hostname` (string, required): The computer's hostname, such as maya-mbp.
  - `platform` (string, required): The operating system as Node.js names it, such as darwin, linux or win32.
- `selection` (object, required): Which tests the run selects.
  - `coverage` (nullable string, one of: `critical`, `acceptance`, `regression`, `exhaustive`, required): The coverage level selected, or null for none.
  - `folders` (array of strings, required): The spec folders the run is limited to, or empty for all.
  - `caseIds` (array of strings, required): The case IDs the run is limited to, or empty for all.

### Returns

Returns an object with these fields. The status is 201.

- `number` (number, required): The run's number in the workspace. Later calls name the run by it.
- `url` (string, required): The run's page on the website.
- `fixture` (nullable object, required): The run's scope and fixture token, or null when the deployment has no Fixture API. See [the FixtureGrant object](https://docs.qa.clankership.net/api/fixture-tokens/object.md).
  - `scope` (string, required): The scope's ID: `scp_` and 12 lowercase letters or digits, such as scp_7h2k9q2m4x1a.
  - `token` (string, required): The fixture token: an EdDSA JWT that only `url` accepts, for this scope only. Send it as `Authorization: Bearer …` to the Fixture API.
  - `url` (string, required): The Fixture API to call.
  - `expiresAt` (string, required): When the token and the scope expire, as an ISO 8601 timestamp.
- `semaphores` (object, required): Where a run or retry takes semaphore leases, and the run token it takes them with.
  - `url` (string, required): Where lease routes start: `{url}/{name}/leases`.
  - `token` (string, required): The run token, which holds leases for this run or retry. Only Clanker Ship accepts it.
  - `expiresAt` (string, required): When the run token expires, six hours after it was issued.

### Request

```bash
curl -X POST https://api.qa.clankership.net/v1/workspaces/{slug}/test-runs \
  -H "Authorization: Bearer $CLANKER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "product": "storefront",
    "environment": "staging",
    "baseUrl": "https://staging.acme.dev",
    "git": {
      "branch": "web-31-docs-search",
      "sha": "9f2c41e7b8a3d5c6f1e0b2a4d6c8e0f1a3b5c7d9",
      "dirty": false,
      "subject": "Add docs search"
    },
    "machine": {
      "hostname": "maya-mbp",
      "platform": "darwin"
    },
    "selection": {
      "coverage": null,
      "folders": [],
      "caseIds": []
    }
  }'
```

### Response

```json
{
  "number": 1289,
  "url": "https://clankertest.com/w/acme/test-runs/1289",
  "fixture": {
    "scope": "scp_7h2k9q2m4x1a",
    "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImZ4XzEifQ.eyJzY29wZSI6InNjcF83aDJrOXEybTR4MWEifQ.c2lnbmF0dXJl",
    "url": "https://staging.acme.dev/fixtures",
    "expiresAt": "2026-10-14T15:40:00.000Z"
  },
  "semaphores": {
    "url": "https://api.clankertest.com/v1/workspaces/Wk7Qm2RtXa9L/semaphores",
    "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImZ4XzEifQ.eyJraW5kIjoicnVuIiwicnVuIjoxMjg5fQ.c2lnbmF0dXJl",
    "expiresAt": "2026-10-14T15:40:00.000Z"
  }
}
```

### Errors

- `400` `InvalidInput`: The request is malformed, or a value is not allowed.
- `401` `Unauthorized`: The request has no valid API key.
- `403` `Forbidden`: The key's user or role may not do this.
- `404` `NotFound`: The workspace or object does not exist, or the caller cannot see it.
- `409` `Conflict`: The change conflicts with current data, such as a slug that is already in use.
- `500` `Internal`: Clanker Ship failed to handle the request. Retry, or report the response's x-request-id.

## Get a test run

`GET /v1/workspaces/{slug}/test-runs/{run}`

Returns one run with its retries and every test with its attempts. Download a stored file with `GET /v1/workspaces/{slug}/test-runs/{run}/files/{path}`, which accepts a `Range` header.

### Parameters

- `slug` (string, in path, required): The workspace's slug or ID, such as acme.
- `run` (integer, in path, required): The run's number in the workspace, such as 1289.

### Returns

Returns the [TestRun](https://docs.qa.clankership.net/api/test-runs/object.md) object. The status is 200.

### Request

```bash
curl https://api.qa.clankership.net/v1/workspaces/{slug}/test-runs/{run} \
  -H "Authorization: Bearer $CLANKER_API_KEY"
```

### Response

```json
{
  "id": "Tr8KcW3nPx5Q",
  "number": 1289,
  "url": "https://clankertest.com/w/acme/test-runs/1289",
  "state": "finished",
  "result": "passed",
  "counts": {
    "total": 2,
    "passed": 1,
    "flaky": 1,
    "failed": 0,
    "skipped": 0
  },
  "passRate": 1,
  "firstAttemptPassRate": 0.5,
  "error": null,
  "product": {
    "slug": "storefront",
    "name": "Storefront"
  },
  "environment": {
    "slug": "staging",
    "name": "Staging",
    "class": "non-production"
  },
  "baseUrl": "https://staging.acme.dev",
  "deploymentBaseUrl": "https://staging.acme.dev",
  "deploymentVersion": "9f2c41e",
  "git": {
    "branch": "web-31-docs-search",
    "sha": "9f2c41e7b8a3d5c6f1e0b2a4d6c8e0f1a3b5c7d9",
    "dirty": false,
    "subject": "Add docs search"
  },
  "machine": {
    "hostname": "maya-mbp",
    "platform": "darwin"
  },
  "coverage": null,
  "cleanup": {
    "scope": "scp_7h2k9q2m4x1a",
    "expiresAt": "2026-10-14T15:40:00.000Z",
    "status": "complete",
    "deleted": 214,
    "remaining": 0
  },
  "publishedBy": {
    "kind": "user",
    "name": "Maya Patel",
    "email": "maya@acme.dev"
  },
  "startedAt": "2026-10-14T09:40:00.000Z",
  "finishedAt": "2026-10-14T09:42:31.000Z",
  "durationMs": 151230,
  "retries": [
    {
      "number": 1,
      "state": "finished",
      "requiredPasses": 2,
      "testCount": 1,
      "flakyCount": 1,
      "baseUrl": "https://staging.acme.dev",
      "gitSha": "9f2c41e7b8a3d5c6f1e0b2a4d6c8e0f1a3b5c7d9",
      "cleanup": {
        "scope": "scp_q8w3e5r7t9y2",
        "expiresAt": "2026-10-14T15:40:00.000Z",
        "status": "complete",
        "deleted": 12,
        "remaining": 0
      },
      "publishedBy": {
        "kind": "user",
        "name": "Maya Patel",
        "email": "maya@acme.dev"
      },
      "startedAt": "2026-10-14T09:44:05.000Z",
      "finishedAt": "2026-10-14T09:44:22.000Z"
    }
  ],
  "tests": [
    {
      "caseId": "checkout-applies-coupon",
      "title": "checkout › applies a coupon",
      "file": "checkout.spec.ts",
      "coverage": "critical",
      "testKind": "behavior",
      "priority": null,
      "status": "flaky",
      "durationMs": 4105,
      "errorMessage": "Timed out 5000ms waiting for expect(locator).toHaveText(\"$45.00\")",
      "files": [
        "case.md",
        "spec.ts"
      ],
      "attempts": [
        {
          "number": 1,
          "retry": null,
          "status": "failed",
          "durationMs": 30412,
          "startedAt": "2026-10-14T09:40:12.000Z",
          "errorMessage": "Timed out 5000ms waiting for expect(locator).toHaveText(\"$45.00\")",
          "errorStack": "Error: Timed out 5000ms waiting for expect(locator).toHaveText(\"$45.00\")\n    at checkout.spec.ts:24:41",
          "files": [
            "video.webm",
            "trace.zip",
            "screenshot-1.png"
          ]
        },
        {
          "number": 2,
          "retry": 1,
          "status": "passed",
          "durationMs": 4210,
          "startedAt": "2026-10-14T09:44:07.000Z",
          "errorMessage": null,
          "errorStack": null,
          "files": []
        },
        {
          "number": 3,
          "retry": 1,
          "status": "passed",
          "durationMs": 4105,
          "startedAt": "2026-10-14T09:44:13.000Z",
          "errorMessage": null,
          "errorStack": null,
          "files": []
        }
      ]
    },
    {
      "caseId": "docs-search-finds-guides",
      "title": "docs › search finds guides",
      "file": "docs.spec.ts",
      "coverage": "acceptance",
      "testKind": "journey",
      "priority": null,
      "status": "passed",
      "durationMs": 6820,
      "errorMessage": null,
      "files": [
        "case.md",
        "spec.ts"
      ],
      "attempts": [
        {
          "number": 1,
          "retry": null,
          "status": "passed",
          "durationMs": 6820,
          "startedAt": "2026-10-14T09:40:12.000Z",
          "errorMessage": null,
          "errorStack": null,
          "files": []
        }
      ]
    }
  ]
}
```

### Errors

- `400` `InvalidInput`: The request is malformed, or a value is not allowed.
- `401` `Unauthorized`: The request has no valid API key.
- `403` `Forbidden`: The key's user or role may not do this.
- `404` `NotFound`: The workspace or object does not exist, or the caller cannot see it.
- `409` `Conflict`: The change conflicts with current data, such as a slug that is already in use.
- `500` `Internal`: Clanker Ship failed to handle the request. Retry, or report the response's x-request-id.

## Finish a test run

`POST /v1/workspaces/{slug}/test-runs/{run}/finish`

Records every test with its first attempt, counts the tests and closes the run as `finished`, or `interrupted` when `interrupted` is true. Only the run's publisher can finish it, and only while it is running. A finished run with no tests gets the error `No tests ran.` and fails.

### Parameters

- `slug` (string, in path, required): The workspace's slug or ID, such as acme.
- `run` (integer, in path, required): The run's number in the workspace, such as 1289.
- `interrupted` (boolean, required): Whether the run stopped early, such as on Ctrl-C. An interrupted run keeps its tests but gets no result.
- `error` (nullable string): What Playwright reported outside the tests, such as a web server that did not start. It fails the run. Ignored when `interrupted` is true.
- `durationMs` (number, required): How long the run took, in milliseconds.
- `cleanup` (nullable object): What deleting the run's scope reported. Required for a run with a scope; a scope with no report counts as incomplete.
  - `status` (string, one of: `complete`, `incomplete`, `kept`, required): `complete` when nothing is left, `incomplete` when records are left or the teardown did not answer, `kept` when the publisher kept the data to debug a non-production environment.
  - `deleted` (nullable number, required): How many records the teardown deleted, or null when it did not answer.
  - `remaining` (nullable number, required): How many records are left in the scope, or null when the teardown did not answer.
  - `error` (nullable string, required): Why the teardown could not finish, or null.
- `tests` (array of objects, required): Every test the run ran, up to 5,000, each with a unique `caseId`.
  - `caseId` (string, required): The case ID: the spec's `@id` tag, or one derived from its file and title, such as checkout-applies-coupon.
  - `title` (string, required): The test's title path as Playwright reports it, such as checkout › applies a coupon.
  - `file` (nullable string, required): The spec file the test is in, such as checkout.spec.ts, or null.
  - `coverage` (nullable string, one of: `critical`, `acceptance`, `regression`, `exhaustive`, required): The case's coverage level, or null when it has none.
  - `testKind` (nullable string, one of: `behavior`, `journey`, required): Whether the case is a `behavior` or a `journey`, or null.
  - `priority` (nullable string, required): The case's priority, such as P1, or null.
  - `files` (array of strings, required): The case files uploaded to `results/{case}/`: case.md, spec.ts or both.
  - `attempt` (object, required): The test's attempt 1.
    - `status` (string, one of: `passed`, `failed`, `timed_out`, `interrupted`, `skipped`, required): How the attempt ended, as Playwright reported it.
    - `durationMs` (number, required): How long the attempt took, in whole milliseconds.
    - `startedAt` (nullable string, required): When the attempt started, as an ISO 8601 timestamp, or null.
    - `errorMessage` (nullable string, required): The error Playwright reported, or null. Only the first 4,000 characters are kept.
    - `errorStack` (nullable string, required): The error's stack trace, or null. Only the first 20,000 characters are kept.
    - `files` (array of strings, required): The evidence uploaded to `results/{case}/attempts/{n}/`: video.webm, trace.zip and screenshot-{k}.png.

### Returns

Returns an object with these fields. The status is 200. A test run without its retries and tests: what it tested, its state, result and counts.

- `id` (string, required): Unique ID of the test run.
- `number` (number, required): The run's number, counting up from 1 in each workspace, such as 1289.
- `url` (string, required): The run's page on the website.
- `state` (string, one of: `running`, `finished`, `interrupted`, required): `running` until its publisher finishes it as `finished` or `interrupted`. One still running after 6 hours becomes `interrupted`.
- `result` (nullable string, one of: `passed`, `failed`, required): `passed` when the run has no error and no failed test, otherwise `failed`. Flaky tests count as passed. Null while running and for interrupted runs.
- `counts` (object, required): How many tests have each status.
  - `total` (number, required): Every test in the run.
  - `passed` (number, required): Tests that passed on attempt 1.
  - `flaky` (number, required): Tests that failed on attempt 1 and then passed every attempt of a retry.
  - `failed` (number, required): Tests that are still failed.
  - `skipped` (number, required): Tests that were skipped.
- `passRate` (nullable number, required): (passed + flaky) ÷ (tests − skipped), from 0 to 1. Null while running or when no test counts.
- `firstAttemptPassRate` (nullable number, required): Tests that passed on attempt 1 ÷ (tests − skipped), from 0 to 1. Retries never change it. Null while running or when no test counts.
- `error` (nullable string, required): What failed outside the tests, such as a web server that did not start, or `No tests ran.` A run with an error is failed and cannot be retried.
- `product` (object, required): The product the run tested.
  - `slug` (string, required): The product's slug, such as storefront.
  - `name` (string, required): The product's display name.
- `environment` (object, required): The environment the run tested.
  - `slug` (string, required): The environment's slug, such as staging.
  - `name` (string, required): The environment's display name.
  - `class` (string, required): The environment's class: production or non-production.
- `baseUrl` (string, required): The URL the tests ran against.
- `deploymentBaseUrl` (nullable string, required): The deployment's URL when the run started, or null. It differs from `baseUrl` when `clanker run --base-url` overrode it.
- `deploymentVersion` (nullable string, required): The deployment's version when the run started, or null.
- `git` (object, required): The commit the run tested.
  - `branch` (nullable string, required): The checked-out branch, or null.
  - `sha` (nullable string, required): The full commit SHA, or null outside a git checkout.
  - `dirty` (boolean, required): Whether the checkout had uncommitted changes.
  - `subject` (nullable string, required): The commit's subject line, or null.
- `machine` (object, required): The computer that ran the tests.
  - `hostname` (string, required): The computer's hostname, such as maya-mbp.
  - `platform` (string, required): The operating system as Node.js names it, such as darwin, linux or win32.
- `coverage` (nullable string, one of: `critical`, `acceptance`, `regression`, `exhaustive`, required): The coverage level the run selected, or null when it selected none.
- `cleanup` (nullable object, required): The run's scope and what deleting it reported, or null when the deployment had no Fixture API.
  - `scope` (string, required): The scope's ID: `scp_` and 12 lowercase letters or digits, such as scp_7h2k9q2m4x1a.
  - `expiresAt` (string, required): When the scope expires, as an ISO 8601 timestamp. A later teardown on the deployment deletes what is left.
  - `status` (nullable string, one of: `complete`, `incomplete`, `kept`, required): How the cleanup ended, or null until the run reports it.
  - `deleted` (nullable number, required): How many records the teardown deleted, or null when it did not answer.
  - `remaining` (nullable number, required): How many records are left in the scope, or null when the teardown did not answer.
- `publishedBy` (object, required): Who published a run or a retry: a person, an account API key, or one since removed.
  - When `kind` is `user`
    - `kind` (string, required): A person published it. Always `user`.
    - `name` (string, required): The person's name.
    - `email` (string, required): The person's email address.
  - When `kind` is `account_key`
    - `kind` (string, required): An account API key published it, such as one CI uses. Always `account_key`.
    - `name` (string, required): The account API key's name.
  - When `kind` is `removed`
    - `kind` (string, required): The person or account API key no longer exists. Always `removed`.
- `startedAt` (string, required): When the run started, as an ISO 8601 timestamp.
- `finishedAt` (nullable string, required): When the run finished or was marked interrupted, as an ISO 8601 timestamp, or null while running.
- `durationMs` (number, required): How long the run took, in milliseconds, as its publisher reported. 0 while running.

### Request

```bash
curl -X POST https://api.qa.clankership.net/v1/workspaces/{slug}/test-runs/{run}/finish \
  -H "Authorization: Bearer $CLANKER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "interrupted": false,
    "error": null,
    "durationMs": 151230,
    "cleanup": {
      "status": "complete",
      "deleted": 214,
      "remaining": 0,
      "error": null
    },
    "tests": [
      {
        "caseId": "checkout-applies-coupon",
        "title": "checkout › applies a coupon",
        "file": "checkout.spec.ts",
        "coverage": "critical",
        "testKind": "behavior",
        "priority": null,
        "files": [
          "case.md",
          "spec.ts"
        ],
        "attempt": {
          "status": "failed",
          "durationMs": 30412,
          "startedAt": "2026-10-14T09:40:12.000Z",
          "errorMessage": "Timed out 5000ms waiting for expect(locator).toHaveText(\"$45.00\")",
          "errorStack": "Error: Timed out 5000ms waiting for expect(locator).toHaveText(\"$45.00\")\n    at checkout.spec.ts:24:41",
          "files": [
            "video.webm",
            "trace.zip",
            "screenshot-1.png"
          ]
        }
      },
      {
        "caseId": "docs-search-finds-guides",
        "title": "docs › search finds guides",
        "file": "docs.spec.ts",
        "coverage": "acceptance",
        "testKind": "journey",
        "priority": null,
        "files": [
          "case.md",
          "spec.ts"
        ],
        "attempt": {
          "status": "passed",
          "durationMs": 6820,
          "startedAt": "2026-10-14T09:40:12.000Z",
          "errorMessage": null,
          "errorStack": null,
          "files": []
        }
      }
    ]
  }'
```

### Response

```json
{
  "id": "Tr8KcW3nPx5Q",
  "number": 1289,
  "url": "https://clankertest.com/w/acme/test-runs/1289",
  "state": "finished",
  "result": "passed",
  "counts": {
    "total": 2,
    "passed": 1,
    "flaky": 1,
    "failed": 0,
    "skipped": 0
  },
  "passRate": 1,
  "firstAttemptPassRate": 0.5,
  "error": null,
  "product": {
    "slug": "storefront",
    "name": "Storefront"
  },
  "environment": {
    "slug": "staging",
    "name": "Staging",
    "class": "non-production"
  },
  "baseUrl": "https://staging.acme.dev",
  "deploymentBaseUrl": "https://staging.acme.dev",
  "deploymentVersion": "9f2c41e",
  "git": {
    "branch": "web-31-docs-search",
    "sha": "9f2c41e7b8a3d5c6f1e0b2a4d6c8e0f1a3b5c7d9",
    "dirty": false,
    "subject": "Add docs search"
  },
  "machine": {
    "hostname": "maya-mbp",
    "platform": "darwin"
  },
  "coverage": null,
  "cleanup": {
    "scope": "scp_7h2k9q2m4x1a",
    "expiresAt": "2026-10-14T15:40:00.000Z",
    "status": "complete",
    "deleted": 214,
    "remaining": 0
  },
  "publishedBy": {
    "kind": "user",
    "name": "Maya Patel",
    "email": "maya@acme.dev"
  },
  "startedAt": "2026-10-14T09:40:00.000Z",
  "finishedAt": "2026-10-14T09:42:31.000Z",
  "durationMs": 151230
}
```

### Errors

- `400` `InvalidInput`: The request is malformed, or a value is not allowed.
- `401` `Unauthorized`: The request has no valid API key.
- `403` `Forbidden`: The key's user or role may not do this.
- `404` `NotFound`: The workspace or object does not exist, or the caller cannot see it.
- `409` `Conflict`: The change conflicts with current data, such as a slug that is already in use.
- `500` `Internal`: Clanker Ship failed to handle the request. Retry, or report the response's x-request-id.

## Start a retry

`POST /v1/workspaces/{slug}/test-runs/{run}/retries`

Starts a retry of a finished run's failed tests and returns them with the URL to test. The run must have no error, `git.sha` must match its commit and only one retry runs at a time, or the request fails with Conflict; viewers cannot retry runs. Its publisher uploads the retry's files like the run's, then finishes the retry.

### Parameters

- `slug` (string, in path, required): The workspace's slug or ID, such as acme.
- `run` (integer, in path, required): The run's number in the workspace, such as 1289.
- `passes` (number, required): How many passing attempts a failed test needs to count as flaky, 1 to 5.
- `baseUrl` (nullable string, required): The URL to test instead of the run's, or null for the run's.
- `fixtureUrl` (nullable string): Use this Fixture API instead of the deployment's, such as a local server on another port. The fixture token is only valid there.
- `git` (object, required): The commit being tested. `sha` must be the commit the run tested.
  - `branch` (nullable string, required): The checked-out branch, or null.
  - `sha` (nullable string, required): The full commit SHA, or null outside a git checkout.
  - `dirty` (boolean, required): Whether the checkout had uncommitted changes.
  - `subject` (nullable string, required): The commit's subject line, or null.
- `machine` (object, required): The computer running the tests.
  - `hostname` (string, required): The computer's hostname, such as maya-mbp.
  - `platform` (string, required): The operating system as Node.js names it, such as darwin, linux or win32.

### Returns

Returns an object with these fields. The status is 201.

- `retry` (number, required): The retry's number within the run.
- `requiredPasses` (number, required): How many passing attempts a failed test needs to count as flaky.
- `baseUrl` (string, required): The URL to test.
- `tests` (array of objects, required): The run's failed tests, to run again.
  - `caseId` (string, required): The case ID: the spec's `@id` tag, or one derived from its file and title, such as checkout-applies-coupon.
  - `title` (string, required): The test's title path as Playwright reports it, such as checkout › applies a coupon.
  - `file` (nullable string, required): The spec file the test is in, such as checkout.spec.ts, or null.
  - `nextAttempt` (number, required): The number of the test's first attempt in this retry. Upload each attempt's files under its number.
- `environment` (object, required): The environment the run tested.
  - `slug` (string, required): The environment's slug, such as staging.
  - `class` (string, one of: `production`, `non-production`, required): Whether the environment is production.
- `fixture` (nullable object, required): A new scope and fixture token for the retry, or null when the run had no Fixture API. See [the FixtureGrant object](https://docs.qa.clankership.net/api/fixture-tokens/object.md).
  - `scope` (string, required): The scope's ID: `scp_` and 12 lowercase letters or digits, such as scp_7h2k9q2m4x1a.
  - `token` (string, required): The fixture token: an EdDSA JWT that only `url` accepts, for this scope only. Send it as `Authorization: Bearer …` to the Fixture API.
  - `url` (string, required): The Fixture API to call.
  - `expiresAt` (string, required): When the token and the scope expire, as an ISO 8601 timestamp.
- `semaphores` (object, required): Where a run or retry takes semaphore leases, and the run token it takes them with.
  - `url` (string, required): Where lease routes start: `{url}/{name}/leases`.
  - `token` (string, required): The run token, which holds leases for this run or retry. Only Clanker Ship accepts it.
  - `expiresAt` (string, required): When the run token expires, six hours after it was issued.

### Request

```bash
curl -X POST https://api.qa.clankership.net/v1/workspaces/{slug}/test-runs/{run}/retries \
  -H "Authorization: Bearer $CLANKER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "passes": 2,
    "baseUrl": null,
    "git": {
      "branch": "web-31-docs-search",
      "sha": "9f2c41e7b8a3d5c6f1e0b2a4d6c8e0f1a3b5c7d9",
      "dirty": false,
      "subject": "Add docs search"
    },
    "machine": {
      "hostname": "maya-mbp",
      "platform": "darwin"
    }
  }'
```

### Response

```json
{
  "retry": 1,
  "requiredPasses": 2,
  "baseUrl": "https://staging.acme.dev",
  "tests": [
    {
      "caseId": "checkout-applies-coupon",
      "title": "checkout › applies a coupon",
      "file": "checkout.spec.ts",
      "nextAttempt": 2
    }
  ],
  "environment": {
    "slug": "staging",
    "class": "non-production"
  },
  "fixture": {
    "scope": "scp_7h2k9q2m4x1a",
    "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImZ4XzEifQ.eyJzY29wZSI6InNjcF83aDJrOXEybTR4MWEifQ.c2lnbmF0dXJl",
    "url": "https://staging.acme.dev/fixtures",
    "expiresAt": "2026-10-14T15:40:00.000Z"
  },
  "semaphores": {
    "url": "https://api.clankertest.com/v1/workspaces/Wk7Qm2RtXa9L/semaphores",
    "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImZ4XzEifQ.eyJraW5kIjoicnVuIiwicnVuIjoxMjg5fQ.c2lnbmF0dXJl",
    "expiresAt": "2026-10-14T15:40:00.000Z"
  }
}
```

### Errors

- `400` `InvalidInput`: The request is malformed, or a value is not allowed.
- `401` `Unauthorized`: The request has no valid API key.
- `403` `Forbidden`: The key's user or role may not do this.
- `404` `NotFound`: The workspace or object does not exist, or the caller cannot see it.
- `409` `Conflict`: The change conflicts with current data, such as a slug that is already in use.
- `500` `Internal`: Clanker Ship failed to handle the request. Retry, or report the response's x-request-id.

## Finish a retry

`POST /v1/workspaces/{slug}/test-runs/{run}/retries/{retry}/finish`

Adds the retry's attempts to the run and recounts it. A failed test that ran at least `requiredPasses` times and passed every time becomes `flaky`. Only the retry's publisher can finish it, and only while it is running.

### Parameters

- `slug` (string, in path, required): The workspace's slug or ID, such as acme.
- `run` (integer, in path, required): The run's number in the workspace, such as 1289.
- `retry` (integer, in path, required): The retry's number within the run, such as 1.
- `interrupted` (boolean, required): Whether the retry stopped early. The attempts sent still count.
- `cleanup` (nullable object): What deleting the retry's scope reported. Required for a retry with a scope.
  - `status` (string, one of: `complete`, `incomplete`, `kept`, required): `complete` when nothing is left, `incomplete` when records are left or the teardown did not answer, `kept` when the publisher kept the data to debug a non-production environment.
  - `deleted` (nullable number, required): How many records the teardown deleted, or null when it did not answer.
  - `remaining` (nullable number, required): How many records are left in the scope, or null when the teardown did not answer.
  - `error` (nullable string, required): Why the teardown could not finish, or null.
- `tests` (array of objects, required): The tests this retry ran.
  - `caseId` (string, required): The case ID of one of the run's failed tests.
  - `attempts` (array of objects, required): This retry's attempts of the test, in order. They are numbered from the test's `nextAttempt`.
    - `status` (string, one of: `passed`, `failed`, `timed_out`, `interrupted`, `skipped`, required): How the attempt ended, as Playwright reported it.
    - `durationMs` (number, required): How long the attempt took, in whole milliseconds.
    - `startedAt` (nullable string, required): When the attempt started, as an ISO 8601 timestamp, or null.
    - `errorMessage` (nullable string, required): The error Playwright reported, or null. Only the first 4,000 characters are kept.
    - `errorStack` (nullable string, required): The error's stack trace, or null. Only the first 20,000 characters are kept.
    - `files` (array of strings, required): The evidence uploaded to `results/{case}/attempts/{n}/`: video.webm, trace.zip and screenshot-{k}.png.

### Returns

Returns an object with these fields. The status is 200. A test run without its retries and tests: what it tested, its state, result and counts.

- `id` (string, required): Unique ID of the test run.
- `number` (number, required): The run's number, counting up from 1 in each workspace, such as 1289.
- `url` (string, required): The run's page on the website.
- `state` (string, one of: `running`, `finished`, `interrupted`, required): `running` until its publisher finishes it as `finished` or `interrupted`. One still running after 6 hours becomes `interrupted`.
- `result` (nullable string, one of: `passed`, `failed`, required): `passed` when the run has no error and no failed test, otherwise `failed`. Flaky tests count as passed. Null while running and for interrupted runs.
- `counts` (object, required): How many tests have each status.
  - `total` (number, required): Every test in the run.
  - `passed` (number, required): Tests that passed on attempt 1.
  - `flaky` (number, required): Tests that failed on attempt 1 and then passed every attempt of a retry.
  - `failed` (number, required): Tests that are still failed.
  - `skipped` (number, required): Tests that were skipped.
- `passRate` (nullable number, required): (passed + flaky) ÷ (tests − skipped), from 0 to 1. Null while running or when no test counts.
- `firstAttemptPassRate` (nullable number, required): Tests that passed on attempt 1 ÷ (tests − skipped), from 0 to 1. Retries never change it. Null while running or when no test counts.
- `error` (nullable string, required): What failed outside the tests, such as a web server that did not start, or `No tests ran.` A run with an error is failed and cannot be retried.
- `product` (object, required): The product the run tested.
  - `slug` (string, required): The product's slug, such as storefront.
  - `name` (string, required): The product's display name.
- `environment` (object, required): The environment the run tested.
  - `slug` (string, required): The environment's slug, such as staging.
  - `name` (string, required): The environment's display name.
  - `class` (string, required): The environment's class: production or non-production.
- `baseUrl` (string, required): The URL the tests ran against.
- `deploymentBaseUrl` (nullable string, required): The deployment's URL when the run started, or null. It differs from `baseUrl` when `clanker run --base-url` overrode it.
- `deploymentVersion` (nullable string, required): The deployment's version when the run started, or null.
- `git` (object, required): The commit the run tested.
  - `branch` (nullable string, required): The checked-out branch, or null.
  - `sha` (nullable string, required): The full commit SHA, or null outside a git checkout.
  - `dirty` (boolean, required): Whether the checkout had uncommitted changes.
  - `subject` (nullable string, required): The commit's subject line, or null.
- `machine` (object, required): The computer that ran the tests.
  - `hostname` (string, required): The computer's hostname, such as maya-mbp.
  - `platform` (string, required): The operating system as Node.js names it, such as darwin, linux or win32.
- `coverage` (nullable string, one of: `critical`, `acceptance`, `regression`, `exhaustive`, required): The coverage level the run selected, or null when it selected none.
- `cleanup` (nullable object, required): The run's scope and what deleting it reported, or null when the deployment had no Fixture API.
  - `scope` (string, required): The scope's ID: `scp_` and 12 lowercase letters or digits, such as scp_7h2k9q2m4x1a.
  - `expiresAt` (string, required): When the scope expires, as an ISO 8601 timestamp. A later teardown on the deployment deletes what is left.
  - `status` (nullable string, one of: `complete`, `incomplete`, `kept`, required): How the cleanup ended, or null until the run reports it.
  - `deleted` (nullable number, required): How many records the teardown deleted, or null when it did not answer.
  - `remaining` (nullable number, required): How many records are left in the scope, or null when the teardown did not answer.
- `publishedBy` (object, required): Who published a run or a retry: a person, an account API key, or one since removed.
  - When `kind` is `user`
    - `kind` (string, required): A person published it. Always `user`.
    - `name` (string, required): The person's name.
    - `email` (string, required): The person's email address.
  - When `kind` is `account_key`
    - `kind` (string, required): An account API key published it, such as one CI uses. Always `account_key`.
    - `name` (string, required): The account API key's name.
  - When `kind` is `removed`
    - `kind` (string, required): The person or account API key no longer exists. Always `removed`.
- `startedAt` (string, required): When the run started, as an ISO 8601 timestamp.
- `finishedAt` (nullable string, required): When the run finished or was marked interrupted, as an ISO 8601 timestamp, or null while running.
- `durationMs` (number, required): How long the run took, in milliseconds, as its publisher reported. 0 while running.

### Request

```bash
curl -X POST https://api.qa.clankership.net/v1/workspaces/{slug}/test-runs/{run}/retries/{retry}/finish \
  -H "Authorization: Bearer $CLANKER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "interrupted": false,
    "cleanup": {
      "status": "complete",
      "deleted": 12,
      "remaining": 0,
      "error": null
    },
    "tests": [
      {
        "caseId": "checkout-applies-coupon",
        "attempts": [
          {
            "status": "passed",
            "durationMs": 4210,
            "startedAt": "2026-10-14T09:44:07.000Z",
            "errorMessage": null,
            "errorStack": null,
            "files": []
          },
          {
            "status": "passed",
            "durationMs": 4105,
            "startedAt": "2026-10-14T09:44:13.000Z",
            "errorMessage": null,
            "errorStack": null,
            "files": []
          }
        ]
      }
    ]
  }'
```

### Response

```json
{
  "id": "Tr8KcW3nPx5Q",
  "number": 1289,
  "url": "https://clankertest.com/w/acme/test-runs/1289",
  "state": "finished",
  "result": "passed",
  "counts": {
    "total": 2,
    "passed": 1,
    "flaky": 1,
    "failed": 0,
    "skipped": 0
  },
  "passRate": 1,
  "firstAttemptPassRate": 0.5,
  "error": null,
  "product": {
    "slug": "storefront",
    "name": "Storefront"
  },
  "environment": {
    "slug": "staging",
    "name": "Staging",
    "class": "non-production"
  },
  "baseUrl": "https://staging.acme.dev",
  "deploymentBaseUrl": "https://staging.acme.dev",
  "deploymentVersion": "9f2c41e",
  "git": {
    "branch": "web-31-docs-search",
    "sha": "9f2c41e7b8a3d5c6f1e0b2a4d6c8e0f1a3b5c7d9",
    "dirty": false,
    "subject": "Add docs search"
  },
  "machine": {
    "hostname": "maya-mbp",
    "platform": "darwin"
  },
  "coverage": null,
  "cleanup": {
    "scope": "scp_7h2k9q2m4x1a",
    "expiresAt": "2026-10-14T15:40:00.000Z",
    "status": "complete",
    "deleted": 214,
    "remaining": 0
  },
  "publishedBy": {
    "kind": "user",
    "name": "Maya Patel",
    "email": "maya@acme.dev"
  },
  "startedAt": "2026-10-14T09:40:00.000Z",
  "finishedAt": "2026-10-14T09:42:31.000Z",
  "durationMs": 151230
}
```

### Errors

- `400` `InvalidInput`: The request is malformed, or a value is not allowed.
- `401` `Unauthorized`: The request has no valid API key.
- `403` `Forbidden`: The key's user or role may not do this.
- `404` `NotFound`: The workspace or object does not exist, or the caller cannot see it.
- `409` `Conflict`: The change conflicts with current data, such as a slug that is already in use.
- `500` `Internal`: Clanker Ship failed to handle the request. Retry, or report the response's x-request-id.
