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.

Test cases on the dashboard is that tree — folders › suites › cases, top to bottom — with a panel to the right that shows the case you click: its state, priority and tags, Open for the case page, Edit, the same ⋯ the row has, the facts (suite, spec, stability, updated) and the body. There is no separate suites screen any more; /suites forwards here, and a link to a node (/cases?open=<id>) unfolds the path to it and puts the cursor on it. Folders below the first level start folded; a suite’s row shows how many cases it holds.

The keys walk it: ↑ ↓ over the rows you see, → unfolds a node or opens a case in the panel, ← folds or climbs to the parent, Home / End, Space picks, Enter unfolds or opens, Esc drops the selection — then, below 1024 px, closes the panel, which is a drawer over the tree there.

Cases that belong to no suite sit at the project root — a manual case written before any suite existed, or a case you unfiled on purpose — and the tree shows them last, under a heading of their own, “No suite”.

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 — Move up / Move down in a node’s or a case’s ⋯, or a drag — 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": [] }
]
}

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 folder — apps › web › e2e › catalog;
  • 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.

Every row of the tree has a checkbox — cases and nodes alike. Tick a few, or tick one and Shift+click another to take everything between them in the order you see. While anything is picked, a toolbar stands where the filter bar was, counts what is picked («3 selected», «2 cases · 1 suite selected») and offers Move to…, Assign…, Priority, Retire, Delete and, under ⋯, Copy IDs and Duplicate — each opening the usual dialog once, for all of them: one target, one person, one priority, one reason for the retirement. Esc drops the selection.

A suite’s or folder’s checkbox picks it as a node: what sits beneath it goes along and is not counted twice — its rows grey out with their checkboxes off — and the ancestors above a pick show a dash. Assign, Priority and Retire are for cases, so they grey out with the reason while a node is picked. Move to… over a mixed pick sorts itself by the target’s kind: the cases go into a suite, the nodes under a folder, and the other kind is refused by name in the same answer.

The dialog does not close by itself. It stays with the answer — how many were done and, by name, which were refused and why — because a change that did two of three is something to read, not a toast to miss. The tree re-reads underneath, and the selection is spent.

The same call is on the API, for a batch of your own:

POST /v1/test-cases/bulk
{ "ids": ["…", "…", "…"], "action": { "kind": "state", "state": "deprecated", "stateReason": "the old checkout flow is gone" } }

action.kind is one of move (suiteId, or null for the root, and an optional position — below), assign (assigneeId — a member, or null to take the cases back), priority, state (with an optional stateReason) and delete; up to 200 ids per call. Every id walks the same road as the single PATCH/DELETE — the same actor rule for states (a token is an agent, a session a person), the same suite rule, the same words when refused — so the answer is always 200 with two lists:

{ "done": ["…", "…"], "refused": [{ "id": "…", "error": "an agent cannot move a case from active to deprecated — allowed: in_review, detached" }] }

A refusal of one case is a result, not an error of the call. The call is sequential and not atomic: what was done before a refusal stays done, and each case gets its own audit entry — under one session hash when the agent sends X-Plune-Session, which is what makes forty rows read as one change on the feed. A move target that would refuse every case — a folder, a suite that is not yours — or an assign to somebody who is not in the project is answered 400 before anything moves. Each picked node walks the single node’s road — PATCH or DELETE /v1/suites/{id} — once per picked root.

Rows drag. Lift any row of the selection and the whole selection travels with it, in its order, under a ghost of the row with a count badge; a row outside the selection travels alone. Where it goes is drawn before you let go:

  • between two cases — a 4 px slot above or below the row under the pointer; the cases land there in their order;
  • into a suite or a folder — a frame around its row; cases go last in the suite, nodes last in the folder;
  • nowhere — a red frame and the rule beside it: cases go in suites over a folder, suites go in folders over a suite, a folder cannot hold itself over its own subtree. Such a row takes no drop at all; nothing is sent.

The originals stay where they are until the server answers. Then the tree re-reads, a line for screen readers says what happened — «Moved 3 cases to payment.spec.ts» — the moved rows light up for a moment, and whatever was refused is named at the top of the page.

Hold Alt while dropping and the cases are copied there instead, each right after the previous copy (the marker shows a «+»). Nodes have no copy — a suite or folder in the pick moves as usual. Drag needs a mouse; on a touch screen, Move to… and the ⋯ menu’s Move up / Move down are the same road.

On the API a drop is the bulk move with a position:

POST /v1/test-cases/bulk
{ "ids": ["…", "…"], "action": { "kind": "move", "suiteId": "6f1c…f1", "position": 2 } }

position is the slot counted among the target suite’s cases that are not in ids; the first id takes it and the rest follow in order, past the end meaning last. Without position each case goes last, as a PATCH with suiteId alone does. A single case reorders with PATCH /v1/test-cases/{id} and position — see the fields.

Duplicate, in the ⋯ menu of a row, of the toolbar and of the case page (POST /v1/test-cases/{id}/duplicate), makes a new case right under the original: the same texts, priority and suite, titled «Copy of …», as a draft. On the dashboard the copy opens in the panel with its name selected — type the real one and press Enter, or Esc to keep «Copy of …». What makes a case itself stays with the original — its external keys and its eval id — so the copy is for the texts a person wrote. An automated case has no copy (409): its identity is the spec, so the test is duplicated in code and the next run brings the case.

The call takes an optional body that says where the copy goes instead:

POST /v1/test-cases/{id}/duplicate
{ "suiteId": "6f1c…f1", "position": 0 }

suiteId alone puts the copy last in that suite (null — out of any suite); position is the slot among that suite’s cases. A folder answers 400 with the placement rule, as any filing does.

The whole tree — or a node, or one case — is also one Markdown document in Testomat’s classical format, and the same document goes back: Markdown ▾ › Export .md / Import .md… over the tree, Copy as Markdown in the ⋯ of a case or node, the form’s Markdown tab, and the API’s GET/POST /v1/test-cases/markdown. What the format is and what a push does — matched by id, never deletes, reported before it writes — is on Cases as Markdown.

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.