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
7 changes: 5 additions & 2 deletions QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,10 @@ browsii session new fresh --port 9222 # wipe state and start fresh

# Recordings capture every action for replay
browsii record start myflow --port 9222
# ... perform actions ...
# ... perform actions (expect calls are recorded as checkpoints) ...
browsii record stop --port 9222
browsii record replay myflow --speed 2.0 --port 9222 # 0=instant, 1=realtime
browsii record replay myflow --port 9222 # instant by default
browsii record export myflow --port 9222 # Playwright spec
browsii record list --port 9222

# Isolated browser contexts (incognito)
Expand Down Expand Up @@ -558,6 +559,8 @@ To update the fixture, delete the HAR and re-record against the live site with a

**Actions carry receipts.** click/press/navigate append what the action caused: navigation, requests (up to 5 samples), dialogs, console-error count. `expect` then independently asserts outcomes — the two compose into a verifiable act→check loop.

**Replay is fingerprint-healed, not selector-bound.** Recordings store each target element's fingerprint (tag, role, text, name, href) plus its position among identical siblings. On replay, a selector that no longer matches its element is healed by relocating the fingerprint; the substitution is reported (`healed: step 2 #p2-add → …`). A semantic change — the element is gone or relabelled — fails at that step with the original identity. Record with `--capture-har` and replay runs fully offline against the recorded traffic (`--live` opts out); `--session <name>` restores a saved login first. `record export` writes a Playwright spec with role-based locators that keeps the same healing properties.

**Capture is destructive.** Calling `network capture stop` / `console capture stop` returns and clears the buffer. A second call returns an empty array.

**`session save`** persists cookies and localStorage — not the actual tab URLs. Use it to checkpoint auth state between runs.
Expand Down
159 changes: 149 additions & 10 deletions cmd/browsii/record.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package main

import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"

Expand All @@ -11,7 +13,12 @@ import (
"github.com/cdg-me/browsii/internal/client"
)

var replaySpeed float64
var (
replaySpeed float64
replayLive bool
replaySession string
recordCaptureHar bool
)

func resolveRecordingName(name string) string {
if strings.HasSuffix(name, ".json") || strings.Contains(name, string(filepath.Separator)) {
Expand All @@ -32,17 +39,30 @@ func init() {
startCmd := &cobra.Command{
Use: "start <name>",
Short: "Start recording browser actions",
Args: cobra.ExactArgs(1),
Long: `Starts recording browser actions.

Every click, hover, and type is stored with the target element's
fingerprint, and every expect call becomes a checkpoint that replay
enforces. Use --capture-har to also record network traffic, which lets
replays run offline.`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
name := resolveRecordingName(args[0])
payload := map[string]string{"name": name}
payload := map[string]any{"name": name}
if recordCaptureHar {
payload["captureHar"] = true
}
_, err := client.SendCommand(port, "record/start", payload)
if err != nil {
log.Fatalf("Record start failed: %v", err)
}
fmt.Printf("Recording started: %s\n", args[0])
if recordCaptureHar {
fmt.Println("Network capture active; replay will run offline against the recorded HAR.")
}
},
}
startCmd.Flags().BoolVar(&recordCaptureHar, "capture-har", false, "Record network traffic to a HAR file alongside the recording")

stopCmd := &cobra.Command{
Use: "stop",
Expand All @@ -53,28 +73,59 @@ func init() {
if err != nil {
log.Fatalf("Record stop failed: %v", err)
}
fmt.Printf("Recording saved: %s\n", string(resp))
var saved struct {
Name string `json:"name"`
Events int `json:"events"`
HAR string `json:"har"`
}
if err := json.Unmarshal(resp, &saved); err != nil {
log.Fatalf("Record stop failed: unexpected response: %v", err)
}
if saved.HAR != "" {
fmt.Printf("Recording saved: %s (%d events, HAR: %s)\n", saved.Name, saved.Events, saved.HAR)
} else {
fmt.Printf("Recording saved: %s (%d events)\n", saved.Name, saved.Events)
}
},
}

replayCmd := &cobra.Command{
Use: "replay <name>",
Short: "Replay a recorded session",
Args: cobra.ExactArgs(1),
Long: `Replays a recorded session.

Element targets are matched by their recorded fingerprint: when the
selector no longer resolves to the same element, the element is relocated
and the substitution is reported under "healed". Recorded expects are
enforced as checkpoints.

When the recording has a HAR file, replay serves all recorded responses
locally and needs no network. Use --live to hit the real network instead,
--session <name> to restore a saved session (cookies, tabs) first.`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
name := resolveRecordingName(args[0])
payload := map[string]interface{}{
payload := map[string]any{
"name": name,
"speed": replaySpeed,
}
_, err := client.SendCommand(port, "record/replay", payload)
if replayLive {
payload["live"] = true
}
if replaySession != "" {
payload["session"] = replaySession
}
resp, err := client.SendCommand(port, "record/replay", payload)
if err != nil {
log.Fatalf("Record replay failed: %v", err)
// The daemon returns the report JSON with a 417 on failure.
printReplayFailure(err)
}
fmt.Printf("Replay of %q complete\n", args[0])
printReplayReport(resp)
},
}
replayCmd.Flags().Float64Var(&replaySpeed, "speed", 1.0, "Replay speed (0=instant, 1=real-time, 2=2x)")
replayCmd.Flags().Float64Var(&replaySpeed, "speed", 0, "Replay speed (0=instant, 1=recorded timing, 2=twice as fast)")
replayCmd.Flags().BoolVar(&replayLive, "live", false, "Hit the real network; ignore any recorded HAR")
replayCmd.Flags().StringVar(&replaySession, "session", "", "Restore this saved session before replaying")

listCmd := &cobra.Command{
Use: "list",
Expand Down Expand Up @@ -104,11 +155,99 @@ func init() {
},
}

exportCmd := &cobra.Command{
Use: "export <name>",
Short: "Write a Playwright TypeScript spec for the recording",
Long: `Writes a Playwright spec (test) that reproduces the recording:
fingerprinted elements become role-based locators, expects become
assertions, and a recorded HAR becomes routeFromHAR so the test runs
offline. Run it with: npx playwright test <file>.`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
name := resolveRecordingName(args[0])
payload := map[string]string{"name": name}
if exportOut != "" {
payload["out"] = exportOut
}
resp, err := client.SendCommand(port, "record/export", payload)
if err != nil {
log.Fatalf("Record export failed: %v", err)
}
var out struct {
Path string `json:"path"`
}
if err := json.Unmarshal(resp, &out); err != nil || out.Path == "" {
log.Fatalf("Record export failed: unexpected response")
}
fmt.Printf("Wrote %s\n", out.Path)
},
}
exportCmd.Flags().StringVar(&exportOut, "out", "", "Output path (default: alongside the recording)")

recordCmd.AddCommand(startCmd)
recordCmd.AddCommand(stopCmd)
recordCmd.AddCommand(replayCmd)
recordCmd.AddCommand(listCmd)
recordCmd.AddCommand(deleteCmd)
recordCmd.AddCommand(exportCmd)

rootCmd.AddCommand(recordCmd)
}

