Test Management: Trash, History, Attachments & Run Reports

Projects and sections say where a case belongs. This page covers what happens to a case and to a run over their lifetime: how a delete becomes recoverable, who changed what and when, how a case is copied, renamed in bulk, carried out as XML and back, and how a finished wave gains a name, comments, defect links and a spreadsheet a QA lead can read.

At a glance
  • Nothing is deleted outright. A delete moves the case to a trash you can list and restore from; only Purge is final.

  • Every case edit is versioned. Editor save, upload, bulk update, duplicate, import, re-import and restore all land in the same numbered history, with a snapshot and a diff.

  • Cases carry files. Trees, screenshots and spec extracts attach to a case without touching its YAML.

  • Runs carry words. A batch has a name, a project and a section; a result row has a comment and a defect key; the whole run exports to CSV, Excel or XML and imports back.

  • The YAML and the batch JSON remain the source of truth — everything here is stored beside them, never inside a step.

Trash and Restore

Deleting a case — Delete in the bulk bar of Test cases, Delete in the Editor’s More menu, or DELETE /api/tests/{filename} — moves the YAML into tests/cases/.trash/ instead of unlinking it. A sidecar <filename>.meta.json records test_id, title, deleted_by, deleted_at and an optional reason, and the event is written to the case’s history as deleted with the account that did it.

The case list only globs *.yaml at the top level, so a trashed case leaves the UI immediately while staying whole on disk.

Trash (N) in the Library menu of the Test cases header switches the list to the trashed rows — Case | Title | Deleted by | When | Reason — each offering Restore and Purge; the view’s own header has Refresh and, for admins, Empty trash. The menu item is offered to every account: the restore and purge buttons inside the view carry their own gate, and hiding the way in would hide that a delete is undoable.

Method Path Purpose

DELETE

/api/tests/{filename}

Move one case to the trash. Answers {file, message, trashed:[name]}.

DELETE

/api/tests?section=

Move a whole section to the trash, each case attributed individually. Answers {deleted, errors, section, trashed:[names]}; an empty section takes every case.

GET

/api/trash

Trashed cases, newest delete first: [{file, test_id, title, deleted_by, deleted_at, reason}].

POST

/api/trash/{filename}/restore

Move the case back. 409 when a live case of that name already exists.

DELETE

/api/trash/{filename}

Purge one trashed case for good, with its attachments and its whole version history.

DELETE

/api/trash

Empty the trash (admin only) — same removal, for every case in it.

A purge is the only step that removes anything permanently, and it removes everything the case had: the YAML, its sidecar, .attachments/<stem>/, .history/<filename>/, the case_history rows and the case_meta row that recorded who created and last edited it. That completeness is the point — a case later created under the same filename starts its history at version 1 rather than inheriting the numbering of the case that is gone.

Each removal is defended separately and none of them is fatal: a directory the name validator will not vouch for is left alone and logged, and a file the OS refuses to delete is logged rather than raised — a purge that half-succeeded is reported, never a 500 over a case that is already gone.

What a restore does and does not do

A restore puts the file back under its original name and logs restored; it never overwrites a live case of the same name — rename or delete that one first. A second delete of the same filename replaces the trashed copy: the newer delete wins and the older copy is gone.

Re-import (POST /api/testrail/reimport) does not go through the trash. It deletes its own previous output outright before converting — a re-conversion may produce a different number of scenario files under different names, and the superseded ones must not linger beside the new — and puts the old text back from memory on any path that then writes nothing (a cache-only miss, a conversion that returns no YAML, a TestRail failure mid-way). Nothing there was deleted by a person, so nothing there belongs in the trash.

Duplicating a Case

Copy on a case row of Test cases (it appears on hover, beside the pencil) and Duplicate in the Editor’s More menu both post POST /api/tests/{filename}/duplicate. The body takes an optional {id?, title?}; neither button sends one, so from the UI you always get the generated pair below. A copy is the original verbatim except for three things:

  • the id — <id>-copy, then -copy2, -copy3… until no case in the corpus carries it (ids are checked across all cases, not just filenames);

  • the title — <title> (copy);

  • testrail_runs, which is dropped. Run membership belongs to TestRail: the copy is a new case, not a second answer for the same run.

