name: qa description: Behaviour QA of a PR/branch before merge in ayunis-core — spin up an isolated dev slot, seed, drive the changed flow end-to-end (API + headless browser), assert acceptance criteria with evidence, then tear everything down. Use when asked to "QA", "verify a PR/branch before merge", "check the behaviour works", or to confirm a change works in the real running app (not just tests).
Pre-merge QA
Verify that a PR actually behaves to spec by exercising it in the running app, then leave the machine exactly as it was. This is behaviour verification, not code review — pair it with /code-review for the diff.
Input
A ticket ID (e.g. AYC-2), a PR number/URL, or a branch name. If none given, use the current branch. Optionally, explicit acceptance criteria — otherwise derive them (below).
0. Establish acceptance criteria FIRST
You can't verify "it works" without knowing what "works" means. Get the criteria before touching the environment:
- Ticket —
linear issue view <ID> --json(the description's "Solution"/acceptance section). - PR —
gh pr view <n> --json title,body. - If still ambiguous, ask the user for the 1–3 concrete behaviours to confirm.
Write them down as a checklist. Every one must end the run marked ✅/❌ with evidence.
1. Worktree the branch
Use the worktree skill. Base the worktree on the PR's branch (not a new one):
git fetch origin
# --detach: QA is read-only, and it avoids git's "already checked out" refusal when <branch> is the one you're currently on
git worktree add --detach /Users/<you>/Developer/ayunis-core-wt-<slug> origin/<branch>
# symlink secret envs + install
ln -sf "$REPO/ayunis-core-backend/.env" "$WT/ayunis-core-backend/.env"
ln -sf "$REPO/ayunis-core-frontend/.env" "$WT/ayunis-core-frontend/.env"
cd "$WT" && pnpm install && (cd ayunis-core-backend && pnpm run build:deps)
2. Bring up an ISOLATED slot
Never reuse or touch a slot that is already running — those are the user's. List them first and pick a free number (avoid 0/1 and anything running):
docker ps --filter name=ayunis-dev --format '{{.Names}}' # see which slots are up
cd "$WT" && ./dev up --slot <FREE_N> # e.g. 2, 3, 4 …
Port formula: port + slot×10 (slot 2 → backend 3020, frontend 3021, postgres 5452, minio 9020). See the dev-environment skill.
Fallback: ./dev up aborts on an unhealthy peripheral container
./dev up does docker compose up --wait, so one unhealthy peripheral (e.g. anonymize in a restart loop) makes it bail before starting the backend — even though postgres/minio/redis are healthy. Do not try to fix the container (see Guardrails). Start the app natively against the healthy infra instead:
-
Confirm core infra is healthy:
docker compose -p ayunis-dev-<N> ps(postgres, minio, redis Up/healthy). -
Read the actual published host ports (don't hardcode peripherals):
docker compose -p ayunis-dev-<N> ps --format '{{.Service}} {{.Publishers}}' -
Write
ayunis-core-backend/.env.dev(gitignored) mirroring what./devgenerates — see the block./dev'scmd_upwrites. Key vars:PORT,POSTGRES_PORT,MINIO_PORT,REDIS_PORT,SMTP_PORT,CODE_EXECUTION_SERVICE_URL,ANONYMIZE_SERVICE_URL,GOTENBERG_URL,CORS_ALLOWED_ORIGINS, and a freshMCP_ENCRYPTION_KEY=$(openssl rand -hex 32). Provider API keys come from the symlinked.env— do NOT put them in.env.dev.Critical:
./dev upgenerates random MinIO/Redis passwords beforecompose --waitbut only writes them to.env.devafter — so when it dies at--wait, the infra is running with passwords that were never persisted. Don't regenerate them (the backend won't match the live containers); recover the real ones from the running containers:Pipe them straight into
.env.dev— never echo secrets to the terminal (transcripts/CI logs are retained):P=ayunis-dev-<N>; ENV=ayunis-core-backend/.env.dev mi=$(docker inspect "$(docker compose -p $P ps -q minio)" -f '{{range .Config.Env}}{{println .}}{{end}}') { echo "MINIO_ACCESS_KEY=$(grep -m1 '^MINIO_ROOT_USER=' <<<"$mi" | cut -d= -f2-)" echo "MINIO_SECRET_KEY=$(grep -m1 '^MINIO_ROOT_PASSWORD=' <<<"$mi" | cut -d= -f2-)" docker inspect "$(docker compose -p $P ps -q redis)" -f '{{range .Config.Env}}{{println .}}{{end}}' | grep -m1 '^REDIS_PASSWORD=' } >> "$ENV"Without these three the backend can't reach MinIO/Redis. (
MINIO_ROOT_USER/MINIO_ROOT_PASSWORDmap toMINIO_ACCESS_KEY/MINIO_SECRET_KEY;REDIS_PASSWORDkeeps its name.) -
cd ayunis-core-backend && pnpm run migration:run:dev -
Backend (from
ayunis-core-backend):pnpm run start:dev(run_in_background). Pollhttp://localhost:<BE>/api/healthuntil{"status":"healthy"}. -
Frontend — must run from
ayunis-core-frontend(Vite is a frontend-only dep and won't resolve from the backend dir or repo root):cd ../ayunis-core-frontend && VITE_API_BASE_URL=http://localhost:<BE>/api pnpm exec vite --port <FE>(run_in_background).
3. Seed
cd ayunis-core-backend && pnpm run seed:minimal:ts # idempotent
Login: admin@demo.local / admin (Admin + Super Admin). See seed-database. Note the seed's teams may belong to a different org — if you need a team in the admin's org, create one via POST /teams.
4. Drive the changed flow
Pick the layers the change touches. Prefer BOTH when the feature spans API + UI.
Backend behaviour — authenticated API
Use a cookie jar; assert response fields, don't just eyeball.
API=http://localhost:<BE>/api ; J=/tmp/qa.cookies ; rm -f $J
curl -sf -c $J -X POST $API/auth/login -H 'Content-Type: application/json' \
-d '{"email":"admin@demo.local","password":"admin"}' >/dev/null
# then -b $J on every subsequent call. Discover payload shapes from:
curl -sf $API/docs-json -o /tmp/dj.json # OpenAPI: paths, request/response schemas
Reproduce the spec: set up preconditions, toggle the thing, assert the observable outcome (e.g. create a thread and assert isAnonymous === true). Test both the positive and the control (off → off).
Frontend behaviour — headless browser
Use puppeteer-core (already a backend dependency) with the system Chrome — this is what works on this machine:
// node script, run from repo root
import puppeteer from '<repo>/node_modules/.pnpm/puppeteer-core@<ver>/node_modules/puppeteer-core/lib/esm/puppeteer/puppeteer-core.js';
const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const b = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
const page = await b.newPage();
page.on('console', m => m.type()==='error' && errs.push(m.text()));
// login (generous waits — auth cookie must land before navigating to a protected route):
await page.goto(`${BASE}/login`, {waitUntil:'networkidle2'});
await page.type("input[name='email']",'admin@demo.local'); await page.type("input[name='password']",'admin');
await Promise.all([page.click("button[type='submit']"), page.waitForNavigation({waitUntil:'networkidle2'}).catch(()=>{})]);
await new Promise(r=>setTimeout(r,2500));
Gotchas that bit us:
- Login timing: wait ~2.5s after submit before navigating, or the protected route bounces back to
/login. - Radix tabs: a synthetic
el.click()insidepage.evaluatedoes NOT switch the tab. Use a real element-handle click:await (await page.$$('button[role=tab]'))[i].click(), thenwaitForFunctionon the active[role=tabpanel][data-state=active]. - Screenshot (
fullPage: true) andReadit as evidence. Assert the concrete DOM node exists (e.g. a switch withid$='-anonymous'), and confirm interactions persist across reload (proves it hit the API). - Assert
console errors: none(ignore incidental 403/favicon noise).
Responsiveness (REQUIRED when the diff touches frontend layout/UI)
Drive the changed screen at each breakpoint and assert it doesn't break. The overflow check is the automatic fail signal — a screenshot alone won't catch a busted layout:
for (const [name, width] of [['mobile',375],['tablet',768],['desktop',1280]]) {
await page.setViewport({ width, height: 900, deviceScaleFactor: 2 });
await new Promise(r=>setTimeout(r,400)); // let CSS/reflow settle
const overflow = await page.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth);
console.log(`${name} ${width}px horizontalOverflow=${overflow}px`); // expect ≤ 1
await page.screenshot({ path: `<scratchpad>/pr-media/resp-${name}.png`, fullPage: true });
}
- Fail if
overflow > 1at any breakpoint (horizontal scrollbar = broken layout). Report the width and px. - Eyeball each shot for the mobile essentials: no clipped controls, nav collapses to its hamburger/drawer, dialogs and tables stay usable.
- The three
resp-*.pngcount as evidence and can double as PR media (step 4b).
4b. Frontend PR media (REQUIRED when the diff touches the frontend)
Standing rule: any PR that changes ayunis-core-frontend/ must ship with a screenshot (and a short GIF) of the change. Since the app is already running from step 2, capture it here. Delivery is automated and follows the repo's existing convention (PRs #989/#1007): media lives on a dedicated orphan pr-media/<ticket-lc> branch at docs/pr-media/<ticket-lc>/, embedded into the PR body via gh (repo is public, so raw.githubusercontent.com URLs render). No manual drag-drop; media never lands in the feature branch or main.
Save everything under a run dir, e.g. <scratchpad>/pr-media/.
Screenshots — key states
Drive the changed UI to each meaningful state and page.screenshot({ path, fullPage: true }):
- before/after, or empty/filled, or toggle off/on — whatever states the change introduces.
- Name them descriptively:
01-toggle-off.png,02-toggle-on.png.
Short GIF — the interaction (needs ffmpeg)
Record the interaction with CDP screencast, then assemble with ffmpeg:
// during the scripted interaction:
const client = await page.target().createCDPSession();
let n = 0; const dir = '<scratchpad>/pr-media/frames';
await client.send('Page.startScreencast', { format: 'jpeg', quality: 80, everyNthFrame: 1 });
client.on('Page.screencastFrame', async ({ data, sessionId }) => {
fs.writeFileSync(`${dir}/f-${String(n++).padStart(4,'0')}.jpg`, Buffer.from(data, 'base64'));
await client.send('Page.screencastFrameAck', { sessionId });
});
// ... perform the clicks/typing you want to show, with small awaited pauses ...
await client.send('Page.stopScreencast');
# frames -> optimized gif (~10 fps, 1000px wide)
ffmpeg -y -framerate 10 -pattern_type glob -i '<dir>/f-*.jpg' \
-vf "fps=10,scale=1000:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" \
<scratchpad>/pr-media/demo.gif
If ffmpeg is missing, deliver screenshots only and note the GIF was skipped (offer to brew install ffmpeg).
Publish & embed — host on the pr-media branch, append to the PR
Push the media to a dedicated orphan branch (media only, no code — kept out of the PR diff and main) at the conventional path, then edit the PR body to embed it. Needs the PR number and the ticket id (lowercased); if there's no ticket, use the PR number in its place.
PR=<n>; TK=<ticket-lc> # e.g. ayc-2 ; fall back to the PR number if none
BR=pr-media/$TK; DIR=docs/pr-media/$TK
RAW=https://raw.githubusercontent.com/ayunis-core/ayunis-core/$BR/$DIR
SRC=<scratchpad>/pr-media # whatever you actually captured lives here
MW=$(mktemp -d)
cd "$REPO" # git worktree ops must run from the checkout, not the scratchpad CWD
git worktree prune; git branch -D "$BR" 2>/dev/null || true # so re-runs on the same ticket don't hit "branch already exists"
git worktree add --orphan -b "$BR" "$MW" # unborn branch, empty tree
mkdir -p "$MW/$DIR"
# copy only the media that exists (screenshots always; gif only if ffmpeg produced one)
find "$SRC" -maxdepth 1 -type f \( -name '*.png' -o -name '*.gif' \) -exec cp {} "$MW/$DIR"/ \;
cd "$MW" && git add -A && git commit -q -m "docs(pr-media): $TK" && git push -fu origin "$BR"
cd "$REPO" && git worktree remove "$MW" --force # local worktree only; remote branch STAYS
# embed EXACTLY the files that were published — never hardcode names, or you get 404s
# read the current body FIRST; abort if it fails, so a transient gh error can't overwrite the description with only the media block
OLD=$(gh pr view "$PR" --json body -q '.body // ""') || { echo "could not read PR body — skipping embed" >&2; exit 1; }
{ printf '%s\n' "$OLD" | sed '/^### Screenshots \/ demo$/,$d'
printf '\n### Screenshots / demo\n\n'
for f in $(cd "$SRC" && ls *.png 2>/dev/null | sort); do
curl -sfI "$RAW/$f" >/dev/null && echo "raw url OK: $f" >&2 || echo "raw url NOT reachable: $f" >&2
printf '\n\n' "${f%.png}" "$RAW" "$f"
done
for g in $(cd "$SRC" && ls *.gif 2>/dev/null); do
printf '\n\n' "$RAW" "$g" # only emitted if a gif exists
done
printf '> Media hosted on the `%s` branch (not part of this PR'\''s diff); safe to delete after merge.\n' "$BR"
} > /tmp/qa-body.md
gh pr edit "$PR" --body-file /tmp/qa-body.md
- The
pr-media/<ticket>branch is not part of the diff and is exempt from teardown — leave it until the PR merges (deleting it while the PR is open breaks the images). - Re-runs
push -fto the same branch and thesedstrips the old block, so repeat QA keeps the PR body clean. - Verify the images actually render:
gh pr view $PR --web(thecurl -Iabove already confirms the raw URL resolves).
5. Report
Present the acceptance-criteria checklist, each ✅/❌ with its evidence (asserted values, screenshot path). If anything failed, say so plainly with the observed vs expected — do not soften it. This is the whole point. For frontend PRs, confirm the media was published to pr-media/<ticket> and embedded in the PR body (step 4b), with the PR link.
6. Tear down — leave the machine exactly as found
Always, even on failure:
# stop the native processes YOU started (use TaskStop on the background task ids)
cd "$WT" && ./dev down --slot <N> # slot you brought up — NEVER a pre-existing one
git -C "$WT" restore packages/*/dist # build:deps rebuilds these; don't leave them dirty
rm -f "$WT"/ayunis-core-backend/.env.dev
cd "$REPO" && git worktree remove "$WT" --force
Verify: docker ps --filter name=ayunis-dev shows only the slots that were running before you started; the QA slot's ports are free; the main checkout is on its original branch.
Guardrails (from CLAUDE.md — non-negotiable)
- Never
kill/pkilla process you didn't start. Stop your own background tasks via TaskStop. - Never destructive Docker flags: no
down -v,volume rm,system prune. Onlyup/down/ps/logs/exec. - Never touch a pre-existing slot or other infra. If a slot's volume is stale (migration
42P07) or a container won't come up, describe it and ask — don't fix it. Just pick a different free slot, or use the native-start fallback against healthy infra. - If the environment is broken in a way the fallback can't route around, stop and report — don't escalate fixes.
TDD Red-Green-Refactor
Testing
Skill that guides Claude through the complete TDD cycle.
Web Accessibility Audit
Testing
Performs a comprehensive web accessibility audit following WCAG standards.
UAT Test Case Generator
Testing
Generates structured and comprehensive user acceptance test cases.