qa-capture
v0.2.3
Published
Proof that a human ran your change, on the pull request: record a manual Android test session and attach it as evidence
Maintainers
Readme
qa-capture
Proof that a human ran your change, on the pull request.
Site and docs: https://qa-capture.pages.dev
You already test a change by hand before you merge it. qa-capture watches that session from outside the app and turns it into evidence a reviewer can trust: every screen, every tap, and every control that did nothing, attached to the pull request as screenshots with a step-by-step record behind them.
It exists because reviewers can no longer tell whether anyone ran a change.
Pull requests got larger and more of them are written with an AI, and agents
now post their own screenshots, which is the one kind of evidence a reviewer
should not take on trust. A recorded human session is different in kind:
taps are read from the device's own input layer, the one scripted
adb shell input bypasses, so a session whose screen changed with no touches
recorded is marked partial rather than presented as a person's work.
What it is not:
- Not an agent that tests for you. That space is crowded and mostly free. The person drives; the tool records.
- Not an AI that decides whether the app works. It reports which checklist items a session reached. Whether they passed is a human verdict, and nothing in this codebase can write one.
- Not snapshot testing. Previews show components in isolation. This shows the flow after three taps, with whatever was really on screen.
- Not a QA-team tool yet. Android, tethered to an emulator or a phone, for the developer opening the pull request. Nothing goes into your app.
Run it from your app repo
The CLI reads the repo it is run in to link the session to a branch and pull request, and reads the device to identify the build. Both are best-effort: a session recorded outside a repo is still a valid session, it just cannot say which change it was testing.
With one device attached it is used. With a phone beside the emulator, pass
--device <serial> (or set ANDROID_SERIAL) to say which; otherwise the first
device adb lists is used and a warning names it.
Install
npm install -g qa-capture # or: npx qa-capture <command>
qa-capture doctor # check what is missing before you need itYou also need:
- adb — found automatically inside a standard Android Studio install, so
usually nothing to do. Set
ANDROID_HOMEif yours lives somewhere unusual. - ffmpeg and ffprobe, 5.1 or newer — on PATH, or point
QA_FFMPEG_DIRat a folder containing them. An older ffprobe reports no frame timestamps, which would turn every tap into a no-op;doctorandstartrefuse it. - gh (recommended) — the GitHub CLI, 2.99 or newer. It finds the pull
request for your branch and posts the comment with screenshots attached.
Without it you can still record: pass
--pr <n>and post the savedcomment.mdyourself. Install withwinget install --id GitHub.cli(Windows) orbrew install gh(macOS), then rungh auth login. Nothing here installs it or logs in for you. - ANTHROPIC_API_KEY (optional) — only for
checklist generateandcoverage. Recording works without it.
qa-capture doctor checks all of these and says what to do about each. Run it
first: a missing dependency discovered halfway through a recording costs the
whole session, and you cannot ask someone to tap through it again.
Commands
qa-capture doctor check that everything needed is present
qa-capture start record until Ctrl-C, then write the session
qa-capture comment put the session on the pull request, screenshots attached
qa-capture checklist the pull request's checklist (list, add, generate, screens, pass)
qa-capture coverage work out which checklist items the session reached
qa-capture view build the session page locally
qa-capture publish optional: give that page a URL on a side branchAnything that sends data somewhere prints what it would send and does nothing
until you add --send, --push or --post.
After a session, build the page with qa-capture view and ask one question:
do these steps describe what you did? Everything downstream rests on that.
Tests
The capture pipeline fails quietly rather than loudly: every bug found so far
produced a plausible-looking session with wrong evidence, and none of them
threw. So each test in tests/ encodes a defect that actually shipped, and
each was checked by reverting the fix and confirming the test fails.
The step detector, gesture parser and clock anchoring are pure functions, so they run without a device or a browser.
Development
Node 22 (.nvmrc). Clone, then:
npm ci
npm run typecheck
npm test
npm run buildRun the CLI from source without building: npm run qa-capture -- doctor. CI
runs the same three checks on Ubuntu, macOS and Windows. Everything that needs
a device is recorded by hand in docs/emulator-validation.md, one numbered
round per session; docs/mac-testing.md is the checklist for the macOS pass.
Generating checklist items
checklist generate reads the diff between your branch and its base and
proposes things worth trying by hand. Like coverage, it prints what it would
send and does nothing without --send.
Suggestions are filtered before you see them, because the way this feature fails is slop rather than silence. An item is dropped if it is known filler ("verify the app doesn't crash", "smoke test the changes") or if it mentions nothing the diff actually touched - that second check catches advice that would fit any pull request ever opened. At most five survive, and what was discarded is printed rather than quietly replaced.
Returning nothing is a valid outcome. Better none than filler.
The tool's own sessions/ and checklists/ folders are excluded from the
diff it reads - add them to your .gitignore too, so captured evidence does
not end up in your app's history.
Coverage matching sends screenshots off your machine
coverage is the only command that transmits anything, and what it transmits
is pictures of your staging app. It prints what it would send and does nothing
without --send. If that data cannot leave your network, do not use it: the
matcher is an interface (CoverageJudge), so a self-hosted model replaces one
file and nothing else.
It answers only whether the session reached an item, never whether the app
worked. Every failure resolves to no_evidence - a model error, an invented
step id, a claim citing nothing - because a false tick is worse than a missing
one.
Screens the change touched
The diff says which screens a pull request touches: the Activity and Fragment
classes it edits, the view ids and test tags it adds, the strings a person
will read. qa-capture checklist screens prints that list. While recording,
the producer follows which screen the device is showing - the accessibility
event stream, logcat, and the Fragment stack sampled once a second - and
at the end every step is labelled with its screen. Steps on a screen the
change touched are flagged, lead the screenshots in the comment, and give
the comment its first line. The moment you reach one of those screens the tool
takes a lossless screenshot on its own and prints a line so you know it did;
--notify and --buzz add an on-device notification and a short buzz.
--highlight goes one step further: it reads the accessibility tree at each
match and outlines, on the screenshot, the element whose id or string the
diff touched - so the reviewer sees not just the screen but the exact control
that changed. Reading the tree pauses the live stream for a few seconds per
shot, which is why it is opt-in; the shot is then taken after the read, so a
screen that was still loading when it appeared is captured with its content.
The comment reads:
**Screens this change touched** · 2 of 3 reached
- `CheckoutFragment` — reached at stp_003, stp_004
- “Coupon code” — reached at stp_005
- `SettingsActivity` — not reached in this sessionChecklist items get the same treatment. An item whose words name a screen in
the diff is marked reached when the session was on that screen, with no
model involved and nothing leaving the machine. Reached means the screen was
on display, nothing more: whether the item worked is still for a person, or
for coverage --send if you ask it.
What stays private: on-screen strings are matched, never stored. Only a matched string or a class name ends up in the session, and typed text is filtered out on the device before anything is written. Compose apps expose their strings but not their navigation routes, so they match on text.
Flutter is different: its event stream carries the app-bar title and
nothing else, while every body string and every Semantics(identifier:)
lives only in the accessibility tree. So for a Flutter app the tool reads
the whole tree after each change on screen and matches on that instead -
strings and identifiers alike, live and for the labelling afterwards. It is
on by default when the package ships libflutter.so; --tree-sample turns
it on for any app and --no-tree-sample turns it off. Each read pauses the
live stream for about two seconds, during which a screen change is noticed
by the next read rather than the instant it happens; a tree read is the
app's own strings only, never what someone typed into a field. One
constraint: the stream holds the device's single accessibility client, so
Maestro, Appium or an agent cannot drive the device during a recording.
iOS Simulator
The same auto-capture, on the iOS Simulator, on a Mac:
qa-capture start --platform ios --pr 42 --highlightIt records the Simulator's screen with simctl io recordVideo, reads the
accessibility tree about once a second with AXe (axe describe-ui), and the
moment a screen the diff touched is showing - its strings or
accessibilityIdentifiers, since iOS has no screen class names to match -
takes a lossless simctl io screenshot and outlines the element. Steps come
from the video; they carry no taps, so there is no dead-control detection on
iOS and the session says so by leaving interaction empty. Needs Xcode, a
booted simulator and AXe on PATH; doctor checks all three. Physical iPhones
are not supported yet.
Posting to the pull request
qa-capture comment # prints the comment, saves sessions/<id>/comment.md
qa-capture comment --post # puts it on the pull requestcomment prints by default and only posts with --post, since it writes in
public under your name. Posting goes through the GitHub CLI, so this tool
never handles a token, and a second run updates the existing comment rather
than piling up (--new forces a fresh one).
Screenshots are attached, not linked. GitHub strips data: URIs from comment
markdown, so the CLI uploads a sample of the session's screens with
gh --attach (gh 2.99 or newer). They render inline on private repositories
too, and nothing is written into your app's git history. Each screen appears
as a small preview that opens at full size. Six are attached by default,
spread across the session; --screenshots <n> changes that, up to 24, and
--no-screenshots posts the summary alone. An older gh posts the summary and
says why.
Attachments work on GitHub.com and GitHub Enterprise Cloud. On Enterprise Server the comment goes up without them.
If the pull request was not resolved when you recorded - no gh yet, or the PR
was opened afterwards - pass --pr <n> once. It is saved into the session,
so later commands find it on their own.
Everything that could stop a post is checked before anything is sent, and the
fix is printed next to it: gh missing, not logged in, too old to attach, no
pull request. qa-capture doctor runs the same checks up front, and start
says at the top of a recording if the pull request could not be found, so
you can fix it before you drive the app rather than after.
Publishing the session page (optional)
view builds a self-contained HTML page of the whole session. Screenshots no
longer need publishing to show on the pull request, so this only matters if
you want reviewers to have the full screen-by-screen page as well.
There is nowhere to serve it from yet, so publish pushes the page onto an
orphan branch of the app's own repository - qa-capture-sessions, sharing no
history with your code - through a temporary git worktree, so your branch and
working tree are never touched. It renders once GitHub Pages is enabled for
that branch.
qa-capture view
qa-capture publish # says what it would push
qa-capture publish --push # pushes the page
qa-capture comment --post --session-url <page url>This is a stopgap. The page inlines every frame, so pushing it puts screenshots of a staging app where anyone who can read the repository can see them. Fine for a throwaway project you are exercising the flow against; not fine for anything real. Self-hosting is on the roadmap for exactly this reason.
The checklist lives on the pull request
Items are written from the diff before testing starts, so they cannot live in
a session that does not exist yet, and one pull request collects several
sessions over its life. Items and their verdicts are stored per PR under
checklists/; a session records only coverage referring to them by id.
Coverage is observed by the tool. The verdict is set by a person, and nothing in this codebase can write one.
Pass --pr <n> when the pull request cannot be resolved from the branch
(no GitHub CLI installed, for instance).
Layout
src/
session/ the contract - schema (zod), ids, writer
producers/adb/ device probe, getevent capture, screenrecord, clock, app id
context/ git repo, branch, PR number
pipeline/ frame diff, step detection, frame extraction
viewer/ session -> self-contained HTML page
report/ session -> pull request comment
publish/ session page -> a branch a reviewer can reach (optional)
coverage/ which checklist items a session reached
cli/ one command, several subcommandsproducers/adb may import from session/, never the reverse. pipeline/
and viewer/ only ever see session objects. An SDK producer later is a new producer, not
a rewrite.
Environment
QA_FFMPEG_DIR— directory containing ffmpeg/ffprobeQA_DEBUG=1— print raw frame PTS per stepQA_KEEP_VIDEO=1— keepcapture.mp4for diagnosis
State
Validated on an emulator only. docs/emulator-validation.md records what was
proven, what the numbers were, and what is still unproven — scrolls beyond a
basic check, fast tapping, text entry, real apps, and physical devices.
A note on the sync marker
At the start of every session the tool briefly opens and closes the notification shade. This is deliberate: it pins the video clock to the touch clock, which is what lets a screen change be attributed to the tap that caused it. It runs before you start testing, does not touch the app under test, and leaves the screen exactly as it found it. Its own steps are dropped from the session.
If it fails, capture continues and the alignment is estimated instead — the run prints a warning, and attribution may be less reliable.