var exportOut string

type replayReportCLI struct {
Name string `json:"name"`
Steps int `json:"steps"`
Checkpoints struct {
Total int `json:"total"`
Passed int `json:"passed"`
} `json:"checkpoints"`
Healed []struct {
Step int `json:"step"`
From string `json:"from"`
To string `json:"to"`
} `json:"healed"`
DurationMs int64 `json:"durationMs"`
FailedStep int `json:"failedStep"`
Error string `json:"error"`
}

func printReplayReport(resp []byte) {
var report replayReportCLI
if err := json.Unmarshal(resp, &report); err != nil {
fmt.Println("Replay complete")
return
}
if report.Error != "" {
fmt.Printf("Replay failed at step %d of %d: %s\n", report.FailedStep, report.Steps, report.Error)
fmt.Printf("checkpoints: %d/%d passed before failure\n", report.Checkpoints.Passed, report.Checkpoints.Total)
os.Exit(1)
}
fmt.Printf("Replayed %d steps, %d/%d checkpoints passed in %dms\n",
report.Steps, report.Checkpoints.Passed, report.Checkpoints.Total, report.DurationMs)
for _, h := range report.Healed {
fmt.Printf(" healed: step %d %s → %s\n", h.Step, h.From, h.To)
}
}

// printReplayFailure extracts the daemon report from the error string and
// renders the failed replay for the operator.
func printReplayFailure(err error) {
msg := err.Error()
const marker = "daemon returned error: "
idx := strings.Index(msg, marker)
if idx < 0 {
log.Fatalf("Replay failed: %v", err)
}
var report replayReportCLI
if json.Unmarshal([]byte(msg[idx+len(marker):]), &report) != nil || report.Error == "" {
log.Fatalf("Replay failed: %v", err)
}
fmt.Fprintf(os.Stderr, "Replay failed at step %d of %d: %s\n", report.FailedStep, report.Steps, report.Error)
fmt.Fprintf(os.Stderr, "checkpoints: %d/%d passed before failure\n", report.Checkpoints.Passed, report.Checkpoints.Total)
for _, h := range report.Healed {
fmt.Fprintf(os.Stderr, " healed: step %d %s → %s\n", h.Step, h.From, h.To)
}
os.Exit(1)
}
24 changes: 24 additions & 0 deletions examples/recording-presets/preset_shop_demo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "preset_shop_demo",
"url": "http://127.0.0.1:8931/shop",
"events": [
{
"t": 120,
"action": "navigate",
"params": { "url": "http://127.0.0.1:8931/shop" }
},
{
"t": 940,
"action": "click",
"params": { "selector": "#p2-add" },
"fp": { "tag": "button", "role": "button", "text": "Add to cart", "name": "", "href": "", "type": "" },
"fpIndex": 1
},
{
"t": 1500,
"action": "expect",
"params": { "text": "Total: $30" },
"timeoutMs": 5000
}
]
}
72 changes: 63 additions & 9 deletions internal/daemon/elements.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package daemon

import (
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
Expand Down Expand Up @@ -211,14 +212,24 @@ var elementsJS = `() => {` + elementsHelpersJS + `
return JSON.stringify(out);
}`

// liveElementJS returns the identity fields of the element currently matching
// the given selector, or null when the selector matches nothing. Shares the
// helper functions with the enumeration so fingerprints are comparable.
// liveElementJS returns [identity, index] for the element currently matching
// the selector, or null when it matches nothing. Index is the element's
// position among all elements with the same identity (0 when unique) —
// needed to disambiguate repeated elements such as identically-labelled
// buttons in a product list.
var liveElementJS = `(sel) => {` + elementsHelpersJS + `
const el = document.querySelector(sel);
if (!el) return null;
const tag = el.tagName.toLowerCase();
return JSON.stringify(identityOf(el, tag));
const id = identityOf(el, tag);
const key = JSON.stringify(id);
let idx = 0;
for (const other of document.querySelectorAll('a, button, input, select, textarea, summary, label, [role], [tabindex], [onclick]')) {
if (other === el) break;
const otag = other.tagName.toLowerCase();
if (JSON.stringify(identityOf(other, otag)) === key) idx++;
}
return JSON.stringify([id, idx]);
}`

// enumerateElements runs the in-page enumeration and refreshes the ref store
Expand Down Expand Up @@ -273,18 +284,39 @@ func fingerprintOf(e elementInfo) string {
// liveFingerprint evaluates the page and returns the identity string of the
// element currently matching selector ("" when the selector matches nothing).
func liveFingerprint(page *rod.Page, selector string) (string, error) {
id, _, err := liveFingerprintEx(page, selector)
if err != nil || id == nil {
return "", err
}
return fingerprintParts(id.Tag, id.Role, id.Text, id.Name, id.Href, id.Type), nil
}

// liveFingerprintEx returns the identity and same-identity index of the
// element matching selector. id is nil when the selector matches nothing.
func liveFingerprintEx(page *rod.Page, selector string) (*elementIdentity, int, error) {
res, err := page.Eval(liveElementJS, selector)
if err != nil {
return "", err
return nil, 0, err
}
if res == nil || res.Value.Val() == nil {
return "", nil // selector matches nothing
return nil, 0, nil
}
var pair []json.RawMessage
if err := json.Unmarshal([]byte(res.Value.Str()), &pair); err != nil {
return nil, 0, err
}
if len(pair) != 2 {
return nil, 0, fmt.Errorf("unexpected live element payload")
}
var id elementIdentity
if err := json.Unmarshal([]byte(res.Value.Str()), &id); err != nil {
return "", err
if err := json.Unmarshal(pair[0], &id); err != nil {
return nil, 0, err
}
return fingerprintParts(id.Tag, id.Role, id.Text, id.Name, id.Href, id.Type), nil
var idx int
if err := json.Unmarshal(pair[1], &idx); err != nil {
return nil, 0, err
}
return &id, idx, nil
}

// lookupRefInStore returns the element recorded at ref in the page's ref
Expand Down Expand Up @@ -402,6 +434,28 @@ func (s *Server) candidatesFor(page *rod.Page, failedSelector string) []elementC
return findCandidates(elems, failedSelector, 5)
}

// findByFingerprint enumerates the page and returns the selector of the
// fpIndex-th element whose fingerprint matches want. Repeated elements with
// identical fingerprints (e.g. identically-labelled buttons) are
// disambiguated by fpIndex in document order.
func (s *Server) findByFingerprint(page *rod.Page, want string, fpIndex int) (string, bool) {
elems, err := s.enumerateElements(page)
if err != nil {
return "", false
}
n := 0
for _, e := range elems {
if fingerprintOf(e) != want {
continue
}
if n == fpIndex {
return e.Selector, true
}
n++
}
return "", false
}

// handleElements lists the interactive elements of the active page.
// POST /elements {"all": bool, "filter": "substring"}
func (s *Server) handleElements(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading
Loading