Skip to content

Releases: mangokingTW/wintegrate

v0.6.5

Choose a tag to compare

@mangokingTW mangokingTW released this 10 Sep 01:20
ea88064

Added

  • AI Agent Skill distribution and CLI installer. Bundled canonical SKILL.md
    into package data under wintegrate.skills and added python -m wintegrate.skills install
    (--target, --global, --force), show, and path subcommands 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

Choose a tag to compare

@mangokingTW mangokingTW released this 09 Sep 04:25
d5cc5a2

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 as discovery_wait events 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 a wpr GeneralProfile trace
    for the session and keeps etw_trace.etl when it closes on an exception, for
    WPA's wait analysis. wintegrate.procwatch is 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, and wpf_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, not file:line: Type; a failed step shows the message too (the
    journal's step_failed now carries detail); 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 to data: 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.evidence holds the shared shaping so the report and the summary
    cannot disagree.

v0.6.3

Choose a tag to compare

@mangokingTW mangokingTW released this 08 Sep 09:09
3c51512

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]
    and pytest --html=report.html, a test that opened a Session gets, in its
    row: the last failure screenshot first, the error, then one row per
    session.step with 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.
    Session now appends a record to wintegrate.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 standalone prepare_desktop.py script for pytest and non-pytest callers alike. (#108)

Full changelog: https://github.com/mangokingTW/wintegrate/blob/main/CHANGELOG.md

v0.6.2

Choose a tag to compare

@mangokingTW mangokingTW released this 05 Sep 12:51
aee12e4

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. WintegrateError keeps the facts passed at raise time
    and builds signature from the class's SIGNATURE_KEYS allow-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
    TextMismatchError too, the raise a mid-typing steal most often reaches.
    step_failed, session_error, the job summary and READ_THIS_FIRST.md print
    it.
  • ConsoleHostEndedError. When the console this process shares is destroyed
    during Session.__enter__, what reaches the traceback is a KeyboardInterrupt
    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 a KeyboardInterrupt, 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_failed now carries the same window census delta as step_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 into frames/, each named by video time and
    event (t041830ms_step-failed_submit.png), with frames/index.json mapping
    files to events. The budget is small (8) and the priority explicit: the window
    around the last step_failed first, 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_marks and video_ms_for
    are public, for pulling frames from any recording with its anchor.
  • expect_artifact(path, timeout), replacing a sleep after launching
    something that writes a file: waits for the file to exist, be non-empty and
    stop growing, and on the deadline raises ArtifactMissingError naming what the
    directory actually held -- the .tmp that was left behind, or nothing.

Changed

  • The sweep's hides are recorded, verified and undone. sanitize_ci_runner_environment()
    used to run three ShowWindow(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 an InterventionResult (what was intended, what
    a re-measurement found 0.3 s later, whether they agree) on KillPlan.interventions,
    journalled as hide_result and written into kill_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 step in_progress after the process that started it had died. Inside
    a Session a child's output goes to launched_NN.out / .err in 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.md explains both when the files are present.

Full changelog: https://github.com/mangokingTW/wintegrate/blob/main/CHANGELOG.md

v0.6.1

Choose a tag to compare

@mangokingTW mangokingTW released this 05 Sep 11:07
1ec1112

Added

  • preflight.json, written before the session touches anything. What this
    process is: pid, ancestors, whether it has a console and GetConsoleWindow
    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 in probe_errors. warnings says the sentence to read
    first -- ending this console ends: pwsh.exe, Runner.Worker.exe -- and calls
    out GetConsoleWindow=0 beside 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.json and a kill_plan journal event, written before the first
    kill.
    sanitize_ci_runner_environment() now builds a KillPlan from one
    process snapshot -- every process on the sweep list, each either kill or
    spare with the measured relation that spares it -- writes and fsyncs it,
    journals it, and only then ends anything, per pid with TerminateProcess,
    recording what each kill returned (kill_result). A journal that ends at
    kill_plan with no kill_result says the sweep ended its own caller, and the
    plan says which entry did it. Never taskkill /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.preflight and Session.kill_plan hold both for the caller;
    READ_THIS_FIRST.md explains the two files when they are present.

Changed

  • sanitize_ci_runner_environment() returns the KillPlan (it returned None).
    Its protection falls back to self-and-ancestors when protected_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

Choose a tag to compare

@mangokingTW mangokingTW released this 05 Sep 09:41
c67da08

Added

  • session_events.jsonl, opened before anything else in Session.__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.json is 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
    showing in_progress for 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.md and artifact_index.json, written at open and at every
    step boundary, generated from the files actually present: which file to trust,
    what an absent session_events.json means (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.json and ContinuousRecorder.anchor(): how an event's
    monotonic maps 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.json so 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's System32\notepad.exe hands
    off to Microsoft.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 policy AutoDownload=2 on 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.find says 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_discover already 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

Choose a tag to compare

@mangokingTW mangokingTW released this 05 Sep 08:38
058a39c

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

Choose a tag to compare

@mangokingTW mangokingTW released this 05 Sep 06:05
9bd3256

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

Choose a tag to compare

@mangokingTW mangokingTW released this 05 Sep 04:12
430a69e

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

Choose a tag to compare

@mangokingTW mangokingTW released this 05 Sep 00:40
5129ded

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