Report Playwright results as they run
@plune-ai/playwright is a Playwright reporter: it sends results to the Plune platform while
the suite runs, instead of parsing a report afterwards.
v0.5.0no runtime depsNode ≥ 20
-
Install it beside the Playwright you already have:
Terminal window npm install --save-dev @plune-ai/playwrightnpx plune login # from @plune-ai/cli — saves your API token -
Add it as a reporter:
playwright.config.ts import { defineConfig } from "@playwright/test";export default defineConfig({reporter: [["list"], ["@plune-ai/playwright"]],});
That is the whole setup. Everything below is for cases the default does not cover.
In CI, without editing the config
Section titled “In CI, without editing the config”A committed config cannot know the run key of a job that does not exist yet, so the environment fills in what the file left out — and anything set in the file wins.
env: PLUNE_TOKEN: ${{ secrets.PLUNE_TOKEN }} PLUNE_RUN: ${{ github.run_id }}-${{ github.run_attempt }} PLUNE_ENV: staging PLUNE_LABELS: smoke,nightly| Variable | What it does |
|---|---|
PLUNE_TOKEN | the API token, so a CI job need not run plune login |
PLUNE_RUN | the shared run key — every process with the same value lands in one run |
PLUNE_API_URL | which deployment to report to |
PLUNE_SHARED_RUN | this process is one of several — do not close the run |
PLUNE_PROCEED | the job closes the run itself, later |
PLUNE_BATCH_SIZE | results per request (default 100, max 500) |
PLUNE_RUN_TITLE · PLUNE_ENV · PLUNE_LABELS | what the run is called, where it ran, how it is marked. Unset, a run is called <directory> · 2026-09-15 15:26 · ci — the project, the minute it started on the reporting machine, and ci or local (from 0.11.0 / adapter 0.2.3) |
PLUNE_CREATE | offer tests Plune has no case for to the review queue |
PLUNE_FULL_RUN | this run is the whole suite: when it finishes with every expected test reported, the cases this source used to report and did not are marked detached — see case states. Set it only on the job that runs everything, never on --grep or one file (from 0.12.0 / adapter 0.2.5) |
Flags are read by value, not by presence: PLUNE_PROCEED=0 in a matrix cell means off, not on.
Sharding
Section titled “Sharding”Give every shard the same key and they land in one run rather than one run each:
env: PLUNE_RUN: ${{ github.run_id }}-${{ github.run_attempt }} PLUNE_SHARED_RUN: "1"The Playwright test id is stable across shards and merges, which is what makes the same test in shard 3 of 8 resolve to the same case it resolved to yesterday.
How a result finds its case
Section titled “How a result finds its case”First match wins:
-
A
PluneIdannotation — names the case outright.test("rejects a negative quantity",{ annotation: { type: "PluneId", description: "tc-1a2b" } },async ({ page }) => { /* … */ }); -
A
@P<id>token in the title — the same statement, where you can read it in the report. No space after@P:@Ptc-1a2b. -
An
application/plune.metadata+jsonattachment — for a fixture that knows the id, or a key from another tool:{"keys": [{"kind": "qase", "value": "Q-9"}]}. -
The Playwright test id — stable across shards.
-
File and title path —
cart.spec.ts#cart#rejects a negative quantity.
A result that matches no case is not sent and not invented. A made-up id would write into some
other test’s history, which is worse than a gap; the run summary counts them instead. To turn those
into proposals, set PLUNE_CREATE=1 — they reach the
review queue and wait for a person.
What a failed test sends
Section titled “What a failed test sends”A failed attempt carries its detail: the first line of the error (on a test timeout, the action that
was still waiting), the declared test.steps down to the one that failed, where it failed from the
repository root (the line in the test’s own file, or the line the error was thrown from when that
file is not on its stack), the names of the attachments Playwright kept as files, and the CI run from
Playwright’s own metadata.ci. Beside it goes the full text of every error, as Playwright’s JSON
report writes it — without colour codes, with paths relative to the repository and the home folder
as ~, and at most 512 KB as it weighs in the request, cut by whole lines. How it reads on the run
page, and how to keep the files in CI: Failed automated results.
That is adapter 0.2.8. The detail first came with 0.2.6, which could put a path of the machine in the headline and in an attachment’s name, lose a whole batch to an attachment with an empty type, count the 512 KB in raw bytes, and close the run before its last batches arrived. 0.2.7 could still break an address whose host has the name of the repository root or of the home folder, and, with no repository, keep the account’s name in a home path written with escaped slashes — take 0.2.8 or newer.
The repository is the nearest .git above Playwright’s rootDir. Without one — a Docker image that
leaves .git out, say — the result names no place, and only home folders in the text become ~.
The same run imported with plune run import 0.14.2 from its JSON report sends the same detail,
when that rootDir is on the machine that imports it.
Screenshots
Section titled “Screenshots”From adapter 0.3.0, the pictures a test kept as files reach Plune with its result — a passed test’s
as well as a failed one’s. Playwright’s own screenshot: 'on' (or 'only-on-failure') keeps one at
the end of every test; a test can keep more, one per state it checks:
const path = testInfo.outputPath("cart.png");await page.screenshot({ path });await testInfo.attach("cart", { path, contentType: "image/png" });-
PNG, JPEG and WebP, up to 2 MiB each and 20 a result. Traces and videos stay in CI, and so does a picture attached as a
bodyinstead of a file. JSON and text files are sent apart: below. -
A file goes by its attachment’s name —
cart.pngabove,screenshot.pngfor Playwright’s own — never by a path of the machine. A name that starts with_is hidden in Playwright’s reports and is not sent either. -
They are sent once the platform has stored the result, before the run is closed. One that cannot be sent costs nothing else; the summary counts them:
plune: 41 accepted · 38 screenshot(s) uploaded, 1 skipped -
A project keeps up to 1 GiB of pictures and text files together; past that, the oldest go first.
GET /v1/runs/{id}/resultslists a result’sfiles, andGET /v1/files/{id}returns one.
plune run import 0.15.0 sends them too, from a Playwright JSON report, when the files are still on
the machine that imports it. How they look on the run page:
Screenshots.
JSON and text files
Section titled “JSON and text files”From adapter 0.5.0, what a test attaches as JSON or plain text — the answer of an API, a log — reaches
its result as well, beside the screenshots, and a passed test’s as much as a failed one’s. Attach it
with a body, which leaves no file, or with a path; both are read:
// the answer of an API, kept in memoryawait testInfo.attach("api-response", { body: JSON.stringify(data), contentType: "application/json",});
// a log the test wrote to diskawait testInfo.attach("server.log", { path: "logs/server.log", contentType: "text/plain" });On the run page a file Plune keeps says Open in the result’s panel and opens in a window of its own — JSON and text files shows what a person sees.
-
Name the type.
contentTypedecides, and onlyapplication/jsonandtext/plainare taken — in any case and with parameters, soText/Plain; charset=UTF-8is fine. A log istext/plain, whatever its file is called. An attachment that names no type is not sent, and its extension is never guessed at. Markdown, HTML, CSV, XML, traces and videos stay in CI, and so does an image attached as abody. -
Limits. UTF-8 without NUL bytes, at most 512 KiB a file and 10 a result — apart from the screenshots’ 20. The reporter checks them before it sends a file, so one Plune would refuse does not cross the wire, and a file that is not UTF-8 stays behind.
-
The name is the attachment’s, cut to 200 characters, and never a path of the machine. Attached by
pathunder a name with no extension of its own, the file’s extension follows it —testInfo.attach("api-response", { path })arrives asapi-response.json— and abodygoes by its name as it is. Name a log….logand the run page calls it a log. A name that starts with_is hidden in Playwright’s own reports and is not sent either. -
What stays behind is counted. A file over the limits, not UTF-8, holding a NUL byte, empty, past the tenth of its result, hidden by its name, or not on this machine is skipped, with one line for each reason, and the summary counts it apart from the screenshots:
plune: text file "huge" not uploaded — Plune takes a text file of at most 512 KiB.plune: 41 accepted · 38 screenshot(s) uploaded · 3 text file(s) uploaded, 1 skippedA file Plune refuses — a platform from before text files refuses every one — is counted as
failed to upload, with one line for the first:plune: could not upload a text file (…) — its result is reported without it.Neither costs a result or changes your suite’s exit code. -
Cleaned by Plune, not by the reporter. The reporter sends a file as it is. Plune takes colour codes and the credentials it recognises out of a text before it keeps it, so the run page can show something other than what the test attached — a JSON with a credential in it can stop being JSON (what Plune keeps). Attach nothing that is not for everyone in the project.
-
A
bodyis never written to the fallback file. It exists only in memory, and is sent once Plune has stored the result..plune/pending-results.jsonlkeeps no byte of it, so a result sent again withplune run reportgoes without it; a file attached bypathis named there, and goes with the result if it is still on disk. More in When Plune is unreachable.
plune run import 0.17.0 sends them too, from a Playwright JSON report — see
JSON and text files there.
The commit and the branch
Section titled “The commit and the branch”From adapter 0.4.0 the run opens with the commit and the branch it ran on, and the run page shows
them — The commit and the branch. The reporter
reads Playwright’s own record of the CI (metadata.ci): the commit from its commitHash, and the
branch from its branch, which Playwright fills on GitLab CI and Jenkins. Playwright gives no branch
on GitHub Actions, so there the reporter reads the job’s GITHUB_HEAD_REF — a pull request’s own
branch — and, when that is empty, as on a push, GITHUB_REF_NAME.
- On a pull request GitHub runs the checks on a merge commit it makes for them, and that is the commit the run names — not the last commit of the branch.
- Off a CI, or on one Playwright does not record, there is nothing to read and the run has neither. Nothing is guessed.
plune run import sends them too, from the CI’s variables — see
The commit and the branch there.
When Plune is unreachable
Section titled “When Plune is unreachable”Nothing is lost and nothing fails. An unreachable platform, a refused token or an already-closed run
all append the batch to .plune/pending-results.jsonl, say so in the run’s summary line, and let
your suite exit with its own code. Your tests do not depend on our uptime.
plune: 90 accepted · 2 with no matching test case · 8 not delivered — written to .plune/pending-results.jsonlIn GitHub Actions a count above zero also prints a warning annotation, once:
::warning::8 result(s) were not delivered to Plune — see the reporting step's log. Batches are
limited by size as well as by count, so a run where every test failed with a long error still
arrives whole.
plune run report sends the file once the platform is back. With @plune-ai/cli 0.16.0 the batches
one reporter run wrote come back as one run — before, every batch that had no run yet opened its
own — and, from adapter 0.4.0, that run keeps the commit, the branch, the CI link and the runner the
reporter recorded. When every result has gone, the file is renamed to
pending-results.<time>.sent.jsonl beside it, so that a second plune run report does not send them
again; when anything did not go, it stays as it was.
What it deliberately does not decide
Section titled “What it deliberately does not decide”The status. Playwright’s own word — passed, failed, timedOut, interrupted, skipped — goes
to Plune as-is, and the project maps it. So your team can decide a timeout counts as broken
(the default) or as something else, and change its mind, without waiting for a release of this
package.