winwright logo
Winwright 1.0.0 · on nuget.org · Windows · .NET 10

A green never covers
a check that did not run.

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.

What a run says
Casecheck the report pane after the language is switched, on whatever desk this is
›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 > Textobserved
1 element · Text “Relatório mensal” · 38ms
·invoke MenuItem#languagePtBRobserved
InvokePattern · no foreground needed · attempt 1 of 3
·read Pane#reportHost > Text covers: report.labelsobserved
27 strings derived from strings.pt-BR.json · 27 read, none left over
·capture Pane#reportHost with: report.pt-BRnot observed
unchecked — “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. Named, not dropped.
·close the machine is as this run found itobserved
store fingerprint unchanged · nothing this run launched outlived it
Before: 352 of 374 ran · reported green · nobody had a reason to read the totalAfter: Degraded (exit 2) · everything that ran held · 1 unchecked, and whose it was

The 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.

🧩 No external dependency in either half🪟 UI Automation and Win32, nothing else✍️ The tool never writes the test
.NET 10 · net10.0-windowsOne locator grammar, read the same by every verbPattern acts need no foregroundTwo packages, and an app takes at most one
The answer

Four outcomes, and the member values are the exit codes

A 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.

0

Passed

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.

1

Failed

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.

2

Degraded

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.

3

Broken

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.

winwright — report.cases.json
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.

Get it

Two package references, and one of them is optional

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 project
claude plugin install winwright@alegauss --scope project

Both 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.

🪟 Windows only — the engine is UI Automation and Win32📦 .NET 10, net10.0-windows · the in-app half needs UseWPF🧩 No external dependency in either half
What it guarantees

Ten rules, in the order a run meets them

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.

L1

A green never covers a check that did not run

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.

L2

“Not observed” is an answer

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.

L3

The desk is not 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.

L4

One grammar, written once

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.

L5

Patterns before pointers

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.

L6

The expectation is derived, never typed

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.

L7

A picture proves what it photographed

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.

L8

A refusal is an instruction

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.

L9

A run leaves the machine as it found it

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.

L10

The tool never writes the test

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.

Addressing an element

One grammar, read the same way by every verb

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 id
Buttonthe control type
Button#saveButtonboth at once
Button[name="Save as..."]the name
Pane[class=Chrome_WidgetWin_1]the window class
Button[pattern=Invoke]it must carry that pattern
Text[name="Statistics"][order=left]the leftmost of the ones that match
MenuItem[order=top][index=2]the second from the top
Window#main > Pane > Button#savea descendant of, at any depth
THE WINDOW THE CONTROL VIEW App — Relatórios Pane Pane (framework wrapper) Save StatusBar Window#main Pane#reportHost Pane <- the wrapper nobody wrote Button#save StatusBar Window#main > Pane > Button#save — matches, because > is a descendant of, at any depth. A direct-child locator is the one that breaks on somebody else's machine.
  • ✓“>” means a descendant of, not a direct child. That is a decision, not a shorthand: UI Automation wraps controls in panes that differ between frameworks, between versions of one framework, and between a maximised window and a restored one — so a direct-child locator is the one that breaks on somebody else's machine.
  • ✓Two elements matching one step is a refusal, not a first-match. A locator that quietly picked one of two is a test that passes against the wrong control until the day the order changes.
  • ✓A misspelt control type is answered with the nearest real one. The vocabulary is UI Automation's own rather than a list kept here by hand, and a list kept by hand is a list that drifts from the thing it describes.
  • ✓The tree is readable. The control view under a window or an element comes back as a tree, or as lines a person reads — which is what turns “it did not find it” into a question with an answer.
Acting on it

A pattern act is the default, and it needs no foreground

It 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.

🎛️

Through the control's own patterns

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.

🚦

Actionability is checked before, not discovered after

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.

🖱️

Pointers exist, and say why

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.

⌨️

Keys, traversal, and what holds the focus

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.

📋

Menus the way a keyboard user walks them

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 notification area, including the overflow

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.

Asserting

