winwright logo
Windows · .NET 10 · Winwright 0.1.0

A green never covers
a check that did not run.

winwright drives a Windows desktop application from a test and reports what was actually observed. It answers 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 cases/report.wwx --case language-round-trip
desk        this run's alone      no other session is attached
binary      bin/Debug/App.exe     built 2m ago, from the tree in hand
language    pt-BR                 settings.json -> ui.language
foreground  granted               App — Relatórios
spectators  none                  nothing else is showing this application
store       fingerprinted         %APPDATA%/App, compared again at the end
·resolve Window#main > Pane#reportHost > Text[order=top]observed
1 element · Text “Relatório mensal” · 38ms
·act invoke MenuItem[key=menu.language.ptBR]observed
InvokePattern · no foreground needed · attempt 1 of 3
·assert every label matches the app's own pt-BR stringsobserved
27 labels derived from strings.pt-BR.json · 27 held
·capture report pane -> artifacts/report.pt-BR.pngnot observed
not observed — the shell would not open the overflow flyout, so the pane could not be brought to the front. Recorded by name, not dropped.
·verify the machine is as this run found itobserved
store fingerprint unchanged · nothing this run wrote survived it
Before: 352 of 374 ran · reported green · nobody had a reason to read the totalAfter: Degraded (2) · everything that ran held · 1 reading named as not observed

The last line is the product. An assertion whose precondition was absent did not pass and did not fail — it never ran, it is named in the summary, 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 the engine🪟 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 reason this project exists.

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 this project exists for. Each unevaluated reading is named in the summary by name; three of the ones a scenario meets often are about the desk rather than about your application, and none of them is your code being wrong.

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 run cases/report.wwx
winwright — cases/report.wwx

  preamble
    desk          this run's alone       nothing else is attached
    binary        bin/Debug/App.exe      built 2m ago, from the tree in hand
    staleness     current                sources unchanged since the build
    language      pt-BR                  settings.json -> ui.language
    foreground    granted                App — Relatorios
    spectators    none                   nothing else is showing this application
    display       not read               the application does not take Winwright.InApp

  assertions
    [held]  report.title.pt-BR          27 labels derived, 27 matched
    [held]  report.totals.rounding      derived from the app's own read-out
    [held]  store.unchanged             fingerprint equal, before and after
    [----]  capture.report              not observed — the shell would not open the
                                       overflow flyout, so the pane could not be
                                       brought to the front

Degraded (2). 3 assertions ran and held. 1 was never evaluated, and it is named above.

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.

The design laws

Ten rules, in the order a run meets them

Binding, in the same sense as the product's own. A feature that breaks one is wrong even if it was asked for, and each names the defect it prevents rather than the value it expresses.

L1

A green never covers a check that did not run

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

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 Toast — App.exe capture refused intruder Toast process App.exe (14820) covers 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

Steps, locators, acts and expectations are fields. The loop, the waits and the verdicts are the engine's. 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.wwx
# the defect this case exists to catch — a case that can name none is one
# nobody can justify, and one removed by accident is one nobody misses
case:     language-round-trip
catches:  a translated menu entry that leaves the report pane's labels in English

precondition:
  language: pt-BR              # absent -> named as unchecked, never red

fixture:
  app:         bin/Debug/App.exe
  environment: [ light, dark ] # every launch this case makes, not only the first
  shareable:   false           # this one writes, so it owns its process

steps:
  - resolve: Window#main > Pane#reportHost > Text[order=top]
  - act:     invoke MenuItem[key=menu.language.ptBR]
  - assert:  labels from strings.pt-BR.json
  - capture: Pane#reportHost -> artifacts/report.pt-BR.png
  • Every field is validated at insertion, so a refusal costs a retry and never a deletion.
  • Run a file, a case or a tag — and it says what it did not run, so a single case is ten seconds when a single act is what changed.
  • Fixtures and sampled environments are declared per case and passed to every launch it makes, or the expectations describe one environment and the window renders another.
  • Preconditions are declared, so an absent one is named as unchecked rather than going red for a reason about the desk it ran on.
  • A window can be declared shareable and lent to the cases that only read it, while a case run alone still owns its process and its first paint.
  • Each case carries the defect it exists to catch, so a case nobody can justify is visible and a case 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. The tools carry this project's scenario schema as their input schema, so a case an agent writes is corrected at insertion rather than at run time; 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.

One entry in .claude/settings.json:

Bash(winwright read:*)

read is a promise, not a naming convention: a verb under it that drives the application is a defect. So this single grant removes every prompt on the inspection path — the tree, the facts, the verdict of the last run — while everything that presses a control still asks.

What the plugin does, verb by verb →

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="0.1.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="0.1.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 this project 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.

Take it

Two package references, and one of them is optional

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="0.1.0" />

the application under test, and only if you want it

<PackageReference Include="Winwright.InApp" Version="0.1.0" />
🪟 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

The suite that proves this creates real windows, takes the foreground and synthesises input, which is why it ships with a way to run inside a virtual machine so the host stays usable. A bare dotnet test takes the roll call too: a run short of what discovery found is not reported as a pass.