Skip to content

Organizing cases

The tree of suites follows one rule, and it is the rule your repository already follows: a folder holds folders and suites, a suite holds test cases. Nothing holds both. The rule is enforced on every write, not suggested by a screen — a case cannot be created in a folder, a suite cannot be nested in a suite, and a node cannot change kind while it holds what the other kind cannot.

Every node in GET /v1/suites carries a kind, a position among its siblings, an optional description, and caseCount — the number of cases in it, or, for a folder, beneath it. The tree comes back in tree order: siblings by position, the order a person set with ↑ / ↓ on the Suites screen, not the alphabet.

{
"id": "6f1c…f1",
"name": "checkout",
"kind": "folder",
"position": 0,
"description": "Everything past the cart",
"caseCount": 41,
"children": [
{ "id": "", "name": "payment.spec.ts", "kind": "suite", "position": 0, "caseCount": 41, "children": [] }
]
}

A case that belongs to no suite sits at the project root. That is a normal state — a manual case written before any suite existed, or a case you unfiled on purpose — and the dashboard shows those cases as their own group, last, headed “Project root”.

Each of the six ways to put something somewhere refuses the wrong place with the same words:

You tryAnswer
create or move a case into a folder400 · suite '<id>' is a folder — cases go in suites
create or move a suite under a suite400 · parent '<id>' is a suite — suites go in folders
turn a suite that still holds cases into a folder409 · suite '<id>' still holds test cases — POST /v1/suites/<id>/convert-to-folder moves them into a child suite
turn a folder that still holds suites into a suite409 · folder '<id>' still holds child suites — move or delete them first

The 409 answers name the way out, which for a suite with cases is the next section.

A suite that has grown a second level cannot simply become a folder — its cases have to go somewhere. POST /v1/suites/{id}/convert-to-folder does the whole move in one transaction:

  • the node keeps its id and its place and becomes a folder;
  • a child suite of the same name appears inside it at position: 0, and every case moves there — and so does the suite’s file, if the suite was made by filing (below), so later imports keep landing in it;
  • both steps are written to the audit log under one session, so the restore of one is the restore of both.

The answer is both nodes:

{ "folder": { "id": "6f1c…f1", "kind": "folder", "…": "" }, "child": { "id": "", "name": "checkout", "kind": "suite", "position": 0, "…": "" } }

Converting a folder answers 409 · suite '<id>' is already a folder. On the dashboard the same action sits in the node’s menu and asks first, showing what the node is and what it becomes.

An automated case knows where its test lives: its specRef is a path such as apps/web/e2e/catalog/search.spec.ts:9. Plune reads that path and files the case by it, on every road a case can arrive by — POST /v1/test-cases, an approval in the review queue, a trusted source:

  • each directory becomes a folderappswebe2ecatalog;
  • the file becomes a suite, named by its basename with the extension — search.spec.ts;
  • the :line or #anchor tail is not part of the place.

Folders and suites are found or created as needed, and a suite made this way remembers its file, so renaming the suite on the dashboard does not stop the next import from finding it. Send a suiteId yourself and the case goes where you said instead; a manual case with no suiteId stays at the root.

Cases that existed before this rule did — imported earlier, approved earlier — may still sit at the root with a specRef that would place them. POST /v1/admin/file-unfiled walks every project and files each such case through the same seam, reporting what it did per project:

{
"projects": [
{ "projectId": "", "filed": 118, "skipped": [{ "caseId": "", "reason": "filing skipped: 'e2e' exists as a suite, expected a folder" }] }
]
}

It is idempotent — a second run reports filed: 0 everywhere and the same skipped — and it is an operator’s call: the route answers only the owner of the deployment, and 404 to everyone else.