Submit a batch of results to a run
const url = 'https://beta-api.plune.ai/v1/runs/example/results';const options = { method: 'POST', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"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"}}]}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}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.
Authorizations
Section titled “Authorizations ”Parameters
Section titled “ Parameters ”Path Parameters
Section titled “Path Parameters ”Request Body required
Section titled “Request Body required ”object
object
Names the result WITHIN its run — the reporter chooses it, and sending it twice is how a retried upload stays free.
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).
The runner’s own word, kept verbatim; status is what it maps to.
Which runner produced this — decides the mapping table used for rawStatus.
Defaults to passed. A failure that was expected is outcome: expected, not a regression.
object
Which attempt this was. Anything above 0 is what makes a pass flaky.
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.
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.
Part of the signature: the same case with different params is a different test.
object
Reserved for per-file links, and never returned. The files a failed attempt kept travel in failure.artifacts.
object
object
A person’s marks per step of the case (D34) — source: manual. A reporter has no steps to mark.
object
The step’s position in the case’s steps, from 0.
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.
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
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.
The declared steps (test.step) from the outermost to the one that failed. Absent when the test declares none.
Where the test failed: the first stack frame in the test file itself, or where the error was thrown when there is none.
object
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 ...
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.
object
As the runner named it — screenshot, trace, error-context.
The CI run holding the artifacts. Absent outside CI: the artifacts are then named without a link.
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).
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
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" } } ]}A person’s mark (D34) — one case, step by step
{ "results": [ { "resultKey": "manual:6f1c2b3a-0000-4000-8000-0000000000c1", "testCaseId": "6f1c2b3a-0000-4000-8000-0000000000c1", "status": "failed", "source": "manual", "errorContext": "Step 3: the coupon field accepted an expired code.", "steps": [ { "index": 0, "status": "passed" }, { "index": 1, "status": "passed" }, { "index": 2, "status": "failed" } ] } ]}Responses
Section titled “ Responses ”Per-element verdicts and counts
object
One verdict per submitted element, in the order they were sent.
object
Position in the submitted batch.
Present when accepted — the result just created.
Present on duplicate and conflict — the result already holding the key.
Present when rejected.
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).
The combination of the batch (#1251); absent for a run without configurations. resultKey is the key as it was sent.
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
fastPass is on and the mark is passed or skipped: ask nothing more (no sub-status).
object
The case to open: the next one the run expects without a result, the same, or null.
object
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
object
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"}The file is a path of the machine, not from the repository root
{ "error": "validation failed — results.1.failure.location.file: must be a path from the repository root — no absolute path, drive, scheme, ~ or .."}The link to the artifacts is not a web address
{ "error": "validation failed — results.1.failure.ciUrl: must be an http or https address"}No / invalid token or session
Not found or not yours
object
Example generated
{ "error": "example"}Over 10485760 bytes — refused before anything reads the body
object
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
object
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"}CI names a 26th combination
{ "error": "this run has 25 configuration combinations — a run takes at most 25, and «Edge · Windows» would be one more"}CI names other groups than the run’s
{ "error": "this run's combinations are of the groups Browser, Operating system — a batch names exactly these, this one names Browser"}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.
object
Example
{ "error": "too many requests in flight for this token (8); retry after the time in Retry-After", "code": "too_many_in_flight"}Headers
Section titled “Headers ”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.
object
Example
{ "error": "the server is busy — the database has no free connection; retry after the time in Retry-After", "code": "pool_overloaded"}Headers
Section titled “Headers ”Seconds to wait before the same request is sent again; grows with the length of the queue for a connection