Repository navigation
Releases: mangokingTW/wintegrate
Release list
v0.6.5
Added
- AI Agent Skill distribution and CLI installer. Bundled canonical
SKILL.md
into package data underwintegrate.skillsand addedpython -m wintegrate.skills install
(--target,--global,--force),show, andpathsubcommands to install the
wintegrate agent skill into any workspace without manual copy-pasting. - CLAUDE.md configuration. Added standard Claude Code project memory file
importing@AGENTS.md.
Changed
- Formalized AGENTS.md. Removed decorative emojis and expanded comprehensive
guidance covering Playwright-style locators, session lifecycle & the pytest fixture
exception-suppression gotcha, WinUI 3 Content Island focus routing, and verified
action patterns.
v0.6.4
Added
- A discovery wait now says what the process was doing.
launch_and_discover
samples the launched process while its window is awaited -- alive, CPU seconds,
and every top-level window it owns, hidden ones included -- every 5s
(wait_for_new(..., watch_pid=, sample_every=)). The samples go to the session
journal asdiscovery_waitevents and end the timeout message. A process that
exits before its window appears ends the wait at once with its exit code
instead of after the whole timeout. When the wait gives up, Windows itself is
asked what it knows (one PowerShell call): the process's threads and what they
wait on, whether the PowerShell engine in it reached "Available" (event 400),
Defender scan events, CAPI2 revocation-check records when that log is on,
FontCache service starts, Application Hang reports -- and the answer ends the
message.SessionConfig(etw_trace=True)records awprGeneralProfile trace
for the session and keepsetw_trace.etlwhen it closes on an exception, for
WPA's wait analysis.wintegrate.procwatchis the instrument.
Measured on a hosted arm64 runner: a WPF fixture showed nothing after 90s on
one launch and came up in 18s on the next, and nothing recorded said whether
the process had been working, idle, or gone -- this would have. The two WPF
fixtures in the test suite now launch through it, andwpf_grid_app.ps1
stamps its phases to stderr.
Changed
- The report says what happened, not where the code was. In the pytest-html
row: the frame per step is taken at the end of the step (its result, or the
moment it failed) rather than its start; the error line is the exception's
message, notfile:line: Type; a failed step shows the message too (the
journal'sstep_failednow carriesdetail); the steps table has a header,
fixed columns, a video-time column, and events summarised as sentences with
the raw lines on demand; frames are decoded at 720 px and enlarge in place on
click (the previous<a href="data:...">zoom opened nothing: Chromium refuses
top-frame navigation todata:URLs); rows carrying sessions are marked and
listed in the summary; the artifact line names the folder in the uploaded
artifacts instead of a runner-local path. - GitHub Step Summary reads like the run. Steps are listed in run order with
their nesting (they were ordered by completion, child before parent); the
failure callout is one line and names the step; the error is no longer printed
twice; the file list is gone; the window-leak warning ignores message-only,
IME, tooltip and shell windows, which made every passing test look leaky.
wintegrate.evidenceholds the shared shaping so the report and the summary
cannot disagree.
v0.6.3
pytest-html rows and GitHub Step Summary carry the session's evidence.
Added
- pytest-html rows and GitHub Step Summary carry the session's evidence. With
wintegrate[html]
andpytest --html=report.html, a test that opened aSessiongets, in its
row: the last failure screenshot first, the error, then one row per
session.stepwith tick or cross, duration, failure signature, the frame the
recording holds at that moment, click-to-zoom thumbnails, tree-guided nesting,
and the events logged inside it; and the recording as a playable video. Leaked
windows detected by census diff are warned in both the report and the GitHub
Job Summary. On GitHub Actions ($GITHUB_STEP_SUMMARY), failed sessions now
stand out with high-priority alert callouts, passed sessions are neatly collapsed
into<details>, and a run-level overview aggregates all sessions executed in the suite.
Sessionnow appends a record towintegrate.session.RECENT_SESSIONS
when it closes, which is what the plugin reads. (#112) - Unified desktop preparation script. Consolidated desktop prep logic in GitHub Actions
setup into a standaloneprepare_desktop.pyscript for pytest and non-pytest callers alike. (#108)
Full changelog: https://github.com/mangokingTW/wintegrate/blob/main/CHANGELOG.md
v0.6.2
Closes the remaining items of #93 (C5, C6, C8, C10, C11): a failure now says
which failure it is, a death names its cause, the sweep's hides are accounted for
and undone, launched children cannot hold the step's pipes, and a failed session
leaves the frames at the moment it failed.
Added
- Failure signatures.
WintegrateErrorkeeps the facts passed at raise time
and buildssignaturefrom the class'sSIGNATURE_KEYSallow-list --
FocusStealDetectedError[foreground_image=explorer.exe],
WindowDiscoveryTimeoutError[process_names=notepad.exe]-- so two failures with
the same count stop reading as the same problem. No hwnd, pid or timestamp may
be a key: one per-run integer makes every signature unique. The foreground is
sampled during the focus retry loop, not at raise time, and carried on
TextMismatchErrortoo, the raise a mid-typing steal most often reaches.
step_failed,session_error, the job summary andREAD_THIS_FIRST.mdprint
it. ConsoleHostEndedError. When the console this process shares is destroyed
duringSession.__enter__, what reaches the traceback is aKeyboardInterrupt
at whatever line was executing.__enter__now re-labels it, on evidence only:
the console probes that answered at preflight answer nothing now. The message
names the phase it was in, how long ago it entered it, the processes the sweep
ended and the console peers that die with it. Still aKeyboardInterrupt, so a
pytest run aborts once. A real Ctrl-C with the console intact is untouched. The
verdict is journaled (console_host_ended) before it is raised.step_failednow carries the same window census delta asstep_ok: the
windows that came and went during the failing step were computed and thrown
away on the only path that matters.frames/at the failure. A failed session now pulls the recording's frames
at the moments the timeline names intoframes/, each named by video time and
event (t041830ms_step-failed_submit.png), withframes/index.jsonmapping
files to events. The budget is small (8) and the priority explicit: the window
around the laststep_failedfirst, the tail of the recording second, the rest
after -- and marks that do not fit are dropped whole and listed as dropped,
because a silently truncated set reads as the whole story.READ_THIS_FIRST.md
says the directory is there.extract_frames,plan_marksandvideo_ms_for
are public, for pulling frames from any recording with its anchor.expect_artifact(path, timeout), replacing asleepafter launching
something that writes a file: waits for the file to exist, be non-empty and
stop growing, and on the deadline raisesArtifactMissingErrornaming what the
directory actually held -- the.tmpthat was left behind, or nothing.
Changed
- The sweep's hides are recorded, verified and undone.
sanitize_ci_runner_environment()
used to run threeShowWindow(SW_HIDE)calls that recorded nothing, verified nothing
and restored nothing -- on a library whose charter names focus stealing as the
enemy. Each window hidden is now anInterventionResult(what was intended, what
a re-measurement found 0.3 s later, whether they agree) onKillPlan.interventions,
journalled ashide_resultand written intokill_plan.json; the foreground is
measured before and after the pass.Session.__exit__shows again what this
session hid and that is still hidden and still exists -- nothing else -- and
records that too (restore_result). The rule (hide_reason) and the restore
selection (restore_targets) are pure functions with offline tests. - Launched children no longer inherit this process's stdio.
Window.launch_and_discover
gave every child the step's stdout/stderr handles; an orphan holding those pipes is
what kept a CI stepin_progressafter the process that started it had died. Inside
aSessiona child's output goes tolaunched_NN.out/.errin the artifact
directory and a discovery timeout quotes it -- a child that printed an error and
exited is the usual reason no window appeared. Outside a session, DEVNULL. stdin is
DEVNULL either way.READ_THIS_FIRST.mdexplains both when the files are present.
Full changelog: https://github.com/mangokingTW/wintegrate/blob/main/CHANGELOG.md
v0.6.1
Added
preflight.json, written before the session touches anything. What this
process is: pid, ancestors, whether it has a console andGetConsoleWindow
beside the list of processes attached to that console, the protected set with
each relation, the foreground window. Every probe is wrapped on its own; a
failing one lands inprobe_errors.warningssays the sentence to read
first -- ending this console ends: pwsh.exe, Runner.Worker.exe -- and calls
outGetConsoleWindow=0beside a non-empty client list, which is the entire
0.5.13 → 0.5.14 delta, now visible in one artifact instead of costing a release.kill_plan.jsonand akill_planjournal event, written before the first
kill.sanitize_ci_runner_environment()now builds aKillPlanfrom one
process snapshot -- every process on the sweep list, each eitherkillor
sparewith the measured relation that spares it -- writes and fsyncs it,
journals it, and only then ends anything, per pid withTerminateProcess,
recording what each kill returned (kill_result). A journal that ends at
kill_planwith nokill_resultsays the sweep ended its own caller, and the
plan says which entry did it. Nevertaskkill /F /PID: the handle opened at
kill time is what pins the identity the plan was written against.dry_run.sanitize_ci_runner_environment(dry_run=True), or
WINTEGRATE_SANITIZE_DRY_RUN=1, builds and writes the plan and ends nothing.
The way to find out what a sweep would do to a machine before letting it,
packaged as a flag; the step completes, so the artifact upload runs.Session.preflightandSession.kill_planhold both for the caller;
READ_THIS_FIRST.mdexplains the two files when they are present.
Changed
sanitize_ci_runner_environment()returns theKillPlan(it returned None).
Its protection falls back to self-and-ancestors whenprotected_pids()
cannot be measured, and the plan records that it did (protection_degraded).
Full changelog: https://github.com/mangokingTW/wintegrate/blob/main/CHANGELOG.md
v0.6.0
Added
session_events.jsonl, opened before anything else inSession.__enter__. One
JSON line per event, flushed as written, with the wall clock, a monotonic offset,
the pid and the current step on every line, and a heartbeat every quiet second.
It exists because a library call inside__enter__once ended the process that
made it:session_events.jsonis written from__exit__,__exit__never ran,
and the artifact directory was empty -- three CI rounds produced no evidence at
all. The heartbeat is the difference between dead and hung, which a CI page
showingin_progressfor twelve minutes cannot tell you. Degrades to memory if
the file cannot open; a diagnostic is never why a session cannot start.READ_THIS_FIRST.mdandartifact_index.json, written at open and at every
step boundary, generated from the files actually present: which file to trust,
what an absentsession_events.jsonmeans (the process never reached teardown),
what a screenshot cannot show (a window excluded from capture by design -- read
the census), and the last events. Also an on-disk record of how far the run got.recording_anchor.jsonandContinuousRecorder.anchor(): how an event's
monotonicmaps onto video time, and the fact -- written nowhere before -- that
the video covers the primary monitor while screenshots cover the virtual desktop.Window.__repr__says when the handle is dead.class='' title=''read like
a window with no name; it now reads(destroyed: handle no longer valid), which is
the answer to a different question -- and the one a failing assertion needs.- The full-suite CI recording writes
full-suite-<arch>.anchor.jsonso a job-log
timestamp can be mapped onto the video without arithmetic on the step's end time. - A job summary on GitHub Actions (
$GITHUB_STEP_SUMMARY): steps, outcomes,
durations, failures and the artifact list, readable on the run page without
downloading anything.
Changed
- A discovery timeout names a packaged (Store) app. When the command that
produced no window is an app execution alias, or an ordinary executable with an
installed package of the same name (Windows 11'sSystem32\notepad.exehands
off toMicrosoft.WindowsNotepad; measured), the error says so, lists the
installed package versions, and says what more than one version means: the Store applies a pending update the moment the app
closes, and a launch during that swap shows no window. Measured on
windows-11-arm, whose Notepad is the Store package: the test after the first
one to close Notepad timed out with nothing on the desktop. The composite
action's quiet step now sets the Store policyAutoDownload=2on hosted runners
and prints the Notepad and Calculator package versions at job start. - The recorder starts before the runner is touched. Killing, hiding and
desktop-switching -- the actions a recording exists to show -- ran off camera,
with the mp4 not yet created at the moment anything went wrong. - The mp4 is fragmented (
frag_keyframe+empty_moov) with a one-second GOP, so a
recording whose process never closed it is still playable and loses at most a
second instead of the whole file. __exit__flushes the timeline first, before the recorder's thread join and
the final census: under a kill deadline the last statement is the one cut off.Window.findsays what it saw when it says it found nothing. The negative
now carries the visible windows at the moment it gave up, as
launch_and_discoveralready did. A diagnostic decoration never raises; if a
dialog's contents cannot be read, it says so instead.
Also in this release line: 0.5.15 (2026-09-05)
0.5.15 went out the same day, ahead of this release, so the KeePassXC uiAccess evidence workflow could run on it. Its notes are repeated here so v0.6.0 reads as one release.
Added
protected_pids() and console_client_pids(). The set of processes this one depends on, each with the measured relation that protects it -- self, ancestors, the processes sharing this console, and their ancestors. Never a list of program names: WindowsTerminal ended a run in 0.5.12 because it hosted that run's console, and is an ordinary target in a run that is not attached to it. Measured on a hosted runner, GetConsoleProcessList returned python.exe, pwsh.exe and Runner.Worker.exe, so ending that console ends the agent reporting the job.
Fixed
AppHandle.close() no longer force-kills a process it did not start. It downgrades to WM_CLOSE alone when the window belongs to a protected process, logs which relation stopped it, and fails closed when the relations cannot be measured. Window.close(force=True) itself is unchanged: a primitive that silently declines is worse than one that kills.
CI
The runner preparation is one script, quiet-runner.ps1, beside the composite action, instead of four inline copies that had already drifted. It hides WindowsTerminal's window rather than killing the process that hosts this step's console; suppresses hard-error modals (ErrorMode=2) and dismisses one already up by window handle; reports free disk and page-file state without repairing them; and is a no-op unless RUNNER_ENVIRONMENT=github-hosted.
Full changelog: https://github.com/mangokingTW/wintegrate/blob/main/CHANGELOG.md
v0.5.15
Added
protected_pids() and console_client_pids(). The set of processes this one depends on, each with the measured relation that protects it -- self, ancestors, the processes sharing this console, and their ancestors. Never a list of program names: WindowsTerminal ended a run in 0.5.12 because it hosted that run's console, and is an ordinary target in a run that is not attached to it. Measured on a hosted runner, GetConsoleProcessList returned python.exe, pwsh.exe and Runner.Worker.exe, so ending that console ends the agent reporting the job.
Fixed
AppHandle.close() no longer force-kills a process it did not start. It downgrades to WM_CLOSE alone when the window belongs to a protected process, logs which relation stopped it, and fails closed when the relations cannot be measured. Window.close(force=True) itself is unchanged: a primitive that silently declines is worse than one that kills.
CI
The runner preparation is one script, quiet-runner.ps1, beside the composite action, instead of four inline copies that had already drifted. It hides WindowsTerminal's window rather than killing the process that hosts this step's console; suppresses hard-error modals (ErrorMode=2) and dismisses one already up by window handle; reports free disk and page-file state without repairing them; and is a no-op unless RUNNER_ENVIRONMENT=github-hosted.
Full changelog: https://github.com/mangokingTW/wintegrate/blob/main/CHANGELOG.md
v0.5.14
Fixed
The console check added in 0.5.13 does not work on a CI runner.
0.5.13 stopped the runner sweep killing terminal hosts when the caller has a console, and asked GetConsoleWindow() whether it does. That is a narrower question than the one that matters — is there a console window — and a ConPTY has none. A hosted runner hands its steps exactly that, so the check said "no console" for a process attached to one all along, the sweep went ahead, and it killed the terminal hosting the step.
Measured on a windows-latest runner, from the step itself:
GetConsoleWindow = 0
GetConsoleProcessList = 3 [python.exe, pwsh.exe, Runner.Worker.exe]
has_console() = False <- 0.5.13 answering wrongly
The three processes sharing that console are the point: Runner.Worker.exe is on it too. Killing the terminal took the runner's own worker with it — which is why the job reported the step as cancelled rather than failed, why cancelling it any other way did nothing, and why Force cancel was the only thing that cleared it, destroying the VM and its logs along with it.
GetConsoleProcessList() answers the question that was meant, including for a windowless console. The window check stays as the first branch, so this is strictly more conservative than 0.5.13. The decision is logged now as well.
Full changelog: https://github.com/mangokingTW/wintegrate/blob/main/CHANGELOG.md
v0.5.13
Fixed
The CI sweep no longer kills the terminal hosting the process that called it.
sanitize_ci_runner_environment() kills WindowsTerminal by name to clear leftover windows. On Windows 11 that is also the default console host, so a console-subsystem caller is attached to it — and killing it destroyed the console and the caller with it, about a second after the call returned. The process then dies at whatever line it happens to be on, which is why this looked like an unrelated hang.
Excluding it by pid is not possible: it is not an ancestor of the caller, and it is not the owner of GetConsoleWindow() either — with Windows Terminal as the host, that window belongs to a brokered OpenConsole.exe whose parent chain runs svchost -> wininit -> services, with the terminal nowhere in it.
So the answerable question is asked instead — does this process have a console at all — and terminal hosts are swept only when it does not, which is the ordinary case for a runner started by a service or a GUI.
Measured on Windows 11 ARM64, same process and command: killing the rest of the list left the caller running for a full 8-second watch; killing WindowsTerminal alone ended it before its next heartbeat; with this change the same run completes.
Full changelog: https://github.com/mangokingTW/wintegrate/blob/main/CHANGELOG.md
v0.5.12
Added
Window.wait_for_new() — the waiting half of launch_and_discover, for the windows something other than a launch opens.
The logic was already written, inside launch_and_discover where nothing else could reach it, so callers who needed it wrote their own: snapshot, act, sleep for a guess, snapshot, take the first added window. That version hands back GDI+ and IME helper windows, accepts a window that is visible but still untitled, cannot express require_all, and when the guess is short fails as a StopIteration on a line that cannot report what did not appear.
before = WindowCensus.capture()
menu.items[4].expand()
popup = Window.wait_for_new(before, window_classes=("Qt681QWindowPopup",))The case it was extracted for is a Qt menu: the popup is a top-level Qt<ver>QWindowPopup rather than a UIA descendant of the item that opened it, so MenuItem.sub_items() comes back empty and the popup has to be found on the desktop.
Changed
launch_and_discover is now launch plus wait_for_new, so the two cannot drift. Behaviour is unchanged, with one dead branch dropped: it scanned the new windows twice and the second pass was the same set under a stricter condition, so it could never match anything the first had not already rejected. Its timeout message keeps the failing command, passed in as context.
Full changelog: https://github.com/mangokingTW/wintegrate/blob/main/CHANGELOG.md