Skip to content

Cases as Markdown

A case is a record with a body of one of three kinds, and until now the only ways to it were the form and the JSON API. People write cases in an editor; agents write them in the files of a repository. This page is the format both can use: a Markdown document that Plune writes and reads without losing anything of its own, and that other tools already speak.

The format is Testomat.io’s Classical Tests Markdown Format, taken as it is — their rules, their parser’s reading of them — with a few columns added for what Plune has and they do not. A document written for them is a document for Plune; a document Plune writes is one their tools import.

<!-- suite
id: 2f6b0f2e-…
-->
# apps > web > checkout.spec.ts
What this suite covers.
<!-- test
id: 9c1e4f7a-…
type: manual
priority: high
state: active
-->
## empty cart says so @cart @smoke
An empty cart names itself and offers no checkout.
## Preconditions
A signed-in shopper with nothing in the cart.
## Steps
* Open the cart
*Expected*: «Your cart is empty» is the only line
* Look for the checkout button
*Expected*: there is none
## Expected result
Nothing to pay for, nothing to click.

A document is blocks: a <!-- suite … --> block opens a suite, a <!-- test … --> block opens a case, and each runs until the next block. The lines between <!-- and --> are the block’s metadata, one key: value per line; a line without : is ignored, and <!-- test --> on one line is a block with none. The title is the first # or ## heading after the block; a case’s @tags live in it, as they live in the title everywhere in Plune. Everything after the title is the description, except the ## sections below, which go to their own fields.

Cases before the first suite block belong to no suite. A nested suite is its path in the title — # apps > web > checkout.spec.ts — folders first, the suite last; a folder has no block of its own, it is the segments before the suite.

KeyOnWhat Plune reads
idsuite, casethe record’s UUID — how a push finds what to update. A Testomat @T…/@S… id is not one of ours: warned, and the block is treated as new (a suite: matched by its path).
typecasemanual, automated or eval; absent means manual. A case’s type never changes through Markdown.
prioritycaselow, normal, important, high, critical
statecasewritten by Plune for the eye; on the way back it is only read — a state moves through its own action, and a different value is a warning, not a change
speccasean automated case’s spec reference (e2e/catalog/search.spec.ts:9) — required for type: automated
tagscasecomma-separated, as Testomat writes them: added to the title as @tags on the way in, never written on the way out (the title is the one source)
goldenSetcasean eval case’s golden set id
assignee, labels, creator, shared, emojieitherTestomat’s; Plune has no home for them — ignored, one warning per key per document
HeadingField
## Preconditionsthe case’s preconditions
## Stepsthe steps: a * or 1. item is a step’s action, *Expected*: (or *Expected result*) nested under it is that step’s outcome; a line that is neither continues the last text
## Expected resultthe case’s overall outcome, for a case that states one result rather than one per step
## Promptan eval case’s prompt — plain, or inside a ``` fence when it holds # lines of its own
## Assertionsan eval case’s assertions: a JSON array, in a ```json fence or bare
<!-- example --> + a tablethe case’s parameters, read by Testomat’s rules: the row above the --- separator is the column names, every row under it an iteration. A table without a header row is refused by line; a document without the table clears it

Any other ## heading stays in the description — including a ## Steps you wrote inside a prompt’s fence, which the reader skips over. Headings are matched without regard to case, so Cairn’s ## Expected Result reads the same.

Terminal window
# the whole tree, unfiled cases first, then every suite in tree order
curl -H "Authorization: Bearer $PLUNE_TOKEN" https://beta-api.plune.ai/v1/test-cases/markdown > cases.md
# one node's subtree, one case's block
curl /v1/test-cases/markdown?suiteId=<id>
curl /v1/suites/<id>/markdown
curl /v1/test-cases/<id>/markdown

The answer is text/markdown; X-Plune-Cases counts the cases in it and Content-Disposition names it cases.md. state=active (repeatable) keeps cases of those states; the bin is never in it. A single case comes without a suite block — so a block copied from one place and pasted back edits the case where it is, rather than moving it.

Terminal window
curl -X POST -H "Authorization: Bearer $PLUNE_TOKEN" -H "content-type: text/markdown" \
--data-binary @cases.md "https://beta-api.plune.ai/v1/test-cases/markdown?dryRun=true"

Each case block is matched by its id:

  • known — the case is updated: title and tags, description, preconditions, priority, the body, and its suite when the document has suite blocks. A case whose fields already read as the document says is unchanged, and nothing is written for it — a file your editor reformatted is not two thousand edits;
  • absent — a new case, as a draft, in the suite of the block it sits under (or none), last;
  • unknown — refused, by line. Ids are the server’s; a document cannot invent one.

Suite blocks are matched by id, else by their path: missing segments are created — folders for the way down, a suite at the end — each with a warning naming it. A path that runs through a suite (a suite holds cases, not suites) refuses the block and every case under it.

Nothing is deleted. A case or suite the document does not name is not touched, so a document of one suite is a safe thing to push. A case’s type never changes this way; state: is read back as a warning. A document with no suite block at all moves nothing.

The answer is always 200 with the report — refusals are inside it, not a status code:

{
"dryRun": true,
"created": [{ "title": "empty cart says so @cart @smoke", "line": 12 }],
"updated": [{ "id": "9c1e…", "title": "search by sku @catalog", "line": 1 }],
"unchanged": [],
"refused": [{ "title": "nobody", "line": 40, "error": "unknown id '0000…'" }],
"suites": { "created": [{ "path": "apps > web", "line": 1 }], "updated": [] },
"warnings": [{ "line": 1, "message": "created folder 'apps > web'" }]
}

Each write is its own transaction, as in a bulk action: what was done before a refusal stays done, and the report says which was which. With ?dryRun=true the same report comes back and nothing is written — not even the folders of a new path. The dashboard shows that report before it imports; plune push --dry-run prints it.

400 is an empty body, 415 a body that is not text/markdown (or text/plain), 413 a document over 10 MB.

  • The form’s Markdown tab. Next to the case’s name in the form, Form | Markdown — the same case as its block. Edit the steps as a list, and the preview under the editor shows what the case page will show; a text that does not read keeps its refusal there, with the line, and the Form tab stays shut until it does. The suite is the field above the editor, the state is not in the block: both have their own controls. Save sends exactly what the form would.
  • Copy as Markdown, in the of a case (the tree, the panel, the case page) and of a suite or folder (its subtree) — for an issue, a chat, a file.
  • Markdown ▾ over the tree: Export .md downloads the whole tree as cases.md; Import .md… takes a file, shows the dry-run report — how many to create, to update, unchanged, refused and why — and writes only when you press Import. Refusals do not stop the rest.

plune pull [file] writes the document (plune/cases.md by default) and refuses to overwrite a file with uncommitted changes unless told --force; plune push [file] [--dry-run] sends it back and prints the report as a table, exiting non-zero when anything was refused. Both are in @plune-ai/cli since 0.12.0.