Attachments are copied with it, into the new case’s own directory. The new file opens its history with a created entry whose change line reads duplicated from <file>. An id or a filename that already exists answers 409, and the filename the new id would produce is validated before it is ever joined to the corpus directory.

Bulk Update (Find / Replace)

Tick rows on Test cases and choose Find / replace… from the bulk bar. The modal previews every before/after in a table before anything is written: Preview runs the call with dry_run (its default) and only then does Apply light up, which repeats the call with dry_run: false behind a confirmation naming the field and the number of cases. dry_run defaults to true on the endpoint too, so the preview is the default and the write is the deliberate second step.

Find / replace — the dry-run preview before anything is written

The modal only ever sends mode: replace. The other mode has its own button: Set section… in the same bar asks for one value and writes it onto every ticked case (mode: set, dry_run: false) — there is nothing to preview when the "after" is the value you just typed.

POST /api/tests/bulk-update:

{ "files": ["C1-a.yaml", "C2-b.yaml"],
  "field": "title",
  "mode": "replace",
  "find": "OIT ACS", "replace": "CPE Admin",
  "dry_run": true }
Key Meaning

field

One of title, section, project, priority, type, reference. Anything else — a step field in particular — is 400: this endpoint never rewrites a step.

mode

replace (case-sensitive substring, needs find) or set (needs value).

value

For set. An empty value removes the field; for a metadata field that empties metadata, the key goes too.

dry_run

true (default) previews; false writes.

The answer is {changed, unchanged, errors, dry_run, items:[{file, before, after, changed, error?}]}; unchanged counts the files that were read and left alone, while a file that could not be read at all is an error, not a decision to skip it. Each written file records an edited history entry whose change line names the field, e.g. title: OIT ACS login → CPE Admin login, and an empty value renders as (none). Setting project registers the project first (the same ensure_project an import uses). The filename never changes — a bulk rename edits titles, not files.

The writer reloads and re-dumps the YAML with the serializer’s own dumper and no line wrapping, so untouched keys stay byte-identical. A one-field edit across hundreds of cases is a one-line diff in each of them.

Change History and Versions

Editor Save and New, Upload, bulk update, duplicate, restore-from-trash, restore-from-version, XML import, TestRail import and re-import all go through one recorder, so the history is complete whichever of those buttons produced the change.

Four writes deliberately do not go through it, because they patch one organisational field rather than edit a case: POST /api/tests/assign-project, a project rename or delete (both rewrite test_case.project), POST /api/testrail/backfill-runs and POST /api/testrail/backfill-fields. They change the YAML without opening a version — see Organizing Cases → Populating the Fields.

A version is a number plus a full snapshot of the file as it was written. Snapshots live in tests/cases/.history/<filename>/v<N>.yaml.

The Editor’s Change history panel lists, newest first: version, action, who, when, the changed fields, the case’s Jira reference (rendered as a link) and the portal build the change was made against. Two actions per row, both offered only while the row still has its snapshot:

  • Diff — a modal holding the unified diff of that snapshot against the file as it is now, and nothing else; when the two are identical it says so. The modal offers Restore this version too, so you can read before deciding. The snapshot’s own YAML is fetched with the diff but is not displayed.

  • Restore — writes the snapshot back as the current file, with a confirmation. The restore is itself a new version (restored v3), so nothing is lost by walking back.

The Editor’s Change history — versions, changed fields, Diff and Restore
Method Path Purpose

GET

/api/tests/{filename}/history

[{version, ts, username, action, changes, reference, build, has_snapshot}], newest first. Rows written before versioning show version: null.

GET

/api/tests/{filename}/history/{version}

{version, yaml, diff} — the snapshot and its unified diff against the current file.

POST

/api/tests/{filename}/history/{version}/restore

Write that snapshot back as the current case and log restored.

The change lines are computed by comparing the two parsed cases, not the two texts: title, section, project, portal, preconditions, metadata.references / priority / type, emulator_setup, and the steps (steps: 12 → 13, step 4 changed, at most ten step numbers listed). A file that cannot be parsed on either side records content changed; two files that differ in nothing the recorder tracks record no visible change.

