Skip to content

Bring an existing suite

Everything else in this CLI generates checks, which is why it needs a provider key. If you already have a suite, there is nothing to generate: the results exist, and they only have to arrive.

Terminal window
plune run import ./junit.xml

That is the whole thing. No provider key, no rewriting your tests, no adapter for your runner — JUnit XML is the format nearly every runner already knows how to print.

Section titled “Print a report your runner already supports”
  1. Produce the XML. Most runners need one flag or one package:

    Terminal window
    # Vitest
    vitest run --reporter=default --reporter=junit --outputFile.junit=junit.xml
    # pytest
    pytest --junitxml=junit.xml
    # PHPUnit
    phpunit --log-junit junit.xml
    # Jest — via the jest-junit reporter
    jest --reporters=default --reporters=jest-junit
    # Playwright — JUnit, or its own JSON
    npx playwright test --reporter=junit

    Maven Surefire writes target/surefire-reports/*.xml without being asked.

    For Playwright, prefer its JSON: PLAYWRIGHT_JSON_OUTPUT_NAME=report.json npx playwright test --reporter=json. For a failed test it carries what failed, the declared steps, where it failed, the attachments Playwright kept and the CI run, and the import sends all of it (take 0.14.2 or newer) when the report’s rootDir is a folder of a git checkout on the machine that imports it; elsewhere it cannot find the repository, and the result names no place — see Failed automated results. JUnit XML carries the error text alone. From 0.15.0 the import also sends the screenshots each test kept as files, to that test’s result, while the files are still on the machine — see Screenshots — and from 0.17.0 the JSON and text files too: JSON and text files.

  2. Give the CLI a token. run import reads PLUNE_TOKEN from the environment, so a CI job can pass a secret straight in without a login step:

    Terminal window
    export PLUNE_TOKEN="plune_xxxxxxxx"

    A .env in the current directory does the same from 0.16.0, and what is already exported wins over it — where the CLI reads its settings. A saved credentials file from plune login works too, and takes second place to the variable.

  3. Import it.

    Terminal window
    plune run import ./junit.xml

    The command prints what it read, what landed, and the URL of the run.

plune: 1465 accepted · 10 with no matching test case · 10 offered for review
Read 1475 result(s) from a junit report: 1465 accepted, 0 already there, 10 unmatched, 0 not delivered.
https://beta.plune.ai/runs/…

Read counts what was in the file. Everything after it says what became of those results. A Playwright report with screenshots adds one more line, 38 screenshot(s) uploaded to their results. — with N skipped and N could not be uploaded when there are any. From 0.17.0 a report with JSON or text files adds the same line for them, after the screenshots’, and says nothing for a report with none: 3 text file(s) uploaded to their results, 1 skipped.

WordMeaning
acceptedmatched a test case in your project and was recorded
already therethis exact result was reported before — a re-run of the same import changes nothing
unmatchedPlune has no case for this test yet. Counted, not lost. See --create below
refusedtwo results in one batch carried the same identity — usually two tests with the same name
not deliverednever reached Plune — a refused batch, or no answer. They are in .plune/pending-results.jsonl, and plune run report sends them. In GitHub Actions a count above zero also prints a warning annotation. Always printed, zero included, from 0.14.0

Statuses travel exactly as your runner wrote them. Plune maps them per project, so error can mean something different to your team than to anyone else’s.

A case an import creates knows the path of its test — apps/web/e2e/catalog/search.spec.ts — and is filed by it: every directory becomes a folder, the file becomes a suite named search.spec.ts, and the case goes in that suite. Folders and suites are made as needed and reused afterwards, so the tree in Plune ends up shaped like the tree in your repository, and the second import finds everything the first one made — a suite you renamed included, because a suite remembers its file.

If the path meets a node of the same name but the other kind — say you made a suite called e2e by hand and the path needs a folder there — the case is still created, at the project root, and the audit log says why. Plune never renames or re-kinds a node a person made; move the case, or rename the node and ask the operator to run the filing again. Organizing cases has the rule and the operation.

A test that keeps its case through a rename

Section titled “A test that keeps its case through a rename”

A JUnit test finds its case by its key — the report’s classname, a #, and the test’s name — so a renamed or moved test arrives as a new one, while the case it used to be keeps the history. A @P<id> in the test’s name says which case it is, whatever its key. No space after the @P:

it("rejects a negative quantity @Ptc-1a2b", () => { /* … */ });

