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. -
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 saved credentials file from
plune 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.https://beta.plune.ai/runs/…Read counts what was in the file. Everything after it says what became of those results:
| 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 |
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.
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 5000 test cases.
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"PLUNE_SHARED_RUN=1 plune run import ./app/junit.xml --key "$GITHUB_RUN_ID"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.
- 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.
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 that file to your
.gitignore.