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.
plune run import ./junit.xmlThat 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.
Print a report your runner already supports
Section titled “Print a report your runner already supports”-
Produce the XML. Most runners need one flag or one package:
Terminal window # Vitestvitest run --reporter=default --reporter=junit --outputFile.junit=junit.xml# pytestpytest --junitxml=junit.xml# PHPUnitphpunit --log-junit junit.xml# Jest — via the jest-junit reporterjest --reporters=default --reporters=jest-junit# Playwright — JUnit, or its own JSONnpx playwright test --reporter=junitMaven Surefire writes
target/surefire-reports/*.xmlwithout 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’srootDiris 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. -
Give the CLI a token.
run importreadsPLUNE_TOKENfrom the environment, so a CI job can pass a secret straight in without aloginstep:Terminal window export PLUNE_TOKEN="plune_xxxxxxxx"A
.envin 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 fromplune loginworks too, and takes second place to the variable. -
Import it.
Terminal window plune run import ./junit.xmlThe command prints what it read, what landed, and the URL of the run.
What the output means
Section titled “What the output means”plune: 1465 accepted · 10 with no matching test case · 10 offered for reviewRead 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.
| Word | Meaning |
|---|---|
accepted | matched a test case in your project and was recorded |
already there | this exact result was reported before — a re-run of the same import changes nothing |
unmatched | Plune has no case for this test yet. Counted, not lost. See --create below |
refused | two results in one batch carried the same identity — usually two tests with the same name |
not delivered | never 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.
Where the cases land
Section titled “Where the cases land”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.
The first import: --create
Section titled “The first import: --create”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:
plune run import ./junit.xml --createApproving 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.
A big suite takes more than one round
Section titled “A big suite takes more than one round”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 iswaiting, 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 longerneed, 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:
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.
Several jobs, one run
Section titled “Several jobs, one run”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:
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 importThe 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 --createTwo 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.
JSON and text files
Section titled “JSON and text files”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.
The commit and the branch
Section titled “The commit and the branch”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:
| CI | Recognised by | Commit | Branch |
|---|---|---|---|
| GitHub Actions | GITHUB_ACTIONS | GITHUB_SHA | GITHUB_HEAD_REF, and when that is empty GITHUB_REF_NAME |
| GitLab CI | GITLAB_CI | CI_COMMIT_SHA | CI_COMMIT_REF_NAME |
| Jenkins | JENKINS_URL | GIT_COMMIT | GIT_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_COMMITon a laptop is no commit. - On GitHub,
GITHUB_HEAD_REFis a pull request’s own branch and is empty on a push, whereGITHUB_REF_NAMEis the branch. On a pull requestGITHUB_SHAis 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.
When the platform is unreachable
Section titled “When the platform is unreachable”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.