feat: AI-driven browser tests — Tests tab + browser_test agent tool
Some checks failed
PR Check / check (pull_request) Failing after 33s
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:
@@ -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) }),
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
238
web/src/components/Tests.jsx
Normal file
238
web/src/components/Tests.jsx
Normal 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'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user