Numbering is taken under a process-wide lock: two saves of one case arriving together cannot both claim the same version and overwrite each other’s snapshot. A snapshot that cannot be written is logged and the version row is still recorded — the row then carries no snapshot, and its Diff and Restore buttons are not offered.

Execution history per case

The Editor’s Execution history panel shows the case’s last 20 runs — Verdict | When | Took | Env | Owner — from GET /api/runs?test_id=<id>, which matches the case id exactly, so asking for C1 never answers with C10. Clicking a row opens that run’s report. GET /api/tests/{filename} carries a last_run summary ({id, status, date, elapsed_ms} or null) drawn from the runs you can see; through the gateway it is the newest across every worker, not just the one that served the case list.

Per-Case Attachments

A case can carry files: the device tree it was written against, a screenshot of the expectation, an extract from a spec. They live in tests/cases/.attachments/<filename stem>/, outside the glob the case list walks — attaching a file never adds a field or a step to the YAML.

The Editor’s Attachments panel lists, uploads, downloads and deletes them, showing each file’s size and uploader, and offers .xml files a one-click Use as tree button that uploads the file through POST /api/trees/upload and writes its name into the form’s tree_file. It needs an Emulator Setup block to write into, and it only fills the form — Save the case to keep it.

Method Path Purpose

GET

/api/tests/{filename}/attachments

[{name, size, mtime, uploaded_by}].

POST

/api/tests/{filename}/attachments

Upload (multipart file). Answers {name, size, replaced}.

GET

/api/tests/{filename}/attachments/{name}

Download one file.

DELETE

/api/tests/{filename}/attachments/{name}

Remove one file.

Limits: 20 MB per file; the stored name is the basename with anything outside A-Za-z0-9.- folded to , so a browser sending a full Windows path and a caller trying to climb out of the directory both end up with a plain name. Uploading the same name again replaces the file and says so (replaced: true). Viewers may read attachments; only admins and editors may upload or delete.

Trashing a case and restoring it leave the attachment directory exactly where it is — while the case sits in the trash the attachment routes answer 404 (there is no such case), and a restore makes them readable again. Purging the case is what removes the files.

Exporting and Importing Cases as XML

TestRail’s own XML cannot carry an executable step, so the agent exports its own lossless format: one <test_case> element per case, with the whole YAML as the text of its <yaml> element. The text is XML-escaped on the way out (&, < and > become entities) and unescaped on the way back in, so the round trip is byte-exact and no CDATA section is involved.

<ft-test-cases version="1" exported_at="2026-09-08T10:00:00Z" exported_by="armen">
  <test_case file="C123-x.yaml" id="C123" title="…" portal="management"
             section="TR" project="P">
    <yaml>test_case:
  id: C123
  title: Devices &gt; Correct display
  steps: []
</yaml>
  </test_case>
</ft-test-cases>
  • Export XML in the bulk bar → POST /api/tests/export-xml, body {files:[…]}; an empty list exports the whole corpus, and the button sends the ticked cases (falling back to everything the filters currently show). The answer is an application/xml attachment named test-cases-<date>.xml.

  • Import XML… in the Library menu of the Test cases header → POST /api/tests/import-xml (multipart file, form on_conflict=skip|overwrite and llm_mode). The on-conflict choice is asked in a dialog once the file has been picked, so it is answered about a known file rather than left standing in the toolbar: the dialog names the file and offers Skip existing cases (the default) or Overwrite existing cases, then Import or Cancel. Cancel has the focus when the dialog opens — it appears by itself after the file picker closes, and a stray Enter must not start an import.

    Import test cases — the on-conflict dialog

    A document rooted at ft-test-cases is written case by case, each recording an imported history entry. The answer is {imported, skipped, overwritten, errors, with_attachments}.

    Any other root is treated as a TestRail export, so the same item takes one. A TestRail export only names its cases. The worker reads each case from TestRail and converts it in the background import job. This path needs a working TestRail connection. The answer is the job, {job_id, status, total}. The job converts the cases in the LLM mode of the dialog. The default is No LLM, cached only: the job skips a case without a cached conversion. A native document ignores the mode. The page shows the result when the job ends. A case missing from its TestRail suite is listed as an error. An export of a suite with another name than TestRail’s suite of the same id is refused.

