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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **`probe device set` and the `set_device_setting` MCP tool change an OS setting of one named emulator or simulator**: appearance (dark mode), font scale, app language, orientation (Android) and opening a deep link, through `adb` and `simctl`. The device must be named (a serial, UDID or unique name, never `booted`), every value is checked before a command runs, and physical iOS devices are refused. Checked against an Android 15 emulator and an iOS simulator. The MCP server now has 36 tools.

### Docs
- **Run recording is designed, not built.** `docs/prd/run-recording.md` proposes a recording of each test run, step by step, with the HTTP traffic each step caused, the screenshots and widget trees, and the element it acted on. It covers a viewer, a replayable test, native capture through a device proxy, and credential redaction. Nothing in the product changes yet.
- **Run recording groundwork, internal only.** The CLI's new `internal/record` package can write a recording bundle, follow the agent's HTTP log without skipping requests still in flight, redact credentials from headers, URLs and bodies, and render a bundle as an offline viewer. Step events now carry their wall-clock time. Nothing is recorded yet and no command changed; the design is in `docs/prd/run-recording.md`.

### Fixed
- **VS Code: the Run Test commands no longer pass `--name` to `probe test`.** This version of the CLI defines no such flag, so those runs failed with an unknown-flag error. "FlutterProbe: Run Test File at Cursor", the CodeLens buttons and the Tests view now run the file that contains the test, and their titles and tooltips say so. Until the CLI has a per-test filter, the extension runs whole files.
Expand Down
176 changes: 176 additions & 0 deletions internal/record/bundle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Package record writes recording bundles for test runs.
package record

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)

// SchemaVersion is the version of the bundle's JSON files. RingSize lives in cursor.go.
const SchemaVersion = 1

type Manifest struct {
SchemaVersion int `json:"schema_version"`
RunID string `json:"run_id"`
TestName string `json:"test_name"`
File string `json:"file"`
FileSHA256 string `json:"file_sha256"`
Attempt int `json:"attempt"`
DeviceName string `json:"device_name"`
Platform string `json:"platform"`
OSVersion string `json:"os_version"`
AgentVersion string `json:"agent_version"`
CLIVersion string `json:"cli_version"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at"`
Status string `json:"status"`
Capture Capture `json:"capture"`
Truncated Truncation `json:"truncated"`
}

type Capture struct {
Screenshots string `json:"screenshots"`
Native string `json:"native"`
Redaction bool `json:"redaction"`
}

type Truncation struct {
Exchanges int `json:"exchanges"`
Bytes int64 `json:"bytes"`
}

type Rect struct {
Left float64 `json:"left"`
Top float64 `json:"top"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}

type Interaction struct {
Kind string `json:"kind"`
Target *Rect `json:"target,omitempty"`
TapX float64 `json:"tap_x,omitempty"`
TapY float64 `json:"tap_y,omitempty"`
}

type Step struct {
Index int `json:"index"`
Depth int `json:"depth"`
File string `json:"file"`
Line int `json:"line"`
Description string `json:"description"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
StartedAt time.Time `json:"started_at"`
DurationMs int64 `json:"duration_ms"`
ScreenshotBefore string `json:"screenshot_before,omitempty"`
ScreenshotAfter string `json:"screenshot_after,omitempty"`
Tree string `json:"tree,omitempty"`
Interaction *Interaction `json:"interaction,omitempty"`
Exchanges []string `json:"exchanges,omitempty"`
AppRestarted bool `json:"app_restarted,omitempty"`
}

type Timeline struct {
Steps []Step `json:"steps"`
}

type Exchange struct {
ID string `json:"id"`
Seq int `json:"seq"`
Method string `json:"method"`
URL string `json:"url"`
Status int `json:"status"`
DurationMs int `json:"duration_ms"`
Mocked bool `json:"mocked"`
Error string `json:"error,omitempty"`
RequestHeaders map[string]string `json:"request_headers"`
ResponseHeaders map[string]string `json:"response_headers"`
RequestBody string `json:"request_body"`
ResponseBody string `json:"response_body"`
Source string `json:"source"`
Duplicate bool `json:"duplicate,omitempty"`
TLSFailed bool `json:"tls_failed,omitempty"`
}

// Bundle is a directory containing a recording and its associated artifacts.
type Bundle struct{ dir string }

