Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions studio/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ package main
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
Expand All @@ -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.
Expand Down Expand Up @@ -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
}
4 changes: 4 additions & 0 deletions studio/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
<button id="btn-run" class="btn btn-primary" disabled title="Run the open file">
<span class="btn-icon">▶</span><span class="btn-label">Run</span>
</button>
<button id="btn-record" class="btn" disabled title="Record interactions (⌘⇧R)">
<span class="btn-icon btn-record-icon">●</span><span class="btn-label">Record</span>
</button>
<button id="btn-save" class="btn" disabled title="Save (⌘S)">
<span class="btn-icon">💾</span><span class="btn-label">Save</span>
</button>
Expand Down Expand Up @@ -168,6 +171,7 @@ <h2>Keyboard shortcuts</h2>
<tbody>
<tr><td><kbd>⌘</kbd> <kbd>S</kbd></td><td>Save the open file</td></tr>
<tr><td><kbd>⌘</kbd> <kbd>R</kbd></td><td>Run the open file</td></tr>
<tr><td><kbd>⌘</kbd> <kbd>⇧</kbd> <kbd>R</kbd></td><td>Start / stop recording</td></tr>
<tr><td><kbd>⌘</kbd> <kbd>B</kbd></td><td>Connect / disconnect device</td></tr>
<tr><td><kbd>⌘</kbd> <kbd>P</kbd></td><td>Open workspace folder</td></tr>
<tr><td><kbd>⌘</kbd> <kbd>K</kbd></td><td>Refresh device list</td></tr>
Expand Down
81 changes: 80 additions & 1 deletion studio/frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ import {
ReadFile,
RunFile,
SaveWorkspaceSettings,
StartRecording,
StartWiFiDiscovery,
Status,
StopRecording,
StopWiFiDiscovery,
WriteFile,
} from "../wailsjs/go/main/App";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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" : "";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions studio/frontend/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions studio/frontend/wailsjs/go/main/App.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,14 @@ export function RunFile(arg1:string):Promise<Array<main.RunResult>>;

export function SaveWorkspaceSettings(arg1:string,arg2:main.WorkspaceSettings):Promise<void>;

export function StartRecording():Promise<void>;

export function StartWiFiDiscovery():Promise<void>;

export function Status():Promise<main.ConnectionStatus>;

export function StopRecording():Promise<string>;

export function StopWiFiDiscovery():Promise<void>;

export function TakeScreenshot():Promise<string>;
Expand Down
8 changes: 8 additions & 0 deletions studio/frontend/wailsjs/go/main/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']();
}
Expand All @@ -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']();
}
Expand Down
76 changes: 76 additions & 0 deletions studio/recorder.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading