Skip to content

Commit ef8913b

Browse files
ohernandezdevclaude
andcommitted
chore(skills): add qa-visual project skill for driving AFKode via CDP
Captures the verified build/launch/drive/verify loop: WebView2 remote debugging launch, single-instance + self-ancestry preflight, playwright-core driver with UI selector map, geometry pass/fail invariants, and a read-only inspection mode. Tested by a fresh agent applying the skill end-to-end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1035632 commit ef8913b

2 files changed

Lines changed: 185 additions & 0 deletions

File tree

‎.claude/skills/qa-visual/SKILL.md‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
---
2+
name: qa-visual
3+
description: Use when you need to launch, run, drive, or visually verify AFKode itself — reproducing UI/terminal bugs (scroll, rendering, last line hidden, layout), taking screenshots of the real app, typing into a live Claude Code tab, or acting as QA after a change to src/ or src-tauri/.
4+
---
5+
6+
# QA visual de AFKode (build + CDP + screenshots)
7+
8+
## Overview
9+
10+
AFKode es Tauri 2 + WebView2. El release no trae devtools, pero WebView2
11+
acepta depuración remota vía variable de entorno al lanzar. Con
12+
`playwright-core` conectado por CDP se puede manejar la UI real, escribir
13+
en un tab de Claude Code de verdad y capturar pantalla para verificar
14+
visualmente.
15+
16+
**Principio: una prueba visual = lanzar la app real, manejarla, y LEER el
17+
screenshot.** Un frame en blanco es un fallo de lanzamiento, no un pass.
18+
19+
## Preflight (obligatorio, en orden)
20+
21+
1. **¿Estoy corriendo dentro de AFKode?** Si el ancestro del proceso es
22+
`afkode.exe`, matar la app mata esta sesión. Verifica:
23+
```powershell
24+
$p = Get-CimInstance Win32_Process -Filter "ProcessId = $PID"
25+
while ($p) { $p.Name; $p = Get-CimInstance Win32_Process -Filter "ProcessId = $($p.ParentProcessId)" -ErrorAction SilentlyContinue }
26+
```
27+
Si aparece `afkode.exe` en la cadena: NO cierres la app; pide al usuario
28+
correr la prueba desde otra terminal.
29+
2. **Instancia corriendo:** `tauri-plugin-single-instance` hace que un
30+
segundo lanzamiento solo enfoque la primera. Hay que cerrar la que corre:
31+
`(Get-Process afkode).CloseMainWindow()`, espera 3s, y si sigue viva
32+
`Stop-Process -Force`. El session restore reofrece los tabs al reabrir,
33+
no se pierde nada.
34+
35+
## Build
36+
37+
```powershell
38+
npm run tauri build -- --no-bundle # solo el exe, sin instaladores (~2-3 min)
39+
# exe: src-tauri\target\release\afkode.exe
40+
```
41+
42+
Para cambios solo de frontend igual hace falta el build completo: el exe
43+
embebe `dist/`.
44+
45+
## Lanzar con CDP
46+
47+
```powershell
48+
$env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = '--remote-debugging-port=9333'
49+
Start-Process 'C:\Projects\afkode\src-tauri\target\release\afkode.exe'
50+
# verificar: Invoke-WebRequest http://127.0.0.1:9333/json/version
51+
```
52+
53+
La variable solo aplica a procesos lanzados desde esa shell.
54+
55+
## Driver
56+
57+
Usa/adapta [drive.js](drive.js) (probado). Setup una vez por scratchpad:
58+
`npm init -y; npm i playwright-core`, y ejecuta `node drive.js` **desde el
59+
scratchpad** para que `require("playwright-core")` resuelva.
60+
61+
**Solo inspección (instancia ya corriendo):** salta el bloque que abre tab
62+
(`#btn-new-tab` + launcher) — clickearlo muta la sesión viva del usuario.
63+
Conecta, screenshot, geometría, y nada más.
64+
65+
Claves:
66+
67+
| Qué | Cómo |
68+
|---|---|
69+
| Conectar | `chromium.connectOverCDP("http://127.0.0.1:9333")` |
70+
| Página principal | url `http://tauri.localhost/` (hud/palette son otras páginas) |
71+
| Estado UI | `#tabs .tab`, `#empty-state` (oculto = hay sesión activa), `.resume-bar` |
72+
| Abrir tab | `#btn-new-tab` fuerza el picker; launchers = `button[data-cmd]` (`claude`, `""` = shell) |
73+
| Esperar a Claude Code | poll hasta que `.term-loader` desaparezca (~10-30 s) |
74+
| Escribir en el terminal | focus a `.term-pane.active textarea.xterm-helper-textarea`, luego `page.keyboard.type(text, {delay: 3})` |
75+
| No enviar el prompt | simplemente no mandes Enter |
76+
| Verificar | `page.screenshot()` y **leer la imagen** con la herramienta Read |
77+
| Geometría | comparar rects de `.term-pane.active`, `.xterm-screen` y el textarea (posición del cursor) |
78+
| Título de tab | `textContent` incluye el glifo `×` del botón cerrar — no hagas match exacto |
79+
80+
**Invariantes de geometría (pass/fail):** `.xterm-screen` contenido en el
81+
pane; `screen.bottom ≤ pane.bottom + 0.5` (si no, hay filas recortadas —
82+
el bug clásico de "última línea invisible"); el textarea (celda del cursor)
83+
dentro del rect del screen.
84+
85+
## Cleanup
86+
87+
- Mata la instancia de prueba (`Stop-Process`) o déjala si el usuario va a
88+
seguir usándola — pero avisa que tiene el puerto de debugging abierto.
89+
- Si cerraste la app instalada del usuario (`AppData\Local\AFKode\afkode.exe`),
90+
reláncala o avisa explícitamente qué quedó corriendo.
91+
92+
## Common mistakes
93+
94+
| Error | Realidad |
95+
|---|---|
96+
| Lanzar segunda instancia para probar | single-instance la reduce a un focus de la primera; cierra la vieja primero |
97+
| Buscar devtools en el release | no está compilado; usa `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS` |
98+
| Matar afkode sin chequear ancestría | si esta sesión corre dentro de afkode, te suicidas |
99+
| `page.keyboard.type` sin enfocar el xterm | el texto va a ninguna parte; enfoca el `textarea.xterm-helper-textarea` del pane activo |
100+
| Declarar éxito sin leer el screenshot | el screenshot es la evidencia; hay que mirarlo |
101+
| Probar justo tras `spawn` | Claude Code tarda; espera a que `.term-loader` desaparezca |

