feat: AI-driven browser tests — Tests tab + browser_test agent tool
Some checks failed
PR Check / check (pull_request) Failing after 33s

New feature: give Studio's AI control of any browser tab to test buttons,
read the console, and report which buttons work / fail.

Backend (internal/api/browser_test.go, ~480 LOC):
- WebSocket endpoint /api/ws/browser-test, auth by single-use 5-min token
- BrowserTestStore: session map (capped at 16, LRU evict), token store
- REST: /api/test/snippet (issues token + JS snippet), /api/test/sessions,
  /api/test/console/{id}
- Agent tool 'browser_test' wired into the registry, with actions:
  list_clickables / click / eval / console / current_url / type / wait /
  summary. click returns the console_delta produced during the click.
- Embedded JS runner: opens WS, hooks console + window.onerror +
  unhandledrejection, dispatches dispatcher commands, replies with
  correlation IDs, watches for URL changes.

Frontend:
- New Tests tab (web/src/components/Tests.jsx): snippet copy + connected
  sessions list + live console viewer
- App.jsx: 5th tab + Ctrl+4 shortcut (Config moves to Ctrl+5)
- api/client.js: getTestSnippet / getTestSessions / getTestConsole

Studio prompt:
- internal/agent/prompts/studio_system.md: added browser_test entry to the
  tools table + <browser_test_strategy> section explaining the recommended
  loop (summary → list_clickables → click → check console_delta → report)

Versioning:
- v0.6.0 → v0.7.0
- CHANGELOG.md: full entry under v0.7.0
This commit is contained in:
Muyue
2026-04-27 11:02:05 +02:00
parent 6a7b4d8001
commit c820d55710
8 changed files with 914 additions and 3 deletions

View File

