Skip to content

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

  1. Install it beside the Playwright you already have:

    Terminal window
    npm install --save-dev @plune-ai/playwright
    npx plune login # from @plune-ai/cli — saves your API token
  2. 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.

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
VariableWhat it does
PLUNE_TOKENthe API token, so a CI job need not run plune login
PLUNE_RUNthe shared run key — every process with the same value lands in one run
PLUNE_API_URLwhich deployment to report to
PLUNE_SHARED_RUNthis process is one of several — do not close the run
PLUNE_PROCEEDthe job closes the run itself, later
PLUNE_BATCH_SIZEresults per request (default 100, max 500)
PLUNE_RUN_TITLE · PLUNE_ENV · PLUNE_LABELSwhat 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_CREATEoffer tests Plune has no case for to the review queue
PLUNE_FULL_RUNthis 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.

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.

First match wins:

  1. A PluneId annotation — names the case outright.

    test("rejects a negative quantity",
    { annotation: { type: "PluneId", description: "tc-1a2b" } },
    async ({ page }) => { /* … */ });
  2. A @P<id> token in the title — the same statement, where you can read it in the report. No space after @P: @Ptc-1a2b.

  3. An application/plune.metadata+json attachment — for a fixture that knows the id, or a key from another tool: {"keys": [{"kind": "qase", "value": "Q-9"}]}.

  4. The Playwright test id — stable across shards.

  5. 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.

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.

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 body instead of a file. JSON and text files are sent apart: below.

  • A file goes by its attachment’s name — cart.png above, screenshot.png for 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}/results lists a result’s files, and GET /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.

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 memory
await testInfo.attach("api-response", {
body: JSON.stringify(data),
contentType: "application/json",
});
// a log the test wrote to disk
await 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. contentType decides, and only application/json and text/plain are taken — in any case and with parameters, so Text/Plain; charset=UTF-8 is fine. A log is text/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 a body.

  • 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 path under a name with no extension of its own, the file’s extension follows it — testInfo.attach("api-response", { path }) arrives as api-response.json — and a body goes by its name as it is. Name a log ….log and 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 skipped

    A 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 body is never written to the fallback file. It exists only in memory, and is sent once Plune has stored the result. .plune/pending-results.jsonl keeps no byte of it, so a result sent again with plune run report goes without it; a file attached by path is 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.

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.

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.jsonl

In 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.

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.