diff --git a/studio/app.go b/studio/app.go index fc1f793..2113b87 100644 --- a/studio/app.go +++ b/studio/app.go @@ -11,6 +11,7 @@ package main import ( "context" "encoding/base64" + "encoding/json" "fmt" "os" "path/filepath" @@ -34,6 +35,11 @@ type App struct { conn *connection // nil when disconnected deviceMgr *device.Manager wifi *wifiDiscovery + + recMu sync.Mutex + recActive bool + recLines []string + recLastAt time.Time } // connection holds the active agent client and cleanup callback. @@ -793,3 +799,96 @@ func toRunResult(res runner.TestResult) RunResult { } return rr } + +// ---- Recording ---------------------------------------------------------- + +// StartRecording tells the agent to enter recording mode and begins capturing +// interaction events as ProbeScript lines. Only WebSocket connections support +// recording (simulators and emulators); physical-device HTTP connections return +// an error. +func (a *App) StartRecording() error { + a.mu.Lock() + conn := a.conn + a.mu.Unlock() + if conn == nil { + return fmt.Errorf("not connected") + } + + wsClient, ok := conn.client.(*probelink.Client) + if !ok { + return fmt.Errorf("recording requires a WebSocket connection (simulators/emulators only)") + } + + a.recMu.Lock() + a.recActive = true + a.recLines = nil + a.recLastAt = time.Now() + a.recMu.Unlock() + + wsClient.OnNotify = func(method string, params json.RawMessage) { + if method != probelink.NotifyRecordedEvent { + return + } + var p map[string]interface{} + if err := json.Unmarshal(params, &p); err != nil { + return + } + + a.recMu.Lock() + now := time.Now() + gap := now.Sub(a.recLastAt) + a.recLastAt = now + a.recMu.Unlock() + + var newLines []string + if gap > 2*time.Second { + newLines = append(newLines, fmt.Sprintf(" wait %d seconds", int(gap.Seconds()))) + } + if line := eventToProbeScriptLine(p); line != "" { + newLines = append(newLines, line) + } + + for _, l := range newLines { + a.recMu.Lock() + a.recLines = append(a.recLines, l) + a.recMu.Unlock() + wailsruntime.EventsEmit(a.ctx, "recorder:line", l) + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := conn.client.Call(ctx, probelink.MethodStartRecording, nil); err != nil { + wsClient.OnNotify = nil + a.recMu.Lock() + a.recActive = false + a.recMu.Unlock() + return fmt.Errorf("start recording: %w", err) + } + return nil +} + +// StopRecording stops the recording session and returns the full assembled +// ProbeScript including the recorded steps. +func (a *App) StopRecording() (string, error) { + a.mu.Lock() + conn := a.conn + a.mu.Unlock() + + if conn != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, _ = conn.client.Call(ctx, probelink.MethodStopRecording, nil) + if wsClient, ok := conn.client.(*probelink.Client); ok { + wsClient.OnNotify = nil + } + } + + a.recMu.Lock() + lines := a.recLines + a.recActive = false + a.recLines = nil + a.recMu.Unlock() + + return strings.Join(lines, "\n") + "\n", nil +} diff --git a/studio/frontend/index.html b/studio/frontend/index.html index be7a15d..4c6770e 100644 --- a/studio/frontend/index.html +++ b/studio/frontend/index.html @@ -17,6 +17,9 @@ + @@ -168,6 +171,7 @@

Keyboard shortcuts

SSave the open file RRun the open file + RStart / stop recording BConnect / disconnect device POpen workspace folder KRefresh device list diff --git a/studio/frontend/src/main.ts b/studio/frontend/src/main.ts index f5389fc..705dbc0 100644 --- a/studio/frontend/src/main.ts +++ b/studio/frontend/src/main.ts @@ -17,8 +17,10 @@ import { ReadFile, RunFile, SaveWorkspaceSettings, + StartRecording, StartWiFiDiscovery, Status, + StopRecording, StopWiFiDiscovery, WriteFile, } from "../wailsjs/go/main/App"; @@ -281,7 +283,11 @@ window.addEventListener("keydown", (e) => { break; case "r": e.preventDefault(); - ($("btn-run") as HTMLButtonElement).click(); + if (e.shiftKey) { + ($("btn-record") as HTMLButtonElement).click(); + } else { + ($("btn-run") as HTMLButtonElement).click(); + } break; case "b": e.preventDefault(); @@ -546,6 +552,7 @@ function setStatus( $("status-text").title = tip; connected = state === "connected"; updateRunButton(); + updateRecordButton(); const inspectorStatus = document.getElementById("inspector-status"); if (inspectorStatus) { inspectorStatus.textContent = connected ? "live" : ""; @@ -583,6 +590,11 @@ function updateRunButton() { ($("btn-run") as HTMLButtonElement).disabled = !connected || !currentPath; } +function updateRecordButton() { + const btn = $("btn-record") as HTMLButtonElement; + btn.disabled = !connected; +} + type DeviceInfo = { id: string; name: string; @@ -771,6 +783,73 @@ $("btn-run").addEventListener("click", async () => { } }); +// ---- Recorder ----------------------------------------------------------- + +let recording = false; + +function setRecording(active: boolean) { + recording = active; + const btn = $("btn-record") as HTMLButtonElement; + const icon = btn.querySelector(".btn-record-icon") as HTMLElement; + const label = btn.querySelector(".btn-label") as HTMLElement; + if (active) { + icon.textContent = "■"; + label.textContent = "Stop"; + btn.classList.add("btn-recording"); + btn.title = "Stop recording (⌘⇧R)"; + } else { + icon.textContent = "●"; + label.textContent = "Record"; + btn.classList.remove("btn-recording"); + btn.title = "Record interactions (⌘⇧R)"; + } +} + +EventsOn("recorder:line", (line: string) => { + if (!recording) return; + const m = editor.getModel()!; + const lastLine = m.getLineCount(); + const lastCol = m.getLineMaxColumn(lastLine); + editor.executeEdits("recorder", [ + { + range: new monaco.Range(lastLine, lastCol, lastLine, lastCol), + text: "\n" + line, + }, + ]); + editor.revealLine(m.getLineCount()); +}); + +$("btn-record").addEventListener("click", async () => { + if (!connected) return; + + if (!recording) { + const now = new Date().toLocaleString(); + const header = `# Recorded on ${now}\n\ntest "recorded flow"\n open the app`; + model.setValue(header); + currentPath = null; + $("editor-filename").textContent = "recorded.probe"; + setDirty(true); + setRecording(true); + try { + await StartRecording(); + } catch (err) { + setRecording(false); + toast(`Recording failed: ${enrichError(err)}`, "error"); + } + } else { + try { + const script = await StopRecording(); + if (script && script.trim()) { + model.setValue(script); + } + } catch (err) { + toast(`Stop failed: ${enrichError(err)}`, "error"); + } finally { + setRecording(false); + } + } +}); + // ---- Boot --------------------------------------------------------------- // macOS traffic lights need ~78px of leading inset; other platforms don't. diff --git a/studio/frontend/src/style.css b/studio/frontend/src/style.css index 5ca40c0..5dee0da 100644 --- a/studio/frontend/src/style.css +++ b/studio/frontend/src/style.css @@ -126,6 +126,10 @@ body { display: flex; flex-direction: column; overflow: hidden; } .btn-primary:hover:not(:disabled) { background: var(--brand); border-color: var(--brand); } .btn-icon-only { padding: 5px 8px; } .btn-icon { font-size: 10px; } +.btn-recording { background: #7f1d1d; border-color: #991b1b; color: #fca5a5; } +.btn-recording:hover:not(:disabled) { background: #991b1b; } +.btn-recording .btn-record-icon { animation: pulse-record 1s ease-in-out infinite; } +@keyframes pulse-record { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } } .btn-label { line-height: 1; } .btn-mini { diff --git a/studio/frontend/wailsjs/go/main/App.d.ts b/studio/frontend/wailsjs/go/main/App.d.ts index ba97a3f..fd2ab03 100755 --- a/studio/frontend/wailsjs/go/main/App.d.ts +++ b/studio/frontend/wailsjs/go/main/App.d.ts @@ -26,10 +26,14 @@ export function RunFile(arg1:string):Promise>; export function SaveWorkspaceSettings(arg1:string,arg2:main.WorkspaceSettings):Promise; +export function StartRecording():Promise; + export function StartWiFiDiscovery():Promise; export function Status():Promise; +export function StopRecording():Promise; + export function StopWiFiDiscovery():Promise; export function TakeScreenshot():Promise; diff --git a/studio/frontend/wailsjs/go/main/App.js b/studio/frontend/wailsjs/go/main/App.js index bde0a43..e660275 100755 --- a/studio/frontend/wailsjs/go/main/App.js +++ b/studio/frontend/wailsjs/go/main/App.js @@ -50,6 +50,10 @@ export function SaveWorkspaceSettings(arg1, arg2) { return window['go']['main']['App']['SaveWorkspaceSettings'](arg1, arg2); } +export function StartRecording() { + return window['go']['main']['App']['StartRecording'](); +} + export function StartWiFiDiscovery() { return window['go']['main']['App']['StartWiFiDiscovery'](); } @@ -58,6 +62,10 @@ export function Status() { return window['go']['main']['App']['Status'](); } +export function StopRecording() { + return window['go']['main']['App']['StopRecording'](); +} + export function StopWiFiDiscovery() { return window['go']['main']['App']['StopWiFiDiscovery'](); } diff --git a/studio/recorder.go b/studio/recorder.go new file mode 100644 index 0000000..e78d250 --- /dev/null +++ b/studio/recorder.go @@ -0,0 +1,76 @@ +package main + +import ( + "fmt" + "strings" +) + +// eventToProbeScriptLine converts a single recorded agent event into a +// ProbeScript step string (with leading 2-space indent), or "" to skip. +func eventToProbeScriptLine(params map[string]interface{}) string { + action, _ := params["action"].(string) + switch action { + case "tap": + return fmt.Sprintf(" tap %s", selectorFromRecordedParams(params)) + case "type": + text, _ := params["text"].(string) + sel := selectorFromRecordedParams(params) + if sel != "the element" { + return fmt.Sprintf(" type %q into the %s field", text, sel) + } + return fmt.Sprintf(" type %q", text) + case "swipe": + dir, _ := params["direction"].(string) + return fmt.Sprintf(" swipe %s", dir) + case "scroll": + dir, _ := params["direction"].(string) + return fmt.Sprintf(" scroll %s", dir) + case "long_press": + return fmt.Sprintf(" long press on %s", selectorFromRecordedParams(params)) + case "navigate": + return " go back" + case "see": + return fmt.Sprintf(" see %s", selectorFromRecordedParams(params)) + default: + if action != "" { + return fmt.Sprintf(" # %s", action) + } + return "" + } +} + +func selectorFromRecordedParams(params map[string]interface{}) string { + if sel, ok := params["selector"].(map[string]interface{}); ok { + kind, _ := sel["kind"].(string) + text, _ := sel["text"].(string) + text = sanitizeRecordedText(text) + switch kind { + case "id": + if !strings.HasPrefix(text, "#") { + return "#" + text + } + return text + case "text": + if text != "" { + return fmt.Sprintf("%q", text) + } + case "type": + if text != "" { + return "the " + text + } + } + } + if text, ok := params["text"].(string); ok && text != "" { + return fmt.Sprintf("%q", sanitizeRecordedText(text)) + } + if id, ok := params["id"].(string); ok && id != "" { + return "#" + sanitizeRecordedText(id) + } + return "the element" +} + +func sanitizeRecordedText(s string) string { + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\r", "") + return strings.TrimSpace(s) +}