Skip to content

Submit a batch of results to a run

POST
/v1/runs/{id}/results
curl --request POST \
--url https://beta-api.plune.ai/v1/runs/example/results \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '{ "results": [ { "resultKey": "e2e/checkout.spec.ts::Checkout — empty cart::chromium", "testCaseId": "6f1c2b3a-0000-4000-8000-0000000000c1", "rawStatus": "passed", "source": "playwright", "execution": { "startedAt": "2026-09-16T09:12:04.000Z", "finishedAt": "2026-09-16T09:12:05.310Z", "durationMs": 1310, "retry": 0, "worker": "chromium-1" }, "params": { "project": "chromium" } }, { "resultKey": "e2e/checkout.spec.ts::Checkout — coupon applied::chromium", "testCaseId": "6f1c2b3a-0000-4000-8000-0000000000c1", "rawStatus": "failed", "source": "playwright", "execution": { "startedAt": "2026-09-16T09:12:05.400Z", "finishedAt": "2026-09-16T09:12:09.021Z", "durationMs": 3621, "retry": 1, "worker": "chromium-1" }, "errorContext": "Error: expect(locator).toHaveText(\"−10 %\")\n Expected: \"−10 %\"\n Received: \"0 %\"\n at e2e/checkout.spec.ts:41:31", "params": { "project": "firefox" }, "failure": { "headline": "Error: expect(locator).toHaveText(\"−10 %\")", "steps": [ "Pay", "Apply the coupon" ], "location": { "file": "e2e/checkout.spec.ts", "line": 41, "column": 31 }, "artifacts": [ { "name": "screenshot", "contentType": "image/png" }, { "name": "error-context", "contentType": "text/markdown" }, { "name": "trace", "contentType": "application/zip" } ], "ciUrl": "https://github.com/plune-ai/plune/actions/runs/35003177826" } } ] }'

Idempotent by resultKey, which is unique within the run. A repeated batch answers duplicate for every element and changes nothing; an element whose key is already held with DIFFERENT content answers conflict and names the result that holds it — a different failure included. Both are 200 with a per-element verdict — one bad element must not cost the other 499.

A failed attempt may carry failure (FailureDetailSubmission). A wrong value in it — a line that is not positive, a file named by a path of the machine, a link that is not http or https — refuses the WHOLE batch with 400, naming the element and the field, and nothing of it is stored. Size never refuses: the error text and every string of failure are stored without terminal colour codes, with credentials replaced by [redacted:<kind>] markers, and only then shortened (FailureDetail). The route takes up to 10485760 bytes, like POST /v1/runs, so a batch of long failures arrives whole.