‎.claude/skills/qa-visual/drive.js‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Drive the freshly built afkode via WebView2 CDP: open a Claude tab,
2+
// type a long paragraph, screenshot to check the last line is visible.
3+
const { chromium } = require("playwright-core");
4+
const path = require("path");
5+
6+
const OUT = __dirname;
7+
const shot = (page, name) =>
8+
page.screenshot({ path: path.join(OUT, name) }).catch((e) => console.log("shot fail", name, e.message));
9+
10+
(async () => {
11+
const browser = await chromium.connectOverCDP("http://127.0.0.1:9333");
12+
const pages = browser.contexts().flatMap((c) => c.pages());
13+
console.log("pages:", pages.map((p) => p.url()).join(" | "));
14+
const page = pages.find((p) => /index\.html|localhost:1420\/?$|^https?:\/\/tauri\.localhost\/?$/i.test(p.url()));
15+
if (!page) throw new Error("main page not found");
16+
17+
await page.waitForTimeout(2000);
18+
await shot(page, "state1.png");
19+
20+
// Dump basic UI state
21+
const state = await page.evaluate(() => ({
22+
tabs: [...document.querySelectorAll("#tabs .tab")].length,
23+
emptyVisible: !document.querySelector("#empty-state")?.classList.contains("hidden"),
24+
resumeBars: document.querySelectorAll(".resume-bar").length,
25+
cliButtons: [...document.querySelectorAll("button[data-cmd]")].map((b) => b.dataset.cmd),
26+
folder: document.querySelector("#picked-folder-label")?.textContent,
27+
}));
28+
console.log("state:", JSON.stringify(state));
29+
30+
// If restored tabs show resume bars, ignore them — start a fresh claude tab.
31+
if (!state.emptyVisible) {
32+
await page.evaluate(() => document.querySelector("#btn-new-tab").click());
33+
await page.waitForTimeout(500);
34+
}
35+
await page.evaluate(() => {
36+
const btn = [...document.querySelectorAll("button[data-cmd]")].find((b) => (b.dataset.cmd || "").startsWith("claude"));
37+
if (!btn) throw new Error("no claude launcher");
38+
btn.click();
39+
});
40+
41+
// Wait for Claude Code to paint (loader disappears)
42+
// The loader appears on spawn; give it a moment to show, then wait until
43+
// it's gone. Fail loudly if it never disappears instead of typing blind.
44+
await page.waitForTimeout(2000);
45+
let started = false;
46+
for (let i = 0; i < 40; i++) {
47+
const loading = await page.evaluate(() => !!document.querySelector(".term-loader"));
48+
if (!loading) { started = true; break; }
49+
await page.waitForTimeout(1000);
50+
}
51+
if (!started) throw new Error("Claude Code never finished loading (.term-loader still visible after 40s)");
52+
await page.waitForTimeout(2000); // let the TUI paint its first frame
53+
await shot(page, "state2-claude-started.png");
54+
55+
// Focus the terminal and type a long paragraph WITHOUT submitting.
56+
await page.evaluate(() => {
57+
const ta = document.querySelector(".term-pane.active textarea.xterm-helper-textarea");
58+
ta?.focus();
59+
});
60+
let para = "";
61+
for (let i = 1; i <= 22; i++) {
62+
para += `linea${String(i).padStart(2, "0")} aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii jjjj `;
63+
}
64+
para += "FINAL-DE-PARRAFO";
65+
await page.keyboard.type(para, { delay: 3 });
66+
await page.waitForTimeout(2500);
67+
await shot(page, "state3-typed.png");
68+
69+
// Diagnostic: geometry of last visible content vs pane
70+
const geo = await page.evaluate(() => {
71+
const pane = document.querySelector(".term-pane.active");
72+
const screen = pane?.querySelector(".xterm-screen");
73+
const ta = pane?.querySelector("textarea.xterm-helper-textarea");
74+
const r = (el) => el && { top: el.getBoundingClientRect().top, bottom: el.getBoundingClientRect().bottom };
75+
return { pane: r(pane), screen: r(screen), textarea: r(ta) };
76+
});
77+
console.log("geometry:", JSON.stringify(geo));
78+
79+
console.log("DONE");
80+
process.exit(0);
81+
})().catch((e) => {
82+
console.error("FAIL:", e.message);
83+
process.exit(1);
84+
});

0 commit comments

Comments
 (0)