Attachments are binary and stay out of the XML; the answer names the cases that had some so you know what did not travel. An import writes live files only — it never empties the trash, so an export/delete/import cycle leaves the trashed copies in place.

What an import refuses before it parses

A document larger than 64 MB answers 413 (the reader stops one byte past the cap, so an oversized file never becomes an oversized object in memory), one that is not UTF-8 answers 400, and any document type declaration answers 400 — anywhere in the text, not just in the prolog. <!DOCTYPE> is the only way an XML file defines its own entities, and the parser would expand them. Nothing legitimate is lost: a case whose YAML quotes the string travels escaped inside its <yaml> element and never appears literally.

Per case, a <yaml> element that does not parse, that has no test_case mapping, or that is missing id / title / steps is reported in errors and skipped; the rest of the document is still written.

The API

GET /api/tests takes, alongside the existing isp and run filters:

Parameter Meaning

q

Case-insensitive substring over id, title, reference and section, all four joined into one haystack.

project, section, portal, type, priority

Whole-field, case-insensitive matches.

reference

Substring over the case’s Jira reference — deliberately not a whole-field match, because one case can carry several references in one string.

sort

id, title, type, priority, reference, section or edited. An unknown value is 400. With no sort the order is unchanged — by filename.

order

asc (default) or desc; anything else is 400.

All of them combine, and with isp and run. id and section sort the case id numerically, so C9 precedes C10; edited reads the DB-side stamp, which is 0 for a case nobody has touched.

The UI

The SPA does not use those parameters. It fetches the corpus once (GET /api/tests, no query string) and narrows and orders it in the browser, so the header’s controls and the API’s parameters overlap without being the same set — the API can sort by id and by edited, the header offers Last run instead.

The filter row under the Test cases header carries the search box (which matches on reference and section as well as on id and title), the All portals / All projects / All sections / All types / All priorities / Any last result filters, the Sort select (Section · ID, Title, Type, Priority, Reference, Last run), Reset filters — shown only while something is set, and clearing all ten at once — and the More filters toggle. Any ISP mode, Any run and Any claims sit behind that toggle, whose label carries how many of the three are active, so a filter set there cannot narrow the list unseen; the row opens by itself when one of them becomes active and remembers its state between visits. None of the ten filters touches the selection: a ticked case stays ticked when the next keystroke hides it, which is how a batch of two is assembled by id (tick C11653, search for C91355, tick that one too). What the filters hide is named instead of dropped — the pill carries an M hidden chip next to N selected, and Run Selected (N) counts the whole selection. Up to the 2026-09-08 build every filter pruned the selection, and the second search emptied what the first had picked.

The header’s remaining controls are the run bar, an icon-only Refresh, and the Library menu — New test case (the Editor’s new-case form), New project (a project before any case lands in it), Import XML…, Sync runs… and Trash (N). See Running Tests → Test Cases for the header read row by row.

Naming, Commenting and Exporting a Run

Giving a batch an identity

The run bar of Test cases accepts an optional Run name. POST /api/run/batch stores name (≤ 120 characters), project, section and source_batch on the batch; they are saved into batch-<id>.json and returned by GET /api/run/batch/summary, /report and /reports, and forwarded by the gateway. The default name is empty, and the UI then shows the batch id as it always did.

source_batch is set by Duplicate run, which re-posts a finished report’s own test_files as a new batch named Copy of <name or id>, carrying the same project and section and pointing back at the report it came from. A duplicate starts empty — it copies the case list, never the results.

Statistics

The batch report opens with the four TestRail-style tiles — Passed, Failed, Blocked, Retest — each with its count and its share of the whole wave, the Passed tile also carrying n measured underneath. Below them the headline "284 of 405 passed", the n blocked and m skipped split when the wave has any, and the verdict chips the table is cut by.

Batch report — four tiles, verdict chips, failure breakdown and the results table