A run launched with configurations (#1251, ADR 0070) takes a batch into one of its combinations: combination names it by its number. The key is then unique within the combination — the same test in Chrome and in Firefox is two results, the same batch sent twice into one combination is duplicate — and every answer gives the key back as it was sent, with combination beside it. A batch without combination into such a run, one with it into a run without configurations, and a number outside the run’s list are refused whole with 422, before anything is written.

CI names the combination instead: configuration — group name → value label (the reporter’s PLUNE_CONFIGURATION). The server finds it in the run’s list or appends it at the end under the run’s row lock, taken first, so two jobs naming one new combination at once make one; a value not in Settings → Configurations is taken, «not in the list». An appended combination keeps the launch’s limits: past 25 combinations, 16384 bytes of the list or 40000 results to expect (the expected cases × the combinations) the batch is refused with 422, as is one that names other groups than the run’s combinations are of, and a configuration into a run that already holds results without one.

id
required
string
Media type application/json
object
results
required
Array<object>
>= 1 items <= 500 items
object
resultKey
required

Names the result WITHIN its run — the reporter chooses it, and sending it twice is how a retried upload stays free.

string
testCaseId
required
string
status

Optional. When sent it is stored as it is and the project’s status map is not consulted. Leave it out and send rawStatus with source: the project’s map, then the source’s defaults, decide (D4).

string
Allowed values: passed failed broken blocked skipped
rawStatus

The runner’s own word, kept verbatim; status is what it maps to.

string
source

Which runner produced this — decides the mapping table used for rawStatus.

string
expectedStatus

Defaults to passed. A failure that was expected is outcome: expected, not a regression.

string
Allowed values: passed failed broken blocked skipped
execution
object
startedAt
required
string format: date-time
finishedAt
required
string format: date-time
durationMs
required
integer
retry

Which attempt this was. Anything above 0 is what makes a pass flaky.

integer
worker
string
errorContext

Never rejected for being large. Stored without terminal colour codes and with every credential replaced by a [redacted:<kind>] marker, then cut to 256 KB, head and tail.

string
errorContextOmitted

How many characters the reporter cut from errorContext before sending it (its own size bound). Returned as errorContextMeta.omittedByReporter; the dashboard’s «Copy prompt» says so on its last line. Omit it when nothing was cut.

integer
>= 1
params

Part of the signature: the same case with different params is a different test.

object
key
additional properties
any
attachments

Reserved for per-file links, and never returned. The files a failed attempt kept travel in failure.artifacts.

Array<object>
<= 50 items
object
key
additional properties
any
assertions
Array<object>
object
key
additional properties
any
steps

A person’s marks per step of the case (D34) — source: manual. A reporter has no steps to mark.

Array<object>
<= 200 items
object
index
required

The step’s position in the case’s steps, from 0.

integer
status
required
string
Allowed values: passed failed skipped blocked
files

The ids of the files a person added to this case while marking it (#1234) — POST /v1/runs/{id}/cases/{caseId}/files — which become the files of the result this element makes. Only finished files of this run and this case are taken; any other id is passed over. A duplicate of a result already stored takes none: that result has its files.

Array<string>
<= 30 items
failure

What a reporter may say about a failed attempt, beside its error text. Every field is optional: a reporter sends the part it knows, and the run page shows the part that is there. No length or count is refused — the server sanitizes every string, then shortens (see FailureDetail). A wrong value is refused: a line that is not a positive integer, a file named by a path of the machine, a link that is not http or https.

object
headline

The first line of the error the reporter chose — on a test timeout, the error of the action that was waiting. Absent or empty, and the page takes the first non-empty line of the text instead.

string
steps

The declared steps (test.step) from the outermost to the one that failed. Absent when the test declares none.

Array<string>
location

Where the test failed: the first stack frame in the test file itself, or where the error was thrown when there is none.

object
file
required

From the repository root in posix form, the way a case’s specRef names it. A path of the machine is refused: absolute, with a drive or a scheme, from a home (~), or climbing out with ...

string
>= 1 characters /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/
line
required
integer
>= 1
column
integer
>= 1
artifacts

The files the runner kept for this attempt, by name — a name, not a link: the files live in the CI run ciUrl points to.

Array<object>
object
name
required

As the runner named it — screenshot, trace, error-context.

string
>= 1 characters
contentType
string
>= 1 characters
ciUrl

The CI run holding the artifacts. Absent outside CI: the artifacts are then named without a link.

string format: uri
/^[Hh][Tt][Tt][Pp][Ss]?:///
combination

The combination every result of the batch is of (#1251, ADR 0070): its number in the run’s combinations. Required for a run launched with configurations, refused for one without (422). A result is kept under its resultKey within the combination, and its signature takes the combination’s values, so Chrome and Firefox are two histories. Not with configuration (400).

integer
configuration

The combination by names, for CI, which knows no ids (#1251, ADR 0070 §5): group name → value label, case not mattering, archived groups and values too. The server finds it in the run’s combinations — by the values’ ids, so a copy of the run and a value renamed since the launch find theirs — or appends it at the end, which is how each job of a CI matrix gets its own combination in one run. A group or value not in Settings → Configurations is taken as sent, with no ids («not in the list»); its signature reads it trimmed and in lower case, as the run matches it. A batch names exactly the groups the run’s combinations are of — the first batch into a run with none sets them. A value of the list joins the run’s custom. Not with combination (400).

object
>= 1 properties <= 50 properties
key
additional properties
string
>= 1 characters <= 200 characters
Examples

A reporter’s batch — a pass, and a failure with its detail

{
"results": [
{
"resultKey": "e2e/checkout.spec.ts::Checkout — empty cart::chromium",
"testCaseId": "6f1c2b3a-0000-4000-8000-0000000000c1",
"rawStatus": "passed",
"source": "playwright",
"execution": {
"startedAt": "2026-09-16T09:12:04.000Z",
"finishedAt": "2026-09-16T09:12:05.310Z",
"durationMs": 1310,
"retry": 0,
"worker": "chromium-1"
},
"params": {
"project": "chromium"
}
},
{
"resultKey": "e2e/checkout.spec.ts::Checkout — coupon applied::chromium",
"testCaseId": "6f1c2b3a-0000-4000-8000-0000000000c1",
"rawStatus": "failed",
"source": "playwright",
"execution": {
"startedAt": "2026-09-16T09:12:05.400Z",
"finishedAt": "2026-09-16T09:12:09.021Z",
"durationMs": 3621,
"retry": 1,
"worker": "chromium-1"
},
"errorContext": "Error: expect(locator).toHaveText(\"−10 %\")\n Expected: \"−10 %\"\n Received: \"0 %\"\n at e2e/checkout.spec.ts:41:31",
"params": {
"project": "firefox"
},
"failure": {
"headline": "Error: expect(locator).toHaveText(\"−10 %\")",
"steps": [
"Pay",
"Apply the coupon"
],
"location": {
"file": "e2e/checkout.spec.ts",
"line": 41,
"column": 31
},
"artifacts": [
{
"name": "screenshot",
"contentType": "image/png"
},
{
"name": "error-context",
"contentType": "text/markdown"
},
{
"name": "trace",
"contentType": "application/zip"
}
],
"ciUrl": "https://github.com/plune-ai/plune/actions/runs/35003177826"
}
}
]
}

Per-element verdicts and counts

Media type application/json
object
items
required

One verdict per submitted element, in the order they were sent.

Array<object>
object
index
required

Position in the submitted batch.

integer
resultKey
required
string
status
required
string
Allowed values: accepted duplicate conflict rejected
id

Present when accepted — the result just created.

string
existingId

Present on duplicate and conflict — the result already holding the key.

string
reason

Present when rejected.

string
resultStatus

A person’s mark (source: manual), when accepted: the status it was stored with - autoPass and failOnStepFail may have moved it off what was sent (#1253).

string
Allowed values: passed failed broken blocked skipped
combination

The combination of the batch (#1251); absent for a run without configurations. resultKey is the key as it was sent.

integer
after

Only when the batch holds an accepted manual mark (#1253): what the project’s execution switches decided about the last one - the page does what it says.

object
testCaseId
required
string
status
required
string
Allowed values: passed failed broken blocked skipped
skipDetails
required

fastPass is on and the mark is passed or skipped: ask nothing more (no sub-status).

boolean
redirect
required
object
mode
required
string
Allowed values: next same none
testCaseId
required

The case to open: the next one the run expects without a result, the same, or null.

string
nullable
counts
required
object
accepted
required
integer
duplicate
required
integer
conflict
required
integer
rejected
required
integer
Example
{
"items": [
{
"index": 0,
"resultKey": "e2e/checkout.spec.ts::Checkout — empty cart::chromium",
"status": "accepted",
"id": "6f1c2b3a-0000-4000-8000-0000000000e1"
},
{
"index": 1,
"resultKey": "e2e/checkout.spec.ts::Checkout — coupon applied::chromium",
"status": "accepted",
"id": "6f1c2b3a-0000-4000-8000-0000000000e2"
}
],
"counts": {
"accepted": 2,
"duplicate": 0,
"conflict": 0,
"rejected": 0
}
}

Body is not a batch of submittable results — the whole batch is refused and nothing of it is stored

Media type application/json
object
error
required
string
Examples

The line of the place is not a positive integer

{
"error": "validation failed — results.1.failure.location.line: Too small: expected number to be >0"
}

No / invalid token or session

Not found or not yours

Media type application/json
object
error
required
string
Example generated
{
"error": "example"
}

Over 10485760 bytes — refused before anything reads the body

Media type application/json
object
error
required
string
Example
{
"error": "request body too large — this route accepts at most 10485760 bytes"
}

The batch and the run disagree about configurations (#1251): no combination (nor configuration) for a run that has them, a combination for a run that has none, a number that is not in the run’s list, or a configuration the list cannot take — a 26th combination, a list over its bytes, more than 40 000 results to expect, other groups than the run’s, a run holding results without one — the whole batch is refused and nothing of it is stored

Media type application/json
object
error
required
string
Examples

A run with configurations, a batch that names none

{
"error": "this run has 4 configuration combinations — the batch names one in `combination`, from 0 to 3"
}

Two refusals share this status, and both are waited out: the per-route throttle (Retry-After is the seconds until the window rolls over), and code too_many_in_flight — this token already has 8 batches being stored, so the ninth is turned away before it touches the database (Retry-After: 2 to 3 seconds). Neither carries a quota object: a 429 with one is a limit that waiting does not free, and the CLI does not retry it. The 0.26.0 CLI sends 4 batches at a time from a process, so a token is over the cap only when two or more processes share it and send at once; the count is per API process.

Media type application/json
object
error
required
string
Example
{
"error": "too many requests in flight for this token (8); retry after the time in Retry-After",
"code": "too_many_in_flight"
}
Retry-After
integer

Seconds to wait

The API could not take the request now, and nothing was stored: code pool_overloaded — every database connection is busy and the queue for one is long (refused at once, or the wait for a connection ran out); statement_timeout — Postgres cancelled a statement that ran for 60 s. Retry-After carries the seconds to wait (a few of jitter on top, so that what was refused together does not come back together). The CLI waits that long and sends the batch again, then falls back to its file.

Media type application/json
object
error
required
string
Example
{
"error": "the server is busy — the database has no free connection; retry after the time in Retry-After",
"code": "pool_overloaded"
}
Retry-After
integer

Seconds to wait before the same request is sent again; grows with the length of the queue for a connection