plune run import reads that token in JUnit XML from 0.16.0; it already did in Playwright’s JSON report, and the reporter does. The name is sent as it is — the token is not cut out of it. In Playwright’s own JUnit report the token is read from the test’s own title, not from the describe titles around it; in any other runner’s report from the whole name, which is all that report holds — so put it in the test’s own title.

Playwright’s own JUnit report is keyed, from 0.16.0, the way the reporter keys a test — file#describe#…#title, the file with forward slashes on every OS — when its classname ends in a spec file’s name (.spec or .test, then a js or ts extension). A suite whose files are named otherwise keeps classname#name. A case an earlier import made from such a report carries the old key, so its test arrives unmatched — and, with --create, is offered to the review queue once more.

A project that has never seen your suite has a case for none of it, so a first import is all unmatched. --create offers those tests to the review queue instead of discarding them:

Terminal window
plune run import ./junit.xml --create

Approving one says “yes, this test exists” — not “yes, this test is good”. Plune saw the result, never the source, so a discovered test carries a title and a location and claims nothing more.

The queue holds 1000 waiting items, deliberately: a queue nobody can finish is not a queue. A suite bigger than that fills it, and the rest are refused with the count still to do:

plune: could not offer 1029 unknown test(s) for review
(review queue quota reached — at most 1000 items waiting.)
1029 more could not be offered — the review queue is full. Approve or reject what is
waiting, then import this report again to offer the rest.

So: import, empty the queue, import again. A 2000-test suite is two rounds; 3000 is three. Once the cases exist, every later import matches them and the queue stays empty.

A project holds up to 20 000 test cases. When an import stops at that limit instead, emptying the queue frees nothing — approving an entry makes a case, and the limit refuses that too — so from 0.16.0 the last line says what does:

1029 more could not be offered — the project is at its test case limit. Delete cases you no longer
need, or ask an operator to raise the limit, then import this report again to offer the rest.

For any other refusal — a throttle, an outage — it points at the could not offer line above it and advises nothing. Before 0.16.0 every refusal got the queue’s advice.

A big suite: a permission instead of a queue

Section titled “A big suite: a permission instead of a queue”

Read the arithmetic above once more and it is ceil(N/1000) imports plus ceil(N/200) decisions — 14 actions for 2000 tests, 120 for 20 000. Neither number is wrong on its own; their product is.

And the question the queue is asking has a known answer. These tests are in your repository. Plune did not propose them, it read them, so approving them one page at a time confirms your authorship of your own code — a hundred times.

Say it once instead, for the whole source:

Terminal window
curl -X PATCH https://beta-api.plune.ai/v1/settings \
-H "Authorization: Bearer $PLUNE_TOKEN" \
-H "content-type: application/json" \
-d '{"trustedDiscoverySources": ["playwright"]}'

Or in the dashboard, under Settings → Trusted sources. junit is the source name for any report read as JUnit XML, whatever wrote it.

Every later --create import from that runner turns its unknown tests into cases straight away, so a 20 000-test suite is one import and one permission rather than 120 clicks. Each case is written to the audit log with trusted-source as the reason, and you can take the permission back at any time.

Two suites that cannot produce one report — a server suite and a browser suite, or a sharded matrix — still belong in one run. Give every job the same key, and tell every job but the last that it is not the one who ends the run:

Terminal window
PLUNE_SHARED_RUN=1 plune run import ./junit.xml --key "$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
PLUNE_SHARED_RUN=1 plune run import ./app/junit.xml --key "$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
plune run finish "$RUN_ID" # or leave the variable unset on the last import

