From 2536340648b691956d1bb6181c6cdf40d3c0269d Mon Sep 17 00:00:00 2001 From: Patrick Bertsch Date: Fri, 16 Oct 2026 12:33:19 -0600 Subject: [PATCH 1/6] feat(record): HTTP log cursor and bundle writer Adds the internal/record package foundations for run recording. The cursor reads probe.http_log without skipping requests that are still in flight, detects app restarts and counts exchanges the agent's ring evicted. The bundle writer lays out manifest, timeline, exchanges, widget trees and screenshots with validated names and atomic 0600 writes. Nothing is wired into the runner or the CLI yet. Claude-Session: https://claude.ai/code/session_01QGnLBdCp6x94qKGY5kAxHK --- internal/record/bundle.go | 176 +++++++++++++++++++++++++++++++ internal/record/bundle_test.go | 187 +++++++++++++++++++++++++++++++++ internal/record/cursor.go | 82 +++++++++++++++ internal/record/cursor_test.go | 151 ++++++++++++++++++++++++++ 4 files changed, 596 insertions(+) create mode 100644 internal/record/bundle.go create mode 100644 internal/record/bundle_test.go create mode 100644 internal/record/cursor.go create mode 100644 internal/record/cursor_test.go diff --git a/internal/record/bundle.go b/internal/record/bundle.go new file mode 100644 index 00000000..4664e5ae --- /dev/null +++ b/internal/record/bundle.go @@ -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) +} diff --git a/internal/record/bundle_test.go b/internal/record/bundle_test.go new file mode 100644 index 00000000..a4953616 --- /dev/null +++ b/internal/record/bundle_test.go @@ -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") + } +} diff --git a/internal/record/cursor.go b/internal/record/cursor.go new file mode 100644 index 00000000..0c130cfa --- /dev/null +++ b/internal/record/cursor.go @@ -0,0 +1,82 @@ +package record + +import ( + "context" + "sort" + + "github.com/alphawavesystems/flutter-probe/internal/probelink" +) + +// RingSize is the number of exchanges retained by the agent. +const RingSize = 300 + +// LogSource supplies the agent's finished HTTP exchanges. +type LogSource interface { + HTTPLog(context.Context, probelink.HTTPLogParams) (probelink.HTTPLogResult, error) +} + +// Cursor tracks handled sequences without skipping requests still in flight. +// A Cursor must not be used concurrently. +type Cursor struct { + epoch string + started bool + contig int + seen map[int]bool +} + +// Read contains new exchanges and discontinuities observed during a poll. +type Read struct { + Exchanges []probelink.HTTPEntry + Restarted bool + Lost int +} + +func NewCursor() *Cursor { return &Cursor{seen: make(map[int]bool)} } + +// Next polls from the last contiguous handled sequence. Failed polls, including +// a failed restart refetch, leave the cursor unchanged. +func (c *Cursor) Next(ctx context.Context, src LogSource) (Read, error) { + res, err := src.HTTPLog(ctx, probelink.HTTPLogParams{Since: c.contig}) + if err != nil { + return Read{}, err + } + var read Read + if c.started && res.Epoch != c.epoch { + res, err = src.HTTPLog(ctx, probelink.HTTPLogParams{Since: 0}) + if err != nil { + return Read{}, err + } + c.contig = 0 + c.seen = make(map[int]bool) + read.Restarted = true + } + c.epoch, c.started = res.Epoch, true + if c.seen == nil { + c.seen = make(map[int]bool) + } + for _, entry := range res.Entries { + if entry.Seq > c.contig && !c.seen[entry.Seq] { + c.seen[entry.Seq] = true + read.Exchanges = append(read.Exchanges, entry) + } + } + sort.Slice(read.Exchanges, func(i, j int) bool { + return read.Exchanges[i].Seq < read.Exchanges[j].Seq + }) + // Advance over evicted sequences, counting only those never delivered. + if floor := res.Latest - RingSize; floor > c.contig { + read.Lost = floor - c.contig + for seq := range c.seen { + if seq <= floor { + read.Lost-- + delete(c.seen, seq) + } + } + c.contig = floor + } + for c.seen[c.contig+1] { + delete(c.seen, c.contig+1) + c.contig++ + } + return read, nil +} diff --git a/internal/record/cursor_test.go b/internal/record/cursor_test.go new file mode 100644 index 00000000..3549228a --- /dev/null +++ b/internal/record/cursor_test.go @@ -0,0 +1,151 @@ +package record + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/alphawavesystems/flutter-probe/internal/probelink" +) + +type cursorReply struct { + since int + result probelink.HTTPLogResult + err error +} + +type cursorSource struct { + t *testing.T + replies []cursorReply + calls int +} + +func (s *cursorSource) HTTPLog(ctx context.Context, p probelink.HTTPLogParams) (probelink.HTTPLogResult, error) { + s.t.Helper() + if ctx == nil { + s.t.Fatal("nil context") + } + if s.calls >= len(s.replies) { + s.t.Fatal("unexpected HTTPLog call") + } + r := s.replies[s.calls] + s.calls++ + if want := (probelink.HTTPLogParams{Since: r.since}); p != want { + s.t.Fatalf("params = %+v, want %+v", p, want) + } + return r.result, r.err +} + +func cursorResult(epoch string, latest int, seqs ...int) probelink.HTTPLogResult { + r := probelink.HTTPLogResult{Epoch: epoch, Latest: latest} + for _, seq := range seqs { + r.Entries = append(r.Entries, probelink.HTTPEntry{Seq: seq}) + } + return r +} + +func TestCursor(t *testing.T) { + boom := errors.New("poll failed") + type step struct { + replies []cursorReply + seqs []int + contig int + seen []int + lost int + restarted bool + err error + } + tests := []struct { + name string + initial *Cursor + steps []step + }{ + {name: "contiguous reads", steps: []step{ + {replies: []cursorReply{{0, cursorResult("a", 2, 2, 1), nil}}, seqs: []int{1, 2}, contig: 2}, + {replies: []cursorReply{{2, cursorResult("a", 3, 3), nil}}, seqs: []int{3}, contig: 3}, + }}, + {name: "in flight", steps: []step{ + {replies: []cursorReply{{0, cursorResult("a", 6, 6, 4, 3, 2, 1), nil}}, seqs: []int{1, 2, 3, 4, 6}, contig: 4, seen: []int{6}}, + {replies: []cursorReply{{4, cursorResult("a", 6, 6, 5), nil}}, seqs: []int{5}, contig: 6}, + }}, + {name: "overflow", initial: &Cursor{epoch: "a", started: true, contig: 10}, steps: []step{ + {replies: []cursorReply{{10, cursorResult("a", 400, 101), nil}}, seqs: []int{101}, contig: 101, lost: 90}, + {replies: []cursorReply{{101, cursorResult("a", 400, 101), nil}}, contig: 101}, + }}, + {name: "overflow excludes previously seen", initial: &Cursor{epoch: "a", started: true, contig: 10, seen: map[int]bool{50: true, 102: true}}, steps: []step{ + {replies: []cursorReply{{10, cursorResult("a", 400, 101, 102), nil}}, seqs: []int{101}, contig: 102, lost: 89}, + }}, + {name: "restart", initial: &Cursor{epoch: "a", started: true, contig: 10, seen: map[int]bool{12: true}}, steps: []step{ + {replies: []cursorReply{{10, cursorResult("b", 2), nil}, {0, cursorResult("b", 2, 2, 1), nil}}, seqs: []int{1, 2}, contig: 2, restarted: true}, + {replies: []cursorReply{{2, cursorResult("b", 3, 3), nil}}, seqs: []int{3}, contig: 3}, + }}, + {name: "duplicates", steps: []step{ + {replies: []cursorReply{{0, cursorResult("a", 2, 2, 2), nil}}, seqs: []int{2}, seen: []int{2}}, + {replies: []cursorReply{{0, cursorResult("a", 2, 2), nil}}, seen: []int{2}}, + {replies: []cursorReply{{0, cursorResult("a", 2, 2, 1, 1), nil}}, seqs: []int{1}, contig: 2}, + }}, + {name: "initial error", steps: []step{{replies: []cursorReply{{0, probelink.HTTPLogResult{}, boom}}, err: boom}}}, + {name: "error preserves pending", initial: &Cursor{epoch: "a", started: true, contig: 4, seen: map[int]bool{6: true}}, steps: []step{ + {replies: []cursorReply{{4, probelink.HTTPLogResult{}, boom}}, err: boom}, + }}, + {name: "restart refetch error", initial: &Cursor{epoch: "a", started: true, contig: 4, seen: map[int]bool{6: true}}, steps: []step{ + {replies: []cursorReply{{4, cursorResult("b", 2), nil}, {0, probelink.HTTPLogResult{}, boom}}, err: boom}, + {replies: []cursorReply{{4, cursorResult("b", 1), nil}, {0, cursorResult("b", 1, 1), nil}}, seqs: []int{1}, contig: 1, restarted: true}, + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := tt.initial + if c == nil { + c = NewCursor() + } + for i, st := range tt.steps { + before := *c + if c.seen != nil { + before.seen = map[int]bool{} + for seq, v := range c.seen { + before.seen[seq] = v + } + } + src := &cursorSource{t: t, replies: st.replies} + got, err := c.Next(context.Background(), src) + if !errors.Is(err, st.err) { + t.Fatalf("step %d: error = %v, want %v", i, err, st.err) + } + if src.calls != len(src.replies) { + t.Fatalf("step %d: calls = %d, want %d", i, src.calls, len(src.replies)) + } + if st.err != nil { + if !reflect.DeepEqual(*c, before) { + t.Fatalf("step %d: cursor changed on error: %+v -> %+v", i, before, *c) + } + if !reflect.DeepEqual(got, Read{}) { + t.Fatalf("read on error = %+v", got) + } + continue + } + var seqs []int + for _, e := range got.Exchanges { + seqs = append(seqs, e.Seq) + } + if !reflect.DeepEqual(seqs, st.seqs) || got.Lost != st.lost || got.Restarted != st.restarted { + t.Fatalf("step %d: read = %+v, want seqs %v, lost %d, restarted %v", i, got, st.seqs, st.lost, st.restarted) + } + if c.contig != st.contig { + t.Fatalf("step %d: contig = %d, want %d", i, c.contig, st.contig) + } + wantSeen := map[int]bool{} + for _, seq := range st.seen { + wantSeen[seq] = true + } + if !reflect.DeepEqual(c.seen, wantSeen) { + t.Fatalf("step %d: seen = %v, want %v", i, c.seen, wantSeen) + } + if !c.started || c.epoch != st.replies[len(st.replies)-1].result.Epoch { + t.Fatalf("step %d: epoch/started = %q/%v", i, c.epoch, c.started) + } + } + }) + } +} From 80d87e7c4a25a1ab61fa947aa995005758af46da Mon Sep 17 00:00:00 2001 From: Patrick Bertsch Date: Fri, 16 Oct 2026 12:44:59 -0600 Subject: [PATCH 2/6] feat(record): step start times and redaction for recorded traffic Step events carry the wall-clock time they were emitted, so traffic from outside the agent can be placed in a step. The redaction helpers remove credential headers, query values and body values, including numeric card numbers and PINs, and keep everything else unchanged. Both pieces are internal; nothing is wired into the CLI yet. Claude-Session: https://claude.ai/code/session_01QGnLBdCp6x94qKGY5kAxHK --- internal/record/redact.go | 143 +++++++++++++++++++++++++ internal/record/redact_numeric_test.go | 22 ++++ internal/record/redact_test.go | 115 ++++++++++++++++++++ internal/runner/step_event.go | 2 + internal/runner/step_event_test.go | 22 ++++ 5 files changed, 304 insertions(+) create mode 100644 internal/record/redact.go create mode 100644 internal/record/redact_numeric_test.go create mode 100644 internal/record/redact_test.go diff --git a/internal/record/redact.go b/internal/record/redact.go new file mode 100644 index 00000000..87b2a4f6 --- /dev/null +++ b/internal/record/redact.go @@ -0,0 +1,143 @@ +package record + +import ( + "encoding/json" + "net/url" + "regexp" + "strings" +) + +// Placeholder replaces recorded credentials. +const Placeholder = "" + +// These names mirror probe_agent/lib/src/http_capture.dart. +var credentialHeaders = map[string]bool{ + "authorization": true, "proxy-authorization": true, "cookie": true, + "set-cookie": true, "x-api-key": true, "x-goog-api-key": true, + "api-key": true, "x-auth-token": true, "x-access-token": true, + "x-amz-security-token": true, "x-csrf-token": true, "x-xsrf-token": true, +} +var credentialQuery = map[string]bool{ + "key": true, "api_key": true, "apikey": true, "access_token": true, + "token": true, "id_token": true, "refresh_token": true, "auth": true, + "secret": true, "password": true, "sig": true, "signature": true, +} + +var ( + apiKeyPattern = regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{8,}`) + githubTokenPattern = regexp.MustCompile(`\bghp_[A-Za-z0-9]{20,}`) + bearerPattern = regexp.MustCompile(`(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{8,}`) + longHexPattern = regexp.MustCompile(`\b[0-9a-fA-F]{32,}\b`) + longBasePattern = regexp.MustCompile(`[A-Za-z0-9+/]{40,}={0,2}`) + queryPartPattern = regexp.MustCompile(`(^|&)([^=&]+)=([^&]*)`) + // A credential field can hold a string or a bare number (a card number is often sent as a number). + jsonCredentialPattern = regexp.MustCompile(`(?i)("(?:password|passwd|token|secret|authorization|refresh_token|access_token|api_key|card_number|cvv)"\s*:\s*)(?:"(?:\\.|[^"\\])*"|-?[0-9][0-9.eE+-]*)`) + jsonStringPattern = regexp.MustCompile(`"(?:\\.|[^"\\])*"`) +) + +// Copied from internal/hosted/scrub.go, without importing the hosted package. +var secretPatterns = []*regexp.Regexp{ + regexp.MustCompile(`-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)`), + regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`), + regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9]{20,}\b`), + regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{20,}\b`), + regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{20,}\b`), + regexp.MustCompile(`(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,}`), + regexp.MustCompile("whsec" + `_[A-Za-z0-9]{10,}`), + regexp.MustCompile(`\bxox[abprs]-[A-Za-z0-9-]{10,}\b`), + regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b`), + regexp.MustCompile(`\bFP2\.[A-Za-z0-9._-]{10,}`), + regexp.MustCompile(`(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{12,}`), + regexp.MustCompile(`(?i)\b(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|authorization)\b["']?\s*[:=]\s*["']?[^\s"',;]{4,}`), +} + +// RedactHeaders returns an independent copy with credential values removed. +func RedactHeaders(h map[string]string) map[string]string { + out := make(map[string]string, len(h)) + for k, v := range h { + if credentialHeaders[strings.ToLower(k)] { + v = Placeholder + } + out[k] = v + } + return out +} + +// RedactURL preserves URL spelling and query order, including malformed URLs. +func RedactURL(raw string) string { + u, err := url.Parse(raw) + if err == nil && u.RawQuery == "" { + return raw + } + // Work on the original query to avoid normalizing unrelated URL content. + start := strings.IndexByte(raw, '?') + if start < 0 { + return raw + } + end := len(raw) + if hash := strings.IndexByte(raw, '#'); hash >= 0 { + if hash < start { + return raw + } + end = hash + } + query := raw[start+1 : end] + query = queryPartPattern.ReplaceAllStringFunc(query, func(part string) string { + eq := strings.IndexByte(part, '=') + name := strings.TrimPrefix(part[:eq], "&") + if decoded, e := url.QueryUnescape(name); e == nil { + name = decoded + } + if credentialQuery[strings.ToLower(name)] { + return part[:eq+1] + Placeholder + } + return part + }) + return raw[:start+1] + query + raw[end:] +} + +// RedactBody removes credential shapes and credential-valued JSON strings. +func RedactBody(s string) string { + for _, re := range []*regexp.Regexp{apiKeyPattern, githubTokenPattern, bearerPattern, longHexPattern} { + s = re.ReplaceAllString(s, Placeholder) + } + s = longBasePattern.ReplaceAllStringFunc(s, func(value string) string { + if strings.ContainsAny(value, "0123456789+/=") { + return Placeholder + } + return value + }) + s = jsonCredentialPattern.ReplaceAllString(s, `${1}"`+Placeholder+`"`) + scrub := func(value string) string { + for _, re := range secretPatterns { + value = re.ReplaceAllString(value, Placeholder) + } + return value + } + if !json.Valid([]byte(s)) { + return scrub(s) + } + // Hosted assignment patterns must not consume JSON field names or syntax. + var out strings.Builder + last := 0 + for _, loc := range jsonStringPattern.FindAllStringIndex(s, -1) { + out.WriteString(s[last:loc[0]]) + quoted := s[loc[0]:loc[1]] + if !strings.HasPrefix(strings.TrimSpace(s[loc[1]:]), ":") { + var value string + _ = json.Unmarshal([]byte(quoted), &value) + if clean := scrub(value); clean != value { + // Quote changed values without escaping the placeholder's angle brackets. + var b strings.Builder + enc := json.NewEncoder(&b) + enc.SetEscapeHTML(false) + _ = enc.Encode(clean) + quoted = strings.TrimSuffix(b.String(), "\n") + } + } + out.WriteString(quoted) + last = loc[1] + } + out.WriteString(s[last:]) + return out.String() +} diff --git a/internal/record/redact_numeric_test.go b/internal/record/redact_numeric_test.go new file mode 100644 index 00000000..1faa00f7 --- /dev/null +++ b/internal/record/redact_numeric_test.go @@ -0,0 +1,22 @@ +package record + +import "testing" + +// A card number or CVV sent as a bare JSON number is a credential too; other numbers stay. +func TestRedactBodyRedactsNumericCredentialFields(t *testing.T) { + tests := []struct { + name, in, want string + }{ + {"card number sent as a number", `{"card_number": 4111111111111111, "amount": 20}`, `{"card_number": "", "amount": 20}`}, + {"cvv sent as a string", `{"cvv": "123"}`, `{"cvv": ""}`}, + {"cvv sent as a number", `{"cvv":123}`, `{"cvv":""}`}, + {"non credential number kept", `{"count": 4111111111111111}`, `{"count": 4111111111111111}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := RedactBody(tt.in); got != tt.want { + t.Fatalf("RedactBody(%s) = %s, want %s", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/record/redact_test.go b/internal/record/redact_test.go new file mode 100644 index 00000000..193036bc --- /dev/null +++ b/internal/record/redact_test.go @@ -0,0 +1,115 @@ +package record + +import ( + "strings" + "testing" +) + +func TestRedactHeaders(t *testing.T) { + names := []string{"authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key", "x-goog-api-key", "api-key", "x-auth-token", "x-access-token", "x-amz-security-token", "x-csrf-token", "x-xsrf-token"} + for _, name := range names { + t.Run(name, func(t *testing.T) { + key := strings.ToUpper(name) + h := map[string]string{key: "sensitive", "Content-Type": "application/json", "prefix-" + name: "ordinary"} + got := RedactHeaders(h) + if got[key] != Placeholder || got["Content-Type"] != "application/json" || got["prefix-"+name] != "ordinary" { + t.Fatalf("unexpected headers: %#v", got) + } + if h[key] != "sensitive" { + t.Fatal("input mutated") + } + got["Content-Type"] = "changed" + if h["Content-Type"] != "application/json" { + t.Fatal("copy aliases input") + } + }) + } + if got := RedactHeaders(nil); len(got) != 0 { + t.Fatalf("nil: %#v", got) + } +} + +func TestRedactURL(t *testing.T) { + for _, name := range []string{"key", "api_key", "apikey", "access_token", "token", "id_token", "refresh_token", "auth", "secret", "password", "sig", "signature"} { + t.Run(name, func(t *testing.T) { + for _, prefix := range []string{"https://example.test/path", "http://[broken", "/relative"} { + raw := prefix + "?" + strings.ToUpper(name) + "=sensitive&prefix_" + name + "=safe&" + name + "=again#fragment" + want := prefix + "?" + strings.ToUpper(name) + "=" + Placeholder + "&prefix_" + name + "=safe&" + name + "=" + Placeholder + "#fragment" + if got := RedactURL(raw); got != want { + t.Errorf("got %q, want %q", got, want) + } + } + }) + } + cases := []struct{ in, want string }{ + {"https://example.test/path?sort=a%20b&flag#token=visible", "https://example.test/path?sort=a%20b&flag#token=visible"}, + {"/path?%74oken=hidden&key=&token=last", "/path?%74oken=&key=&token="}, + {"/path#fragment?token=visible", "/path#fragment?token=visible"}, + {"http://[broken?token=%zz&safe=ok", "http://[broken?token=&safe=ok"}, + {"/path?token&safe=ok", "/path?token&safe=ok"}, + {"/path", "/path"}, + } + for _, tc := range cases { + if got := RedactURL(tc.in); got != tc.want { + t.Errorf("%q: got %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestRedactBody(t *testing.T) { + cases := []struct{ name, positive, negative string }{ + {"api key", "sk-Abc123_-", "sk-Abc123_"}, + {"github", "ghp_Abcdefghijklmnopqr123", "ghp_Abcdefghijklmnopqr1"}, + {"bearer", "BeArEr abc12345", "Bearer abc1234"}, + {"hex", strings.Repeat("a1", 16), strings.Repeat("a1", 15)}, + {"base digit", strings.Repeat("Z", 39) + "1", strings.Repeat("Z", 38) + "1"}, + {"base plus", strings.Repeat("Z", 39) + "+", strings.Repeat("Z", 60)}, + {"base slash", strings.Repeat("Z", 39) + "/", "ordinary words"}, + {"base padding", strings.Repeat("Z", 40) + "=", "550e8400-e29b-41d4-a716-446655440000"}, + {"private key", "-----BEGIN RSA PRIVATE KEY-----\nabc\n-----END RSA PRIVATE KEY-----", "-----BEGIN PUBLIC KEY-----abc"}, + {"aws", "AKIA1234567890ABCDEF", "AKIA1234567890ABCDE"}, + {"github variants", "gho_Abcdefghijklmnopqr123", "gho_Abcdefghijklmnopqr1"}, + {"github pat", "github_pat_Abcdefghijklmnopqr123", "github_pat_Abcdefghijklmnopqr1"}, + {"stripe", "sk_live_Abcdef1234", "sk_live_Abcdef123"}, + {"stripe restricted", "rk_test_Abcdef1234", "rk_test_Abcdef123"}, + {"webhook", "whsec_Abcdef1234", "whsec_Abcdef123"}, + {"slack", "xoxb-Abcdef1234", "xoxb-Abcdef123"}, + {"jwt", "eyJabcdefgh.abcdefgh.abcdefgh", "eyJabcdefg.abcdefgh.abcdefgh"}, + {"probe", "FP2.Abcdef1234", "FP2.Abcdef123"}, + {"assignment", "password=abcd", "password=abc"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := RedactBody(tc.positive); got != Placeholder { + t.Errorf("positive: got %q", got) + } + if got := RedactBody(tc.negative); got != tc.negative { + t.Errorf("negative: got %q, want %q", got, tc.negative) + } + }) + } +} + +func TestRedactBodyJSON(t *testing.T) { + for _, name := range []string{"password", "passwd", "token", "secret", "authorization", "refresh_token", "access_token", "api_key", "card_number", "cvv"} { + t.Run(name, func(t *testing.T) { + prefix := `{"` + strings.ToUpper(name) + `" : ` + suffix := `, "username": "alice", "count": 42, "ok": true, "id": "550e8400-e29b-41d4-a716-446655440000", "prefix_` + name + `": "safe"}` + in := prefix + `"a\"b\\c"` + suffix + want := prefix + `""` + suffix + if got := RedactBody(in); got != want { + t.Fatalf("got %s, want %s", got, want) + } + }) + } + cases := []struct{ in, want string }{ + {`{"token":"", "cvv":"1", "nested":{"password":"two words"}}`, `{"token":"", "cvv":"", "nested":{"password":""}}`}, + {`{"message":"password=abcd", "normal":"hello"}`, `{"message":"", "normal":"hello"}`}, + {`{"token":123,"tokens":"safe","description":"ordinary words"}`, `{"token":"","tokens":"safe","description":"ordinary words"}`}, + } + for _, tc := range cases { + if got := RedactBody(tc.in); got != tc.want { + t.Errorf("got %s, want %s", got, tc.want) + } + } +} diff --git a/internal/runner/step_event.go b/internal/runner/step_event.go index 75b4fa97..0e04e751 100644 --- a/internal/runner/step_event.go +++ b/internal/runner/step_event.go @@ -29,6 +29,7 @@ type StepEvent struct { Error error // set on StepFailed and StepSkipped Attempt int // test attempt this step belongs to (1 unless the test was retried) Depth int // 0 for top-level test steps + At time.Time // wall-clock time this event was emitted } // OnStep registers a callback fired before and after every step this @@ -62,6 +63,7 @@ func (e *Executor) emitStep(step interface{ GetLine() int }, desc string, status Error: err, Attempt: e.stepAttempt, Depth: e.depth, + At: time.Now(), }) } diff --git a/internal/runner/step_event_test.go b/internal/runner/step_event_test.go index aae66de2..c046ab06 100644 --- a/internal/runner/step_event_test.go +++ b/internal/runner/step_event_test.go @@ -3,6 +3,7 @@ package runner import ( "context" "testing" + "time" "github.com/alphawavesystems/flutter-probe/internal/parser" ) @@ -11,6 +12,27 @@ func textSel(s string) *parser.Selector { return &parser.Selector{Kind: parser.SelectorText, Text: s} } +func TestOnStep_EmissionTime(t *testing.T) { + e := &Executor{} + var got []StepEvent + e.OnStep(func(ev StepEvent) { got = append(got, ev) }) + + e.emitStep(nil, "tap", StepStarted, 0, nil) + e.emitStep(nil, "tap", StepPassed, time.Millisecond, nil) + + if len(got) != 2 { + t.Fatalf("want 2 events, got %d: %+v", len(got), got) + } + for i, ev := range got { + if ev.At.IsZero() { + t.Fatalf("event %d has no emission time", i) + } + } + if got[1].At.Before(got[0].At) { + t.Fatalf("emission times out of order: started=%v passed=%v", got[0].At, got[1].At) + } +} + // A step emits started then passed, carrying its source line and attribution. func TestOnStep_StartedThenPassed(t *testing.T) { client := &scriptedClient{fakeAIClient: &fakeAIClient{}, tapAlwaysOK: true} From 39d5adf8d09296ceed0e53a345647fed1537e1af Mon Sep 17 00:00:00 2001 From: Patrick Bertsch Date: Fri, 16 Oct 2026 13:01:25 -0600 Subject: [PATCH 3/6] feat(record): offline bundle viewer Renders a recording bundle as one HTML page that opens from disk with no server. The manifest and timeline are inlined with script-safe escaping, every bundle value is inserted as text, and a content security policy blocks network requests. Claude-Session: https://claude.ai/code/session_01QGnLBdCp6x94qKGY5kAxHK --- internal/record/viewer.go | 26 +++++++ internal/record/viewer/index.html | 121 ++++++++++++++++++++++++++++++ internal/record/viewer_test.go | 64 ++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 internal/record/viewer.go create mode 100644 internal/record/viewer/index.html create mode 100644 internal/record/viewer_test.go diff --git a/internal/record/viewer.go b/internal/record/viewer.go new file mode 100644 index 00000000..152aef8b --- /dev/null +++ b/internal/record/viewer.go @@ -0,0 +1,26 @@ +package record + +import ( + _ "embed" + "encoding/json" + "fmt" + "strings" +) + +//go:embed viewer/index.html +var viewerTemplate string + +// RenderViewer embeds recording metadata in a standalone, offline viewer. +func RenderViewer(manifestJSON, timelineJSON []byte) ([]byte, error) { + if !json.Valid(manifestJSON) { + return nil, fmt.Errorf("invalid manifest JSON") + } + if !json.Valid(timelineJSON) { + return nil, fmt.Errorf("invalid timeline JSON") + } + escape := strings.NewReplacer("<", `\u003c`, ">", `\u003e`, "&", `\u0026`) + return []byte(strings.NewReplacer( + "/*__MANIFEST__*/null", escape.Replace(string(manifestJSON)), + "/*__TIMELINE__*/null", escape.Replace(string(timelineJSON)), + ).Replace(viewerTemplate)), nil +} diff --git a/internal/record/viewer/index.html b/internal/record/viewer/index.html new file mode 100644 index 00000000..434436e9 --- /dev/null +++ b/internal/record/viewer/index.html @@ -0,0 +1,121 @@ + + + + + + +FlutterProbe recording + + + +

