Your first case
A case is a data file, not a script. The steps, their locators, their acts and their expectations are fields; the loop, the waits, the attempts and the verdict belong to the engine. That is the whole trade: two cases driving the same window do not carry two copies of the same loop, and nothing about how long to wait is decided in the file that says what to check.
There are three things to write, and two of them you write once per repository.
1. Declare the project
Section titled “1. Declare the project”winwright.json, found by walking up from wherever a run starts. Every key is optional, and
that matters more than it sounds: a reading that needs a key this file does not declare is
recorded as not taken, never quietly skipped. The reading is still in the report, marked
absent, which is what stops an unasked question from reading like an answered one.
The smallest useful one:
{ "executable": "bin/Debug/net10.0-windows/YourApp.exe", "timeouts": { "resolve": 5000, "stop": 5000 }, "attempts": 3}Declaring a project is every key this build reads, with what each one does when you leave it out. The ones worth adding early, once you have a case that needs them:
| Key | What it buys |
|---|---|
captures |
Where pictures go. A capture step names what to call the picture; the folder inside this one is the case’s name, so no case carries a path. |
languageFiles |
Your application’s own strings. This is what lets a locator say {settings.nav.about} instead of typing the words a translation will rewrite. |
loading |
The keys of the strings shown while a page is still computing, never the text. A page still saying it is loading becomes a failure rather than a photograph. |
destructive |
The entries that end the run. A step may not touch one without saying it meant to. |
sourceRoot |
The source a staleness check compares the built binary against, so a run cannot report on a build from last week. |
fingerprintStore |
The region of the machine the run must leave exactly as it found it — where your application keeps its settings and caches. |
2. Write the case
Section titled “2. Write the case”Any file ending .cases.json, anywhere under the directory your run points at — they are read
recursively, in path order. A file is an object with cases in it, and optionally the
fixtures those cases are launched against.
{ "cases": [ { "name": "renaming a profile writes it back", "catches": "a rename that updates the list and never the file", "tags": ["smoke", "profiles"], "steps": [ { "locator": "TabItem[name=\"Profiles\"]", "act": "select" }, { "locator": "Edit#profileName", "act": "set value", "with": "Beta", "expect": "Beta", "reads": "value" }, { "locator": "CheckBox#autosave", "act": "toggle", "expect": "On", "reads": "toggle" }, { "locator": "Button#save", "act": "invoke", "named": "save the profile" }, { "locator": "Text#status", "act": "read", "expect": "Saved", "reads": "text" } ] } ]}Reading that file field by field:
nameselects the case, so it has to be unique across the whole suite. A name declared in two files is refused, naming both, before anything runs.catchesis what the case exists to catch. It is optional, and a run counts the cases that passed without saying — a check nobody can justify is one nobody dares delete and nobody dares change.tagsare the other way to select.Selection.Tag("smoke")runs these.actplus exactly one oflocatorortrayis what every step is. Naming both is refused; naming neither is refused.withis required exactly where the act takes something, and refused where it does not.expectis what the element should read once the act has landed, andreadssays which reading that is —value,toggle,text,name,selected,enabledand the rest, defaulting toanything.namedrenames the step in the report, and is also how a later step points back at this one when it wants to claim the reading came back to what it was.
expect is not the only kind of claim, and for most real windows it is not the best one. A
case that cannot know the value can still say the reading moves, that it answers
something rather than nothing, that it matches a shape, or — with label — that it
equals a string derived from your application’s own declarations rather than typed into the
case. The full list is in
the README; the rule behind all of
them is the same, that a value typed into a case is a hardcoded set with one member.
What the case is launched against
Section titled “What the case is launched against”A fixture says what the application is started with, and any case in the suite may name one declared in any file:
{ "fixtures": [ { "name": "pt-BR", "environment": "pt-BR", "flag": "--language", "shareable": true, "language": "pt-BR" } ]}environment is the machine setting the launch samples, reached through flag; language
says which language the resulting window is in, so derived expectations read the strings
that window is actually showing. A fixture naming an environment nothing carries to the launch
is refused, and so is one that names it twice — two places deciding one thing means whichever
the application reads last wins while the expectations still describe the other.
3. Run it
Section titled “3. Run it”Four calls, and that is the whole of an adopting project’s driving half:
using Winwright.Processes;using Winwright.Projects;using Winwright.Scenarios;
var project = ProjectDeclaration.Find(repository);var declared = ScenarioFile.Across(ScenarioFile.LoadAll(Path.Combine(repository, "cases")));
// The register is disposed here, so nothing this run started outlives it — and whatever// would not stop is named rather than cleaned up in silence.using var register = ProcessRegister.For(project);var verdict = Suite.Launch(declared, Selection.All, register, project);
Console.WriteLine(verdict.Sentence());return verdict.ExitCode;Selection.All is one of three: Selection.Case("renaming a profile writes it back") and
Selection.Tag("smoke") are the others. A selector that matches nothing is refused, with
the names or tags there are — a run of no cases has no failure and no hole in it, so it reads
as a pass, and the pass is about nothing.
4. Read what came back
Section titled “4. Read what came back”Sentence() is the one line a report opens with, and it states what did not run before it
states the outcome:
Passed: 1 of 9 cases, 8 not run, 3 assertions over case 'renaming a profile writes it back'.A pass over two of nine cases is a different claim from a pass, and putting the qualification
second is how it gets skimmed. ExitCode is the outcome itself rather than a second mapping,
so CI needs nothing translated.
Before the assertions, a run also takes one reading of the machine: the desk it is on, which binary it is driving, whether that binary is stale, the resolved language, the foreground, the launch arguments, whether anything else is showing the application, and whether the desk is this run’s alone. Each is reported as measured, absent, or not read — because an absent line and a missing line read the same to somebody skimming, and only one of them is a statement.
That reading is printed above the verdict, by VerdictSummary.Render(verdict, reading). A
reader who has just been told four assertions never ran wants the absent precondition before
the tally rather than after.
Reading the verdict is the page for the moment a run answers 2:
what each exit code means, and every condition an assertion can be held up by.
A run needs a desk it may have to itself
Section titled “A run needs a desk it may have to itself”Cases create real windows, take the foreground and synthesise input. On the machine you are working at, that means your own typing lands in the application under test and the run reports holes about a foreground it never got. It is not a flaky suite; it is two people using one desk.
Two ways out, in the order most projects reach them:
-
Run it somewhere nobody is sitting. A VM with an interactive session, a dedicated runner, or a second machine — anything where the desk is the run’s alone.
-
Point this repository’s guest runner at your tree. A checkout of winwright carries
tools\run-tests-vm.ps1, which takes a tree rather than its own:tools\run-tests-vm.ps1 -Tree D:\path\to\yours -Run "run-cases.cmd" -Bring @('yours.trx')-Namedefaults to the tree’s own folder, so it lands inC:\src\<name>and two projects cannot collide in one guest;-ResultsInsays where your command left what it wrote. It prints which tree it took, because a runner that can carry two is one where a green is otherwise a green about whichever tree the caller believed they named.
Check the file before you run it
Section titled “Check the file before you run it”If you drive your application from Claude Code, winwright_check reads a case back before
the file exists. Its input schema is the loader’s schema, so a misspelled key is not
something that can be sent; what comes back is either the loader’s own refusal, addressed as
cases[0].steps[1].act, or what a run of it would do.
Name a project beside the file and it also answers what the door of a run would refuse —
a capture step in a project declaring no captures, which loads cleanly and then never runs.
Leave it off and the answer says it read the file alone, rather than letting that read as
“this would run”.
Where to go from here
Section titled “Where to go from here”- Addressing an element — the locator grammar those
locatorfields are written in, every form of it, and every way one is refused. - The case format — every field the loader reads, including the ones this page never reached for.
- The verbs — everything
actcan name, and what each needs of the desk before it can answer. - The README — the reasoning behind each refusal, and the engine’s own API.
- What this is — the verdicts, and what a hole is.
- Installing it — if you arrived here first.