The key says where results go. PLUNE_SHARED_RUN=1 says who does not close the run — without it, the first job finishes the run and every later one is answered “this run is closed”, writing its results to a fallback file instead. plune run exec sets the variable for you.

In GitHub Actions the key carries the attempt, as above. Re-run failed jobs keeps GITHUB_RUN_ID, so a key made of the run id alone sends the second attempt to the first one’s run — closed by then — and its results land in the fallback file instead of Plune. With the attempt in the key every attempt is a run of its own, and the red one stays in the history beside the green one.

- name: Test
run: pnpm test:ci # writes junit.xml
- name: Report the run to Plune
if: always()
continue-on-error: true
env:
PLUNE_TOKEN: ${{ secrets.PLUNE_TOKEN }}
PLUNE_ENV: ci
PLUNE_LABELS: web,vitest
run: npx -y @plune-ai/cli run import junit.xml --create

Two choices worth copying. if: always() because a failed run is the one worth recording — a history of good days measures nothing. continue-on-error because reporting sits downstream of the build: a hiccup on our side must never turn your build red, or the step gets deleted rather than fixed.

PLUNE_ENV and PLUNE_LABELS name the run so staging and production do not average into one number.

From 0.17.0, the import of a Playwright JSON report also sends what each test attached as JSON or plain text — the answer of an API, a log — to that test’s result, beside the screenshots, a passed test’s too. The report names such a file in one of two ways, and both are read: by its path, which has to be on the machine that imports, so run the import where the report was written; or as a body, which the report holds itself, so no file is needed.

The rules are the reporter’s — only application/json and text/plain, as the attachment names them; UTF-8 without NUL bytes; at most 512 KiB a file and 10 a result, apart from the screenshots’ 20 — and they are set out with the examples in JSON and text files. A file left behind is counted, with one line for each reason, and never costs a result:

plune: text file "huge" not uploaded — Plune takes a text file of at most 512 KiB.
3 text file(s) uploaded to their results, 1 skipped.

N could not be uploaded says the platform refused some — one from before text files refuses them all. Plune cleans a text before it keeps it, and a person opens it from the result’s panel: JSON and text files.

From 0.16.0 the run opens with the commit and the branch it ran on, and the run page shows them — The commit and the branch. plune run import reads them from the job’s own variables, and only where it recognises the CI by the variable in the second column:

CIRecognised byCommitBranch
GitHub ActionsGITHUB_ACTIONSGITHUB_SHAGITHUB_HEAD_REF, and when that is empty GITHUB_REF_NAME
GitLab CIGITLAB_CICI_COMMIT_SHACI_COMMIT_REF_NAME
JenkinsJENKINS_URLGIT_COMMITGIT_BRANCH
  • Nothing is guessed. Off a CI, on one that is not in the table, or in a container that was not given the job’s variables, the run has neither — so a stray GIT_COMMIT on a laptop is no commit.
  • On GitHub, GITHUB_HEAD_REF is a pull request’s own branch and is empty on a push, where GITHUB_REF_NAME is the branch. On a pull request GITHUB_SHA is the merge commit GitHub made to run the checks, not the last commit of the branch.
  • A Playwright JSON report names the commit it ran on — Playwright writes it into metadata.ci — and the run gets that one, not the importing job’s: another job may be importing it. The job’s branch goes with it only when the job is at that same commit. JUnit XML names no commit, so its run takes the job’s own.

Nothing is lost and nothing hangs. Results are written to .plune/pending-results.jsonl, the command says so, and plune run report sends them when the platform is back. Add .plune/pending-results*.jsonl to your .gitignore — the pattern also covers what the file becomes once it has been sent.

A run sent in batches leaves several lines in that file. From 0.16.0 plune run report opens one run for the lines of one import that never reached a run, and gives it the commit, the branch, the CI link and the runner the run had — not those of the machine that replays it. Once 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 all again; it is not deleted. If anything did not go, the file stays exactly as it was. A file written before 0.16.0 replays a run to a line, as it did.