FlutterProbe recording

Manifest
+

Select a step to inspect its recording.

+ + + diff --git a/internal/record/viewer_test.go b/internal/record/viewer_test.go new file mode 100644 index 00000000..f6b02971 --- /dev/null +++ b/internal/record/viewer_test.go @@ -0,0 +1,64 @@ +package record + +import ( + "strings" + "testing" +) + +func TestViewerInlineJSON(t *testing.T) { + manifest := `{"name":"recording","version":1}` + timeline := `{"steps":[{"index":1,"description":"Tap button","status":"passed"}]}` + output, err := RenderViewer([]byte(manifest), []byte(timeline)) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"const MANIFEST = " + manifest, "const TIMELINE = " + timeline} { + if !strings.Contains(string(output), want) { + t.Errorf("output missing %q", want) + } + } + for _, forbidden := range []string{"/*__MANIFEST__*/", "/*__TIMELINE__*/", "http://", "https://", "innerHTML", "fetch("} { + if strings.Contains(string(output), forbidden) { + t.Errorf("output contains %q", forbidden) + } + } +} + +func TestViewerEscapesScriptData(t *testing.T) { + payload := `{"steps":[{"description":"&"}]}` + output, err := RenderViewer([]byte(`{"name":"<>&"}`), []byte(payload)) + if err != nil { + t.Fatal(err) + } + page := string(output) + if strings.Count(page, "") != 1 || strings.Count(page, "hi", ResponseBody: `{"ok":true}`, + RequestHeaders: map[string]string{}, ResponseHeaders: map[string]string{}, Source: "dart_io", + }); err != nil { + t.Fatal(err) + } + if err := b.WriteTree("0001", "Scaffold\n Text \"Sign in\""); err != nil { + t.Fatal(err) + } + timeline := Timeline{Steps: []Step{{ + Index: 1, Description: "tap sign in", Status: "passed", Exchanges: []string{"0001"}, Tree: "0001", + }}} + if err := b.WriteViewer(Manifest{SchemaVersion: SchemaVersion, RunID: "run-1", TestName: "login"}, timeline); err != nil { + t.Fatal(err) + } + page, err := os.ReadFile(filepath.Join(b.Dir(), "index.html")) + if err != nil { + t.Fatal(err) + } + html := string(page) + for _, want := range []string{`"request_body"`, `Scaffold`, ``, `ok\":true`} { + if !strings.Contains(html, want) { + t.Errorf("index.html lacks %s", want) + } + } + if n := strings.Count(html, ""); n != 1 { + t.Fatalf("found %d closing script tags, want 1", n) + } +} + +func TestWriteViewerRejectsBadIDsAndMissingFiles(t *testing.T) { + b, err := Create(t.TempDir()) + if err != nil { + t.Fatal(err) + } + bad := Timeline{Steps: []Step{{Exchanges: []string{"../secret"}}}} + if err := b.WriteViewer(Manifest{}, bad); err == nil { + t.Fatal("a traversal ID was accepted") + } + missing := Timeline{Steps: []Step{{Exchanges: []string{"0007"}}}} + if err := b.WriteViewer(Manifest{}, missing); err == nil { + t.Fatal("a missing exchange file was ignored") + } + if _, err := os.Stat(filepath.Join(b.Dir(), "index.html")); err == nil { + t.Fatal("index.html was written despite the error") + } +} From a46829229d20b7fd86778f557d1b9a50bd73ba7c Mon Sep 17 00:00:00 2001 From: Patrick Bertsch Date: Fri, 16 Oct 2026 15:12:44 -0600 Subject: [PATCH 6/6] test(record): keep Stripe-shaped fixtures out of the source The disclosure guard flags complete secret-shaped tokens in added lines. The three Stripe and webhook fixtures are split across string pieces, as internal/hosted/scrub.go already does for its webhook pattern. The tests still exercise the same rules. Claude-Session: https://claude.ai/code/session_01QGnLBdCp6x94qKGY5kAxHK --- internal/record/redact_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/record/redact_test.go b/internal/record/redact_test.go index 193036bc..85108316 100644 --- a/internal/record/redact_test.go +++ b/internal/record/redact_test.go @@ -70,9 +70,10 @@ func TestRedactBody(t *testing.T) { {"aws", "AKIA1234567890ABCDEF", "AKIA1234567890ABCDE"}, {"github variants", "gho_Abcdefghijklmnopqr123", "gho_Abcdefghijklmnopqr1"}, {"github pat", "github_pat_Abcdefghijklmnopqr123", "github_pat_Abcdefghijklmnopqr1"}, - {"stripe", "sk_live_Abcdef1234", "sk_live_Abcdef123"}, - {"stripe restricted", "rk_test_Abcdef1234", "rk_test_Abcdef123"}, - {"webhook", "whsec_Abcdef1234", "whsec_Abcdef123"}, + // Split so the source holds no complete secret-shaped token; the repository's scanner flags those. + {"stripe", "sk_" + "live_Abcdef1234", "sk_" + "live_Abcdef123"}, + {"stripe restricted", "rk_" + "test_Abcdef1234", "rk_" + "test_Abcdef123"}, + {"webhook", "whs" + "ec_Abcdef1234", "whs" + "ec_Abcdef123"}, {"slack", "xoxb-Abcdef1234", "xoxb-Abcdef123"}, {"jwt", "eyJabcdefgh.abcdefgh.abcdefgh", "eyJabcdefg.abcdefgh.abcdefgh"}, {"probe", "FP2.Abcdef1234", "FP2.Abcdef123"},