The expectation is derived, never typed

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.

  • ✓Labels come from the project's own language files. Switch the resolved language and the expected set switches with it; a key none of the files carries refuses the run rather than matching nothing.
  • ✓A derived set names what it derived from. An expectation nobody can trace back to a source is an expectation somebody will eventually “fix” by editing the number.
  • ✓Every expectation is falsifiable. A check that cannot fail is a check that is not being run, and it is worth exactly as much as the one that never ran at all.
  • ✓A timed-out read is a reading, not a value. It is recorded as the thing that did not arrive, so it reaches the summary as unobserved rather than as a default that looks like data.
  • ✓The store is fingerprinted before and compared after. The promise that a run leaves the machine as it found it is asserted on the path most likely to break it — the one case that rewrites a real setting.
  • ✓A failing step carries its diagnosis. The tree as it was, the element's facts, and what its patterns read at the moment it did not hold.
Capture

A picture that proves what it photographed

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.

App — Relatórios the copy rectangle Update available a toast — pid 14820 nothing stands over it: absent 1 window(s) stand over 376x166 at 62,90, taking 15360 of its 62416 pixel(s): 'Update available' (pid 14820) over 240x64 at 194,92 A copy trimmed around it is a picture of something else. The z order above the window is enumerated once and intersected with the rectangle — sampled points cannot answer for an area.
1

Nothing is over the rectangle

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.

2

An overlap fails rather than crops

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.

3

The compositor is asked about the glass

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.

4

One flat colour is not a picture

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.

5

A page still computing is not a report

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.

6

Two renders of unchanged code are byte-identical

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.

The scenario

A case is a data file, not two hundred lines of script

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.

cases/report.cases.json
{
  "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" }
      ]
    }
  ]
}
  • ✓Every field is validated against the loader's own schema, so a refusal costs a retry and never a deletion — which is what matters most to whoever is writing the file a field at a time.
  • ✓Run everything, one case by name, or one tag — and the run names every case it left alone, so a pass over two of nine never reads as a pass. A selector matching nothing is refused with the names there are.
  • ✓A fixture carries the launch — its sampled environment, the flag that environment arrives through, the arguments, the variables, and the language the window it opens is in. Every launch a case makes reads the same one.
  • ✓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.
Declaring a project

winwright.json, found by walking up from where a run starts

Every 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.

For the agent driving it

It ships as a Claude Code plugin

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_run

Three 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.

What the plugin does, tool by tool →

What needs the application to cooperate

Nothing above this line does

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.

  • ✓Coordinates — whether this process's idea of the display is trustworthy, in a sentence a report prints. A picture drawn by a system-aware process on a scaled display has a size that does not mean what it says, and nothing else about the file would ever say so.
  • ✓Render — an element to a PNG, measured, arranged and updated in that order.
  • ✓Backgrounds — what a capture is drawn on, from a brush the application declares under WinwrightCaptureBackground, or the window's own. The system palette is not consulted at all: it answers white on a machine whose window is dark.
  • ✓Geometry and surfaces — the laid-out tree and what was drawn, written only where the harness asked. An application shipped to its users reports nothing and writes no file.
  • ✓Popups — every popup under a window held open for as long as a run lasts. A preview has no hand to click with, and fixing that at one call site leaves the next popup to rediscover it.
  • ✓Freezables and apartment — a brush that may cross to a capture thread, and bounded work on the application's own dispatcher.
Who takes what

Two packages, and your application takes at most one of them

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.

Winwrightthe test project
<PackageReference Include="Winwright" Version="1.0.0" />

Locate, act, assert, capture, and assemble the verdict. Referenced by whoever drives the application — never by the application.

Winwright.InAppthe application under test, and only if you want it
<PackageReference Include="Winwright.InApp" Version="1.0.0" />

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.

In depth

One page per pillar, for whoever is deciding

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.

The verdict

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 →

Locators

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 →

Acts

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 →

Assertions

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 →

Capture

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 →

Scenarios

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 →

The in-app half

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 →
Scope

What it is not

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.

✗Cross-platform

The whole engine is UI Automation and Win32. Portability would mean a different tool wearing the same name, and it is not coming.

✗An external dependency in the engine

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.

✗An assertion about individual pixels

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 recorder that turns clicks into a scenario

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 service, a daemon or a database

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.

Release

Winwright 1.0.0

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.

Version
1.0.0
Frameworks
net10.0-windows · Windows only
Licence
Apache-2.0
Dependencies
none in either half

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.