winwright is a UI test framework for Windows desktop applications. It drives one from a test and reports what was actually observed — answering in four verdicts rather than two, so a check whose precondition was absent is named in the summary instead of disappearing from the count.
winwright_run { "project": ".", "cases": "cases", "case": "language round trip" }this run measured 5 conditions, 1 not read. ok the running instance is the binary this run named ok a binary built from this source agrees the application is in the language this scenario is written for ok the foreground belongs to the window under test not read an application that renders its own tree when asked
resolve Window#main > Pane#reportHost > Textobservedinvoke MenuItem#languagePtBRobservedread Pane#reportHost > Text covers: report.labelsobservedcapture Pane#reportHost with: report.pt-BRnot observedclose the machine is as this run found itobservedThe last line is the point. An assertion whose precondition was absent did not pass and did not fail — it never ran, it is named in the summary along with whose the absence was, and collapsing it into either of the other two is the thing winwright will not do. The exit code is 2, so CI can tell the difference without reading a word.
net10.0-windowsOne locator grammar, read the same by every verbPattern acts need no foregroundTwo packages, and an app takes at most oneA mapping written twice is a mapping that drifts, and CI reads the number rather than the word — so the enum's values are the process exit codes rather than being translated into them. 2 is the one the rest of this framework was built to make possible.
Every assertion ran and every one of them held.
And it means the roll call agreed: a run short of what discovery found is not reported as a pass, because that is exactly the shape the original defect had.
At least one assertion ran and did not hold.
One assertion ran and did not hold. The failing step carries the view the diagnosis built — the tree as it was, the element's facts, what its patterns read — so nobody writes a throwaway script to see what the window had.
Everything that ran passed, and something could not be evaluated at all.
The one the rest of this was built for. Each unevaluated reading is named in the summary, and so is whose the absence was — the desk's, this run's, or nobody's yet. A reader told three checks never ran has to know whether to clear a machine or open a repository.
The harness broke — something threw, and what it says is about this tool rather than about the application under test.
It outranks the rest, because a reader told the build failed opens the wrong repository. What the message says is about this tool, and nothing after the throw was observed at all.
this run measured 5 conditions, 1 not read: an application that renders its own tree when asked. ok the running instance is the binary this run named: bin/Debug/App.exe, launched by this run ok a binary built from this source: sources unchanged since the build agrees the application is in the language this scenario is written for: pt-BR, from settings.json ok the foreground belongs to the window under test: App — Relatorios not read an application that renders its own tree when asked: it does not take Winwright.InApp DEGRADED (exit 2) - 4 assertions: 3 passed, 0 failed, 1 unchecked (all the desk's) unchecked step 4 report.pt-BR - 'an overflow flyout this run can work' absent (the desk's): the shell would not open the flyout, so the pane could not be brought to the front
Before the assertions, a run 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 — an absent line and a missing line read the same to somebody skimming, and only one of them is a statement.
Both halves are on nuget.org at 1.0.0. The harness half goes in the project that drives the application; the in-app half goes in the application, and only if you want the readings that can only be taken from inside — every verb on this page works without it.
the test project
<PackageReference Include="Winwright" Version="1.0.0" />the application under test, and only if you want it
<PackageReference Include="Winwright.InApp" Version="1.0.0" />One line if your application's .csproj sits at the repository root: add <DefaultItemExcludes>$(DefaultItemExcludes);tests\**</DefaultItemExcludes> to it. Without it the SDK's default globs walk the whole tree below the project file and compile the driving project into the application — and the build stops with duplicate-attribute errors that point at your own generated file rather than at the folder that caused them.
And in the repository that drives the application, if you use Claude Code:
claude plugin marketplace add alegauss/winwright --scope projectclaude plugin install winwright@alegauss --scope projectBoth write into that repository's .claude/settings.json, so committing that file wires every clone. There is no per-machine install and nothing added to any path.
Every one of these is paired in the engine's own suite with the thing that would break it, and each names the defect it prevents rather than the value it expresses.
All of it follows from one measurement: a suite reported a pass with a total of 352 where the run before it had 374. Twenty-two checks were gone and the only sign was a number nobody had a reason to read.
Absent and not read are different facts and they read the same to somebody skimming. Every reading is reported as measured, absent, or not taken — and only one of those three is a statement about your application.
A foreground Windows would not grant, a focus that left mid-poll, a flyout the shell would not open. None of them is your code being wrong, so none of them goes red — the answer names what the desk did instead.
Every verb reads the same locator string the same way, and the combinator means a descendant of, at any depth — because UI Automation wraps controls in panes that differ between frameworks and between a maximised window and a restored one.
An act asks the control through its own accessibility peer, which needs no foreground and no mouse. The verbs that do synthesise input are marked as such in the catalogue rather than discovered on a red run.
An expected label read from the application's own language file cannot drift from it. A string retyped into a test is a second copy of the truth, and the second copy is the one that goes stale.
A capture that a window covered, that a backdrop transmitted through, that came back one flat colour, or that photographed a page still computing is refused — and the refusal names the intruder, its process and the rectangle.
A locator that does not parse names the position and the reason. A misspelt control type is answered with the nearest name UI Automation actually has. An error that costs a round trip to understand is a defect.
The store a case writes through is fingerprinted before and compared after, so the promise that a run changed nothing of yours is asserted where it is most likely to be broken rather than stated in a readme. Leftover processes are stopped and named in the same breath.
There is no recorder that turns clicks into a scenario, and no assertion invented on your behalf. What the engine owns is the loop, the waits and the verdicts; what you own is what is worth asserting.
A locator is a string, it parses or it is refused, and the refusal names the position and the reason. Locator.TryParse answers without throwing, for a caller collecting refusals rather than stopping at the first.
#saveButtonthe automation idButtonthe control typeButton#saveButtonboth at onceButton[name="Save as..."]the namePane[class=Chrome_WidgetWin_1]the window classButton[pattern=Invoke]it must carry that patternText[name="Statistics"][order=left]the leftmost of the ones that matchMenuItem[order=top][index=2]the second from the topWindow#main > Pane > Button#savea descendant of, at any depthIt asks the control through its own accessibility peer rather than asking the desktop to move a mouse. So a run does not need the screen to itself, and an act does not fail because something else took the foreground while it was polling.
Invoke, toggle, set a value or a range, select, expand, collapse. Each act is attempted to a cap and counted, so attempt 3 of 3 is in the record rather than in somebody's memory of the run.
Whether an element can take an act at all — and where it cannot, why not. A preflight takes every declared act and checks what it needs against the tree before anything is pressed.
Synthesised mouse input is there for the controls that have no peer worth asking. Every verb that needs the foreground is marked as such in the catalogue, and the declared reading says why the act could not be done any other way.
Synthesised keys, traversal keys at a window, and a focus read against the application under test rather than against the whole desk — because “something else has the focus” and “your window lost it” are different facts.
Enter a menu bar, walk to an entry, open a submenu, dismiss. The walk reports its hop count, which is what makes a timing observation about a picker mean anything.
The tray, the overflow flyout, the icons on either, and an icon's context menu. Finding an icon says which of the two happened — the icon is not there, or the flyout would not open — because a caller reading a null cannot tell them apart.
A declared destructive entry reached without saying you meant it is refused. destructive is the one project key with a refusal of its own: a bare name is rejected where the project ships more than one language, because a name is the field a translation rewrites — and a safety check compared against text a person sees has an expiry date.
A string retyped into a test is a second copy of the truth, and the second copy is the one that goes stale. So the expected set is read from what the application itself declares — its language files, its layout, its own read-out — and the comparison is against that.
A screenshot is evidence only if something rules out the ways it can lie. Each of these was a real session that exited zero with a file nobody could use, so each is a refusal rather than a warning.
The z order above the window is enumerated and every frame intersected with the copy rectangle, which answers for the whole area in one pass — sampled points cannot — and names the intruder, its process and the rectangle it covers.
A copy trimmed around an intruder is a picture of something nobody asked for. So the refusal is the answer, and it is actionable instead of mysterious.
A window with a system backdrop transmits what is behind it, and z-order reasoning cannot answer for that. A window that opted into one is refused rather than merely warned about.
A copy of exactly one colour is what a session where nothing was rendering produced — and it was written, reported as a capture, and exited zero.
The loading strings are read from the project's own language files before anything launches, so a key none of them carries refuses the run instead of matching nothing.
Which is what makes a difference a real difference, and means nobody has to choose a tolerance for a comparison to mean anything.
A render is measured, arranged and updated in that order. The arrange is why the verb exists: a tree that was measured and never arranged renders as a fully transparent picture of exactly the right size, which looks like a drawing bug and is a calling bug.
A scenario file is a .cases.json — an object with cases in it, and optionally the fixtures they are launched against. A run is pointed at a directory and walks it. Steps, locators, acts and expectations are fields; the loop, the waits, the attempts and the verdict belong to CaseRun. What is left in the file is the part that is actually about your application — including the defect the case exists to catch.
{
"fixtures": [
{ "name": "report", "environment": "dark", "flag": "--theme",
"language": "pt-BR", "shareable": true }
],
"cases": [
{
"name": "the report pane comes back in the resolved language",
"catches": "a translated menu entry that leaves its labels in English",
"filed": "WW63",
"tags": ["smoke", "i18n"],
"needs": ["the foreground belongs to the window under test"],
"fixture": "report",
"steps": [
{ "locator": "MenuItem#languagePtBR", "act": "invoke",
"named": "switch to pt-BR" },
{ "locator": "Pane#reportHost > Text", "act": "read",
"covers": "report.labels" }, // every string the key declares
{ "locator": "Text#total", "act": "read",
"expectReported": "monthlyTotal" }, // the app's own read-out
{ "locator": "Pane#reportHost", "act": "capture",
"with": "report.pt-BR" }
]
}
]
}needs declares what the machine must have, by the name the engine gives the condition — so an absent one is named as unchecked rather than going red for a reason about the desk it ran on.shareable on the fixture and onlyReads on the case are the two halves of lending one window to the cases that merely read it, while the same case run alone still owns its process and its first paint.forEach runs a case once per string a key declares, derived from the project's own language files, with the member reaching a locator through {} — so twenty-seven panels are one case rather than twenty-seven.catches is the defect the case exists for. A case that names none is counted in the run's own reading, so a check nobody can justify is visible and one removed by accident is missed.winwright.json, found by walking up from where a run startsEvery key is optional. A reading that needs one this file does not declare is recorded as not taken, never quietly skipped — which is the same rule as everything else here, applied to configuration.
{
"executable": "bin/Debug/net10.0-windows/YourApp.exe",
"sourceRoot": "src/YourApp",
"sourceIgnore": ["bin", "obj"],
"fingerprintStore": "%APPDATA%/YourApp",
"languageFiles": ["strings.en.json", "strings.pt-BR.json"],
"language": { "preferenceFile": "settings.json", "preferenceKey": "ui.language", "fallback": "en" },
"timeouts": { "resolve": 5000, "stop": 5000 },
"attempts": 3,
"destructive": [{ "id": "quitCommand" }, { "key": "menu.exit" }]
}destructive names the entries that end the run. Write {"id": …} or {"key": …} rather than a bare name where the project ships more than one language: a name is the field a translation rewrites, and a safety check compared against text a person sees expires the moment somebody translates the application.
Two commands in the repository wire every clone, and nothing is added to any path. Four MCP tools answer the format, the vocabulary, whether a case would load, and what a run of it did — and the one that reads a case back carries the loader's own schema as its input schema, so a misspelt key is not a thing the caller can send. The skill loads when a window is in play rather than on every turn, and a hook denies a hand-written harness script and names the verb that replaces it.
winwright_formatwinwright_vocabularywinwright_checkwinwright_runThree of the four launch nothing and press nothing, which is the distinction a session needs first: whether a case would load is a claim nothing about the machine can change, and whether it passed is not.
Every reading and every pattern act so far runs against an application that references nothing. What the in-app half adds is the handful of readings a harness cannot take from outside the process — and it references the engine not at all, so nothing here ships a test harness to your users.
WinwrightCaptureBackground, or the window's own. The system palette is not consulted at all: it answers white on a machine whose window is dark.The harness half is referenced by the project that drives the application. The in-app half is optional, and deliberately so: every reading and every pattern act runs against an application that references nothing. What the second package buys is the handful of readings a harness cannot take from outside the process.
Locate, act, assert, capture, and assemble the verdict. Referenced by whoever drives the application — never by the application.
Coordinates, render, backgrounds, geometry, popups — the readings that can only be taken from inside. It references the engine not at all, so nothing here ships a test harness to your users.
An application shipped to its users reports nothing and writes no file, which is what makes the in-app protocol safe to leave in a release.
Each of these is the argument for one part of the tool, at the length the argument actually takes. The summaries above are the same claims, shorter.
A run ends in one of four outcomes whose enum values are the process exit codes. Degraded (2) is what an assertion that could not be evaluated produces, named rather than dropped.
Read it →A locator is a string that parses or is refused with the position and the reason. The combinator means a descendant of, at any depth, because the panes UI Automation inserts differ between frameworks and between a maximised window and a restored one.
Read it →Invoke, toggle, set a value or a range, select, expand, collapse — through the control's own patterns, which needs no foreground. The verbs that synthesise input are marked as such in the catalogue rather than discovered on a red run.
Read it →Expectations are read from what the application itself declares — its language files, its layout, its own read-out — so switching the resolved language switches the expectation with it and a retyped string cannot go stale in silence.
Read it →Every way a screenshot can lie is a refusal: something over the rectangle, a system backdrop transmitting through the glass, one flat colour, a page still computing, or a window this run is not driving.
Read it →Steps, locators, acts and expectations are fields; the loop, the waits and the verdicts belong to the engine. Every field is validated at insertion, so a refusal costs a retry and never a deletion.
Read it →An optional second package for the readings a harness cannot take from outside the process: coordinates, render, backgrounds, geometry, popups. It references the engine not at all, so nothing here ships a test harness to your users.
Read it →Five things winwright has decided against, written down where they can be pointed at. A tool with no stated non-goals is a tool that will eventually be asked for all of them.
The whole engine is UI Automation and Win32. Portability would mean a different tool wearing the same name, and it is not coming.
A package in the engine is a package every adopting project inherits. The two halves are separate assemblies precisely so an application takes the one it needs without inheriting the other.
A picture is evidence that something was drawn and that nothing was over it. What a pixel is worth arguing about belongs to a design review, not to a suite.
A recorded case asserts what happened rather than what matters, and nobody can tell the two apart afterwards. The tool never writes the test.
A run is a process that starts, observes, reports and stops. Nothing here outlives it, and nothing here is installed on the machine that hosts it.
Both halves are published together and carry the same version, and the release is checked against the version the source declares — so a package and the commit it was built from cannot disagree about which build it is.
The suite that holds every guarantee on this page creates real windows, takes the foreground and synthesises input — so a bare dotnet test over it takes the roll call as part of the run: a suite short of what discovery found is not reported as a pass. The rule this framework exists for is applied to this framework.