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.
The document
Section titled “The document”<!-- suiteid: 2f6b0f2e-…--># apps > web > checkout.spec.ts
What this suite covers.
<!-- testid: 9c1e4f7a-…type: manualpriority: highstate: 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.
Metadata
Section titled “Metadata”| Key | On | What Plune reads |
|---|---|---|
id | suite, case | the 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). |
type | case | manual, automated or eval; absent means manual. A case’s type never changes through Markdown. |
priority | case | low, normal, important, high, critical |
state | case | written 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 |
spec | case | an automated case’s spec reference (e2e/catalog/search.spec.ts:9) — required for type: automated |
tags | case | comma-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) |
goldenSet | case | an eval case’s golden set id |
assignee, labels, creator, shared, emoji | either | Testomat’s; Plune has no home for them — ignored, one warning per key per document |
Sections
Section titled “Sections”| Heading | Field |
|---|---|
## Preconditions | the case’s preconditions |
## Steps | the 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 result | the case’s overall outcome, for a case that states one result rather than one per step |
## Prompt | an eval case’s prompt — plain, or inside a ``` fence when it holds # lines of its own |
## Assertions | an eval case’s assertions: a JSON array, in a ```json fence or bare |
<!-- example --> + a table | the 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.
Reading the document
Section titled “Reading the document”# the whole tree, unfiled cases first, then every suite in tree ordercurl -H "Authorization: Bearer $PLUNE_TOKEN" https://beta-api.plune.ai/v1/test-cases/markdown > cases.md# one node's subtree, one case's blockcurl … /v1/test-cases/markdown?suiteId=<id>curl … /v1/suites/<id>/markdowncurl … /v1/test-cases/<id>/markdownThe 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.
Writing it back
Section titled “Writing it back”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.
On the dashboard
Section titled “On the dashboard”- 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.
From the CLI
Section titled “From the CLI”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.