// Create creates a private bundle directory and its artifact subdirectories.
func Create(dir string) (*Bundle, error) {
for _, path := range []string{dir, filepath.Join(dir, "http"), filepath.Join(dir, "tree"), filepath.Join(dir, "screenshots")} {
if err := os.MkdirAll(path, 0700); err != nil {
return nil, err
}
if err := os.Chmod(path, 0700); err != nil {
return nil, err
}
}
return &Bundle{dir: dir}, nil
}

func (b *Bundle) Dir() string { return b.dir }
func (b *Bundle) WriteManifest(m Manifest) error { return b.writeJSON("manifest.json", m) }
func (b *Bundle) WriteTimeline(t Timeline) error { return b.writeJSON("timeline.json", t) }

var bundleID = regexp.MustCompile(`^[0-9]{4,}$`)
var screenshotName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)

func (b *Bundle) WriteExchange(e Exchange) error {
if !bundleID.MatchString(e.ID) {
return fmt.Errorf("invalid exchange ID %q", e.ID)
}
return b.writeJSON(filepath.Join("http", e.ID+".json"), e)
}

func (b *Bundle) WriteTree(id, text string) error {
if !bundleID.MatchString(id) {
return fmt.Errorf("invalid tree ID %q", id)
}
return b.writeFile(filepath.Join("tree", id+".txt"), []byte(text))
}

func (b *Bundle) WriteScreenshot(name string, png []byte) error {
if !screenshotName.MatchString(name) || strings.Contains(name, "..") {
return fmt.Errorf("invalid screenshot name %q", name)
}
return b.writeFile(filepath.Join("screenshots", name), png)
}

func (b *Bundle) writeJSON(name string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
return b.writeFile(name, data)
}

// writeFile only receives fixed or validated relative names. Renaming a closed
// temporary file in the destination directory publishes the complete contents.
func (b *Bundle) writeFile(name string, data []byte) error {
path := filepath.Join(b.dir, name)
f, err := os.CreateTemp(filepath.Dir(path), ".record-*")
if err != nil {
return err
}
defer os.Remove(f.Name())
defer f.Close()
if err := f.Chmod(0600); err != nil {
return err
}
if _, err := f.Write(data); err != nil {
return err
}
if err := f.Sync(); err != nil {
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(f.Name(), path)
}
187 changes: 187 additions & 0 deletions internal/record/bundle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package record

import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
"time"
)

func TestBundleLayoutAndRoundTrip(t *testing.T) {
dir := filepath.Join(t.TempDir(), "bundle")
b, err := Create(dir)
if err != nil {
t.Fatal(err)
}
if b.Dir() != dir {
t.Fatalf("Dir() = %q, want %q", b.Dir(), dir)
}
now := time.Date(2026, 10, 16, 12, 0, 0, 123, time.UTC)
manifest := Manifest{SchemaVersion: SchemaVersion, RunID: "run", TestName: "test", File: "test.probe", FileSHA256: "abc", Attempt: 2, DeviceName: "phone", Platform: "ios", OSVersion: "18", AgentVersion: "1", CLIVersion: "2", StartedAt: now, EndedAt: now.Add(time.Second), Status: "passed", Capture: Capture{Screenshots: "all", Native: "on", Redaction: true}, Truncated: Truncation{Exchanges: 3, Bytes: 42}}
timeline := Timeline{Steps: []Step{{Index: 1, Depth: 2, File: "test.probe", Line: 3, Description: "tap", Status: "failed", Error: "error", StartedAt: now, DurationMs: 21, ScreenshotBefore: "before.png", ScreenshotAfter: "after.png", Tree: "0001.txt", Interaction: &Interaction{Kind: "tap", Target: &Rect{Left: 1, Top: 2, Width: 3, Height: 4}, TapX: 5, TapY: 6}, Exchanges: []string{"0001"}, AppRestarted: true}}}
exchange := Exchange{ID: "0001", Seq: 1, Method: "POST", URL: "https://example.test", Status: 200, DurationMs: 17, Mocked: true, Error: "error", RequestHeaders: map[string]string{"Content-Type": "text/plain"}, ResponseHeaders: map[string]string{"X-Test": "yes"}, RequestBody: "request", ResponseBody: "response", Source: "native", Duplicate: true, TLSFailed: true}
for _, tc := range []struct {
name string
value any
write func() error
}{
{"manifest.json", manifest, func() error { return b.WriteManifest(manifest) }},
{"timeline.json", timeline, func() error { return b.WriteTimeline(timeline) }},
{"http/0001.json", exchange, func() error { return b.WriteExchange(exchange) }},
} {
if err := tc.write(); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, tc.name))
if err != nil {
t.Fatal(err)
}
got := reflect.New(reflect.TypeOf(tc.value))
if err := json.Unmarshal(data, got.Interface()); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got.Elem().Interface(), tc.value) {
t.Errorf("%s round-trip differs: %s", tc.name, data)
}
var indented bytes.Buffer
if err := json.Indent(&indented, data, "", " "); err != nil {
t.Fatal(err)
}
if !bytes.Equal(data, indented.Bytes()) {
t.Errorf("%s not two-space indented", tc.name)
}
}
if err := b.WriteTree("0001", "tree text"); err != nil {
t.Fatal(err)
}
png := []byte{137, 80, 78, 71, 0, 255}
if err := b.WriteScreenshot("step_1-after.png", png); err != nil {
t.Fatal(err)
}
for name, want := range map[string][]byte{"tree/0001.txt": []byte("tree text"), "screenshots/step_1-after.png": png} {
got, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, want) {
t.Errorf("%s = %q, want %q", name, got, want)
}
}
expected := map[string]bool{".": true, "http": true, "tree": true, "screenshots": true, "manifest.json": false, "timeline.json": false, "http/0001.json": false, "tree/0001.txt": false, "screenshots/step_1-after.png": false}
err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(dir, path)
if err != nil {
return err
}
isDir, ok := expected[filepath.ToSlash(rel)]
if !ok {
t.Errorf("unexpected file (possible leftover temporary): %s", rel)
return nil
}
if info.IsDir() != isDir {
t.Errorf("wrong file type for %s", rel)
}
if runtime.GOOS != "windows" {
want := os.FileMode(0600)
if isDir {
want = 0700
}
if info.Mode().Perm() != want {
t.Errorf("%s mode = %o, want %o", rel, info.Mode().Perm(), want)
}
}
delete(expected, filepath.ToSlash(rel))
return nil
})
if err != nil {
t.Fatal(err)
}
if len(expected) != 0 {
t.Errorf("missing paths: %v", expected)
}
}

