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.

  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 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.
https://beta.plune.ai/runs/…

Read counts what was in the file. Everything after it says what became of those results:

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

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 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 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:

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

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

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.