The buckets are computed on the server (_summarize_batch_results) so the modal, the exports and a printed report cannot each round differently:

  • retest = inconclusive + error — the runs that say nothing yet and have to be repeated;

  • the blocked share also counts skipped rows (a precondition the stand could not meet), while the blocked count stays verdict-only;

  • passed_of_total is the pre-composed "<passed> of <total>" line.

A report saved before the server computed these is counted in the browser from its own rows, reading skipped as blocked and error as retest exactly as the server does — so an old report and a new one are read the same way.

Each percentage is rounded to one decimal independently, so the four shares can add up to 99.9 or 100.1. The counts are the arithmetic of record; the percentages are a reading aid.

Comments and defects

A wave is discussed, and the discussion belongs with the report rather than in chat. The report modal has a Comments panel at its foot for the batch, and every result row carries a Notes & defects cell edited in place — a pencil opens a Comment and a Defects field, each capped at 2000 characters. Defects are free text of Jira keys, rendered as links by the same helper the case reference uses.

The Notes & defects column on every result row — empty here — and the report’s Comments composer
Method Path Purpose

GET

/api/run/batch/reports/{batch_id}/comments

The batch’s comments.

POST

/api/run/batch/reports/{batch_id}/comments

Add one. Body {text} (trimmed, ≤ 2000 chars) → {id, ts, username, text}.

DELETE

/api/run/batch/reports/{batch_id}/comments/{id}

Remove one — author or admin.

PATCH

/api/run/batch/reports/{batch_id}/results/{run_id}

Set {comment?, defects?} on one result row; mirrored into that run’s own JSON. 409 while the batch is still running.

Every one of these is owner-scoped: a batch you do not own answers 404, not 403 — you are not told it exists. The row mirror is checked separately, so a row naming somebody else’s run updates the report and leaves that run alone.

Reading and exporting a report you own needs no more than a viewer account; writing on one — a comment, a defect key, an imported run — needs the write role, because a report is the artefact the morning review is held over. A saved report that cannot be read at all is treated as a missing one.

Export: CSV, Excel and XML

GET /api/run/batch/reports/{batch_id}/export?format=csv|xlsx|xml, reached from the report modal’s Export menu — CSV, Excel, XML. All three are built by the server, so they carry the columns of the printed report rather than the rows the modal happens to be showing, and each downloads as run-<batch_id>.<ext>.

The CSV and the workbook carry the same columns, in this order:

Title | Build | Case ID | Defects | Section | Status | Version | Expended time

then Verdict, Reason, Run ID and Retry of, and finally a summary block with the counts, the percentages and the total time.

Column Where it comes from

Title, Case ID, Section

The row, stamped from the case when the run was created.

Build, Version

The batch’s environment stamp — see Portal Build and Version.

Defects

The row’s defects if one was typed, otherwise the case’s own reference.

Status

The QA-facing label (see the mapping below).

Verdict

The raw runner value, unmapped.

Expended time

H:MM:SS.

Verdict Status

passed

Passed

failed

Failed

blocked, skipped

Blocked

inconclusive, error

Retest

The XML export is the round-trip format: <ft-test-run> with the batch attributes, a <summary/>, a <results> list of <result> elements (each optionally carrying a <comment>) and a <comments> block. POST /api/run/batch/reports/import-xml — Import XML in the header of the Batch reports view — takes it back in as a new report owned by the importer, status completed, with imported_from naming the original batch, a fresh eight-hex id and the summary recomputed from the rows. Imported comments keep their original author and go through the same validator a typed one does; an empty one is dropped. Through the gateway the import lands on the primary worker, since a report that has not run anywhere has no worker of its own yet.

The importer refuses a document over 20 MB, one whose root is not <ft-test-run>, and any that declares a document type or an entity — the same "billion laughs" guard the case import uses.

Excel and spreadsheet safety

xlsx needs openpyxl. When the module is absent the endpoint answers 501, and the UI says so and saves the browser-built CSV instead, so nobody is left holding nothing.

A CSV cell starting with =, +, -, @, a tab or a carriage return is prefixed with an apostrophe, and every string cell of the workbook is written as text, so a title or a defect note is never executed as a formula. The CSV also opens with a byte-order mark, which is the only way Excel reads it as UTF-8. The XML export is data, not a spreadsheet, and is left unquoted; control characters and surrogates are stripped from every cell of all three, because a traceback carries them and both renderers choke on them.

