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
| ⌘ S | Save the open file |
| ⌘ R | Run the open file |
+ | ⌘ ⇧ R | Start / stop recording |
| ⌘ B | Connect / disconnect device |
| ⌘ P | Open workspace folder |
| ⌘ K | Refresh 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)
+}