func TestBundleInvalidNames(t *testing.T) {
b, err := Create(filepath.Join(t.TempDir(), "bundle"))
if err != nil {
t.Fatal(err)
}
for _, id := range []string{"", "1", "123", "abcd", "../x", "0001/0002", "0001\\0002", "/0001", "0001\n", "1234"} {
if err := b.WriteExchange(Exchange{ID: id}); err == nil {
t.Errorf("exchange accepted %q", id)
}
if err := b.WriteTree(id, "text"); err == nil {
t.Errorf("tree accepted %q", id)
}
}
for _, name := range []string{"", "..", "../x", "a..png", "/x.png", "a/b.png", "a\\b.png", "a b.png", "a.png\n", "☃.png"} {
if err := b.WriteScreenshot(name, nil); err == nil {
t.Errorf("screenshot accepted %q", name)
}
}
for _, sub := range []string{"http", "tree", "screenshots"} {
entries, err := os.ReadDir(filepath.Join(b.Dir(), sub))
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Errorf("invalid names created files in %s: %v", sub, entries)
}
}
if err := b.WriteTree("12345", "ok"); err != nil {
t.Fatal(err)
}
if err := b.WriteScreenshot("Az09._-.png", nil); err != nil {
t.Fatal(err)
}
}

func TestBundleReplacementAndFailureCleanup(t *testing.T) {
b, err := Create(t.TempDir())
if err != nil {
t.Fatal(err)
}
for _, value := range []string{"old contents", "new"} {
if err := b.WriteTree("0001", value); err != nil {
t.Fatal(err)
}
}
data, err := os.ReadFile(filepath.Join(b.Dir(), "tree", "0001.txt"))
if err != nil {
t.Fatal(err)
}
if string(data) != "new" {
t.Fatalf("replacement = %q", data)
}
// A directory at the destination forces rename to fail after writing the temp.
if err := os.Mkdir(filepath.Join(b.Dir(), "tree", "0002.txt"), 0700); err != nil {
t.Fatal(err)
}
if err := b.WriteTree("0002", "failure"); err == nil {
t.Fatal("expected rename failure")
}
entries, err := os.ReadDir(filepath.Join(b.Dir(), "tree"))
if err != nil {
t.Fatal(err)
}
if len(entries) != 2 {
t.Fatalf("temporary file left behind: %v", entries)
}
}

func TestBundleCreateFailure(t *testing.T) {
path := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(path, nil, 0600); err != nil {
t.Fatal(err)
}
if _, err := Create(path); err == nil {
t.Fatal("Create accepted a regular file")
}
}
Loading
Loading