Editing a run in flight

PATCH /api/run/batch/{batch_id} body {add:[files], remove:[files]}, owner-scoped, accepted while the batch is running or paused — any other status answers 409. Edit run exposes it with an add/remove case picker, both on the batch report and on the Wave in progress card.

The loop consumes a live queue, so an added case is appended to what is still to come and a removed one is dropped if it has not started. Removals are processed before additions, so one call can swap a case out for another without the new one being refused on a name that is about to leave. Nothing is all-or-nothing: every file the batch cannot take comes back in rejected with its reason — already started, not queued, not found, not a case name, not batchable (a draft or an explore file) or already in batch — and the rest is applied, so one stale name in a list of forty does not cost the other thirty-nine. The answer is {added, removed, rejected, total}. GET /api/run/batch is what the picker reads: it splits the wave into started_files and queued_files, because only a queued case can still be withdrawn. Through the gateway the PATCH fans out to every worker; the one holding your batch answers, the rest 404 by ownership.

Portal Build and Version

Every batch stamps the portal it ran against, and the two values fill the Build and Version columns of the export and the header of the report.

They come from POST {base_url}/iot-webservice/iotw/System/information with an empty body — the call needs no token and answers e.g. {"appVersion": "7.1.1 Build master_7.1.2.RC"}, which is split on " Build " into portal_version (7.1.1) and portal_build (master_7.1.2.RC). A shape that does not split reports the whole string as the version rather than dropping what was known. The answer is cached for 10 minutes per base URL with a 3-second timeout, and any failure caches an empty answer just as a success is cached, so a stand that is down costs one connect attempt per TTL rather than one per run: the stamp never delays or blocks a run.

The probe is skipped entirely when FT_PORTAL_VERSION_PROBE=0 — no unit test may dial a stand. A case edit reads the cache and never dials, because three of the four case-write endpoints are async, and a synchronous probe on the event loop would stall every other request for as long as the portal took to answer; an edit made on a server that has never run against the stand therefore records no build rather than delaying the save to learn one.

A batch that ran before the stamp existed carries neither value, so the Build column of the batch report list is empty for it. It fills in from the first wave started after the stamp shipped.

GET /System/version exists but answers 500 against the shipped version.xml, so it is deliberately not used.

Where the Data Lives

Nothing on this page adds a field to a case YAML or a step to a test. The side data sits next to the corpus, in directories the *.yaml glob never sees:

Location Holds

tests/cases/.trash/

Deleted cases plus a <filename>.meta.json naming who deleted them and when.

tests/cases/.history/<filename>/v<N>.yaml

One snapshot per version. The version rows themselves (who, when, action, changes, reference, build) are in the SQLite case_history table, beside the case_meta row that records who created and last edited the case.

tests/cases/.attachments/<stem>/

A case’s files, with a .meta.json recording the uploader.

run-reports/batch-<id>.json

The batch report, including its name, project, section, comments and per-row comments/defects.

The trash and the history snapshots are never purged automatically — there is no retention job, and no route or button removes a snapshot on its own. A case’s snapshots, its version rows and its case_meta row go exactly once: when the case is purged from the trash, which takes its attachments with them. Short of that, the only way to remove one is by hand on the volume — so nothing disappears while you are not looking.
Which worker holds it

Everything on this page except the batch routes is proxied to the worker the gateway resolves for you, and each worker keeps its own tests/cases volume: a trash listing, a version history, an attachment or a bulk update is about that worker’s copy of the corpus, and a write reaches no other worker.

A batch report is different — it lives on the one worker that ran it, so the gateway resolves the batch id to that worker and sends the comments, the row annotations and the export there. The exception is import-xml, which mints a report that has not run anywhere and therefore lands on the primary worker.

See Also

  • Organizing Cases — projects, sections and the org fields a bulk update edits.

  • Editor Guide — the form the Change history, Execution history and Attachments panels sit beside.

  • Running Tests → Batch Runs — starting the wave these reports come from.

  • REST API — the one-line reference for every route named here.