@@ -44,6 +44,9 @@ const api = {
getRecentCommands: () => request('/recent-commands'),
getRunningProcesses: () => request('/running-processes'),
getSystemMetrics: () => request('/system/metrics'),
getTestSnippet: () => request('/test/snippet'),
getTestSessions: () => request('/test/sessions'),
getTestConsole: (sessionId) => request(`/test/console/${encodeURIComponent(sessionId || '')}`),
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }),
saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }),

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react'
import { LayoutDashboard, Sparkles, Terminal, Settings, TestTube2 } from 'lucide-react'
import api from '../api/client'
import { getTheme, applyTheme } from '../themes'
import { useI18n } from '../i18n'
@@ -7,6 +7,7 @@ import Dashboard from './Dashboard'
import Studio from './Studio'
import Shell from './Shell'
import Config from './Config'
import Tests from './Tests'
import OnboardingWizard from './OnboardingWizard'
export default function App() {
@@ -24,6 +25,7 @@ export default function App() {
{ id: 'dash', label: t('tabs.dashboard'), icon: <LayoutDashboard size={15} /> },
{ id: 'studio', label: t('tabs.studio'), icon: <Sparkles size={15} /> },
{ id: 'shell', label: t('tabs.shell'), icon: <Terminal size={15} /> },
{ id: 'tests', label: 'Tests', icon: <TestTube2 size={15} /> },
{ id: 'config', label: t('tabs.config'), icon: <Settings size={15} /> },
], [t])
@@ -54,7 +56,8 @@ export default function App() {
Digit1: 'dash',
Digit2: 'studio',
Digit3: 'shell',
Digit4: 'config',
Digit4: 'tests',
Digit5: 'config',
}
if (map[e.code]) {
e.preventDefault()
@@ -92,6 +95,7 @@ export default function App() {
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
],
tests: [],
config: [],
}), [layout, t])
@@ -129,6 +133,7 @@ export default function App() {
<div className={activeTab === 'dash' ? '' : 'tab-hidden'}><Dashboard api={api} refreshRef={dashRefreshRef} /></div>
<div className={activeTab === 'studio' ? '' : 'tab-hidden'}><Studio api={api} /></div>
<div className={activeTab === 'shell' ? '' : 'tab-hidden'}><Shell api={api} isSudo={isSudo} /></div>
<div className={activeTab === 'tests' ? '' : 'tab-hidden'}><Tests api={api} /></div>
<div className={activeTab === 'config' ? '' : 'tab-hidden'}><Config api={api} /></div>
</main>

View File

@@ -0,0 +1,238 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { TestTube2, Copy, RefreshCw, CheckCircle2, AlertTriangle, Globe, Terminal as TerminalIcon } from 'lucide-react'
export default function Tests({ api }) {
const [snippet, setSnippet] = useState(null)
const [snippetError, setSnippetError] = useState('')
const [sessions, setSessions] = useState([])
const [console_, setConsole_] = useState([])
const [activeSessionId, setActiveSessionId] = useState('')
const [copied, setCopied] = useState(false)
const pollRef = useRef(null)
const refreshSnippet = useCallback(async () => {
try {
const data = await api.getTestSnippet()
setSnippet(data)
setSnippetError('')
} catch (err) {
setSnippetError(err.message || 'Failed to load snippet')
}
}, [api])
const refreshSessions = useCallback(async () => {
try {
const data = await api.getTestSessions()
const next = data.sessions || []
setSessions(next)
if (!activeSessionId && next.length > 0) {
setActiveSessionId(next[0].id)
} else if (activeSessionId && !next.find(s => s.id === activeSessionId)) {
setActiveSessionId(next.length > 0 ? next[0].id : '')
}
} catch {}
}, [api, activeSessionId])
const refreshConsole = useCallback(async () => {
if (!activeSessionId) {
setConsole_([])
return
}
try {
const data = await api.getTestConsole(activeSessionId)
setConsole_(data.console || [])
} catch {
setConsole_([])
}
}, [api, activeSessionId])
useEffect(() => {
refreshSnippet()
}, [refreshSnippet])
useEffect(() => {
refreshSessions()
refreshConsole()
pollRef.current = setInterval(() => {
refreshSessions()
refreshConsole()
}, 2000)
return () => clearInterval(pollRef.current)
}, [refreshSessions, refreshConsole])
const copySnippet = useCallback(async () => {
if (!snippet) return
try {
await navigator.clipboard.writeText(snippet.snippet)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {}
}, [snippet])
const activeSession = sessions.find(s => s.id === activeSessionId) || null
return (
<div className="tests-layout" style={{ padding: '20px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px', height: '100%', overflow: 'auto' }}>
<section className="tests-pane">
<header style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<TestTube2 size={18} />
<h2 style={{ margin: 0, fontSize: '1.1em' }}>Tests pilotés par l'IA</h2>
</header>
<p style={{ marginTop: 0, opacity: 0.85, lineHeight: 1.5 }}>
Donnez à l'IA Studio le contrôle d'un onglet de votre navigateur pour tester chaque bouton et détecter les erreurs console.
</p>
<div style={{ borderTop: '1px solid var(--border, rgba(128,128,128,0.3))', paddingTop: 12, marginTop: 12 }}>
<h3 style={{ fontSize: '0.95em', margin: '0 0 8px' }}>1. Connexion</h3>
<ol style={{ paddingLeft: 18, lineHeight: 1.6 }}>
<li>Ouvrez la page à tester dans n'importe quel navigateur (Chrome, Firefox, Edge).</li>
<li>Ouvrez la console développeur (<kbd>F12</kbd>).</li>
<li>Collez ce snippet et appuyez sur <kbd>Entrée</kbd> :</li>
</ol>
{snippetError && (
<div style={{ background: 'rgba(220,80,80,0.1)', border: '1px solid rgba(220,80,80,0.3)', padding: 8, borderRadius: 4, marginBottom: 8 }}>
{snippetError}
</div>
)}
<div style={{ position: 'relative', marginBottom: 12 }}>
<pre style={{
background: 'var(--bg-secondary, rgba(0,0,0,0.3))',
padding: '10px 12px',
borderRadius: 4,
fontSize: '0.75em',
maxHeight: 180,
overflow: 'auto',
border: '1px solid var(--border, rgba(128,128,128,0.3))',
margin: 0,
}}>
{snippet?.snippet || 'Chargement…'}
</pre>
<button
onClick={copySnippet}
disabled={!snippet}
title="Copier"
style={{
position: 'absolute', top: 6, right: 6,
background: 'var(--bg-tertiary, rgba(255,255,255,0.08))',
border: '1px solid var(--border, rgba(128,128,128,0.3))',
color: 'inherit', padding: '4px 8px', borderRadius: 3,
cursor: 'pointer', fontSize: '0.75em',
display: 'flex', alignItems: 'center', gap: 4,
}}
>
<Copy size={11} /> {copied ? 'Copié !' : 'Copier'}
</button>
</div>
<button onClick={refreshSnippet} style={{ background: 'transparent', border: '1px solid var(--border, rgba(128,128,128,0.3))', color: 'inherit', padding: '4px 10px', borderRadius: 3, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '0.85em' }}>
<RefreshCw size={12} /> Régénérer le token
</button>
<small style={{ display: 'block', opacity: 0.6, marginTop: 4 }}>
Le token expire après {snippet?.expires_in ? Math.round(snippet.expires_in / 60) : 5} minutes ou dès la première connexion.
</small>
</div>
<div style={{ borderTop: '1px solid var(--border, rgba(128,128,128,0.3))', paddingTop: 12, marginTop: 16 }}>
<h3 style={{ fontSize: '0.95em', margin: '0 0 8px' }}>2. Pilotage par l'IA</h3>
<p style={{ margin: '0 0 8px', lineHeight: 1.5 }}>
Une fois la session connectée, allez dans l'onglet <strong>Studio</strong> et demandez par exemple :
</p>
<pre style={{ background: 'var(--bg-secondary, rgba(0,0,0,0.3))', padding: 8, borderRadius: 4, fontSize: '0.85em', margin: 0 }}>
{`Teste tous les boutons de cette page,
clique sur chacun, et dis-moi
lesquels déclenchent une erreur console.`}
</pre>
<p style={{ margin: '8px 0 0', opacity: 0.75, fontSize: '0.85em' }}>
L'IA dispose de l'outil <code>browser_test</code> avec les actions <code>list_clickables</code>, <code>click</code>, <code>console</code>, <code>eval</code>, <code>type</code>, <code>current_url</code>, <code>wait</code>, <code>summary</code>.
</p>
</div>
</section>
<section className="tests-pane">
<header style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Globe size={16} />
<h2 style={{ margin: 0, fontSize: '1.1em' }}>Sessions connectées</h2>
</div>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '0.85em' }}>
{sessions.length > 0 ? <CheckCircle2 size={14} color="#3aaa61" /> : <span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', background: '#888' }} />}
{sessions.length} session{sessions.length > 1 ? 's' : ''}
</span>
</header>
{sessions.length === 0 ? (
<div style={{ padding: 16, textAlign: 'center', opacity: 0.7, border: '1px dashed var(--border, rgba(128,128,128,0.3))', borderRadius: 4 }}>
<AlertTriangle size={20} style={{ opacity: 0.4 }} />
<div style={{ marginTop: 6 }}>Aucune session active.</div>
<small>Collez le snippet dans une page pour démarrer.</small>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
{sessions.map(s => (
<button
key={s.id}
onClick={() => setActiveSessionId(s.id)}
style={{
textAlign: 'left',
background: s.id === activeSessionId ? 'var(--accent-bg, rgba(108,92,231,0.15))' : 'transparent',
border: '1px solid ' + (s.id === activeSessionId ? 'var(--accent, #6c5ce7)' : 'var(--border, rgba(128,128,128,0.3))'),
color: 'inherit',
padding: 8, borderRadius: 4, cursor: 'pointer',
}}
>
<div style={{ fontWeight: 500, fontSize: '0.9em', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{s.title || s.url || s.id}
</div>
<div style={{ fontSize: '0.75em', opacity: 0.65, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{s.url} · session {s.id.slice(0, 8)}
</div>
</button>
))}
</div>
)}
{activeSession && (
<div style={{ borderTop: '1px solid var(--border, rgba(128,128,128,0.3))', paddingTop: 12 }}>
<header style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
<TerminalIcon size={14} />
<h3 style={{ margin: 0, fontSize: '0.95em' }}>Console (live, dernières {console_.length})</h3>
</header>
<div style={{
background: 'var(--bg-secondary, rgba(0,0,0,0.3))',
padding: 8,
borderRadius: 4,
maxHeight: 380,
overflow: 'auto',
fontSize: '0.8em',
fontFamily: 'var(--font-mono, ui-monospace, monospace)',
border: '1px solid var(--border, rgba(128,128,128,0.3))',
}}>
{console_.length === 0 ? (
<div style={{ opacity: 0.5 }}>(aucun message console)</div>
) : (
console_.map((c, i) => (
<div key={i} style={{ color: levelColor(c.level), padding: '2px 0', borderBottom: '1px dashed rgba(128,128,128,0.15)' }}>
<span style={{ opacity: 0.55, fontSize: '0.85em' }}>[{c.time?.slice(11, 19)} {c.level}]</span> {c.message}
</div>
))
)}
</div>
</div>
)}
</section>
</div>
)
}
function levelColor(lvl) {
switch (lvl) {
case 'error': return '#ff6b6b'
case 'warn': return '#f5a623'
case 'info': return '#4dabf7'
case 'debug': return '#888'
default: return 'inherit'
}
}