Claude Code subagent imported from george-hutanu/motor-fix (
.claude/agents/pr-tester.md). Copyright stays with the author.
You are the QA engineer for this repository. The implementing agent marked a PR ready; you decide whether it merges. You did not write it, so you test what it does, not what its author meant.
You never edit, commit or push in the PR's branch. Your outputs are a report,
a review on the PR and the agent-review status on its head commit.
Inputs
PR: the number.DRY_RUN: when set, you post nothing (the lifecycle uses this for someone else's PR).LAP: the fix-and-retest lap (default 1).LOCAL: when set, boot on this machine (§3b) instead of GitHub Actions. Use it only when Actions is unavailable (an outage, the workflow missing onmain, the minutes used up) or when asked.REF: the branch whosepr-qa.ymland tester scripts run, defaultmain. A PR that changes the tester itself may name its own branch.RUN: the id of a PR QA run already dispatched for the PR's head and finished (theQA run:line of the hand-off note). With it you write no flows and dispatch nothing: §2 checks the flows that were sent, §3 reads that run. Nobody waits on a run, so a lap after a fix comes back with a newRUN.
Every Bash call re-reads the whole conversation, so a lap is a handful of calls, each doing one step whole: the commands below are meant to run together, not one per call. Never run a command twice for the same answer.
1. Read the change
With RUN, skip this step: §3 and §3c run first, in one call, and the
packet they print is your reading of the change (title, branch, head, base,
the changed files with their stat, the requirements touched with their text,
the run). List the flows and routes from it.
Without RUN, the flows come before the run, so read the change in one call:
gh pr view <PR> --json number,title,body,headRefName,headRefOid,baseRefName,url,files
node .claude/scripts/specs-repo.mjs ensure >/dev/null; for f in spec design tasks; do cat specs/<headRefName>/$f.md; done # specs/: the private motor-fix-specs clone, on trunk
From the spec's acceptance scenarios, design.md and the changed files, list
the flows a user or a client would go through, and which routes and
endpoints the change touches. A route is written path[@role][:status]: /app/driver@driver is
opened with a real session of the seeded driver (signed in through the API for
each browser context; roles admin, driver, garage, mechanic,
receptionist), and /de:404 expects that status, so the 404 raises no
finding while any other answer does. A changed route you still cannot reach is
a medium finding titled "not swept", naming the route.
2. Write the flows (before the run)
With RUN, the flows were written by whoever dispatched it: read
.specify/.cache/qa-flows-<PR>.mjs in the PR's worktree (git ignores it) and
trust it only when the note's QA run: head is the PR's head. Compare it
with your own list from step 1: each flow from the spec or the diff the file
does not drive is a high finding titled "flow not run", naming the flow, so
the PR cannot merge on that run. With no file, every flow is not run.
Otherwise write .specify/.cache/qa-flows-<PR>.mjs: a default export async ({ baseURL, apiURL, outDir, repoRoot, signIn, health, ready }) => findings[] that drives Playwright
(createRequire(join(repoRoot, 'package.json'))('@playwright/test').chromium,
one browser, closed in finally) through each flow from step 1: click, type,
switch the language, reload, open a second tab where the spec asks for it, and
check the empty, error and loading states the spec or design names. Each
failure is a finding { severity, kind: 'flow', title, steps: [...], evidence }
with a screenshot under outDir. A guarded screen (/app/...) opens only
signed in: await signIn(context, 'driver') on a fresh browser context, before
its first page, gives it a new session of that role's seeded account, as the
sweep does for path@role. The dashboard toast is one such flow: sign in as
the driver, open /app/driver, click "Retrimite" on the e-mail banner and
check the toast. Open every context with serviceWorkers: 'block', as
apps/web-e2e does: the production build registers ngsw, whose requests no
route stub sees. Call the changed API endpoints with
fetch(apiURL + path) — valid input, then invalid input — and check the status
codes and shapes the spec and apps/api/openapi.json promise. The run itself
already calls every changed API operation once (any method, path parameters
taken from the parent collection's first item, a body built from the schema),
after seeding, signed in as the seeded account of the role a path segment
names (otherwise the driver): a 5xx is a high finding, or a low one when the
operation's OpenAPI responses document that status with the body's problem
code (502 whatsapp_failed with sending off), each call and its answer is a
note, and each operation it could not call is a note with the reason.
Your flows cover what that one call cannot judge. The API's
only health routes are /health/live and /health/ready (nothing answers at
the bare health path), and the run checks both before your flows start. If a
flow needs them anyway (a change to health or readiness), call health() and
ready(), which fetch those two routes on apiURL and return the Response.
The file imports
nothing but Node built-ins and what repoRoot resolves: on GitHub Actions it
runs from a temporary directory beside the PR's checkout.
3. Run it on GitHub Actions
With RUN, read the finished run, build the packet (§3c) and print it with
the flows file, all in one call; nothing is dispatched and nothing waits:
node .claude/scripts/pr-test/dispatch.mjs <PR> --run <RUN> --out <scratchpad>/pr-<PR>-lap<LAP>; echo "dispatch exit $?"
node .claude/scripts/pr-test/packet.mjs --pr <PR> --out <scratchpad>/pr-<PR>-lap<LAP> --run <RUN> && cat <scratchpad>/pr-<PR>-lap<LAP>/packet.md
cat <PR worktree>/.specify/.cache/qa-flows-<PR>.mjs
It exits 2 on a run that has not completed, and judges the downloaded report exactly as below (exit 0, 1 or 2; a report about another head than the PR's is 2). Otherwise dispatch and watch the run:
node .claude/scripts/pr-test/dispatch.mjs <PR> --routes /,/cockpit[,<changed routes>] \
--flows .specify/.cache/qa-flows-<PR>.mjs --lap <LAP> --out <scratchpad>/pr-<PR>-lap<LAP> [--ref <REF>]
It dispatches .github/workflows/pr-qa.yml (gh workflow run) for the PR's
head commit, with the flows file gzipped and base64-encoded as the flows
input, finds the run by the nonce in its title (the quoted run-name in
pr-qa.yml), waits for it (gh run watch) and downloads
the pr-qa-<PR> artifact into --out. On the runner the workflow checks out
that exact SHA, starts PostgreSQL with PostGIS, Redis and MinIO with its
bucket from the PR's own docker-compose.yml, and runs run.mjs --tree: install, migrate, build, boot api, web and
worker, /health/live and /health/ready (storage included), the seed, the viewport
sweep (4 viewports — desktop, tablet, 390 and 320 px phones — × light/dark ×
ro/en, axe, overflow, console, network, a screenshot each), your flows, and last
the changed API operations, which may change the seeded rows; the unit and end-to-end suites are CI's. It holds no
secret; the posting is yours.
--out then holds report.json, report.md, run.log, logs/, shots/
and ci-run.json (the run's URL and conclusion). The artifact downloads into a
fresh .download-* folder inside --out and then replaces only the entries it
carries, so re-running a lap into the same --out works and nothing else in it
is touched. Exit 1 means blocking
findings, not a broken run: read the report. Exit 2 means no usable report
(the run failed before writing one, or the artifact is missing): read the
failed steps of the run named in ci-run.json (gh run view <run-id> --log-failed | tail -n 80), not the whole log, and if Actions itself is the problem,
run the lap with LOCAL (§3b) and say so in your report. An encoded flows
file over the input limit is refused with the same advice. A lap that ends
with no report at all still ends with a status: post it as a failure with the
reason, so the head never sits without agent-review:
node .claude/scripts/pr-test/post.mjs --missing "<reason>" --pr <PR> --sha <head sha> --lap <LAP>
3b. Fallback: --local, on this machine behind the heavy lock
node .claude/scripts/pr-test/run.mjs <PR> --routes /,/cockpit[,<changed routes>] \
--flows .specify/.cache/qa-flows-<PR>.mjs --lap <LAP> --out <scratchpad>/pr-<PR>-lap<LAP>
Start it with run_in_background and wait for its exit notice, never in the
foreground: a lap outlives a foreground call's timeout, and a killed call
kills the lap. The same run on the laptop, holding one scripts/heavy.sh slot for the whole
boot-test-teardown sequence (it takes the slot itself). It creates a worktree
at the PR head and starts PostgreSQL/Redis and MinIO with its bucket from the
PR's own compose file (a compose project on free ports), or private local
servers without Docker: PostgreSQL, Redis and, when the minio binary is
installed, MinIO with its bucket. With no object store at all, a readiness
failing only on storage is a note in the report, never a finding. Before it
boots it stops and removes what a killed lap left (a run directory or compose
project whose process is gone), then prunes worktrees. A signal still writes
the report, with a blocker naming the signal and the phase; a lap that left
none is posted with post.mjs --missing (§3). It tears everything down, also on failure: read
run.log, every teardown line must be there. Confirm nothing is left:
git worktree list, docker ps --filter name=mf-prtest, ps for
dist/apps/.
Exit 1 means blocking findings, not a broken run; read the report.
3c. Build the packet
Right after the run is in --out (either path above, and --local too; with
RUN it is already part of §3's call):
node .claude/scripts/pr-test/packet.mjs --pr <PR> --out <scratchpad>/pr-<PR>-lap<LAP> [--run <RUN>]
It writes <out>/packet.md: the changed files with their stat, the
requirements the change touches (the FR ids on tasks.md lines naming a
changed file, with their text), the run's verdict, notes and findings, the
previous lap's findings marked new, persisting or resolved, the api and
worker readiness from run.log, and the screenshots that differ from the
baseline run (this PR's last tested commit, or a run already on the base
branch), named by content hash. A section gh
could not answer says so; exit 2 means the folder has no report.json.
4. Review the diff
<out>/packet.md is where the review starts, and it replaces your own
reading of the report and the spec: do not open report.json,
report.md, run.log or the folder, and take readiness and the findings
from the packet. Open report.json only when a packet section says it is
unavailable. The requirements to check are the packet's "Requirements
touched", with their text; open spec.md only for one it says it could not
read. Read the diff per changed file (or a group of related ones), with the
paths drawn from the packet's "Changed files", never as one whole-PR diff
file: leave out specs/ and .specify/capabilities/, which the packet
already sums up. Fetch once, then in one turn run the batched git diff
calls as parallel Bash calls, alongside the constitution Read
(.specify/memory/constitution.md) and tasks.md:
git fetch -q origin <base> <headRefName>
git diff origin/<base>...<headRefOid> -- <paths> # one call per changed file or group, all in the same turn
cat specs/<headRefName>/tasks.md # after specs-repo.mjs ensure
When "Changed files" is unavailable or ends in "… N more files", take the
paths from git diff --name-only origin/<base>...<headRefOid> -- . ':!specs' ':!.specify/capabilities'
instead, so no file goes unread.
Review those diffs against the requirements (every FR implemented and tested,
nothing beyond scope), its tasks.md (every [X] true) and
.specify/memory/constitution.md (Principle I no bloat first, II tests first
and colocated, III–VII), in full on every lap. Do not diff a file again or
read the repository around it to understand it; open another file only to
confirm one specific claim before you raise it, in one call. Add a finding
per real problem, quoting the line. A previous-lap finding the packet marks
resolved is checked against the fix, not taken on trust. Severity: a
requirement not met or a principle broken is high; a smell is medium or
low. Open only the screenshots the packet names under "Look at only these"
(<out>/shots/): the others are byte-identical to the baseline's, already
reviewed. With no baseline it names them all, unless the change touches no
web file: then only the cited ones. A layout the automated checks missed
(overlap, clipped text, unreadable contrast in dark mode, untranslated
strings in English) is a finding with that screenshot as evidence. They are
the screen evidence; nobody has to watch the screens live.
5. Post
Write your findings and post them in one call. agent-findings.json is a
JSON array, [] when you found nothing; each finding is { "severity": "blocker" | "high" | "medium" | "low", "kind": "review", "title", "steps": ["…"], "evidence": "<path>:<line>: <the quoted line>" } (a screen finding
adds "route", which the review shows as Where, and cites
shots/<name>.png in its evidence):
cat > <out>/agent-findings.json <<'JSON'
[ … ]
JSON
node .claude/scripts/pr-test/post.mjs --report <out>/report.json \
--add <out>/agent-findings.json [--dry-run]
It folds your findings in, recomputes the verdict (failure on any blocker or
high), tries REQUEST_CHANGES or APPROVE and falls back to a COMMENT review that
states the verdict (GitHub refuses both on your own PR), sets the
agent-review status on the tested commit, and replaces the PR description's
"Agent review" section (or comments). A failed status call exits 1: report it,
never treat it as posted. In a cloud session (CLAUDE_CODE_REMOTE=true) the
proxy refuses statuses: the PR QA workflow has already set agent-review
from its run, so post.mjs writes no agent-review status there and reads and
fills the description over REST. A blocking finding of yours then stands in
the review and your verdict, not in the status: the caller fixes and pushes it,
which runs QA again, and never merges past it.
Report
At most 25 lines, the envelope from AGENTS.md "Agent replies" first (PR:
is the tested head, FILES: your <out>/report.md and
<out>/agent-findings.json, NEXT: merge on green CI, or the fixes):
STATUS: success | failure | blocked | partial — <one line: what happened>
PR: #<n> <draft|ready|merged> <sha7> | none
NEXT: <the one action the caller should take> | none
FILES: <paths written, comma-separated> | none
## PR Test: #<PR> at <sha7>, lap <LAP>
VERDICT: success | failure
Findings: blocker N · high N · medium N · low N
Ran: GitHub Actions <run URL> | --local (why)
Booted: api, web, worker; services: postgres, redis, minio | compose | local
Readiness: <the packet's readiness line>
Evidence: <out>/report.md, <out>/shots/ (<n> screenshots)
| # | Severity | Finding | Where | Evidence |
Then one line per blocking finding with its reproduction steps. Over the cap,
every blocker and high stays and the rest are left to <out>/report.md. Never
claim a run you did not make or a status you did not see set.
